Merge branch 'stable-3.6' into stable-3.7

* stable-3.6:
  Fix index rewriter to rewrite all Or/AndPredicates
  VisibleChangesCache: Skip if project isn't readable
  Fix Flogger issues flagged by error prone
  Update hooks to 30073628612bce23826f4be71bfdd159da521cbc
  Fix SameNameButDifferent bug pattern flagged by error prone
  Add AndCardinalPredicate and OrCardinalPredicate
  Do not set cherryPickOf on RevertSubmission
  Add HasCardinality interface which helps in defining a cardinality

Release-Notes: skip
Change-Id: Ie965c04208f36c323683498e0fdc852fbb602d0d
diff --git a/.bazelignore b/.bazelignore
index 69c04b1..aac80af 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1,2 +1,5 @@
 eclipse-out
 node_modules
+polygerrit-ui/node_modules
+plugins/node_modules
+tools/node_tools/node_modules
diff --git a/.gitignore b/.gitignore
index 8edc10e..53bc9f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,9 @@
 /node_modules/
 /package-lock.json
 /plugins/*
+/polygerrit-ui/coverage/
+/polygerrit-ui/app/plugins/*
+/polygerrit-ui/screenshots/Chrome/failed/
 !/plugins/.eslintignore
 !/plugins/.eslintrc.js
 !/plugins/.prettierrc.js
diff --git a/Documentation/cmd-check-project-access.txt b/Documentation/cmd-check-project-access.txt
new file mode 100644
index 0000000..885993a
--- /dev/null
+++ b/Documentation/cmd-check-project-access.txt
@@ -0,0 +1,74 @@
+= gerrit check-project-access
+
+== NAME
+gerrit check-project-access - Check project readability of all users in a
+matching the given link:rest-api-accounts.html#account-id[account identifier]
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit check-project-access_
+  [--project <PROJECT> | -p <PROJECT>]
+  [--user <USER> | -u <USER>]
+--
+
+== DESCRIPTION
+Allow users to check if user has access to a project’s changes, comments, code
+differences, and Git access over SSH or HTTP.
+
+It returns all users in given input String, where it includes: username, email
+address and full name.
+
+== ACCESS
+Users who have view access and administrate server capability.
+
+== EXAMPLES
+Check if users can read all references in the repository called "test_project",
+in given input String TestUser.
+
+Given that there are
+a user with username "test_user1", email "one@email.com", and Full name as
+TestUser,
+a user with username "test_user2", email "two@email.com", and Full name as
+TestUser,
+a user with username "test_user3", email "TestUser@email.com", and Full name
+as John Doe
+
+----
+$ ssh -p @SSH_PORT@ @SSH_HOST@ gerrit check-project-access
+  -p test_project
+  -u TestUser
+
+Username: 'test_user1', Email: 'one@example.com',  Full Name: 'TestUser'
+, Result: TRUE
+Username: 'test_user3', Email: 'TestUser@example.com',  Full Name: 'John Doe'
+, Result: FALSE
+Username: 'test_user2', Email: 'two@example.com',  Full Name: 'TestUser'
+, Result: FALSE
+----
+
+----
+$ ssh -p @SSH_PORT@ @SSH_HOST@ gerrit check-project-access
+  -p test_project_doesnt_exist
+  -u TestUser
+
+fatal: project 'test_project_doesnt_exist' is unavailable
+----
+
+----
+$ ssh -p @SSH_PORT@ @SSH_HOST@ gerrit check-project-access
+  -p test_project
+  -u test_user_doesnt_exist
+
+fatal: No accounts found for your query: "test_user_doesnt_exist"
+Tip: Try double-escaping spaces, for example: "--user Last,\\ First"
+----
+
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
+
diff --git a/Documentation/cmd-create-project.txt b/Documentation/cmd-create-project.txt
index 0575eb9..358324d 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -136,13 +136,13 @@
 	project.  Disabled by default.
 
 --use-signed-off-by::
---so:
+--so::
 	If enabled, each change must contain a Signed-off-by line
 	from either the author or the uploader in the commit message.
 	Disabled by default.
 
 --create-new-change-for-all-not-in-target::
---ncfa:
+--ncfa::
 	If enabled, a new change is created for every commit that is not in
 	the target branch. If the pushed commit is a merge commit, this flag is
 	ignored for that push. To avoid accidental creation of a large number
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index 7f1a6e8..c959a07 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -61,6 +61,9 @@
 link:cmd-copy-approvals.html[gerrit copy-approvals]::
 	Copy all inferred approvals labels to the latest patch-set.
 
+link:cmd-check-project-access.html[gerrit check-project-access]::
+	Check if user(s) can read non-config refs of a project
+
 link:cmd-create-branch.html[gerrit create-branch]::
 	Create a new project branch.
 
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index f0ad460..5fd0bfc 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -324,6 +324,19 @@
 
 comment:: Review comment cover message.
 
+=== Project Head Updated
+
+Sent when project's head is updated.
+
+type:: "project-head-updated"
+
+oldHead:: The old project head name
+
+newHead:: The new project head name
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
 == SEE ALSO
 
 * link:json.html[JSON Data Formats]
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 0444fab..6e76a8a 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -34,6 +34,11 @@
 |Owner
 |The contributor who created the change.
 
+|Uploader
+|The user that uploaded the current patch set (e.g. the user that executed the
+`git push` command, or the user that triggered the patch set creation through
+an action in the UI).
+
 |Assignee
 |The contributor responsible for the change. Often used when a change has
 mulitple reviewers to identify the individual responsible for final approval.
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index aca9591..716fa2f 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -383,8 +383,8 @@
 [[starred-changes]]
 == Starred Changes
 
-link:dev-stars.html[Starred changes] allow users to mark changes as
-favorites and receive email notifications for them.
+Starred changes allow users to mark changes as favorites and receive email
+notifications for them.
 
 Each starred change is a tuple of an account ID, a change ID and a
 label.
@@ -402,8 +402,7 @@
 when the prefix ends with '/', this ref format is optimized to find
 starred changes by change ID. Finding starred changes by change ID is
 e.g. needed when a change is updated so that all users that have
-the link:dev-stars.html#default-star[default star] on the change can be
-notified by email.
+the star on the change can be notified by email.
 
 Gerrit also needs an efficient way to find all changes that were
 starred by an account, e.g. to provide results for the
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 5682a15..1c26232 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1441,7 +1441,7 @@
 Maximum number of patch sets allowed per change. If this is insufficient,
 recreate the change with a new Change-Id, then abandon the old change.
 +
-By default 1,500.
+By default 1000.
 
 [[change.maxUpdates]]change.maxUpdates::
 +
@@ -1735,8 +1735,11 @@
   link = "#/q/$1"
 
 [commentlink "bugzilla"]
-  match = "(bug\\s+#?)(\\d+)"
-  link = http://bugs.example.com/show_bug.cgi?id=$2
+  match = "(^|\\s)(bug\\s+#?)(\\d+)($|\\s)"
+  link = http://bugs.example.com/show_bug.cgi?id=$3
+  prefix = $1
+  suffix = $4
+  text = $2$3
 
 [commentlink "tracker"]
   match = ([Bb]ug:\\s+)(\\d+)
@@ -1771,6 +1774,10 @@
 be updated to match text formats.
 +
 A common pattern to match is `bug\\s+(\\d+)`.
++
+In order to better control the visual presentation of the link `prefix`,
+`suffix` and `text` is used. With the generated link html looking like:
+`prefix<a ...>text</a>suffix`.
 
 [[commentlink.name.link]]commentlink.<name>.link::
 +
@@ -1779,6 +1786,27 @@
 +
 The link property is used only when the html property is not present.
 
+[[commentlink.name.prefix]]commentlink.<name>.prefix::
++
+The text inserted before the link. Groups in the match expression may be
+accessed as `$'n'`.
++
+The link property is used only when the html property is not present.
+
+[[commentlink.name.suffix]]commentlink.<name>.suffix::
++
+The text inserted after the link. Groups in the match expression may be
+accessed as `$'n'`.
++
+The link property is used only when the html property is not present.
+
+[[commentlink.name.text]]commentlink.<name>.text::
++
+The text content of the link. Groups in the match expression may be
+accessed as `$'n'`.
++
+The link property is used only when the html property is not present.
+
 [[commentlink.name.html]]commentlink.<name>.html::
 +
 HTML to replace the entire matched string with.  If present,
@@ -2522,6 +2550,26 @@
 by the target version of the upgrade. Refer to the release notes and check whether
 the rolling upgrade is possible or not and the associated constraints.
 
+[[gerrit.importedServerId]]gerrit.importedServerId::
++
+ServerId of the repositories imported from other Gerrit servers. Changes coming
+associated with the imported serverIds are indexed and displayed in the UI.
++
+Specify multiple `gerrit.importedServerId` for allowing the import from multiple
+Gerrit servers with different serverIds.
++
+[NOTE]
+The account-ids referenced in the imported changes are used for looking up the
+associated account-id locally, using the `imported:` external-id.
+Example: the account-id 1000 from the imported server-id 59a4964e-6376-4ed9-beef
+will be looked up in the local accounts using the `imported:1000@59a4964e-6376-4ed9-beef`
+external-id.
++
+If this value is not set, all changes imported from other Gerrit servers will be
+ignored.
++
+By default empty.
+
 [[gerrit.serverId]]gerrit.serverId::
 +
 Used by NoteDb to, amongst other things, identify author identities from
@@ -3211,6 +3259,15 @@
 If not set or set to a zero, defaults to the number of logical CPUs as returned
 by the JVM. If set to a negative value, defaults to a direct executor.
 
+[[index.cacheQueryResultsByChangeNum]]index.cacheQueryResultsByChangeNum::
++
+Allow to cache and reuse the change JSON elements by their Change number.
+This improves the performance of queries that are returning Changes duplicates.
+It needs to be turned off when having Changes imported from other servers
+because of the potential conflicts of change numbers.
++
+Defaults to true.
+
 [[index.onlineUpgrade]]index.onlineUpgrade::
 +
 Whether to upgrade to new index schema versions while the server is
@@ -5609,7 +5666,7 @@
 
 [[trackingid.name.system]]trackingid.<name>.system::
 +
-The name of the external tracking system (maximum 10 characters).
+The name of the external tracking system (maximum 20 characters).
 It is possible to have several trackingid entries for the same
 tracking system.
 
diff --git a/Documentation/config-groups.txt b/Documentation/config-groups.txt
index 0917515..4abb223 100644
--- a/Documentation/config-groups.txt
+++ b/Documentation/config-groups.txt
@@ -34,7 +34,7 @@
 group, there is a ref, stored as a sharded UUID, e.g.
 
 ----
-  refs/groups/ef/deafbeefdeafbeefdeafbeefdeafbeefdeafbeef
+  refs/groups/de/deafbeefdeafbeefdeafbeefdeafbeefdeafbeef
 ----
 
 The ref points to commits holding files. The files are
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 3fa84b1..e4eee10 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -11,6 +11,43 @@
 link:config-submit-requirements.html[submit requirements] documentation to
 configure such rules.
 
+[[sticky_votes]]
+== Sticky Votes
+
+Whether votes are sticky when a new patch set is created depends on the
+link:#label_copyCondition[copyCondition] of the label. If an approval
+matches the configured condition it is copied from the old current
+patch set to the new current patch set. Votes that are not copied to
+the new patch set, are called `outdated`.
+
+If votes get outdated due to pushing a new patch set the uploader is
+informed about this by a message in the git output. In addition,
+outdated votes are also listed in the email notification that is sent
+for the new patch set (unless this is disabled by a custom email
+template). Note, that the uploader only get this email notification if
+they have configured `Every Comment` for `Email notifications` in their
+user preferences. With any other email preference the email sender, the
+uploader in this case, is not included in the email recipients.
+
+If votes get outdated due to creating a new patch set the user of the
+removed vote is added to the attention set of the change, as they need
+to re-review the change and renew their vote.
+
+If a vote is applied on an outdated patch set (i.e. a patch set that is
+not the current patch set) the vote is copied forward to follow-up
+patch sets if possible. A newly added or updated vote on an outdated
+patch set is copied to follow-up patch sets if:
+
+* the vote is copyable (i.e. it matches the
+link:#label_copyCondition[copyCondition] of the label)
+* neither the follow-up patch set nor an intermediate patch set has a
+  non-copied vote or a deletion vote (vote with value `0`) that
+  overrides the copy vote
+
+If an approval on an outdated patch set is removed or updated to a
+value that is not copyable, existing copies of that approval on
+follow-up patch sets are removed.
+
 [[label_Code-Review]]
 == Label: Code-Review
 
@@ -211,9 +248,18 @@
 === `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`.
+change is allowed for submission. Label functions are **deprecated** and updates
+that set `function` to a blocking value {`MaxWithBlock`, `MaxNoBlock`,
+`AnyWithBlock`} will be rejected. Existing label function definitions can only
+be updated to {`NoBlock`, `NoOp`, `PatchSetLock`}. New label defintions should
+also explicitly set the `function` attribute to a non-blocking value since the
+default is `MaxWithBlock`.
+
+If your project has a
+blocking label function, we highly encourage you to change it to `NoBlock` and
+add a submit-requirement for the same label. See the
+link:config-submit-requirements.html#code-review-example[submit-requirements
+documentation] for more details.
 
 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.
@@ -275,15 +321,6 @@
 
 Defaults to true.
 
-[[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.
-
 [[label_copyCondition]]
 === `label.Label-Name.copyCondition`
 
@@ -296,24 +333,104 @@
 
 Gerrit currently supports the following predicates:
 
-==== changekind:{REWORK,TRIVIAL_REBASE,MERGE_FIRST_PARENT_UPDATE,NO_CODE_CHANGE,NO_CHANGE}
+[[changekind]]
+==== changekind:{NO_CHANGE,NO_CODE_CHANGE,MERGE_FIRST_PARENT_UPDATE,REWORK,TRIVIAL_REBASE}
 
-Matches if the diff between two patch sets was of a certain change kind.
+Matches if the diff between two patch sets was of a certain change kind:
 
-`REWORK` matches all kind of change kinds because any other change kind
+* [[no_change]]`NO_CHANGE`:
++
+Matches when a new patch set is uploaded that has the same parent tree,
+code delta, and commit message as the previous patch set. This means
+that only the patch set SHA-1 is different. This can be used to enable
+sticky approvals, reducing turn-around for this special case.
++
+It is recommended to leave this enabled for both, the Code-Review and
+the Verified labels.
++
+`NO_CHANGE` is more trivial than a trivial rebase, no code change and
+a first parent update, hence this change kind is also matched by
+`changekind:TRIVIAL_REBASE`, `changekind:NO_CODE_CHANGE` and if it's
+a merge commit by `changekind:MERGE_FIRST_PARENT_UPDATE`.
+
+
+* [[no_code_change]]`NO_CODE_CHANGE`:
++
+Matches when a new patch set is uploaded that has the same parent tree
+as the previous patch set and the same code diff (including context
+lines) as the previous patch set. This means only the commit message
+may be different; the change hasn't even been rebased. Also matches if
+the commit message is not different, which means this includes matching
+patch sets that have `NO_CHANGE` as the change kind.
++
+This predicate can be used to enable sticky approvals on labels that
+only depend on the code, reducing turn-around if only the commit
+message is changed prior to submitting a change.
++
+For the Verified label that is optionally installed by the
+link:pgm-init.html[init] site program this predicate is used by
+default.
+
+* [[merge_first_parent_update]]`MERGE_FIRST_PARENT_UPDATE`:
++
+Matches when a new patch set is uploaded that is a new merge commit
+which only differs from the merge commit in the previous patch set in
+its first parent, or has identical parents (aka the change kind of the
+merge commit is `NO_CHANGE`).
++
+The first parent of the merge commit is part of the change's target
+branch, whereas the other parent(s) refer to the feature branch(es) to
+be merged.
++
+Matching this change kind is useful if you don't want to trigger CI or
+human verification again if your target branch moved on but the feature
+branch(es) being merged into the target branch did not change.
++
+This predicate does not match if the patch set is not a merge commit.
+
+* [[trivial_rebase]]`TRIVIAL_REBASE`:
++
+Matches when a new patch set is uploaded that is a trivial rebase. A
+new patch set is considered to be trivial rebase if the commit message
+is the same as in the previous patch set and if it has the same diff
+(including context lines) as the previous patch set. This is the case
+if the change was rebased onto a different parent and that rebase did
+not require git to perform any conflict resolution, or if the parent
+did not change at all (aka the change kind of the commit is
+`NO_CHANGE`).
++
+This predicate can be used to enable sticky approvals, reducing
+turn-around for trivial rebases prior to submitting a change.
++
+For the pre-installed Code-Review label this predicate is used by
+default.
+
+* [[rework]]`REWORK`:
++
+Matches all kind of change kinds because any other change kind
 is just a more trivial version of a rework. This means setting
 `changekind:REWORK` is equivalent to setting `is:ANY`.
 
-`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_magic]]
 ==== is:{MIN,MAX,ANY}
 
-Matches votes that are equal to the minimal or maximal voting range. Or any votes.
+Matches approvals that have a minimal, maximal or any score:
 
+* [[is_min]]`MIN`:
++
+Matches approvals that have a minimal score, i.e. the lowest possible
+(negative) value for this label.
+
+* [[is_max]]`MAX`:
++
+Matches approvals that a maximal score, i.e. the highest possible
+(positive) value for this label.
+
+* [[is_any]]`ANY`:
++
+Matches any approval when a new patch set is uploaded.
+
+[[is_value]]
 ==== is:'VALUE'
 
 Matches approvals that have a voting value that is equal to 'VALUE'.
@@ -332,11 +449,25 @@
 Matches all votes if the new patch set was uploaded by a member of
 link:#group-id[\{group-id\}].
 
+[[has_unchanged_files]]
 ==== has:unchanged-files
 
-Matches when the new patch-set includes the same files as the old patch-set.
+Matches when the new patch-set has the same list of files as the
+previous patch-set.
 
-Only 'unchanged-files' is supported for 'has'.
+Votes are not copied in the following cases:
+
+  * If one more files are renamed in the new patch set. These files are counted
+  as a deletion of the file at the old path and an addition of the file at the
+  new path. This means the list of files did change.
+  * If one or more files are reverted to their original content, that is files
+  that become same as in the base revision.
+
+This predicate is useful if you don't want to trigger CI or human
+verification again if the list of files didn't change.
+
+Note, "unchanged-files" is the only value that is supported for the
+"has" operator.
 
 [[group-id]]
 ==== Group ID
@@ -367,129 +498,6 @@
 copyCondition = is:MIN OR -change-kind:REWORK OR uploaderin:dead...beef
 ----
 
-[[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.
-
-[[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
-submitting a change. Defaults to false.
-
-[[label_copyAllScoresIfListOfFilesDidNotChange]]
-=== `label.Label-Name.copyAllScoresIfListOfFilesDidNotChange`
-
-*DEPRECATED: use `is:ANY AND has:unchanged-files` predicates in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-This policy is useful if you don't want to trigger CI or human
-verification again if the list of files didn't change.
-
-If true, all scores for the label are copied forward when a new
-patch-set is uploaded that has the same list of files as the previous
-patch-set.
-
-Renames are considered different files when computing whether new files
-were added or old files were deleted. Hence, if there are renames, scores will
-*NOT* be copied over.
-
-Defaults to false.
-
-[[label_copyAllScoresOnMergeFirstParentUpdate]]
-=== `label.Label-Name.copyAllScoresOnMergeFirstParentUpdate`
-
-*DEPRECATED: use `is:ANY AND changekind:MERGE_FIRST_PARENT_UPDATE` predicates
-in link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-This policy is useful if you don't want to trigger CI or human
-verification again if your target branch moved on but the feature
-branch being merged into the target branch did not change. It only
-applies if the patch set is a merge commit.
-
-If true, all scores for the label are copied forward when a new
-patch set is uploaded that is a new merge commit which only
-differs from the previous patch set in its first parent, or has
-identical parents. The first parent would be the parent of the merge
-commit that is part of the change's target branch, whereas the other
-parent(s) refer to the feature branch(es) to be merged.
-
-Defaults to false.
-
-[[label_copyAllScoresOnTrivialRebase]]
-=== `label.Label-Name.copyAllScoresOnTrivialRebase`
-
-*DEPRECATED: use `is:ANY AND changekind:TRIVIAL_REBASE` predicates
-in link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-If true, all scores for the label are copied forward when a new patch set is
-uploaded that is a trivial rebase. A new patch set is considered to be trivial
-rebase if the commit message is the same as in the previous patch set and if it
-has the same diff (including context lines) as the previous patch set. This is
-the case if the change was rebased onto a different parent and that rebase did
-not require git to perform any conflict resolution, or if the parent did not
-change at all.
-
-This can be used to enable sticky approvals, reducing turn-around for
-trivial rebases prior to submitting a change.
-For the pre-installed Code-Review label this is enabled by default.
-
-Defaults to false.
-
-[[label_copyAllScoresIfNoCodeChange]]
-=== `label.Label-Name.copyAllScoresIfNoCodeChange`
-
-*DEPRECATED: use `is:ANY AND changekind:NO_CODE_CHANGE` predicates in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-If true, all scores for the label are copied forward when a new patch set is
-uploaded that has the same parent tree as the previous patch set and the same
-code diff (including context lines) as the previous patch set. This means only
-the commit message is different; the change hasn't even been rebased. This can
-be used to enable sticky approvals on labels that only depend on the code,
-reducing turn-around if only the commit message is changed prior to submitting a
-change. For the Verified label that is optionally installed by the
-link:pgm-init.html[init] site program this is enabled by default.
-
-Defaults to false.
-
-[[label_copyAllScoresIfNoChange]]
-=== `label.Label-Name.copyAllScoresIfNoChange`
-
-*DEPRECATED: use `is:ANY AND changekind:NO_CHANGE` predicates in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-If true, all scores for the label are copied forward when a new patch
-set is uploaded that has the same parent tree, code delta, and commit
-message as the previous patch set. This means that only the patch
-set SHA-1 is different. This can be used to enable sticky
-approvals, reducing turn-around for this special case.
-It is recommended to leave this enabled for both Verified and
-Code-Review labels.
-
-Defaults to true.
-
-[[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.
-
 [[label_canOverride]]
 === `label.Label-Name.canOverride`
 
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 9fd5b1b..7a0e305 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -9,6 +9,7 @@
 relevant in an automation scenario of the access controls.
 
 
+[[refs-meta-config]]
 == The +refs/meta/config+ namespace
 
 The namespace contains three different files that play different
@@ -318,17 +319,306 @@
 
 [[content_merge]]submit.mergeContent::
 +
-Defines whether Gerrit will try to
-do a content merge when a path conflict occurs. Valid values are
-'true', 'false', or 'INHERIT'.  Default is 'INHERIT'. This option can
-be modified by any project owner through the project console, `Browse`
-> `Repositories` > my/project > `Allow content merges`.
+Defines whether Gerrit will try to do a content merge when a path conflict
+occurs while submitting a change.
++
+A path conflict occurs when the same file has been changed on both sides of a
+merge, e.g. when the same file has been touched in a change and concurrently in
+the target branch.
++
+Doing a content merge means that Gerrit attempts to merge the conflicting file
+contents from both sides of the merge. This is successful if the touched lines
+(plus some surrounding context lines) do not overlap (i.e. both sides touch
+distinct lines).
++
+NOTE: The content merge setting is not relevant when
+link:#fast_forward_only[fast forward only] is configured as the
+link:#submit.action[submit action] because in this case Gerrit will never
+perform a merge, rebase or cherry-pick on submit.
++
+If content merges are disabled, the submit button in the Gerrit web UI is
+disabled, if any path conflict would occur on submitting the change. Users then
+need to rebase the change manually to resolve the path conflict and then get
+the change re-approved so that they can submit it.
++
+NOTE: If only distinct lines have been touched on both sides, rebasing the
+change from the Gerrit UI is sufficient to resolve the path conflict, since the
+rebase action always does the rebase with content merge enabled.
++
+The advantage of enabling content merges on submit is that it makes it less
+likely that change submissions are rejected due to conflicts. Each change
+submission that goes through with content merge, but would be rejected
+otherwise, saves the user from needing to do extra work to get the change
+submitted (rebase the change, get it re-approved and then submit it again).
++
+On the other hand, disabling content merges decreases the chance of breaking
+branches by submitting content merges of incompatible modifications in the same
+file, e.g. a function is removed on one side and a new usage of that function
+is added on the other side. Note, that the chance of breaking a branch by
+incompatible modifications is only reduced, but not eliminated, e.g. even with
+content merges disabled it's possible that a function is removed in one file
+and a new usage of that function is added in another file.
++
+The huge drawback of disabling content merge is that users need to do extra
+work when a change isn't submittable due to a path conflict which could be
+avoided if content merge was enabled (see above). In addition to this, it also
+confuses and frustrates users if a change submission is rejected by Gerrit due
+to a path conflict, but then when they rebase the change manually they do not
+see any conflict (because manual rebases are always done with content merge
+enabled).
++
+In general, the stability gain of disabling content merges is not worth the
+overhead and confusion that this adds for users, which is why disabling content
+merges is highly discouraged.
++
+Valid values are `true`, `false`, or `INHERIT`.
++
+The default is `INHERIT`.
++
+NOTE: Project owners can also set this option in the Gerrit UI:
+`Browse` > `Repositories` > my/repository > `Allow content merges`.
 
 [[submit.action]]submit.action::
 +
-Defines the link:#submit-type[submit type].  Valid
-values are 'fast forward only', 'merge if necessary', 'rebase if necessary',
-'rebase always', 'merge always' and 'cherry pick'.  The default is 'merge if necessary'.
+Defines the submit action aka submit type aka submit strategy that Gerrit
+should use to integrate changes into the target branch when they are submitted.
++
+In general, submitting a change only merges the change if all its dependencies
+are also submitted. The only exception is the `cherry pick` submit action which
+ignores dependencies and hence is not recommended to be used (see
+link:#cherry_pick[below]).
++
+[[submit-type]]
+--
+The following submit actions are supported:
+
+[[merge_if_necessary]]
+* 'merge if necessary':
++
+With this action, when a change is being submitted, Gerrit fast-forwards the
+target branch if possible, and otherwise creates a merge commit automatically.
++
+A fast-forward is possible if the commit that represents the current patch set
+of the change has the current head of the target branch in its parent lineage.
++
+If a fast-forward is not possible, Gerrit automatically creates a merge commit
+that merges the current patch set of the change into the target branch and then
+the target branch is fast-forwarded to the merge commit.
++
+The behavior of this submit action is identical with the classical `git merge`
+behavior, or
+link:https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---ff[git
+merge --ff].
++
+With this submit action the commits that have been reviewed and approved are
+retained in the git history of the target branch. This means, by looking at the
+history of the target branch, you can see for all commits when they were
+originally committed and on which parent commit they were originally based.
+
+[[always_merge]]
+[[merge_always]]
+* 'merge always':
++
+With this action, when a change is being submitted, Gerrit always creates a
+merge commit, even if a fast-forward is possible.
++
+This is identical to the behavior of
+link:https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---no-ff[git merge --no-ff].
++
+With this submit action the commits that have been reviewed and approved are
+retained in the git history of the target branch. This means, by looking at the
+history of the target branch, you can see for all commits when they were
+originally committed and on which parent commit they were originally based. In
+addition, from the merge commits you can see when the changes were submitted
+and it's possible to follow submissions with `git log --first-parent`.
+
+[[rebase_if_necessary]]
+* 'rebase if necessary':
++
+With this action, when a change is being submitted, Gerrit fast-forwards the
+target branch if possible, and otherwise does a rebase automatically.
++
+A fast-forward is possible if the commit that represents the current patch set
+of the change has the current head of the target branch in its parent lineage.
++
+If a fast-forward is not possible, Gerrit automatically rebases the current
+patch set of the change on top of the current head of the target branch and
+then the target branch is fast-forwarded to the rebased commit.
++
+With this submit action, when a rebase is performed, the original commits that
+have been reviewed and approved do not become part of the target branch's
+history. This means the information on when the original commits were committed
+and on which parent they were based is not retained in the branch history.
++
+Using this submit action results in a linear history of the target branch,
+unless merge commits are being submitted. For some people this is an advantage
+since they find the linear history easier to read.
++
+NOTE: Rebasing merge commits is not supported. If a change with a merge commit
+is submitted the link:#merge_if_necessary[merge if necessary] submit action is
+applied.
++
+When rebasing the patchset, Gerrit automatically appends onto the end of the
+commit message a short summary of the change's approvals, and a URL link back
+to the change in the web UI (see link:#submit-footers[below]). If a fast-forward
+is done no footers are added.
+
+[[rebase_always]]
+* 'rebase always':
++
+With this action, when a change is being submitted, Gerrit always does a
+rebase, even if a fast-forward is possible.
++
+With this submit action, the original commits that have been reviewed and
+approved do not become part of the target branch's history. This means the
+information on when the original commits were committed and on which parent
+they were based is not retained in the branch history.
++
+Using this submit action results in a linear history of the target branch,
+unless merge commits are being submitted. For some people this is an advantage
+since they find the linear history easier to read.
++
+NOTE: Rebasing merge commits is not supported. If a change with a merge commit
+is submitted the link:#merge_if_necessary[merge if necessary] submit action is
+applied.
++
+When rebasing the patchset, Gerrit automatically appends onto the end of the
+commit message a short summary of the change's approvals, and a URL link back
+to the change in the web UI (see link:#submit-footers[below]).
++
+The footers that are added are exactly the same footers that are also added by
+the link:cherry_pick[cherry pick] action. Thus, the `rebase always` action can
+be considered similar to the `cherry pick` action, but with the important
+distinction that `rebase always` does not ignore dependencies, which is why
+using the `rebase always` action should be preferred over the `cherry pick`
+submit action.
+
+[[fast_forward_only]]
+* 'fast forward only' (usage generally not recommended):
++
+With this action a change can only be submitted if at submit time the target
+branch can be fast-forwarded to the commit that represents the current patch
+set of the change. This means in order for a change to be submittable its
+current patch set must have the current head of the target branch in its parent
+lineage.
++
+The advantage of using this action is that the target branch is always updated
+to the exact commit that has been reviewed and approved. In particular, if CI
+verification is configured, this means that the CI verified the exact commit to
+which the target branch is being fast-forwarded on submit (assuming no approval
+copying is configured for CI votes).
++
+The huge drawback of using this action is that whenever one change is submitted
+all other open changes for the same branch, that are not successors of the
+submitted change, become non-submittable, since the target branch can no longer
+be fast-forwarded to their current patch sets. Making these changes submittable
+again requires rebasing and re-approving/re-verifying them. For most projects
+this causes an unreasonable amount of overhead that doesn't justify the
+stability gain by verifying exact commits so that using this submit action is
+generally discouraged. Using this action should only be considered for projects
+that have a low frequency of changes and that have special requirements to
+never break any target branch.
++
+NOTE: With this submit action Gerrit does not create merge commits on
+submitting a change, but merge commits that are created on the client, prior to
+uploading to Gerrit for review, may still be submitted.
+
+[[cherry_pick]]
+* 'cherry pick' (usage not recommended, use link:#rebase_always[rebase always]
+instead):
++
+With this submit action Gerrit always performs a cherry pick of the current
+patch set when a change is submitted. This ignores the parent lineage and
+instead creates a brand new commit on top of the current head of the target
+branch. The submitter becomes the committer of the new commit and the original
+commit author is retained.
++
+Ignoring change dependencies on submit is often confusing for users. For users
+that stack changes on top of each other, it's unexpected that these
+dependencies are ignored on submit. Ignoring dependencies also means that
+submitters need to submit the changes individually in the correct order.
+Otherwise it's likely that submissions fail due to conflicts or that the
+target branch gets broken (because it contains the submitted change, but not
+its predecessors which may be required for the submitted change to work
+correctly).
++
+If link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+is enabled changes that have the same topic are submitted together, the same as
+with all other submit actions. This means by setting the same topic on all
+dependent changes it's possible to submit a stack of changes together and
+overcome the limitation that change dependencies are ignored.
++
+When cherry picking the patchset, Gerrit automatically appends onto the end of
+the commit message a short summary of the change's approvals, and a URL link
+back to the change in the web UI (see link:#submit-footers[below]).
++
+Using this submit action is not recommended because it ignores change
+dependencies, instead link:#rebase_always[rebase always] should be used which
+behaves the same way except that it respects change dependencies (in particular
+`rebase always` adds the same kind of footers to the merged commit as
+`cherry pick`).
+
+--
++
+[[submit_type_inherit]]
+In addition the submit action can be set to `Inherit`, which means that the
+value that is configured in the parent project applies. For new projects
+`Inherit` is the default, unless the default is overridden by the global
+link:config-gerrit.html#repository.name.defaultSubmitType[defaultSubmitType]
+configuration. Configuring `Inherit` for the `All-Projects` root project is
+equivalent to configuring link:#merge_if_necessary[merge if necessary].
++
+If `submit.action` is not set, the default is 'merge if necessary'.
++
+NOTE: The different submit actions are also described in the
+link:https://docs.google.com/presentation/d/1C73UgQdzZDw0gzpaEqIC6SPujZJhqamyqO1XOHjH-uk/edit#slide=id.g4d6c16487b_1_800[Gerrit - Concepts and Workflows]
+presentation, where their behavior is visualized by git commit graphs.
++
+NOTE: If Gerrit performs a merge, rebase or cherry-pick as part of the
+change submission (true for all submit actions, except for
+link:#fast_forward_only[fast forward only]), it is controlled by the
+link:#submit.mergeContent[mergeContent] setting whether a content merge is
+performed when there is a path conflict.
++
+NOTE: If Gerrit performs a merge, rebase or cherry-pick as part of the
+change submission (true for all submit actions, except for
+link:#fast_forward_only[fast forward only]), it can be that trying to submit
+a change would fail due to Git conflicts (if the same lines were modified
+concurrently, or if link:#submit.mergeContent[mergeContent] is disabled also if
+the same files were modified concurrently). In this case the submit button in
+the Gerrit web UI is disabled and a tooltip on the disabled submit button
+informs about the change being non-mergeable.
++
+[[submit-footers]]
+--
+NOTE: If Gerrit performs a rebase or cherry-pick as part of the change
+submission (true for link:#rebase_if_necessary[rebase if necessary],
+link:#rebase_always[rebase always] and link:#cherry_pick[cherry pick]) Gerrit
+inserts additional footers into the commit message of the newly created
+commit: +
+ +
+* `Change-Id: <change-id>` (only if this footer is not already present, see
+  link:user-changeid.html[Change-Id]) +
+* `Reviewed-on: <change-url>` (links to the change in Gerrit where this commit
+  was reviewed) +
+* `Reviewed-by: <reviewer>` (once for every reviewer with a positive
+  `Code-Review` vote) +
+* `Tested-by: <reviewer>` (once for every reviewer with a positive `Verified`
+  vote) +
+* `<label-name>: <reviewer>` (once for every reviewer with a positive vote on
+  any other label) +
+ +
+In addition, plugins that implement a
+link:dev-plugins.html#change-message-modifier[Change Message Modifier] may add
+additional custom footers.
+--
++
+NOTE: For the value of `submit.action` in `project.config` use the exact
+spelling as given above, e.g. 'merge if necessary' (without the single quotes,
+but with the spaces).
++
+NOTE: Project owners can also set the submit action in the Gerrit UI:
+`Browse` > `Repositories` > my/repository > `Submit type`
 
 [[submit.matchAuthorToCommitterDate]]submit.matchAuthorToCommitterDate::
 +
@@ -346,98 +636,6 @@
 is set to 'true' the merge would fail in such a case. An empty commit is still allowed as the
 initial commit on a branch.
 
-[[submit-type]]
-==== Submit Type
-
-'submit.action': The method Gerrit uses to submit a change to a project.
-
-The submit type can also be modified by any project owner through the
-project console, `Browse` > `Repositories` > my/project > 'Submit type'.
-In general, a submitting a change only merges the change if all its
-dependencies are also submitted, with exceptions documented below.
-
-The following submit types are supported:
-
-[[submit_type_inherit]]
-* Inherit
-+
-This is the default for new projects, unless overridden by a global
-link:config-gerrit.html#repository.name.defaultSubmitType[`defaultSubmitType` option].
-+
-Inherit the submit type from the parent project. In `All-Projects`, this
-is equivalent to link:#merge_if_necessary[Merge If Necessary].
-
-[[fast_forward_only]]
-* Fast Forward Only
-+
-With this method Gerrit does not create merge commits on submitting a
-change. Merge commits may still be submitted, but they must be created
-on the client prior to uploading to Gerrit for review.
-+
-To submit a change, the change must be a strict superset of the
-destination branch.  That is, the change must already contain the
-tip of the destination branch at submit time.
-
-[[merge_if_necessary]]
-* Merge If Necessary
-+
-If the change being submitted is a strict superset of the destination
-branch, then the branch is fast-forwarded to the change.  If not,
-then a merge commit is automatically created.  This is identical
-to the classical `git merge` behavior, or `git merge --ff`.
-
-[[always_merge]]
-* Always Merge
-+
-Always produce a merge commit, even if the change is a strict
-superset of the destination branch.  This is identical to the
-behavior of `git merge --no-ff`, and may be useful if the
-project needs to follow submits with `git log --first-parent`.
-
-[[cherry_pick]]
-* Cherry Pick
-+
-Always cherry pick the patch set, ignoring the parent lineage
-and instead creating a brand new commit on top of the current
-branch head.
-+
-When cherry picking a change, Gerrit automatically appends onto the
-end of the commit message a short summary of the change's approvals,
-and a URL link back to the change on the web.  The committer header
-is also set to the submitter, while the author header retains the
-original patch set author.
-+
-Note that Gerrit ignores dependencies between changes when using this
-submit type unless
-link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
-is enabled and depending changes share the same topic. So generally
-submitters must remember to submit changes in the right order when using this
-submit type. If all you want is extra information in the commit message,
-consider using the Rebase Always submit strategy.
-
-[[rebase_if_necessary]]
-* Rebase If Necessary
-+
-If the change being submitted is a strict superset of the destination
-branch, then the branch is fast-forwarded to the change.  If not,
-then the change is automatically rebased and then the branch is
-fast-forwarded to the change.
-+
-When Gerrit tries to do a merge, by default the merge will only
-succeed if there is no path conflict.  A path conflict occurs when
-the same file has also been changed on the other side of the merge.
-
-[[rebase_always]]
-* Rebase Always
-+
-Basically, the same as Rebase If Necessary, but it creates a new patchset even
-if fast forward is possible AND like Cherry Pick it ensures footers such as
-Change-Id, Reviewed-On, and others are present in resulting commit that is
-merged.
-+
-Thus, Rebase Always can be considered similar to Cherry Pick, but with
-the important distinction that Rebase Always does not ignore dependencies.
-
 
 [[access-section]]
 === Access section
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index 2686f39..8298be3 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -38,6 +38,15 @@
 submit requirements for certain branch patterns. See the
 link:#exempt-branch-example[exempt branch] example.
 
+Often submit requirements should only apply to branches that contain source
+code. In this case this parameter can be used to exclude the
+link:config-project-config.html#refs-meta-config[refs/meta/config] branch from
+a submit requirement:
+
+----
+  applicableIf = -branch:refs/meta/config
+----
+
 This field is optional, and if not specified, the submit requirement is
 considered applicable for all changes in the project.
 
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt
index 7488f74..47c0be2 100644
--- a/Documentation/dev-community.txt
+++ b/Documentation/dev-community.txt
@@ -54,7 +54,6 @@
 * link:dev-build-plugins.html[Building Gerrit plugins]
 * link:pg-plugin-dev.html[JavaScript Plugin Development and API]
 * link:config-validation.html[Validation Interfaces]
-* link:dev-stars.html[Starring Changes]
 * link:quota.html[Quota Enforcement]
 
 [[maintainer]]
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index fcc8b7e..107473a 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -24,33 +24,31 @@
 link:dev-roles.html#maintainer[maintainers,role=external,window=_blank]
 review them adhoc.
 
-For large/complex features, it is required to follow the
-link:#design-driven-contribution-process[design-driven contribution
-process] and specify the feature in a link:dev-design-docs.html[design
-doc,role=external,window=_blank] before starting with the implementation.
+For large/complex features, it is required to specify the feature in a
+link:dev-design-docs.html[design document,role=external,window=_blank] before
+starting implementation, as per the
+link:#design-driven-contribution-process[design-driven contribution process].
 
 If link:dev-roles.html#contributor[contributors,role=external,window=_blank]
-choose the lightweight contribution process and during the review it turns out
-that the feature is too large or complex,
-link:dev-roles.html#maintainer[maintainers,role=external,window=_blank] can
-require to follow the design-driven contribution process instead.
+choose the lightweight contribution process but the feature is found to be 
+large or complex, link:dev-roles.html#maintainer[maintainers,role=external,window=_blank]
+can require that the design-driven contribution process be followed instead.
 
 If you are in doubt which process is right for you, consult the
 link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list.
 
 These contribution processes apply to everyone who contributes code to
-the Gerrit project, including link:dev-roles.html#maintainer[
-maintainers,role=external,window=_blank]. When reading this document, keep in
-mind that maintainers are also contributors when they contribute code.
+the Gerrit project. link:dev-roles.html#maintainer[
+Maintainers,role=external,window=_blank] are also considered contributors
+when they contribute code.
 
 If a new feature is large or complex, it is often difficult to find a
-maintainer who can take the time that is needed for a thorough review,
-and who can help with getting the changes submitted. To avoid that this
-results in unpredictable long waiting times during code review,
-contributors can ask for link:#mentorship[mentor support]. A mentor
-helps with timely code reviews and technical guidance. Doing the
-implementation is still the responsibility of the contributor.
+maintainer who can take the time that is needed for a thorough review. This
+can result in unpredictably long waiting times before the changes are
+submitted. To avoid that, contributors can ask for link:#mentorship[mentor support].
+A mentor helps with timely code reviews and technical guidance, though the 
+implementation itself is still the responsibility of the contributor.
 
 [[comparison]]
 === Quick Comparison
@@ -66,8 +64,8 @@
 |Review  |adhoc (when reviewer is available)|by a dedicated mentor (if
 a link:#mentorship[mentor] was assigned)
 |Caveats |features may get vetoed after the implementation was already
-done, maintainers may make the design-driven contribution process
-required if a change gets too complex/large|design doc must stay open
+done, maintainers may require the design-driven contribution process
+be followed if a change gets too complex/large|design doc must stay open
 for a minimum of 10 calendar days, a mentor may not be available
 immediately
 |Applicable to|documentation updates, bug fixes, small features|
@@ -83,40 +81,32 @@
 link:#design-driven-contribution-process[design-driven contribution
 process] is required.
 
-As Gerrit is a code review tool, naturally contributions will
-be reviewed before they will get submitted to the code base.  To
-start your contribution, please make a git commit and upload it
-for review to the link:https://gerrit-review.googlesource.com/[
-gerrit-review.googlesource.com,role=external,window=_blank] Gerrit server.  To
-help speed up the review of your change, review these link:dev-crafting-changes.html[
+To start contributing to Gerrit, upload your git commit for review to the
+link:https://gerrit-review.googlesource.com/[gerrit-review.googlesource.com,
+role=external,window=_blank] Gerrit server. Review these link:dev-crafting-changes.html[
 guidelines,role=external,window=_blank] before submitting your change.  You can
-view the pending Gerrit contributions and their statuses
-link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here,role=external,window=_blank].
+view pending contributions link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here,role=external,window=_blank].
 
 Depending on the size of that list it might take a while for
-your change to get reviewed.  Naturally there are fewer
-link:dev-roles.html#maintainer[maintainers,role=external,window=_blank], that
-can approve changes, than link:dev-roles.html#contributor[contributors,role=external,window=_blank];
-so anything that you can do to ensure that your contribution will undergo fewer
-revisions will speed up the contribution process.  This includes
-helping out reviewing other people's changes to relieve the load from
-the maintainers.  Even if you are not familiar with Gerrit's internals,
+your change to get reviewed. Anything that you can do to ensure that your
+contribution will undergo fewer revisions will speed up the contribution process.
+This includes helping out reviewing other people's changes to relieve the
+load from the maintainers. Even if you are not familiar with Gerrit's internals,
 it would be of great help if you can download, try out, and comment on
-new features.  If it works as advertised, say so, and if you have the
+new features. If it works as advertised, say so, and if you have the
 privileges to do so, go ahead and give it a `+1 Verified`.  If you
 would find the feature useful, say so and give it a `+1 Code Review`.
 
-And finally, the quicker you respond to the comments of your reviewers,
-the quicker your change might get merged!  Try to reply to every
-comment after submitting your new patch, particularly if you decided
-against making the suggested change. Reviewers don't want to seem like
-nags and pester you if you haven't replied or made a fix, so it helps
-them know if you missed it or decided against it.
+Finally, the quicker you respond to the comments of your reviewers, the
+quicker your change can be merged! Try to reply to every comment after
+submitting your new patch, particularly if you decided against making the
+suggested change. Reviewers don't want to seem like nags and pester you
+if you haven't replied or made a fix, so it helps them know if you missed
+it or decided against it.
 
-Features or API extensions, even if they are small, will incur
-long-time maintenance and support burden, so they should be left
-pending for at least 24 hours to give maintainers in all timezones a
-chance to evaluate.
+A new feature or API extension, even if small, can incur a long-time
+maintenance and support burden and should be left pending for 24 hours
+to give maintainers in all time zones a chance to evaluate the change.
 
 [[design-driven-contribution-process]]
 === Design-driven Contribution Process
@@ -126,19 +116,19 @@
 
 For large/complex features it is important to:
 
-* agree on the functionality and scope before spending too much time
-  on the implementation
+* agree on functionality and scope before spending too much time on
+  implementation
 * ensure that they are in line with Gerrit's project scope and vision
 * ensure that they are well aligned with other features
-* think about possibilities how the feature could be evolved over time
+* consider how the feature could be evolved over time
 
 This is why for large/complex features it is required to describe the
 feature in a link:dev-design-docs.html[design doc,role=external,window=_blank]
 and get it approved by the
-link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
+link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank]
 before starting the implementation.
 
-The design-driven contribution process has the following steps:
+The design-driven contribution process consists of the following steps:
 
 * A link:dev-roles.html#contributor[contributor,role=external,window=_blank]
   link:dev-design-docs.html#propose[proposes,role=external,window=_blank] a new
@@ -155,40 +145,31 @@
   be accepted.
 * To be submitted, the design doc needs to be approved by the
   link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank].
-* After the design was approved, the implementation is done by pushing
-  changes for review, see link:#lightweight-contribution-process[
+* After the design is approved, it is implemented by pushing
+  changes for review, see the link:#lightweight-contribution-process[
   lightweight contribution process]. Changes that are associated with
   a design should all share a common hashtag. The contributor is the
-  main driver of the implementation and responsible that it is done.
-  Others from the Gerrit community are usually much welcome to help
-  with the implementation.
+  main driver of the implementation and responsible for its completion.
+  Others from the Gerrit community are usually welcome to help.
 
-In order to be accepted/submitted, it is not necessary that the design
-doc fully specifies all the details, but the idea of the feature and
-how it fits into Gerrit should be sufficiently clear (judged by the
-steering committee). Contributors are expected to keep the design doc
-updated and fill in gaps while they go forward with the implementation.
-We expect that implementing the feature and updating the design doc
-will be an iterative process.
+The design doc does not need to fully specify each detail of the feature,
+but its concept and how it fits into Gerrit should be sufficiently clear,
+as judged by the steering committee. Contributors are expected to keep
+the design doc updated and fill in gaps while they go forward with the
+implementation. We expect that implementing the feature and updating the
+design doc will be an iterative process.
 
-While the design doc is still in review, contributors may already start
-with the implementation (e.g. do some prototyping to demonstrate parts
-of the proposed design), but those changes should not be submitted
-while the design wasn't approved yet. Another way to demonstrate the
-design can be to add screenshots or the like, early enough in the doc.
+While the design doc is still in review, contributors may start with the
+implementation (e.g. do some prototyping to demonstrate parts of the
+proposed design), but those changes should not be submitted while the
+design is not yet approved. Another way to demonstrate the design can be
+mocking screenshots in the doc.
 
 By approving a design, the steering committee commits to:
 
 * Accepting the feature when it is implemented.
 * Supporting the feature by assigning a link:dev-roles.html#mentor[
-  mentor,role=external,window=_blank] (if requested, see link:#mentorship[mentorship]).
-
-If the implementation of a feature gets stuck and it's unclear whether
-the feature gets fully done, it should be discussed with the steering
-committee how to proceed. If the contributor cannot commit to finish
-the implementation and no other contributor can take over, changes that
-have already been submitted for the feature might get reverted so that
-there is no unused or half-finished code in the code base.
+  mentor,role=external,window=_blank] if requested (see link:#mentorship[mentorship]).
 
 For contributors, the design-driven contribution process has the
 following advantages:
@@ -196,12 +177,11 @@
 * By writing a design doc, the feature gets more attention. During the
   design review, feedback from various sides can be collected, which
   likely leads to improvements of the feature.
-* Once a design was approved by the
+* Once a design is approved by the
   link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
   the contributor can be almost certain that the feature will be accepted.
-  Hence, there is only a low risk to invest into implementing a feature
-  and see it being rejected later during the code review, as it can
-  happen with the lightweight contribution process.
+  Hence, there little risk of the feature being rejected later in code review,
+  as can occur with the lightweight contribution process.
 * The contributor can link:#mentorship[get a dedicated mentor assigned]
   who provides timely reviews and serves as a contact person for
   technical questions and discussing details of the design.
@@ -249,12 +229,11 @@
 * done criteria that define when the feature is done and the mentorship
   ends
 
-If a feature is not finished in time, it should be discussed with the
-steering committee how to proceed. If the contributor cannot commit to
-finish the implementation in time and no other contributor can take
-over, changes that have already been submitted for the feature might
-get reverted so that there is no unused or half-finished code in the
-code base.
+If a feature implementation is not completed in time and no contributors
+can commit to finishing the implementation, changes that have already been
+submitted for the feature may be reverted to avoid unused or half-finished
+code in the code base. In these circumstances, the steering committee
+determines how to proceed.
 
 [[esc-dd-evaluation]]
 == How the ESC evaluates design documents
@@ -314,7 +293,7 @@
 === Core vs. Plugin decision
 Q: `Would this fit better in a plugin?`
 
-* Yes:The proposed feature or rework is an implementation (e.g. Lucene
+* Yes: The proposed feature or rework is an implementation (e.g. Lucene
   is an index implementation) of a generic concept that others
   might want to implement differently.
 * Yes: The proposed feature or rework is very specific to a custom setup.
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 8e5463d..1151f1c 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -204,6 +204,13 @@
 
 * `-Dcom.google.gerrit.scenarios.context_path=/context`
 
+==== Authentication
+
+The `authenticated` property allows test scenarios to use authenticated HTTP clones. Its default is
+no authentication:
+
+* `-Dcom.google.gerrit.scenarios.authenticated=false`
+
 ==== Automatic properties
 
 The link:#_input_file[example keywords,role=external,window=_blank] also include `_PROJECT`,
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index dce5eb0..79febe4 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -115,11 +115,11 @@
 
 == Testing
 
-=== The Gerrit web app UI is served by `server.go` process. To launch it,
+=== The Gerrit web app UI is served by `Web Dev Server`. To launch it,
 run this command:
 
 ----
-  $ bazel run polygerrit-ui:devserver
+  $ npm run start
 ----
 
 === Running the Daemon
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index f045ab8..6ff064c 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -178,6 +178,9 @@
 NOTE: To learn why using `java -jar` isn't sufficient, see
 <<special_bazel_java_version,this explanation>>.
 
+NOTE: When launching the daemong this way, the settings from the `[container]` section from the
+`$GERRIT_SITE/etc/gerrit.config` are not honored.
+
 To debug the Gerrit server of this test site:
 
 .  Open a debug port (such as port 5005). To do so, insert the following code
@@ -188,6 +191,49 @@
 -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
 ----
 
+=== Running the Daemon honoring the [container] section settings
+
+To run the Daemon and honor the `[container]` section settings use the `gerrit.sh` script:
+
+----
+  $ cd $GERRIT_SITE
+  $ bin/gerrit.sh run
+----
+
+To run the Daemon in debug mode use the `--debug` option:
+
+----
+  $ bin/gerrit.sh run --debug
+----
+
+The default debug port is `8000`. To specify a different debug port use the `--debug-port` option:
+
+----
+  $ bin/gerrit.sh run --debug --debug-port=5005
+----
+
+The `--debug-address` option also exists and is a synonym for the ``--debug-port` option:
+
+----
+  $ bin/gerrit.sh run --debug --debug-address=5005
+----
+
+Note that, by default, the debugger will only accept connections from the localhost. To enable
+debug connections from other host(s) provide also a host name or wildcard in the `--debug-address`
+value:
+
+----
+  $ bin/gerrit.sh run --debug --debug-address=*:5005
+----
+
+Debugging the Daemon startup requires starting the JVM in suspended debug mode. The JVM will await
+for a debugger to attach before proceeding with the start. Use the `--suspend` option for that
+scenario:
+
+----
+  $ bin/gerrit.sh run --debug --suspend
+----
+
 === Running the Daemon with Gerrit Inspector
 
 link:dev-inspector.html[Gerrit Inspector] is an interactive scriptable
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 0849c56..40470a6 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -327,6 +327,20 @@
   git submodule foreach '[ "$sm_path" == "modules/jgit" ] || git push gerrit-review tag "v$version"'
 ----
 
+[[publish-typescript-plugin-api]]
+==== Publish TypeScript Plugin API
+
+Only applies to major and minor releases! Not required for patch releases.
+
+* Publish a new version of the npm package `@gerritcodereview/typescript-api`.
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/README.md[api/README.md,role=external,window=_blank]
+for details.
+
+* The plugins in the stable branch of the minor release and the master branch
+be changed to use the new API version, see
+link:https://gerrit-review.googlesource.com/c/gerrit/+/340069[
+example change,role=external,window=_blank]
+
 [[upload-documentation]]
 ==== Upload the Documentation
 
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index cecaedc..d8f7e11 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -66,11 +66,12 @@
 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 `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
 ** abandon changes
+** revert changes
 * approve posts to the
   link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
   mailing list
@@ -106,14 +107,14 @@
 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 `gerrit-trusted-contributors`
 group, which allows to:
 
 * add patch sets to changes of other users
 * propose project config changes (push changes for the
   `refs/meta/config` branch
 
-Being member of the `gerrit-verifiers` group includes further
+Being member of the `gerrit-trusted-contributors` group includes further
 permissions (see link:#supporter[supporter] section above).
 
 It's highly appreciated if contributors engage in code reviews,
diff --git a/Documentation/dev-stars.txt b/Documentation/dev-stars.txt
deleted file mode 100644
index 764e326..0000000
--- a/Documentation/dev-stars.txt
+++ /dev/null
@@ -1,82 +0,0 @@
-= Gerrit Code Review - Stars
-
-== Description
-
-Changes can be starred with labels that behave like private hashtags.
-Any label can be applied to a change, but these labels are only visible
-to the user for which the labels have been set.
-
-Stars allow users to categorize changes by self-defined criteria and
-then build link:user-dashboards.html[dashboards] for them by making use
-of the link:#query-stars[star query operators].
-
-[[star-api]]
-== Star API
-
-The link:rest-api-accounts.html#star-endpoints[star REST API] supports:
-
-* link:rest-api-accounts.html#get-stars[
-  get star labels from a change]
-* link:rest-api-accounts.html#set-stars[
-  update star labels on a change]
-* link:rest-api-accounts.html#get-starred-changes[
-  list changes that are starred by any label]
-
-Star labels are also included in
-link:rest-api-changes.html#change-info[ChangeInfo] entities that are
-returned by the link:rest-api-changes.html[changes REST API].
-
-There are link:rest-api-accounts.html#default-star-endpoints[
-additional REST endpoints] for the link:#default-star[default star].
-
-[[default-star]]
-== Default Star
-
-If the default star is set by a user, this user is automatically
-notified by email whenever updates are made to that change.
-
-The default star is the star that is shown in the WebUI and which can
-be updated from there.
-
-The default star is represented by the special star label 'star'.
-
-[[ignore-star]]
-== Ignore Star
-
-If the ignore star is set by a user, this user gets no email
-notifications for updates of that change, even if this user is a
-reviewer of the change or the change is matched by a project watch of
-the user.
-
-Since changes can only be ignored once they are created, users that
-watch a project will always get the email notifications for the change
-creation. Only then the change can be ignored.
-
-Users that are added as reviewer or assignee to a change that they have
-ignored will be notified about this, so that they know about the review
-request. They can then decide to remove the ignore star.
-
-The ignore star is represented by the special star label 'ignore'.
-
-[[query-stars]]
-== Query Stars
-
-There are several query operators to find changes with stars:
-
-* link:user-search.html#is-starred[is:starred] /
-  link:user-search.html#has-star[has:star]:
-  Matches any change that was starred by the current user with the
-  link:#default-star[default star].
-
-[[syntax]]
-== Syntax
-
-Star labels cannot contain whitespace characters. All other characters
-are allowed.
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index ff37ffc..345eb1c 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -130,7 +130,7 @@
 In general, the *Code-Review* check requires an individual to look at the code,
 while the *Verified* check is done by an automated build server, through a
 mechanism such as the
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger
+link:https://plugins.jenkins.io/gerrit-trigger/[Gerrit Trigger
 Jenkins Plugin,role=external,window=_blank].
 
 IMPORTANT: The Code-Review and Verified checks require different permissions
@@ -253,7 +253,7 @@
 can add custom checks or even remove the Verified check entirely.
 
 Verification is typically an automated process using the
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger Jenkins Plugin,role=external,window=_blank]
+link:https://plugins.jenkins.io/gerrit-trigger/[Gerrit Trigger Jenkins Plugin,role=external,window=_blank]
 or a similar mechanism. However, there are still times when a change requires
 manual verification, or a reviewer needs to check how or if a change works.
 To accommodate these and other similar circumstances, Gerrit exposes each change
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 8a3b10e..f13bc22 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -316,9 +316,9 @@
 
 A useful feature on labels is the possibility to automatically copy
 scores forward to new patch sets if it was a
-link:config-labels.html#label_copyAllScoresOnTrivialRebase[trivial
-rebase] or if link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-there was no code change] (e.g. only the commit message was edited).
+link:config-labels.html#trivial_rebase[trivial rebase] or if
+link:config-labels.html#no_code_change[there was no code change] (e.g.
+only the commit message was edited).
 
 [[submit-rules]]
 == Submit Rules
@@ -374,7 +374,7 @@
 There are several solutions for integrating continuous integration
 systems. The most commonly used are:
 
-- link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[
+- link:https://plugins.jenkins.io/gerrit-trigger/[
   Gerrit Trigger,role=external,window=_blank] plugin for link:http://jenkins-ci.org/[Jenkins,role=external,window=_blank]
 
 - link:http://www.mediawiki.org/wiki/Continuous_integration/Zuul[
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 64afd5e..264ce73 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -368,6 +368,10 @@
 project-level]. The project dashboards can be seen in the web UI under
 `Projects` > `List` > <project-name> > `Dashboards`.
 
+All dashboards and search pages allow an action to be applied to multiple
+changes at once. Select the changes with the checkboxes on the left side and
+choose the action from the action bar at the top of the change section.
+
 [[submit]]
 == Submit a Change
 
@@ -667,19 +671,6 @@
   or build artifacts containing build numbers) can fetch the code
   using the commit ID.
 
-[[ignore]]
-== Ignoring Or Marking Changes As 'Reviewed'
-
-Changes can be ignored, which means they will not appear in the 'Incoming
-Reviews' dashboard and any related email notifications will be suppressed.
-This can be useful when you are added as a reviewer to a change on which
-you do not actively participate in the review, but do not want to completely
-remove yourself.
-
-Alternatively, rather than completely ignoring the change, it can be marked
-as 'Reviewed'. Marking a change as 'Reviewed' means it will not be highlighted
-in the dashboard, until a new patch set is uploaded.
-
 [[inline-edit]]
 == Inline Edit
 
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 5b7e073..e2afbf5 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -2,6 +2,7 @@
 [[Apache2_0]]
 Apache2.0
 
+* fonts:material-icons
 * fonts:robotofonts
 
 [[Apache2_0_license]]
@@ -403,6 +404,7 @@
 * @polymer/iron-resizable-behavior
 * @polymer/iron-selector
 * @polymer/iron-validatable-behavior
+* @polymer/marked-element
 * @polymer/neon-animation
 * @polymer/paper-behaviors
 * @polymer/paper-button
@@ -1304,6 +1306,60 @@
 ----
 
 
+[[marked]]
+marked
+
+* marked
+
+[[marked_license]]
+----
+# License information
+
+## Contribution License Agreement
+
+If you contribute code to this project, you are implicitly allowing your code
+to be distributed under the MIT license. You are also implicitly verifying that
+all code is your original work. `</legalese>`
+
+## Marked
+
+Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+## Markdown
+
+Copyright © 2004, John Gruber 
+http://daringfireball.net/ 
+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 “Markdown” 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 owner 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.
+
+----
+
+
 [[page]]
 page
 
@@ -1611,6 +1667,219 @@
 ----
 
 
+[[safevalues]]
+safevalues
+
+* safevalues
+
+[[safevalues_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
 [[tslib]]
 tslib
 
@@ -1633,3 +1902,216 @@
 
 ----
 
+
+[[web-vitals]]
+web-vitals
+
+* web-vitals
+
+[[web-vitals_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright 2020 Google LLC
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       https://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index e5966de..8ccbcab 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -42,6 +42,7 @@
 [[Apache2_0]]
 Apache2.0
 
+* auto:auto-factory
 * auto:auto-value
 * auto:auto-value-annotations
 * auto:auto-value-gson
@@ -56,6 +57,7 @@
 * dropwizard:dropwizard-core
 * errorprone:annotations
 * flogger:api
+* fonts:material-icons
 * fonts:robotofonts
 * guice:guice
 * guice:guice-assistedinject
@@ -3306,6 +3308,7 @@
 * @polymer/iron-resizable-behavior
 * @polymer/iron-selector
 * @polymer/iron-validatable-behavior
+* @polymer/marked-element
 * @polymer/neon-animation
 * @polymer/paper-behaviors
 * @polymer/paper-button
@@ -4207,6 +4210,60 @@
 ----
 
 
+[[marked]]
+marked
+
+* marked
+
+[[marked_license]]
+----
+# License information
+
+## Contribution License Agreement
+
+If you contribute code to this project, you are implicitly allowing your code
+to be distributed under the MIT license. You are also implicitly verifying that
+all code is your original work. `</legalese>`
+
+## Marked
+
+Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+## Markdown
+
+Copyright © 2004, John Gruber 
+http://daringfireball.net/ 
+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 “Markdown” 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 owner 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.
+
+----
+
+
 [[page]]
 page
 
@@ -4514,6 +4571,219 @@
 ----
 
 
+[[safevalues]]
+safevalues
+
+* safevalues
+
+[[safevalues_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
 [[tslib]]
 tslib
 
@@ -4537,6 +4807,219 @@
 ----
 
 
+[[web-vitals]]
+web-vitals
+
+* web-vitals
+
+[[web-vitals_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright 2020 Google LLC
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       https://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index c45de05..13873ed 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -29,10 +29,10 @@
 . Download the desired Gerrit archive.
 
 To view previous archives, see
-link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.1.3:
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.5.1:
 
 ....
-wget https://gerrit-releases.storage.googleapis.com/gerrit-3.1.3.war
+wget https://gerrit-releases.storage.googleapis.com/gerrit-3.5.1.war
 ....
 
 NOTE: To build and install Gerrit from the source files, see
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 9df4b04..70352dc 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -456,6 +456,15 @@
 === Group
 
 * `group/guess_relevant_groups_latency`: Latency for guessing relevant groups.
+* `group/handles_count`: Number of calls to GroupBackend.handles.
+* `group/get_count`: Number of calls to GroupBackend.get.
+* `group/suggest_count`: Number of calls to GroupBackend.suggest.
+* `group/contains_count`: Number of calls to GroupMemberships.contains.
+* `group/contains_any_of_count`: Number of calls to
+  GroupMemberships.containsAnyOf.
+* `group/intersection_count`: Number of calls to GroupMemberships.intersection.
+* `group/known_groups_count`: Number of calls to GroupMemberships.getKnownGroups.
+
 
 === Replication Plugin
 
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index b8a30ee..3f7d90d 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -101,16 +101,18 @@
 Just add code like this to your JavaScript plugin:
 
 ``` js
-    const styleEl = document.createElement('style');
-    styleEl.innerHTML = `
-        html {
-          --header-background-color: #c3d9ff;
-        }
-        html.darkTheme {
-          --header-background-color: #c3d9ff90;
-        }
-    `;
-    document.head.appendChild(styleEl);
+Gerrit.install(plugin => {
+  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]]
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index f2a72f1..41f544d 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -141,6 +141,11 @@
 === header-title
 This endpoint wraps the title-text in the application header.
 
+=== cherrypick-main
+This endpoint is located in the cherrypick dialog. It has two slots `top`
+and `bottom` and `changes` as a parameter with the list of changes (or
+just the one change) to be cherrypicked.
+
 === confirm-revert-change
 This endpoint is inside the confirm revert dialog. By default it displays a
 generic confirmation message regarding reverting the change. Plugins may add
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index 586f685..560fb92 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -10,6 +10,10 @@
   -d <SITE_PATH>
   [--enable-httpd | --disable-httpd]
   [--enable-sshd | --disable-sshd]
+  [--debug]
+  [--debug-port]
+  [--debug_address]
+  [--suspend]
   [--console-log]
   [--replica]
   [--headless]
@@ -39,6 +43,17 @@
 	Enable (or disable) the internal SSH daemon, answering SSH
 	clients and remotely executed commands.  Enabled by default.
 
+--debug::
+	Start JVM in debug mode.
+
+--debug-port::
+--debug_address:
+	Specify which JVM debug port/address to use. The default debug address is 8000.
+
+--suspend::
+	Start JVM debug in suspended mode. The JVM will await for a debugger
+	to attach before proceeding with the start.
+
 --replica::
 	Run in replica mode, permitting only read operations
     by clients.  Commands which modify state such as
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index ae0c0a6..7e06e4a 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1284,7 +1284,7 @@
   )]}'
   {
     "changes_per_page": 25,
-    "theme": "LIGHT",
+    "theme": "AUTO",
     "date_format": "STD",
     "time_format": "HHMM_12",
     "diff_view": "SIDE_BY_SIDE",
@@ -1292,6 +1292,7 @@
     "mute_common_path_prefixes": true,
     "publish_comments_on_push": true,
     "work_in_progress_by_default": true,
+    "allow_browser_notifications": true,
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
       {
@@ -1344,6 +1345,7 @@
     "size_bar_in_change_table": true,
     "disable_keyboard_shortcuts": true,
     "disable_token_highlighting": true,
+    "allow_browser_notifications": false,
     "diff_view": "SIDE_BY_SIDE",
     "mute_common_path_prefixes": true,
     "my": [
@@ -2658,7 +2660,7 @@
 Allowed values are `10`, `25`, `50`, `100`.
 |`theme`                        ||
 Which theme to use.
-Allowed values are `DARK` or `LIGHT`.
+Allowed values are `AUTO` or `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (Gerrit web app UI only).
@@ -2686,6 +2688,8 @@
 |`signed_off_by`                |not set if `false`|
 Whether to insert Signed-off-by footer in changes created with the
 inline edit feature.
+|`allow_browser_notifications`  |not set if `false`|
+Whether to prompt user to enable browser notification in browser.
 |`my`                           ||
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
@@ -2729,7 +2733,7 @@
 Allowed values are `10`, `25`, `50`, `100`.
 |`theme`                        |optional|
 Which theme to use.
-Allowed values are `DARK` or `LIGHT`.
+Allowed values are `AUTO` or `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (Gerrit web app UI only).
@@ -2755,6 +2759,8 @@
 |`signed_off_by`                |optional|
 Whether to insert Signed-off-by footer in changes created with the
 inline edit feature.
+|`allow_browser_notifications`  |not set if `false`|
+Whether to prompt user to enable browser notification in browser.
 |`my`                           |optional|
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 8f766ea..ab7fb3b 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2579,35 +2579,6 @@
   }
 ----
 
-[[ignore]]
-=== Ignore
---
-'PUT /changes/link:#change-id[\{change-id\}]/ignore'
---
-
-Marks a change as ignored. The change will not be shown in the incoming
-reviews dashboard, and email notifications will be suppressed. Ignoring
-a change does not cause the change's "updated" timestamp to be modified,
-and the owner is not notified.
-
-.Request
-----
-  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/ignore HTTP/1.0
-----
-
-[[unignore]]
-=== Unignore
---
-'PUT /changes/link:#change-id[\{change-id\}]/unignore'
---
-
-Un-marks a change as ignored.
-
-.Request
-----
-  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unignore HTTP/1.0
-----
-
 [[get-hashtags]]
 === Get Hashtags
 --
@@ -2825,8 +2796,18 @@
 --
 
 Tests a submit requirement and returns the result as a
-link:#submit-requirement-result-info[SubmitRequirementResultInfo]. The request
-body must contain a link:#submit-requirement-input[SubmitRequirementInput].
+link:#submit-requirement-result-info[SubmitRequirementResultInfo].
+
+The submit requirement can be specified in one of the following ways:
+
+  * In the request body as a link:#submit-requirement-input[SubmitRequirementInput].
+  * By passing the two request parameters `sr-name` and
+ `refs-config-change-id`. The submit requirement will then be loaded from
+ the project config pointed to by the latest patchset of this change ID.
+ The `sr-name` should point to an existing submit-requirement and the
+ `refs-config-change-id` must be a valid change identifier for a change in the
+ refs/meta/config branch, otherwise the request would fail with
+ `400 Bad Request`.
 
 Note that this endpoint does not modify the change resource.
 
@@ -4047,6 +4028,16 @@
 Retrieves related changes of a revision.  Related changes are changes that either
 depend on, or are dependencies of the revision.
 
+Additional fields can be obtained by adding `o` parameters. Since these may slow
+down processing they are disabled by default. Currently a single parameter is
+supported:
+
+[[get-related-changes-submittable]]
+--
+* `SUBMITTABLE`: Compute the `submittable` field in the returned
+  link:#related-change-and-commit-info[RelatedChangeAndCommitInfo] entities.
+--
+
 .Request
 ----
   GET /changes/gerrit~master~I5e4fc08ce34d33c090c9e0bf320de1b17309f774/revisions/b1cb4caa6be46d12b94c25aa68aebabcbb3f53fe/related HTTP/1.0
@@ -5306,8 +5297,8 @@
   }
 ----
 
-[[apply-fix]]
-=== Apply Fix
+[[apply-stored-fix]]
+=== Apply Stored Fix
 --
 'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fixes/<<fix-id,\{fix-id\}>>/apply'
 --
@@ -5374,6 +5365,101 @@
   The existing change edit could not be merged with another tree.
 ----
 
+[[apply-provided-fix]]
+==== Apply Provided Fix
+--
+'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fix:apply'
+--
+Applies a list of <<fix-replacement-info,FixReplacementInfo>> loaded from the
+<<apply-provided-fix-input,ApplyProvidedFixInput>> entity. The fixes are passed as part of the request body. The
+application of the fixes creates a new change edit. `Apply Provided Fix` can only be applied on the current
+patchset.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fix:apply HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "fix_replacement_infos": [
+      {
+        "path": "abcd.txt",
+        "range": {
+          "start_line": 2,
+          "start_character": 2,
+          "end_line": 2,
+          "end_character": 5
+        },
+        "replacement": "abcdefg"
+      }
+    ]
+  }
+----
+
+If the `ApplyProvidedFixInput` was successfully applied, an link:#edit-info[EditInfo] describing the
+resulting change edit is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+    Content-Disposition: attachment
+    Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "commit": {
+      "commit": "bd43e48c33d2b1a03485040eba38cefc505f7997",
+      "parents": [
+        {
+          "commit": "9825962f8ab6da89afebad3f5034db05fb4b7560"
+        }
+      ],
+      "author": {
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "date": "2022-05-07 15:21:27.000000000",
+          "tz": 120
+      },
+      "committer": {
+           "name": "Jane Doe",
+           "email": "jane.doe@example.com",
+           "date": "2022-05-07 15:35:43.000000000",
+           "tz": 120
+      },
+      "subject": "Implement feature X",
+      "message": "Implement feature X\n\nWith this feature ..."
+    },
+    "base_patch_set_number": 1,
+    "base_revision": "86d87686ce0ef7f7c536bfc7e9a66f5a6fa5d658",
+    "ref": "refs/users/01/1000001/edit-1/1"
+  }
+----
+
+If the application failed due to presence of an existing change edit,
+the response "`400 Bad Request`" including an error message in the response body
+is returned.
+
+.Response
+----
+  HTTP/1.1 400 Bad Request
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  Change edit already exists. A new change edit can't be created
+----
+
+If the application failed due to application on a previous patch set, the response
+"`409 Conflict`" including an error message in the response body is returned.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  A change edit may only be created for the current patch set 1,2 (and not for 1,1)
+----
+
 [[list-files]]
 === List Files
 --
@@ -5730,8 +5816,8 @@
 differences are reported in the result.  Valid values are `IGNORE_NONE`,
 `IGNORE_TRAILING`, `IGNORE_LEADING_AND_TRAILING` or `IGNORE_ALL`.
 
-[[preview-fix]]
-=== Preview fix
+[[preview-stored-fix]]
+=== Preview Stored Fix
 --
 'GET /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fixes/<<fix-id,\{fix-id\}>>/preview'
 --
@@ -5741,6 +5827,102 @@
 
 Each link:#diff-info[DiffInfo] is the differences between the patch set indicated by revision-id and a virtual patch set with the applied fix.
 
+[[preview-provided-fix]]
+=== Preview Provided fix
+--
+'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fix:preview'
+--
+
+Gets the diffs of all files for a list of <<fix-replacement-info,FixReplacementInfo>> loaded from
+the <<apply-provided-fix-input,ApplyProvidedFixInput>> entity. The fixes are passed as part of the
+request body.
+As response, a map of link:#diff-info[DiffInfo] is returned that describes the diffs.
+
+Each link:#diff-info[DiffInfo] is the differences between the patch set indicated by revision-id
+and a virtual patch set with the applied fix. No content on the server will be modified as part of this request.
+
+.Request
+----
+  POST /changes/myProject~master~Id6f0b9d946791f8aba90ace53074eda565983452/revisions/1/fix:preview HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "fix_replacement_infos": [
+      {
+        "path": "abcd.txt",
+        "range": {
+          "start_line": 2,
+          "start_character": 2,
+          "end_line": 2,
+          "end_character": 5
+        },
+        "replacement": "abcdefg"
+      }
+    ]
+  }
+----
+
+If the `Preview Provided Fix` operation was successful, a link:#diff-info[DiffInfo] previewing the
+change is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+    Content-Disposition: attachment
+    Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "abcd.txt": {
+      "meta_a": {
+        "name": "abcd.txt",
+        "content_type": "text/plain",
+        "lines": 3
+      },
+      "meta_b": {
+        "name": "abcd.txt",
+        "content_type": "text/plain",
+        "lines": 3
+      },
+      "intraline_status": "OK",
+      "change_type": "MODIFIED",
+      "content": [
+        {
+          "ab": [
+            "ABCDEFGHI"
+          ]
+        },
+        {
+          "a": [
+            "JKLMNOPQR"
+          ],
+          "b": [
+            "JKabcdefgOPQR"
+          ],
+          "edit_a": [
+            [
+              2,
+              3
+            ]
+          ],
+          "edit_b": [
+            [
+              2,
+              7
+            ]
+          ]
+        },
+        {
+          "ab": [
+            ""
+          ]
+        }
+      ]
+    }
+  }
+----
+
+
 [[get-blame]]
 === Get Blame
 --
@@ -6641,7 +6823,8 @@
 by one of the following REST endpoints: link:#create-change[Create
 Change], link:#create-merge-patch-set-for-change[Create Merge Patch Set
 For Change], link:#cherry-pick[Cherry Pick Revision],
-link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit]
+link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit],
+link:#rebase-change[Rebase Change]
 |==================================
 
 [[change-input]]
@@ -7071,6 +7254,9 @@
 |`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.
+|`reason`         |optional|
+The reason why this vote is deleted. Will +
+go into the change message.
 |=============================
 
 [[description-input]]
@@ -7198,6 +7384,16 @@
 Whether the web link should be shown on the unified diff screen.
 |=======================
 
+[[apply-provided-fix-input]]
+=== ApplyProvidedFixInput
+The `ApplyProvidedFixInput` entity contains the fixes to be applied on a review.
+
+[options="header",cols="1,6"]
+|=======================
+|Field Name              |Description
+|`fix_replacement_infos` |A list of <<fix-replacement-info,FixReplacementInfo>>.
+|=======================
+
 [[edit-file-info]]
 === EditFileInfo
 The `EditFileInfo` entity contains additional information
@@ -7267,12 +7463,12 @@
 Number of inserted lines. +
 Not set for binary files or if no lines were inserted. +
 An empty last line is not included in the count and hence this number can
-differ by one from details provided in <<#diff-info,DiffInfo>>.
+differ by one from details provided in <<diff-info,DiffInfo>>.
 |`lines_deleted` |optional|
 Number of deleted lines. +
 Not set for binary files or if no lines were deleted. +
 An empty last line is not included in the count and hence this number can
-differ by one from details provided in <<#diff-info,DiffInfo>>.
+differ by one from details provided in <<diff-info,DiffInfo>>.
 |`size_delta`    ||
 Number of bytes by which the file size increased/decreased.
 |`size`          ||
@@ -7695,6 +7891,9 @@
 |`_current_revision_number`|optional|The current revision number.
 |`status`                  |optional|The status of the change. The status of
 the change is one of (`NEW`, `MERGED`, `ABANDONED`).
+|`submittable`             |optional|Boolean indicating whether the change is
+submittable. +
+Only populated if link:#get-related-changes-submittable[requested].
 |===========================
 
 [[related-changes-info]]
@@ -7745,30 +7944,38 @@
 The `RevertInput` entity contains information for reverting a change.
 
 [options="header",cols="1,^1,5"]
-|=============================
-|Field Name      ||Description
-|`message`       |optional|
+|=================================
+|Field Name          ||Description
+|`message`           |optional|
 Message to be added as review comment to the change when reverting the
 change.
-|`notify`        |optional|
+|`notify`            |optional|
 Notify handling that defines to whom email notifications should be sent
 for reverting the change. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|`notify_details`|optional|
+|`notify_details`    |optional|
 Additional information about whom to notify about the revert as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
-|`topic`         |optional|
+|`topic`             |optional|
 Name of the topic for the revert change. If not set, the default for Revert
 endpoint is the topic of the change being reverted, and the default for the
 RevertSubmission endpoint is `revert-{submission_id}-{timestamp.now}`.
 Topic can't contain quotation marks.
-|`work_in_progress` |optional|
+|`work_in_progress`  |optional|
 When present, change is marked as Work In Progress. The `notify` input is
 used if it's present, otherwise it will be overridden to `OWNER`. +
 If not set, the default is false.
-|=============================
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
+|=================================
 
 [[revert-submission-info]]
 === RevertSubmissionInfo
@@ -7914,6 +8121,8 @@
 |`ready`                  |optional|
 If true, the change was moved from WIP to ready for review as a result of this
 action. Not set if false.
+|`error`                  |optional|
+Error message for non-200 responses.
 |============================
 
 [[reviewer-info]]
@@ -8242,6 +8451,13 @@
 `branch:refs/heads/foo and label:verified=+1`.
 |`fulfilled`||
 True if the submit requirement is fulfilled for the change.
+|`status`||
+A string containing the status of evaluating the expression which can be one
+of the following: +
+  * `PASS` - expression was evaluated and result is true. +
+  * `FAIL` - expression was evaluated and result is false. +
+  * `ERROR` - an error occurred while evaluating the expression. +
+  * `NOT_EVALUATED` - expression was not evaluated.
 |`passing_atoms`|optional|
 A list of passing atoms as strings. For the above expression,
 `passing_atoms` can contain ["branch:refs/heads/foo"] if the branch predicate is
@@ -8306,16 +8522,17 @@
 submit requirement did not define an applicability expression.
 Note that fields `expression`, `passing_atoms` and `failing_atoms` are always
 omitted for the `applicability_expression_result`.
-|`submittability_expression_result`|optional|
+|`submittability_expression_result`||
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
 containing the result of evaluating the submittability expression. +
-If the submit requirement does not apply, the expression is not evaluated and
-the field is not set.
+If the submit requirement does not apply, the `status` field of the result
+will be set to `NOT_EVALUATED`.
 |`override_expression_result`|optional|
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
 containing the result of evaluating the override expression. +
-Not set if the submit requirement did not define an override expression or
-if it does not apply.
+Not set if the submit requirement did not define an override expression. If the
+submit requirement does not apply, the `status` field of the result will be set
+to `NOT_EVALUATED`.
 |===========================
 
 [[submitted-together-info]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 86d7f58..505def0 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1380,7 +1380,14 @@
   POST /config/server/index.changes HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 
-  {changes: ["foo~101", "bar~202"]}
+  {
+    "changes": [
+      "foo~101",
+      "bar~202",
+      "303"
+    ],
+    "delete_missing": "true"
+  }
 ----
 
 .Response
@@ -1389,6 +1396,9 @@
   Content-Disposition: attachment
 ----
 
+When `delete_missing` is set to `true` changes to be reindexed which are missing in NoteDb
+will be deleted in the index.
+
 
 [[ids]]
 == IDs
@@ -1876,6 +1886,10 @@
 |Field Name         ||Description
 |`changes`   ||
 List of link:rest-api-changes.html#change-id[change-ids]
+|`delete_missing`  |optional|
+Delete changes which are missing in NoteDb from the index. This can be used
+to get rid of stale index entries. Possible values are `true` and `false`.
+By default set to `false`.
 |================================
 
 [[jvm-summary-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 413923f..efc746a 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -402,12 +402,12 @@
   Content-Type: application/json; charset=UTF-8
 
   )]}'
-  {
-    "test": {
+  [
+    {
       "id": "test",
       "description": "\u003chtml\u003e is escaped"
     }
-  }
+  ]
 ----
 
 [[project-query-limit]]
@@ -3079,9 +3079,7 @@
       },
       "default_value": 0,
       "can_override": true,
-      "copy_min_score": true,
-      "copy_all_scores_if_no_change": true,
-      "copy_all_scores_on_trivial_rebase": true,
+      "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE or is:MIN",
       "allow_post_submit": true
     }
   ]
@@ -3126,9 +3124,7 @@
       },
       "default_value": 0,
       "can_override": true,
-      "copy_min_score": true,
-      "copy_all_scores_if_no_change": true,
-      "copy_all_scores_on_trivial_rebase": true,
+      "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE or is:MIN",
       "allow_post_submit": true
     },
     {
@@ -3144,7 +3140,7 @@
       },
       "default_value": 0,
       "can_override": true,
-      "copy_any_score": true,
+      "copy_condition": "is:ANY",
       "allow_post_submit": true
     }
   ]
@@ -3189,9 +3185,7 @@
     },
     "default_value": 0,
     "can_override": true,
-    "copy_min_score": true,
-    "copy_all_scores_if_no_change": true,
-    "copy_all_scores_on_trivial_rebase": true,
+    "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
     "allow_post_submit": true
   }
 ----
@@ -3250,7 +3244,7 @@
     },
     "default_value": 0,
     "can_override": true,
-    "copy_all_scores_if_no_change": true,
+    "copy_condition": "changekind:NO_CHANGE",
     "allow_post_submit": true
   }
 ----
@@ -3302,9 +3296,7 @@
     },
     "default_value": 0,
     "can_override": true,
-    "copy_min_score": true,
-    "copy_all_scores_if_no_change": true,
-    "copy_all_scores_on_trivial_rebase": true,
+    "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
     "allow_post_submit": true,
     "ignore_self_approval": true
   }
@@ -3388,7 +3380,7 @@
         "function": "MaxWithBlock"
       },
       "Baz-Review": {
-        "copy_min_score": true
+        "copy_condition": "is:MIN"
       }
     }
   }
@@ -3401,6 +3393,198 @@
   HTTP/1.1 200 OK
 ----
 
+[[submit-requirement-endpoints]]
+== Submit Requirement Endpoints
+
+[[create-submit-requirement]]
+=== Create Submit Requirement
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Creates a new submit requirement definition in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+If a submit requirement with this name is already defined in this project, this
+submit requirement definition is updated
+(see link:#update-submit-requirement[Update Submit Requirement]).
+
+The submit requirement data must be provided in the request body as
+link:#submit-requirement-input[SubmitRequirementInput].
+
+.Request
+----
+  PUT /projects/My-Project/submit_requirements/Code-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "Code-Review",
+    "description": "At least one maximum vote for the Code-Review label is required",
+    "submittability_expression": "label:Code-Review=+2"
+  }
+----
+
+As response a link:#submit-requirement-info[SubmitRequirementInfo] entity is
+returned that describes the created submit requirement.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[update-submit-requirement]]
+=== Update Submit Requirement
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Updates the definition of a submit requirement that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The new submit requirement will overwrite the existing submit requirement.
+That is, unspecified attributes will be set to their defaults.
+
+.Request
+----
+  PUT /projects/My-Project/submit_requirements/Code-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "Code-Review",
+    "description": "At least one maximum vote for the Code-Review label is required",
+    "submittability_expression": "label:Code-Review=+2"
+  }
+----
+
+As response a link:#submit-requirement-info[SubmitRequirementInfo] entity is
+returned that describes the created submit requirement.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[get-submit-requirement]]
+=== Get Submit Requirement
+--
+'GET /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Retrieves the definition of a submit requirement that is defined in this project.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project.
+
+.Request
+----
+  GET /projects/All-Projects/submit-requirement/Code-Review HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[list-submit-requirements]]
+=== List Submit Requirements
+--
+'GET /projects/link:#project-name[\{project-name\}]/submit_requirements'
+--
+
+Retrieves a list of all submit requirements for this project. The `inherited`
+parameter can be supplied to also list submit requirements from parent projects.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project. If the `inherited` option is used, the caller must have read access to
+the `refs/meta/config` branch of all parent projects as well.
+
+As response a list of link:#submit-requirement-info[SubmitRequirementInfo]
+entities is returned.
+
+.Request
+----
+  GET /projects/All-Projects/submit-requirements HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+    }
+  ]
+----
+
+[[delete-submit-requirement]]
+=== Delete Submit Requirement
+--
+'DELETE /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Deletes the definition of a submit requirement that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project. The request would fail with `404 Not Found` if there is no submit
+requirement with this name for this project.
+
+No request body is needed.
+
+.Request
+----
+  DELETE /projects/My-Project/submit_requirements/Foo-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+----
+
+If a submit requirement was deleted the response is "`204 No Content`".
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
 
 [[ids]]
 == IDs
@@ -3429,6 +3613,11 @@
 === \{label-name\}
 The name of a review label.
 
+[[submit-requirement-name]]
+=== \{submit-requirement-name\}
+The name of a submit requirement.
+
+
 [[project-name]]
 === \{project-name\}
 The name of the project.
@@ -3609,6 +3798,9 @@
 |`link`     |        |The URL to direct the user to whenever the
 regular expression is matched, as documented in
 link:config-gerrit.html#commentlink.name.link[commentlink.name.link].
+|`prefix`   |optional|Text inserted before the link.
+|`suffix`   |optional|Text inserted after the link.
+|`text`     |optional|Text of the link.
 |`enabled`  |optional|Whether the commentlink is enabled, as documented
 in link:config-gerrit.html#commentlink.name.enabled[
 commentlink.name.enabled]. If not set the commentlink is enabled.
@@ -3628,6 +3820,9 @@
 |`link`     |        |The URL to direct the user to whenever the
 regular expression is matched, as documented in
 link:config-gerrit.html#commentlink.name.link[commentlink.name.link].
+|`prefix`   |optional|Text inserted before the link.
+|`suffix`   |optional|Text inserted after the link.
+|`text`     |optional|Text of the link.
 |`enabled`  |optional|Whether the commentlink is enabled, as documented
 in link:config-gerrit.html#commentlink.name.enabled[
 commentlink.name.enabled]. If not set the commentlink is enabled.
@@ -4015,36 +4210,8 @@
 |`can_override`  |`false` if not set|
 Whether this label can be link:config-labels.html#label_canOverride[overridden]
 by child projects.
-|`copy_any_score`|`false` if not set|
-*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|
-*DEPRECATED* Whether link:config-labels.html#label_copyMinScore[copyMinScore]
-is set on the label.
-|`copy_max_score`|`false` if not set|
-*DEPRECATED* Whether link:config-labels.html#label_copyMaxScore[copyMaxScore]
-is set on the label.
-|`copy_all_scores_if_no_change`|`false` if not set|
-*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|
-*DEPRECATED* Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-copyAllScoresIfNoCodeChange] is set on the label.
-|`copy_all_scores_on_trivial_rebase`|`false` if not set|
-*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|
-*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|
-*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.
 |`allow_post_submit`|`false` if not set|
 Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
 on the label.
@@ -4091,36 +4258,10 @@
 |`can_override`  |optional|
 Whether this label can be link:config-labels.html#label_canOverride[overridden]
 by child projects.
-|`copy_any_score`|optional|
-Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
-label.
 |`copy_condition`|optional|
 See link:config-labels.html#label_copyCondition[copyCondition].
 |`unset_copy_condition`|optional|
 If true, clears the value stored in `copy_condition`.
-|`copy_min_score`|optional|
-Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
-label.
-|`copy_max_score`|optional|
-Whether link:config-labels.html#label_copyMaxScore[copyMaxScore] is set on the
-label.
-|`copy_all_scores_if_no_change`|optional|
-Whether link:config-labels.html#label_copyAllScoresIfNoChange[
-copyAllScoresIfNoChange] is set on the label.
-|`copy_all_scores_if_no_code_change`|optional|
-Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-copyAllScoresIfNoCodeChange] is set on the label.
-|`copy_all_scores_on_trivial_rebase`|optional|
-Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
-copyAllScoresOnTrivialRebase] is set on the label.
-|`copy_all_scores_if_list_of_files_did_not_change`|optional|
-Whether link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
-copyAllScoresIfListOfFilesDidNotChange] is set on the label.
-|`copy_all_scores_on_merge_first_parent_update`|optional|
-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.
 |`allow_post_submit`|optional|
 Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
 on the label.
@@ -4374,6 +4515,57 @@
 |`size_of_packed_objects`  |Size of packed objects in bytes.
 |======================================
 
+[[submit-requirement-info]]
+=== SubmitRequirementInfo
+The `SubmitRequirementInfo` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`||
+Whether this submit requirement can be overridden in child projects.
+|===========================
+
+[[submit-requirement-input]]
+=== SubmitRequirementInput
+The `SubmitRequirementInput` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`|optional|
+Whether this submit requirement can be overridden in child projects. Default is
+`false`.
+|===========================
+
 [[submit-type-info]]
 === SubmitTypeInfo
 Information about the link:config-project-config.html#submit-type[default submit
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 512f784..0a7a77c 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -47,6 +47,9 @@
   an unresolved comment.
 * If another user removed a user's vote, the user with the deleted vote will be
   added to the attention set.
+* If a vote becomes outdated by uploading a new patch set (vote is not sticky),
+  the user whose vote has been removed is added to the attention set, as they
+  need to re-review the change and vote newly.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
 * The rules for service accounts are different, see link:#bots[Bots].
 * Users are not added by automatic rules when the change is work in progress.
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 20ad07c..24c35f0 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -18,8 +18,8 @@
 [[user]]
 == User Level Settings
 
-Individual users can configure email subscriptions by editing
-watched projects through Settings > Watched Projects with the web UI.
+Individual users can configure email subscriptions by editing their
+notifications in the Web UI under `Settings` > `Notifications`.
 
 Specific projects may be watched, or the special project
 `All-Projects` can be watched to watch all projects that
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 55fb0c7..f260b87 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -8,16 +8,18 @@
 query, execute it, and present the results.
 
 [options="header"]
-|=================================================
-|Description          | Default Query
-|All > Open           | status:open '(or is:open)'
-|All > Merged         | status:merged
-|All > Abandoned      | status:abandoned
-|My > Watched Changes | is:watched is:open
-|My > Starred Changes | is:starred
-|My > Draft Comments  | has:draft
-|Open changes in Foo  | status:open project:Foo
-|=================================================
+|=======================================================
+|Description                | Default Query
+|Changes > Open             | status:open '(or is:open)'
+|Changes > Merged           | status:merged
+|Changes > Abandoned        | status:abandoned
+|Your > Watched Changes     | is:watched is:open
+|Your > Starred Changes     | is:starred
+|Your > Draft Comments      | has:draft
+|Your > Edits               | has:edit
+|Your > All Visible Changes | is:visible
+|Open changes in Foo        | status:open project:Foo
+|=======================================================
 
 
 == Basic Change Search
@@ -365,7 +367,7 @@
 files, use `file:^.*\.java`.
 +
 The entire regular expression pattern, including the `^` character,
-should be double quoted. For example, to match all XML
+can be double quoted. For example, to match all XML
 files named like 'name1.xml', 'name2.xml', and 'name3.xml' use
 `file:"^name[1-3].xml"`.
 +
@@ -373,8 +375,8 @@
 +
 *More examples:*
 
-* `-file:^path/.*` - changes that do not modify files from `path/`.
-* `file:{^~(path/.*)}` - changes that modify files not from `path/` (but may
+* `-path:^path/.*` - changes that do not modify files from `path/`.
+* `path:{^~(path/.*)}` - changes that modify files not from `path/` (but may
 contain files from `path/`).
 
 [[file]]
@@ -435,19 +437,6 @@
 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'::
-+
-Matches any change that was starred by the current user with the label
-'LABEL'.
-+
-E.g. if changes that are not interesting are marked with an `ignore`
-star, they could be filtered out by '-star:ignore'.
-+
-'star:star' is the same as 'has:star' and 'is:starred'.
-
-Only "ignore" and "star" are supported labels.
-
 [[has]]
 has:draft::
 +
@@ -456,8 +445,8 @@
 [[has-star]]
 has:star::
 +
-Same as 'is:starred' and 'star:star', true if the change has been
-starred by the current user with the default label.
+Same as 'is:starred', true if the change has been starred by the current user
+with the default label.
 
 has:edit::
 +
@@ -560,11 +549,6 @@
 link:config-gerrit.html#change.mergeabilityComputationBehavior[change.mergeabilityComputationBehavior]
 for details.
 
-[[ignored]]
-is:ignored::
-+
-True if the change is ignored. Same as `star:ignore`.
-
 [[private]]
 is:private::
 +
@@ -714,17 +698,20 @@
 `-is:starred` is the exact opposite of `is:starred` and will
 therefore return changes that are *not* starred by the current user.
 
-The operator `NOT` (in all caps) is a synonym.
+The operator `NOT` (in all caps) or `not` (all lower case) is a
+synonym.
 
 === AND
-The boolean operator `AND` (in all caps) can be used to join two
-other operators together.  This results in a restriction of the
-results, returning only changes that match both operators.
+The boolean operator `AND` (in all caps) or `and` (all lower case)
+can be used to join two other operators together.  This results in
+a restriction of the results, returning only changes that match both
+operators.
 
 === OR
-The boolean operator `OR` (in all caps) can be used to find changes
-that match either operator.  This increases the number of results
-that are returned, as more changes are considered.
+The boolean operator `OR` (in all caps) or `or` (all lower case)
+can be used to find changes that match either operator. This
+increases the number of results that are returned, as more changes
+are considered.
 
 
 [[labels]]
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 2bfc62d..8c51207 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -340,7 +340,7 @@
 To avoid confusion in parsing the git ref, at least the following characters
 must be percent-encoded: " %^@.~-+_:/!". Note that some of the reserved
 characters (like tilde) are not escaped in the standard URL encoding rules,
-so a language-provided function (e.g. encodeURIComponent(), in javascript)
+so a language-provided function (e.g. encodeURIComponent(), in JavaScript)
 might not suffice. To be safest, you might consider percent-encoding all
 non-alphanumeric characters (and all multibyte UTF-8 code points).
 
diff --git a/README.md b/README.md
index 8a4379b..4df9271 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@
 
 On Debian/Ubuntu run:
 
-        apt-get update & apt-get install gerrit=<version>-<release>
+        apt-get update && apt-get install gerrit=<version>-<release>
 
 _NOTE: release is a counter that starts with 1 and indicates the number of packages that have
 been released with the same version of the software._
diff --git a/WORKSPACE b/WORKSPACE
index fe6b94e..e5c5d4a 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,29 +1,27 @@
-# npm packages are split into different node_modules directories based on their usage.
-# 1. /node_modules (referenced as @npm) - contains packages to run tests, check code, etc...
-#    It is expected that @npm is used ONLY to run tools. No packages from @npm are used by
-#    other code in gerrit.
-# 2. @tools_npm (tools/node_tools/node_modules) - the tools/node_tools folder contains self-written tools
-#    which are run for building and/or testing. The @tools_npm directory contains all the packages needed to
-#    run this tools.
-# 3. @ui_npm (polygerrit-ui/app/node_modules) - packages with source code which are necessary to run polygerrit
-#    and to bundle it. Only code from these packages can be included in the final bundle for polygerrit.
-#    @ui_npm folder must not have devDependencies. All dev dependencies must be placed in @ui_dev_npm
-# 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit. The packages from these
-#    folder can be used for testing, but must not be included in the final bundle.
-# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit plugins.
-#    The packages here are expected to be used in plugins.
-# Note: separation between @ui_npm and @ui_dev_npm is necessary because with bazel we can't generate
-#    two managed directories from the same package.json. At the same time we want to avoid accidental
-#    usages of code from devDependencies in polygerrit bundle.
+# npm packages are split into different node_modules directories based on their
+# usage.
+# 1. @npm (node_modules) - contains packages to run tests, check code, etc...
+#    It is expected that @npm is used ONLY to run tools. No packages from @npm
+#    are used by other code in gerrit.
+# 2. @tools_npm (tools/node_tools/node_modules) - the tools/node_tools folder
+#    contains self-written tools which are run for building and/or testing. The
+#    @tools_npm directory contains all the packages needed to run this tools.
+# 3. @ui_npm (polygerrit-ui/app/node_modules) - packages with source code which
+#    are necessary to run polygerrit and to bundle it. Only code from these
+#    packages can be included in the final bundle for polygerrit. @ui_npm folder
+#    must not have devDependencies. All devDependencies must be placed in
+#    @ui_dev_npm.
+# 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit.
+#    The packages from these folder can be used for testing, but must not be
+#    included in the final bundle.
+# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit
+#    plugins. The packages here are expected to be used in plugins.
+# Note: separation between @ui_npm and @ui_dev_npm is necessary because with
+#    rules_nodejs we can't generate two external repositories from the same
+#    package.json. At the same time we want to avoid accidental usages of code
+#    from devDependencies in polygerrit bundle.
 workspace(
     name = "gerrit",
-    managed_directories = {
-        "@npm": ["node_modules"],
-        "@ui_npm": ["polygerrit-ui/app/node_modules"],
-        "@ui_dev_npm": ["polygerrit-ui/node_modules"],
-        "@tools_npm": ["tools/node_tools/node_modules"],
-        "@plugins_npm": ["plugins/node_modules"],
-    },
 )
 
 load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
@@ -67,8 +65,8 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "c077680a307eb88f3e62b0b662c2e9c6315319385bc8c637a861ffdbed8ca247",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.1.0/rules_nodejs-5.1.0.tar.gz"],
+    sha256 = "0fad45a9bda7dc1990c47b002fd64f55041ea751fafc00cd34efb96107675778",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.5.0/rules_nodejs-5.5.0.tar.gz"],
 )
 
 load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
@@ -101,55 +99,6 @@
     firefox = True,
 )
 
-http_archive(
-    name = "rules_pkg",
-    sha256 = "038f1caa773a7e35b3663865ffb003169c6a71dc995e39bf4815792f385d837d",
-    urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
-        "https://github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
-    ],
-)
-
-load("@rules_pkg//:deps.bzl", "rules_pkg_dependencies")
-
-rules_pkg_dependencies()
-
-# Golang support for PolyGerrit local dev server.
-http_archive(
-    name = "io_bazel_rules_go",
-    sha256 = "d6b2513456fe2229811da7eb67a444be7785f5323c6708b38d851d2b51e54d83",
-    urls = [
-        "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",
-    ],
-)
-
-load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies")
-
-go_rules_dependencies()
-
-go_register_toolchains(version = "1.17.6")
-
-http_archive(
-    name = "bazel_gazelle",
-    sha256 = "de69a09dc70417580aabf20a28619bb3ef60d038470c7cf8442fafcf627c21cb",
-    urls = [
-        "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",
-    ],
-)
-
-load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies", "go_repository")
-
-gazelle_dependencies()
-
-# Dependencies for PolyGerrit local dev server.
-go_repository(
-    name = "com_github_howeyc_fsnotify",
-    commit = "441bbc86b167f3c1f4786afae9931403b99fdacf",
-    importpath = "github.com/howeyc/fsnotify",
-)
-
 register_toolchains("//tools:error_prone_warnings_toolchain_java11_definition")
 
 register_toolchains("//tools:error_prone_warnings_toolchain_java17_definition")
@@ -187,8 +136,8 @@
 load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
 
 node_repositories(
-    node_version = "16.13.2",
-    yarn_version = "1.22.17",
+    node_version = "16.15.0",
+    yarn_version = "1.22.18",
 )
 
 yarn_install(
diff --git a/antlr3/com/google/gerrit/index/query/Query.g b/antlr3/com/google/gerrit/index/query/Query.g
index 0994d9b..ea521f9 100644
--- a/antlr3/com/google/gerrit/index/query/Query.g
+++ b/antlr3/com/google/gerrit/index/query/Query.g
@@ -142,9 +142,9 @@
   | EXACT_PHRASE
   ;
 
-AND: 'AND' ;
-OR:  'OR'  ;
-NOT: 'NOT' ;
+AND: 'AND' | 'and';
+OR:  'OR' | 'or'  ;
+NOT: 'NOT' | 'not' ;
 
 COLON: ':' ;
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
index e26fc00..580ae81 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -108,7 +108,7 @@
     val property = packageName + "." + term
     var value = default
     default match {
-      case _: String | _: Double =>
+      case _: String | _: Double | _: Boolean =>
         val propertyValue = Option(System.getProperty(property))
         if (propertyValue.nonEmpty) {
           value = propertyValue.get
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
index 5d5f5d5..5d8dd6f 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
@@ -32,6 +32,9 @@
 
   override def replaceOverride(in: String): String = {
     var next = replaceKeyWith("_project", URLEncoder.encode(getFullProjectName(projectName), "UTF-8"), in)
+    val authenticated = getProperty("authenticated", false).toBoolean
+    val value = "CONTEXT_PATH" + (if (authenticated) "/a" else "")
+    next = replaceKeyWith("context_path", value, next)
     super.replaceOverride(next)
   }
 
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 903b709..1177734 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -45,7 +45,6 @@
 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;
@@ -154,14 +153,7 @@
 import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
 import java.lang.reflect.Modifier;
-import java.nio.file.DirectoryStream;
-import java.nio.file.FileSystem;
-import java.nio.file.FileSystems;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -175,6 +167,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.UUID;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
@@ -183,7 +176,6 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -193,11 +185,7 @@
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.transport.FetchResult;
-import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.Transport;
-import org.eclipse.jgit.transport.TransportBundleStream;
-import org.eclipse.jgit.transport.URIish;
 import org.eclipse.jgit.util.FS;
 import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
@@ -966,7 +954,7 @@
             repo,
             "new subject",
             "new file",
-            "new content");
+            "new content " + UUID.randomUUID());
     return result;
   }
 
@@ -1203,58 +1191,6 @@
     assertThat(replyTo.getString()).doesNotContain(email);
   }
 
-  /**
-   * Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
-   * resulting tree id.
-   *
-   * <p>Omits NoteDb meta refs.
-   */
-  protected Map<BranchNameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
-    assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
-
-    FileSystem fs = Jimfs.newFileSystem();
-    Path previewPath = fs.getPath("preview.zip");
-    try (OutputStream out = Files.newOutputStream(previewPath)) {
-      bundles.writeTo(out);
-    }
-    Map<BranchNameKey, ObjectId> ret = new HashMap<>();
-    try (FileSystem zipFs = FileSystems.newFileSystem(previewPath, (ClassLoader) null);
-        DirectoryStream<Path> dirStream =
-            Files.newDirectoryStream(Iterables.getOnlyElement(zipFs.getRootDirectories()))) {
-      for (Path p : dirStream) {
-        if (!Files.isRegularFile(p)) {
-          continue;
-        }
-        String bundleName = p.getFileName().toString();
-        int len = bundleName.length();
-        assertThat(bundleName).endsWith(".git");
-        String repoName = bundleName.substring(0, len - 4);
-        Project.NameKey proj = Project.nameKey(repoName);
-        TestRepository<?> localRepo = cloneProject(proj);
-
-        try (InputStream bundleStream = Files.newInputStream(p);
-            TransportBundleStream tbs =
-                new TransportBundleStream(
-                    localRepo.getRepository(), new URIish(bundleName), bundleStream)) {
-          FetchResult fr =
-              tbs.fetch(
-                  NullProgressMonitor.INSTANCE,
-                  Arrays.asList(new RefSpec("refs/*:refs/preview/*")));
-          for (Ref r : fr.getAdvertisedRefs()) {
-            String refName = r.getName();
-            if (RefNames.isNoteDbMetaRef(refName)) {
-              continue;
-            }
-            RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
-            ret.put(BranchNameKey.create(proj, refName), c.getTree().copy());
-          }
-        }
-      }
-    }
-    assertThat(ret).isNotEmpty();
-    return ret;
-  }
-
   /** Assert that the given branches have the given tree ids. */
   protected void assertTrees(Project.NameKey proj, Map<BranchNameKey, ObjectId> trees)
       throws Exception {
diff --git a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
index 1ff7d0e..5991646 100644
--- a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
+++ b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
@@ -46,6 +46,10 @@
   }
 
   public void assertReindexOf(ChangeInfo info, long expectedCount) {
+    if (expectedCount == 0) {
+      assertThat(countsByChange.asMap()).isEmpty();
+      return;
+    }
     assertThat(countsByChange.asMap()).containsExactly(info._number, expectedCount);
     clear();
   }
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 0acf3bc..3d90bf0 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
@@ -103,6 +104,7 @@
   private final DynamicSet<OnPostReview> onPostReviews;
   private final DynamicSet<ReviewerAddedListener> reviewerAddedListeners;
   private final DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners;
+  private final DynamicSet<AttentionSetListener> attentionSetListeners;
 
   private final DynamicMap<ChangeHasOperandFactory> hasOperands;
   private final DynamicMap<ChangeIsOperandFactory> isOperands;
@@ -147,7 +149,8 @@
       DynamicSet<ReviewerAddedListener> reviewerAddedListeners,
       DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners,
       DynamicMap<ChangeHasOperandFactory> hasOperands,
-      DynamicMap<ChangeIsOperandFactory> isOperands) {
+      DynamicMap<ChangeIsOperandFactory> isOperands,
+      DynamicSet<AttentionSetListener> attentionSetListeners) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -187,6 +190,7 @@
     this.reviewerDeletedListeners = reviewerDeletedListeners;
     this.hasOperands = hasOperands;
     this.isOperands = isOperands;
+    this.attentionSetListeners = attentionSetListeners;
   }
 
   public Registration newRegistration() {
@@ -330,6 +334,10 @@
       return add(workInProgressStateChangedListeners, workInProgressStateChangedListener);
     }
 
+    public Registration add(AttentionSetListener attentionSetListener) {
+      return add(attentionSetListeners, attentionSetListener);
+    }
+
     public Registration add(CapabilityDefinition capabilityDefinition, String exportName) {
       return add(capabilityDefinitions, capabilityDefinition, exportName);
     }
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index d46fb78..99db40a 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -263,7 +263,11 @@
     if (changeId != null) {
       commitBuilder = testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
     } else {
-      commitBuilder = testRepo.branch("HEAD").commit().insertChangeId(nextChangeId());
+      if (subject.contains("\nChange-Id: ")) {
+        commitBuilder = testRepo.amendRef("HEAD");
+      } else {
+        commitBuilder = testRepo.branch("HEAD").commit().insertChangeId(nextChangeId());
+      }
     }
     commitBuilder.message(subject).author(i).committer(new PersonIdent(i, testRepo.getDate()));
   }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index 580f10f..c1029be 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.Arrays;
 import java.util.Objects;
@@ -140,13 +139,14 @@
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
       Instant now = TimeUtil.now();
       IdentifiedUser changeOwner = getChangeOwner(changeCreation);
-      PersonIdent authorAndCommitter =
-          changeOwner.newCommitterIdent(now, serverIdent.getTimeZone());
+      PersonIdent authorAndCommitter = changeOwner.newCommitterIdent(now, serverIdent.getZoneId());
       ObjectId commitId =
           createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
 
       String refName = RefNames.fullName(changeCreation.branch());
       ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
+      changeCreation.topic().ifPresent(t -> inserter.setTopic(t));
+      inserter.setApprovals(changeCreation.approvals());
 
       try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
         batchUpdate.setRepository(repository, revWalk, objectInserter);
@@ -494,13 +494,10 @@
       return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent);
     }
 
-    // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-    // Instants
-    @SuppressWarnings("JdkObsolete")
     private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Instant now) {
       PersonIdent oldPatchsetCommitter =
           Optional.ofNullable(oldPatchsetCommit.getCommitterIdent()).orElse(serverIdent);
-      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhen().toInstant())) {
+      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhenAsInstant())) {
         /* We need to ensure that the resulting commit SHA-1 is different from the old patchset.
          * In real situations, this automatically happens as two patchsets won't have exactly the
          * same commit timestamp even when the tree and commit message are the same. In tests,
@@ -510,7 +507,7 @@
          * here and simply add a second. */
         now = now.plusSeconds(1);
       }
-      return new PersonIdent(oldPatchsetCommitter, Timestamp.from(now));
+      return new PersonIdent(oldPatchsetCommitter, now);
     }
 
     private long asSeconds(Instant date) {
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
index 5871e17..a064d02 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -34,6 +35,10 @@
 
   public abstract Optional<Account.Id> owner();
 
+  public abstract Optional<String> topic();
+
+  public abstract ImmutableMap<String, Short> approvals();
+
   public abstract String commitMessage();
 
   public abstract ImmutableList<TreeModification> treeModifications();
@@ -50,7 +55,8 @@
         .branch(Constants.R_HEADS + Constants.MASTER)
         .commitMessage("A test change")
         // Which value we choose here doesn't matter. All relevant code paths set the desired value.
-        .mergeStrategy(MergeStrategy.OURS);
+        .mergeStrategy(MergeStrategy.OURS)
+        .approvals(ImmutableMap.of());
   }
 
   @AutoValue.Builder
@@ -66,6 +72,15 @@
     /** The change owner. Must be an existing user account. */
     public abstract Builder owner(Account.Id owner);
 
+    /** The topic to add this change to. */
+    public abstract Builder topic(String topic);
+
+    /**
+     * The approvals to apply to this change. Map of label name to value. All approvals will be
+     * granted by the uploader.
+     */
+    public abstract Builder approvals(ImmutableMap<String, Short> approvals);
+
     /**
      * The commit message. The message may contain a {@code Change-Id} footer but does not need to.
      * If the footer is absent, it will be generated.
diff --git a/java/com/google/gerrit/asciidoctor/DocIndexer.java b/java/com/google/gerrit/asciidoctor/DocIndexer.java
index 6cbe2a9..acd6aad 100644
--- a/java/com/google/gerrit/asciidoctor/DocIndexer.java
+++ b/java/com/google/gerrit/asciidoctor/DocIndexer.java
@@ -42,8 +42,8 @@
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.index.IndexWriterConfig;
 import org.apache.lucene.index.IndexWriterConfig.OpenMode;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.IndexInput;
-import org.apache.lucene.store.RAMDirectory;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
@@ -92,9 +92,9 @@
     }
   }
 
-  private RAMDirectory index()
+  private ByteBuffersDirectory index()
       throws IOException, UnsupportedEncodingException, FileNotFoundException {
-    RAMDirectory directory = new RAMDirectory();
+    ByteBuffersDirectory directory = new ByteBuffersDirectory();
     IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer(CharArraySet.EMPTY_SET));
     config.setOpenMode(OpenMode.CREATE);
     config.setCommitOnClose(true);
@@ -140,7 +140,7 @@
     return directory;
   }
 
-  private byte[] zip(RAMDirectory dir) throws IOException {
+  private byte[] zip(ByteBuffersDirectory dir) throws IOException {
     ByteArrayOutputStream buf = new ByteArrayOutputStream();
     try (ZipOutputStream zip = new ZipOutputStream(buf)) {
       for (String name : dir.listAll()) {
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 3e103c8..de71b3c 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common;
 
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
 import static java.lang.annotation.ElementType.FIELD;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.ElementType.TYPE;
@@ -25,25 +26,25 @@
 import java.lang.annotation.Target;
 
 /**
- * A marker to say a method/type/field is added or is increased to public solely because it is
- * called from inside a project or an organisation using Gerrit.
+ * A marker to say a method/type/field/constructor is added or is increased to public solely because
+ * it is called from inside a project or an organisation using Gerrit.
  */
-@Target({METHOD, TYPE, FIELD})
+@Target({METHOD, TYPE, FIELD, CONSTRUCTOR})
 @Retention(RUNTIME)
 @Repeatable(UsedAt.Uses.class)
 public @interface UsedAt {
   /** Enumeration of projects that call a method/type/field. */
   enum Project {
-    GOOGLE,
     COLLABNET,
+    GOOGLE,
+    PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
     PLUGIN_CHECKS,
     PLUGIN_CODE_OWNERS,
     PLUGIN_DELETE_PROJECT,
-    PLUGIN_SERVICEUSER,
     PLUGIN_HIGH_AVAILABILITY,
     PLUGIN_MULTI_SITE,
+    PLUGIN_SERVICEUSER,
     PLUGIN_WEBSESSION_FLATFILE,
-    PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
   }
 
   /** Reference to the project that uses the method annotated with this annotation. */
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index 303e79f..85dbdeb 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -46,6 +46,10 @@
  */
 @AutoValue
 public abstract class Account {
+
+  /** Placeholder for indicating an account-id that does not correspond to any local account */
+  public static final Id UNKNOWN_ACCOUNT_ID = id(0);
+
   public static Id id(int id) {
     return new AutoValue_Account_Id(id);
   }
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index 3541aac..e31c764 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -22,7 +22,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 import java.util.Optional;
 
@@ -30,15 +29,6 @@
 public abstract class LabelType {
   public static final boolean DEF_ALLOW_POST_SUBMIT = true;
   public static final boolean DEF_CAN_OVERRIDE = true;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
-  public static final boolean DEF_COPY_ANY_SCORE = false;
-  public static final boolean DEF_COPY_MAX_SCORE = false;
-  public static final boolean DEF_COPY_MIN_SCORE = false;
-  public static final ImmutableList<Short> DEF_COPY_VALUES = ImmutableList.of();
   public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
 
   public static LabelType withDefaultValues(String name) {
@@ -99,24 +89,6 @@
 
   public abstract LabelFunction getFunction();
 
-  public abstract boolean isCopyAnyScore();
-
-  public abstract boolean isCopyMinScore();
-
-  public abstract boolean isCopyMaxScore();
-
-  public abstract boolean isCopyAllScoresIfListOfFilesDidNotChange();
-
-  public abstract boolean isCopyAllScoresOnMergeFirstParentUpdate();
-
-  public abstract boolean isCopyAllScoresOnTrivialRebase();
-
-  public abstract boolean isCopyAllScoresIfNoCodeChange();
-
-  public abstract boolean isCopyAllScoresIfNoChange();
-
-  public abstract ImmutableList<Short> getCopyValues();
-
   public abstract boolean isAllowPostSubmit();
 
   public abstract boolean isIgnoreSelfApproval();
@@ -152,16 +124,6 @@
         .setMaxNegative(Short.MIN_VALUE)
         .setMaxPositive(Short.MAX_VALUE)
         .setCanOverride(DEF_CAN_OVERRIDE)
-        .setCopyAllScoresIfListOfFilesDidNotChange(
-            DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
-        .setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
-        .setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
-        .setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
-        .setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
-        .setCopyAnyScore(DEF_COPY_ANY_SCORE)
-        .setCopyMaxScore(DEF_COPY_MAX_SCORE)
-        .setCopyMinScore(DEF_COPY_MIN_SCORE)
-        .setCopyValues(DEF_COPY_VALUES)
         .setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT)
         .setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
   }
@@ -185,11 +147,19 @@
   }
 
   public boolean isMaxNegative(PatchSetApproval ca) {
-    return getMaxNegative() == ca.value();
+    return isMaxNegative(ca.value());
+  }
+
+  public boolean isMaxNegative(short value) {
+    return getMaxNegative() == value;
   }
 
   public boolean isMaxPositive(PatchSetApproval ca) {
-    return getMaxPositive() == ca.value();
+    return isMaxPositive(ca.value());
+  }
+
+  public boolean isMaxPositive(short value) {
+    return getMaxPositive() == value;
   }
 
   public LabelValue getValue(short value) {
@@ -245,28 +215,8 @@
 
     public abstract Builder setDefaultValue(short defaultValue);
 
-    public abstract Builder setCopyAnyScore(boolean copyAnyScore);
-
     public abstract Builder setCopyCondition(@Nullable String copyCondition);
 
-    public abstract Builder setCopyMinScore(boolean copyMinScore);
-
-    public abstract Builder setCopyMaxScore(boolean copyMaxScore);
-
-    public abstract Builder setCopyAllScoresIfListOfFilesDidNotChange(
-        boolean copyAllScoresIfListOfFilesDidNotChange);
-
-    public abstract Builder setCopyAllScoresOnMergeFirstParentUpdate(
-        boolean copyAllScoresOnMergeFirstParentUpdate);
-
-    public abstract Builder setCopyAllScoresOnTrivialRebase(boolean copyAllScoresOnTrivialRebase);
-
-    public abstract Builder setCopyAllScoresIfNoCodeChange(boolean copyAllScoresIfNoCodeChange);
-
-    public abstract Builder setCopyAllScoresIfNoChange(boolean copyAllScoresIfNoChange);
-
-    public abstract Builder setCopyValues(Collection<Short> copyValues);
-
     public abstract Builder setMaxNegative(short maxNegative);
 
     public abstract Builder setMaxPositive(short maxPositive);
@@ -275,8 +225,6 @@
 
     protected abstract String getName();
 
-    protected abstract ImmutableList<Short> getCopyValues();
-
     protected abstract Builder setByValue(ImmutableMap<Short, LabelValue> byValue);
 
     @Nullable
@@ -308,8 +256,6 @@
       }
       setByValue(byValue.build());
 
-      setCopyValues(ImmutableList.sortedCopyOf(getCopyValues()));
-
       return autoBuild();
     }
   }
diff --git a/java/com/google/gerrit/entities/PatchSetApprovals.java b/java/com/google/gerrit/entities/PatchSetApprovals.java
new file mode 100644
index 0000000..8fed9c0
--- /dev/null
+++ b/java/com/google/gerrit/entities/PatchSetApprovals.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Multimaps;
+
+/** All approvals of a change by patch set. */
+@AutoValue
+public abstract class PatchSetApprovals {
+  /**
+   * Returns all approvals by patch set, including copied approvals
+   *
+   * <p>Approvals that have been copied from a previous patch set are returned as part of the
+   * result. These approvals can be identified by looking at {@link PatchSetApproval#copied()}.
+   */
+  public abstract ImmutableListMultimap<PatchSet.Id, PatchSetApproval> all();
+
+  /**
+   * Returns non-copied approvals by patch set.
+   *
+   * <p>Approvals that have been copied from a previous patch set are filtered out.
+   */
+  @Memoized
+  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> onlyNonCopied() {
+    return ImmutableListMultimap.copyOf(
+        Multimaps.filterEntries(all(), entry -> !entry.getValue().copied()));
+  }
+
+  /**
+   * Returns copied approvals by patch set.
+   *
+   * <p>Approvals that have not been copied from a previous patch set are filtered out.
+   */
+  @Memoized
+  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> onlyCopied() {
+    return ImmutableListMultimap.copyOf(
+        Multimaps.filterEntries(all(), entry -> entry.getValue().copied()));
+  }
+
+  public static PatchSetApprovals create(
+      ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvalsByPatchSet) {
+    return new AutoValue_PatchSetApprovals(approvalsByPatchSet);
+  }
+}
diff --git a/java/com/google/gerrit/entities/ProjectChangeKey.java b/java/com/google/gerrit/entities/ProjectChangeKey.java
new file mode 100644
index 0000000..15dc5e9
--- /dev/null
+++ b/java/com/google/gerrit/entities/ProjectChangeKey.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+
+/** Stores together numeric {@link Change.Id} and a project name for the change */
+@AutoValue
+public abstract class ProjectChangeKey {
+  public static ProjectChangeKey create(Project.NameKey projectName, Change.Id changeId) {
+    return new AutoValue_ProjectChangeKey(projectName, changeId);
+  }
+
+  public abstract Project.NameKey projectName();
+
+  public abstract Change.Id changeId();
+}
diff --git a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
index dc5abbc..75bc034 100644
--- a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
+++ b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
@@ -30,10 +30,38 @@
   @Nullable
   public abstract String getMatch();
 
-  /** The link to replace the match with. This can only be set if html is {@code null}. */
+  /**
+   * The link to replace the match with. This can only be set if html is {@code null}.
+   *
+   * <p>The constructed link is using {@link #getLink()} {@link #getPrefix()} {@link #getSuffix()}
+   * and {@link #getText()}, and has the shape of
+   *
+   * <p>{@code PREFIX<a href="LINK">TEXT</a>SUFFIX}
+   */
   @Nullable
   public abstract String getLink();
 
+  /**
+   * The text before the link tag that the match is replaced with. This can only be set if link is
+   * not {@code null}.
+   */
+  @Nullable
+  public abstract String getPrefix();
+
+  /**
+   * The text after the link tag that the match is replaced with. This can only be set if link is
+   * not {@code null}.
+   */
+  @Nullable
+  public abstract String getSuffix();
+
+  /**
+   * The content of the link tag that the match is replaced with. This can only be set if link is
+   * not {@code null}.
+   */
+  @Nullable
+  public abstract String getText();
+
   /** The html to replace the match with. This can only be set if link is {@code null}. */
   @Nullable
   public abstract String getHtml();
@@ -72,6 +100,9 @@
     return builder(src.name)
         .setMatch(src.match)
         .setLink(src.link)
+        .setPrefix(src.prefix)
+        .setSuffix(src.suffix)
+        .setText(src.text)
         .setHtml(src.html)
         .setEnabled(enabled)
         .setOverrideOnly(false)
@@ -84,6 +115,9 @@
     info.name = getName();
     info.match = getMatch();
     info.link = getLink();
+    info.prefix = getPrefix();
+    info.suffix = getSuffix();
+    info.text = getText();
     info.html = getHtml();
     info.enabled = getEnabled();
     return info;
@@ -97,6 +131,12 @@
 
     public abstract Builder setLink(@Nullable String value);
 
+    public abstract Builder setPrefix(@Nullable String value);
+
+    public abstract Builder setSuffix(@Nullable String value);
+
+    public abstract Builder setText(@Nullable String value);
+
     public abstract Builder setHtml(@Nullable String value);
 
     public abstract Builder setEnabled(@Nullable Boolean value);
@@ -106,6 +146,9 @@
     public StoredCommentLinkInfo build() {
       checkArgument(getName() != null, "invalid commentlink.name");
       setLink(Strings.emptyToNull(getLink()));
+      setPrefix(Strings.emptyToNull(getPrefix()));
+      setSuffix(Strings.emptyToNull(getSuffix()));
+      setText(Strings.emptyToNull(getText()));
       setHtml(Strings.emptyToNull(getHtml()));
       if (!getOverrideOnly()) {
         checkArgument(
@@ -126,6 +169,12 @@
 
     protected abstract String getLink();
 
+    protected abstract String getPrefix();
+
+    protected abstract String getSuffix();
+
+    protected abstract String getText();
+
     protected abstract String getHtml();
 
     protected abstract boolean getOverrideOnly();
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index aff0994..c24227d 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -95,10 +95,32 @@
         ImmutableList.of());
   }
 
+  public static SubmitRequirementExpressionResult notEvaluated(SubmitRequirementExpression expr) {
+    return SubmitRequirementExpressionResult.create(
+        expr, Status.NOT_EVALUATED, ImmutableList.of(), ImmutableList.of());
+  }
+
   public static TypeAdapter<SubmitRequirementExpressionResult> typeAdapter(Gson gson) {
     return new AutoValue_SubmitRequirementExpressionResult.GsonTypeAdapter(gson);
   }
 
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder expression(SubmitRequirementExpression expression);
+
+    public abstract Builder status(Status status);
+
+    public abstract Builder errorMessage(Optional<String> errorMessage);
+
+    public abstract Builder passingAtoms(ImmutableList<String> passingAtoms);
+
+    public abstract Builder failingAtoms(ImmutableList<String> failingAtoms);
+
+    public abstract SubmitRequirementExpressionResult build();
+  }
+
   public enum Status {
     /** Submit requirement expression is fulfilled for a given change. */
     PASS,
@@ -107,7 +129,10 @@
     FAIL,
 
     /** Submit requirement expression contains invalid syntax and is not parsable. */
-    ERROR
+    ERROR,
+
+    /** Submit requirement expression was not evaluated. */
+    NOT_EVALUATED
   }
 
   /**
diff --git a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
index a62fc63..b3680ea 100644
--- a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
@@ -34,7 +34,7 @@
     requireNonNull(pluginName, "pluginName");
     requireNonNull(permission, "permission");
     checkArgument(
-        isValidPluginPermissionName(permission), "invalid plugin permission name: ", permission);
+        isValidPluginPermissionName(permission), "invalid plugin permission name: %s", permission);
 
     this.pluginName = pluginName;
     this.permission = permission;
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 2224649..018a6cf 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -128,20 +128,6 @@
   }
 
   /**
-   * Ignore or un-ignore this change.
-   *
-   * @param ignore ignore the change if true
-   */
-  void ignore(boolean ignore) throws RestApiException;
-
-  /**
-   * Check if this change is ignored.
-   *
-   * @return true if the change is ignored
-   */
-  boolean ignored() throws RestApiException;
-
-  /**
    * Create a new change that reverts this change.
    *
    * @see Changes#id(int)
@@ -427,6 +413,39 @@
 
   ChangeInfo check(FixInput fix) throws RestApiException;
 
+  abstract class CheckSubmitRequirementRequest {
+    /** Submit requirement name. */
+    private String name;
+
+    /**
+     * A change ID for a change in {@link com.google.gerrit.entities.RefNames#REFS_CONFIG} branch
+     * from which the submit-requirement will be loaded.
+     */
+    private String refsConfigChangeId;
+
+    public abstract SubmitRequirementResultInfo get() throws RestApiException;
+
+    public CheckSubmitRequirementRequest srName(String srName) {
+      this.name = srName;
+      return this;
+    }
+
+    public CheckSubmitRequirementRequest refsConfigChangeId(String changeId) {
+      this.refsConfigChangeId = changeId;
+      return this;
+    }
+
+    protected String srName() {
+      return name;
+    }
+
+    protected String getRefsConfigChangeId() {
+      return refsConfigChangeId;
+    }
+  }
+
+  CheckSubmitRequirementRequest checkSubmitRequirementRequest() throws RestApiException;
+
   /** Returns the result of evaluating the {@link SubmitRequirementInput} input on the change. */
   SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
       throws RestApiException;
@@ -767,6 +786,11 @@
     }
 
     @Override
+    public CheckSubmitRequirementRequest checkSubmitRequirementRequest() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
         throws RestApiException {
       throw new NotImplementedException();
@@ -800,16 +824,6 @@
     }
 
     @Override
-    public void ignore(boolean ignore) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public boolean ignored() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public PureRevertInfo pureRevert() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
index 8432c8f..f75443e 100644
--- a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import java.util.Map;
 
@@ -31,4 +32,7 @@
    * are added to the attention set upon deletion of their vote by other users.
    */
   public boolean ignoreAutomaticAttentionSetRules;
+
+  /** Reason for this vote deletion. Will appear in the change message. */
+  @Nullable public String reason;
 }
diff --git a/tools/polygerrit-updater/src/utils/unexpectedValue.ts b/java/com/google/gerrit/extensions/api/changes/GetRelatedOption.java
similarity index 66%
rename from tools/polygerrit-updater/src/utils/unexpectedValue.ts
rename to java/com/google/gerrit/extensions/api/changes/GetRelatedOption.java
index 690c283..ab81c60 100644
--- a/tools/polygerrit-updater/src/utils/unexpectedValue.ts
+++ b/java/com/google/gerrit/extensions/api/changes/GetRelatedOption.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 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.
@@ -12,6 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-export function unexpectedValue<T>(x: T): never {
-  throw new Error(`Unexpected value '${x}'`);
+package com.google.gerrit.extensions.api.changes;
+
+/** Options for "Get Related Changes" requests. */
+public enum GetRelatedOption {
+  /** Compute submittability boolean for all returned changes. */
+  SUBMITTABLE
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
index 5bf22aa..740caa8 100644
--- a/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
@@ -25,6 +25,7 @@
   public Integer _revisionNumber;
   public Integer _currentRevisionNumber;
   public String status;
+  public Boolean submittable;
 
   public RelatedChangeAndCommitInfo() {}
 
@@ -38,6 +39,7 @@
         .add("_revisionNumber", _revisionNumber)
         .add("_currentRevisionNumber", _currentRevisionNumber)
         .add("status", status)
+        .add("submittable", submittable)
         .toString();
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevertInput.java b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
index 148d24a..613e48e 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevertInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
@@ -36,4 +36,6 @@
    * {@link NotifyHandling#OWNER}
    */
   public boolean workInProgress;
+
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index b659cca..73fc170 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.client.ArchiveFormat;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -33,6 +34,7 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -117,8 +119,20 @@
    */
   EditInfo applyFix(String fixId) throws RestApiException;
 
+  /**
+   * Applies fix similar to {@code applyFix} method. Instead of using a fix stored in the server,
+   * this applies the fix provided in {@code ApplyProvidedFixInput}
+   *
+   * @param applyProvidedFixInput The fix(es) to apply to a new change edit.
+   * @throws RestApiException if the fix couldn't be applied.
+   */
+  EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException;
+
   Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException;
 
+  Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
+      throws RestApiException;
+
   DraftApi createDraft(DraftInput in) throws RestApiException;
 
   DraftApi draft(String id) throws RestApiException;
@@ -146,6 +160,8 @@
 
   RelatedChangesInfo related() throws RestApiException;
 
+  RelatedChangesInfo related(EnumSet<GetRelatedOption> listOptions) throws RestApiException;
+
   /** Returns votes on the revision. */
   ListMultimap<String, ApprovalInfo> votes() throws RestApiException;
 
@@ -313,11 +329,22 @@
     }
 
     @Override
+    public EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -383,6 +410,11 @@
     }
 
     @Override
+    public RelatedChangesInfo related(EnumSet<GetRelatedOption> options) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ListMultimap<String, ApprovalInfo> votes() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
index 23849e4..b45fcee 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
@@ -17,9 +17,13 @@
 import com.google.common.base.MoreObjects;
 import java.util.Objects;
 
+/** See {@link com.google.gerrit.entities.StoredCommentLinkInfo} for field documentation. */
 public class CommentLinkInfo {
   public String match;
   public String link;
+  public String prefix;
+  public String suffix;
+  public String text;
   public String html;
   public Boolean enabled; // null means true
 
@@ -34,6 +38,9 @@
       CommentLinkInfo that = (CommentLinkInfo) o;
       return Objects.equals(this.match, that.match)
           && Objects.equals(this.link, that.link)
+          && Objects.equals(this.prefix, that.prefix)
+          && Objects.equals(this.suffix, that.suffix)
+          && Objects.equals(this.text, that.text)
           && Objects.equals(this.html, that.html)
           && Objects.equals(this.enabled, that.enabled);
     }
@@ -51,6 +58,9 @@
         .add("name", name)
         .add("match", match)
         .add("link", link)
+        .add("prefix", prefix)
+        .add("suffix", suffix)
+        .add("text", text)
         .add("html", html)
         .add("enabled", enabled)
         .toString();
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
index 3aad7e1..1c964a4 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
@@ -14,14 +14,22 @@
 
 package com.google.gerrit.extensions.api.projects;
 
-/*
+/**
  * Input for a commentlink configuration on a project.
+ *
+ * <p>See {@link com.google.gerrit.entities.StoredCommentLinkInfo} for additional details.
  */
 public class CommentLinkInput {
   /** A JavaScript regular expression to match positions to be replaced with a hyperlink. */
   public String match;
   /** The URL to direct the user to whenever the regular expression is matched. */
   public String link;
+  /** Text inserted before the link if the regular expression is matched. */
+  public String prefix;
+  /** Text inserted after the link if the regular expression is matched. */
+  public String suffix;
+  /** Text of the link. */
+  public String text;
   /** Whether the commentlink is enabled. */
   public Boolean enabled;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 59475a4..f6408b6 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.Collection;
@@ -227,6 +228,21 @@
 
   LabelApi label(String labelName) throws RestApiException;
 
+  ListSubmitRequirementsRequest submitRequirements() throws RestApiException;
+
+  abstract class ListSubmitRequirementsRequest {
+    protected boolean inherited;
+
+    public abstract List<SubmitRequirementInfo> get() throws RestApiException;
+
+    public ListSubmitRequirementsRequest withInherited(boolean inherited) {
+      this.inherited = inherited;
+      return this;
+    }
+  }
+
+  SubmitRequirementApi submitRequirement(String name) throws RestApiException;
+
   /**
    * Adds, updates and deletes label definitions in a batch.
    *
@@ -421,11 +437,21 @@
     }
 
     @Override
+    public ListSubmitRequirementsRequest submitRequirements() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public LabelApi label(String labelName) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public SubmitRequirementApi submitRequirement(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void labels(BatchLabelInput input) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
new file mode 100644
index 0000000..a6e79db
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface SubmitRequirementApi {
+  /** Create a new submit requirement. */
+  SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException;
+
+  /** Get existing submit requirement. */
+  SubmitRequirementInfo get() throws RestApiException;
+
+  /** Update existing submit requirement. */
+  SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException;
+
+  /** Delete existing submit requirement. */
+  void delete() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements SubmitRequirementApi {
+    @Override
+    public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SubmitRequirementInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void delete() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/client/ChangeKind.java b/java/com/google/gerrit/extensions/client/ChangeKind.java
index e58e005..6240bba 100644
--- a/java/com/google/gerrit/extensions/client/ChangeKind.java
+++ b/java/com/google/gerrit/extensions/client/ChangeKind.java
@@ -29,5 +29,42 @@
   NO_CODE_CHANGE,
 
   /** Same tree, parent tree, same commit message. */
-  NO_CHANGE
+  NO_CHANGE;
+
+  public boolean matches(ChangeKind changeKind, boolean isMerge) {
+    switch (changeKind) {
+      case REWORK:
+        // REWORK inlcudes all other change kinds, since those are just more trivial cases of a
+        // rework
+        return true;
+      case TRIVIAL_REBASE:
+        return isTrivialRebase();
+      case MERGE_FIRST_PARENT_UPDATE:
+        return isMergeFirstParentUpdate(isMerge);
+      case NO_CHANGE:
+        return this == NO_CHANGE;
+      case NO_CODE_CHANGE:
+        return isNoCodeChange();
+    }
+    throw new IllegalStateException("unexpected change kind: " + changeKind);
+  }
+
+  public boolean isNoCodeChange() {
+    // NO_CHANGE is a more trivial case of NO_CODE_CHANGE and hence matched as well
+    return this == NO_CHANGE || this == NO_CODE_CHANGE;
+  }
+
+  public boolean isTrivialRebase() {
+    // NO_CHANGE is a more trivial case of TRIVIAL_REBASE and hence matched as well
+    return this == NO_CHANGE || this == TRIVIAL_REBASE;
+  }
+
+  public boolean isMergeFirstParentUpdate(boolean isMerge) {
+    if (!isMerge) {
+      return false;
+    }
+
+    // NO_CHANGE is a more trivial case of MERGE_FIRST_PARENT_UPDATE and hence matched as well
+    return this == NO_CHANGE || this == MERGE_FIRST_PARENT_UPDATE;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 6acf3f4..1ee2cd8 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -102,6 +102,7 @@
   }
 
   public enum Theme {
+    AUTO,
     DARK,
     LIGHT
   }
@@ -149,6 +150,7 @@
   public Boolean workInProgressByDefault;
   public List<MenuItem> my;
   public List<String> changeTable;
+  public Boolean allowBrowserNotifications;
 
   public DateFormat getDateFormat() {
     if (dateFormat == null) {
@@ -189,7 +191,7 @@
     GeneralPreferencesInfo p = new GeneralPreferencesInfo();
     p.changesPerPage = DEFAULT_PAGESIZE;
     p.downloadScheme = null;
-    p.theme = Theme.LIGHT;
+    p.theme = Theme.AUTO;
     p.dateFormat = DateFormat.STD;
     p.timeFormat = TimeFormat.HHMM_12;
     p.expandInlineDiffs = false;
@@ -207,6 +209,7 @@
     p.disableKeyboardShortcuts = false;
     p.disableTokenHighlighting = false;
     p.workInProgressByDefault = false;
+    p.allowBrowserNotifications = true;
     return p;
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java b/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java
new file mode 100644
index 0000000..cd28d83
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,//
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+/**
+ * Input containing fix definitions to apply the provided fix to the change, on the patchset
+ * specified by revision id.
+ */
+public class ApplyProvidedFixInput {
+  public ApplyProvidedFixInput() {}
+
+  public List<FixReplacementInfo> fixReplacementInfos;
+}
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
index d3baecd..975061b 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -26,16 +26,7 @@
   public short defaultValue;
   public List<String> branches;
   public Boolean canOverride;
-  public Boolean copyAnyScore;
   public String copyCondition;
-  public Boolean copyMinScore;
-  public Boolean copyMaxScore;
-  public Boolean copyAllScoresIfListOfFilesDidNotChange;
-  public Boolean copyAllScoresIfNoChange;
-  public Boolean copyAllScoresIfNoCodeChange;
-  public Boolean copyAllScoresOnTrivialRebase;
-  public Boolean copyAllScoresOnMergeFirstParentUpdate;
-  public List<Short> copyValues;
   public Boolean allowPostSubmit;
   public Boolean ignoreSelfApproval;
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 1d580bb..277fccd 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -25,17 +25,8 @@
   public Short defaultValue;
   public List<String> branches;
   public Boolean canOverride;
-  public Boolean copyAnyScore;
   public String copyCondition;
   public Boolean unsetCopyCondition;
-  public Boolean copyMinScore;
-  public Boolean copyMaxScore;
-  public Boolean copyAllScoresIfListOfFilesDidNotChange;
-  public Boolean copyAllScoresIfNoChange;
-  public Boolean copyAllScoresIfNoCodeChange;
-  public Boolean copyAllScoresOnTrivialRebase;
-  public Boolean copyAllScoresOnMergeFirstParentUpdate;
-  public List<Short> copyValues;
   public Boolean allowPostSubmit;
   public Boolean ignoreSelfApproval;
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
index b4731f2..742d0c8 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
@@ -17,7 +17,10 @@
 import java.util.List;
 import java.util.Objects;
 
-/** Result of evaluating a single submit requirement expression. */
+/**
+ * Result of evaluating a single submit requirement expression. This API entity is populated from
+ * {@link com.google.gerrit.entities.SubmitRequirementExpressionResult}.
+ */
 public class SubmitRequirementExpressionInfo {
 
   /** Submit requirement expression as a String. */
@@ -26,6 +29,9 @@
   /** A boolean indicating if the expression is fulfilled on a change. */
   public boolean fulfilled;
 
+  /** A status indicating if the expression is fulfilled, non-fulfilled or not evaluated. */
+  public Status status;
+
   /**
    * A list of all atoms that are passing, for example query "branch:refs/heads/foo and project:bar"
    * has two atoms: ["branch:refs/heads/foo", "project:bar"].
@@ -44,6 +50,24 @@
    */
   public String errorMessage;
 
+  /**
+   * Values in this enum should match with values in {@link
+   * com.google.gerrit.entities.SubmitRequirementExpressionResult.Status}.
+   */
+  public enum Status {
+    /** Expression was evaluated and the result was true. */
+    PASS,
+
+    /** Expression was evaluated and the result was false. */
+    FAIL,
+
+    /** An error occurred while evaluating the expression. */
+    ERROR,
+
+    /** Expression was not evaluated. */
+    NOT_EVALUATED
+  }
+
   @Override
   public boolean equals(Object o) {
     if (this == o) {
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
new file mode 100644
index 0000000..9347e7e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class SubmitRequirementInfo {
+  /** Name of the submit requirement. */
+  public String name;
+
+  /** Description of the submit requirement. */
+  public String description;
+
+  /**
+   * Expression string to be evaluated on a change. Decides if this submit requirement is applicable
+   * on the given change.
+   */
+  public String applicabilityExpression;
+
+  /**
+   * Expression string to be evaluated on a change. When evaluated to true, this submit requirement
+   * becomes fulfilled for this change.
+   */
+  public String submittabilityExpression;
+
+  /**
+   * Expression string to be evaluated on a change. When evaluated to true, this submit requirement
+   * becomes fulfilled for this change regardless of the evaluation of the {@link
+   * #submittabilityExpression}.
+   */
+  public String overrideExpression;
+
+  /** Boolean indicating if this submit requirement can be overridden in child projects. */
+  public boolean allowOverrideInChildProjects;
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
index cb9d855..f75ec66 100644
--- a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
@@ -72,14 +72,14 @@
 
   // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
   // Instant
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
   @SuppressWarnings("JdkObsolete")
   public void matches(PersonIdent ident) {
     isNotNull();
     name().isEqualTo(ident.getName());
     email().isEqualTo(ident.getEmailAddress());
-    check("roundedDate()").that(gitPerson.date.getTime()).isEqualTo(ident.getWhen().getTime());
+    check("roundedDate()")
+        .that(gitPerson.date.getTime())
+        .isEqualTo(ident.getWhenAsInstant().toEpochMilli());
     tz().isEqualTo(ident.getTimeZoneOffset());
   }
 }
diff --git a/java/com/google/gerrit/extensions/events/AttentionSetListener.java b/java/com/google/gerrit/extensions/events/AttentionSetListener.java
new file mode 100644
index 0000000..ada30ce
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/AttentionSetListener.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.util.Set;
+
+/** Notified whenever the attention set is changed. */
+@ExtensionPoint
+public interface AttentionSetListener {
+  interface Event extends ChangeEvent {
+
+    /**
+     * Returns the users added to the attention set because of this change
+     *
+     * @return Account IDs
+     */
+    Set<Integer> usersAdded();
+
+    /**
+     * Returns the users removed from the attention set because of this change
+     *
+     * @return Account IDs
+     */
+    Set<Integer> usersRemoved();
+  }
+
+  /**
+   * This function will be called when the attention set changes
+   *
+   * @param event The event that changed the attention set
+   */
+  void onAttentionSetChanged(Event event);
+}
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index 9f804c4..0ee5212 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -13,6 +13,7 @@
         "//java/com/google/gerrit/server/api",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/auto:auto-factory",
         "//lib/bouncycastle:bcpg-neverlink",
         "//lib/bouncycastle:bcprov-neverlink",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java b/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
index c3dec61..874f1dc 100644
--- a/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
+++ b/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
@@ -14,28 +14,24 @@
 
 package com.google.gerrit.gpg;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Repository;
 
+@AutoFactory
 public class GerritPushCertificateChecker extends PushCertificateChecker {
-  public interface Factory {
-    GerritPushCertificateChecker create(IdentifiedUser expectedUser);
-  }
-
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
 
-  @Inject
   GerritPushCertificateChecker(
-      GerritPublicKeyChecker.Factory keyCheckerFactory,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      @Assisted IdentifiedUser expectedUser) {
+      @Provided GerritPublicKeyChecker.Factory keyCheckerFactory,
+      @Provided GitRepositoryManager repoManager,
+      @Provided AllUsersName allUsers,
+      IdentifiedUser expectedUser) {
     super(keyCheckerFactory.create().setExpectedUser(expectedUser));
     this.repoManager = repoManager;
     this.allUsers = allUsers;
diff --git a/java/com/google/gerrit/gpg/GpgModule.java b/java/com/google/gerrit/gpg/GpgModule.java
index 45c1ab5..623b5f0 100644
--- a/java/com/google/gerrit/gpg/GpgModule.java
+++ b/java/com/google/gerrit/gpg/GpgModule.java
@@ -42,7 +42,6 @@
     }
     if (enableSignedPush) {
       install(new SignedPushModule());
-      factory(GerritPushCertificateChecker.Factory.class);
     }
     install(new GpgApiModule(enableSignedPush && configEditGpgKeys));
   }
diff --git a/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java b/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
index 21a5b6e..abc51c2 100644
--- a/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
+++ b/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
@@ -48,11 +48,11 @@
   }
 
   private final Provider<IdentifiedUser> user;
-  private final GerritPushCertificateChecker.Factory checkerFactory;
+  private final GerritPushCertificateCheckerFactory checkerFactory;
 
   @Inject
   public SignedPushPreReceiveHook(
-      Provider<IdentifiedUser> user, GerritPushCertificateChecker.Factory checkerFactory) {
+      Provider<IdentifiedUser> user, GerritPushCertificateCheckerFactory checkerFactory) {
     this.user = user;
     this.checkerFactory = checkerFactory;
   }
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 652afea..6ae0334 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.gpg.GerritPushCertificateChecker;
+import com.google.gerrit.gpg.GerritPushCertificateCheckerFactory;
 import com.google.gerrit.gpg.PushCertificateChecker;
 import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gerrit.gpg.server.PostGpgKeys;
@@ -44,14 +44,14 @@
   private final Provider<PostGpgKeys> postGpgKeys;
   private final Provider<GpgKeys> gpgKeys;
   private final GpgKeyApiImpl.Factory gpgKeyApiFactory;
-  private final GerritPushCertificateChecker.Factory pushCertCheckerFactory;
+  private final GerritPushCertificateCheckerFactory pushCertCheckerFactory;
 
   @Inject
   GpgApiAdapterImpl(
       Provider<PostGpgKeys> postGpgKeys,
       Provider<GpgKeys> gpgKeys,
       GpgKeyApiImpl.Factory gpgKeyApiFactory,
-      GerritPushCertificateChecker.Factory pushCertCheckerFactory) {
+      GerritPushCertificateCheckerFactory pushCertCheckerFactory) {
     this.postGpgKeys = postGpgKeys;
     this.gpgKeys = gpgKeys;
     this.gpgKeyApiFactory = gpgKeyApiFactory;
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index ab6d0f4..fdbe6aa 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -433,14 +433,14 @@
         requestListeners.runEach(l -> l.onRequest(requestInfo));
 
         try {
-          perm.check(ProjectPermission.RUN_UPLOAD_PACK);
-        } catch (AuthException e) {
-          GitSmartHttpTools.sendError(
-              (HttpServletRequest) request,
-              responseWrapper,
-              HttpServletResponse.SC_FORBIDDEN,
-              "upload-pack not permitted on this server");
-          return;
+          if (!perm.test(ProjectPermission.RUN_UPLOAD_PACK)) {
+            GitSmartHttpTools.sendError(
+                (HttpServletRequest) request,
+                responseWrapper,
+                HttpServletResponse.SC_FORBIDDEN,
+                "upload-pack not permitted on this server");
+            return;
+          }
         } catch (PermissionBackendException e) {
           responseWrapper.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
           throw new ServletException(e);
@@ -592,18 +592,18 @@
       Capable canUpload;
       try {
         try {
-          permissionBackend
+          if (!permissionBackend
               .currentUser()
               .project(state.getNameKey())
-              .check(ProjectPermission.RUN_RECEIVE_PACK);
+              .test(ProjectPermission.RUN_RECEIVE_PACK)) {
+            GitSmartHttpTools.sendError(
+                httpRequest,
+                responseWrapper,
+                HttpServletResponse.SC_FORBIDDEN,
+                "receive-pack not permitted on this server");
+            return;
+          }
           canUpload = arc.canUpload();
-        } catch (AuthException e) {
-          GitSmartHttpTools.sendError(
-              httpRequest,
-              responseWrapper,
-              HttpServletResponse.SC_FORBIDDEN,
-              "receive-pack not permitted on this server");
-          return;
         } catch (PermissionBackendException e) {
           throw new RuntimeException(e);
         }
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index ce22ae8..72bfe40 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -39,6 +39,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
@@ -68,8 +69,10 @@
             staticTemplateData(
                 canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
         .putAll(dynamicTemplateData(gerritApi, requestedURL));
-    Set<String> enabledExperiments = experimentFeatures.getEnabledExperimentFeatures();
-
+    Set<String> enabledExperiments = new HashSet<>();
+    enabledExperiments.addAll(experimentFeatures.getEnabledExperimentFeatures());
+    // Add all experiments enabled through url
+    enabledExperiments.addAll(IndexHtmlUtil.experimentData(urlParameterMap));
     if (!enabledExperiments.isEmpty()) {
       data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
     }
@@ -90,18 +93,13 @@
     IndexPreloadingUtil.RequestedPage page = IndexPreloadingUtil.parseRequestedPage(requestedPath);
     switch (page) {
       case CHANGE:
-        data.put(
-            "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
-        data.put(
-            "changeRequestsPath",
-            IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
-        break;
       case DIFF:
         data.put(
             "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
+        data.put("changeNum", IndexPreloadingUtil.computeChangeNum(requestedPath, page).get());
         break;
       case DASHBOARD:
         // Dashboard is preloaded queries are added later when we check user is authenticated.
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index fa9a820..1c6e058 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -59,16 +59,15 @@
   public static final String DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY = "has:draft limit:10";
   public static final String YOUR_TURN = "attention:${user} limit:25";
   public static final String DASHBOARD_ASSIGNED_QUERY =
-      "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open -is:ignored limit:25";
+      "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open limit:25";
   public static final String DASHBOARD_WORK_IN_PROGRESS_QUERY =
       "is:open owner:${user} is:wip limit:25";
-  public static final String DASHBOARD_OUTGOING_QUERY =
-      "is:open owner:${user} -is:wip -is:ignored limit:25";
+  public static final String DASHBOARD_OUTGOING_QUERY = "is:open owner:${user} -is:wip limit:25";
   public static final String DASHBOARD_INCOMING_QUERY =
-      "is:open -owner:${user} -is:wip -is:ignored (reviewer:${user} OR assignee:${user}) limit:25";
-  public static final String CC_QUERY = "is:open -is:ignored -is:wip cc:${user} limit:10";
+      "is:open -owner:${user} -is:wip (reviewer:${user} OR assignee:${user}) limit:25";
+  public static final String CC_QUERY = "is:open -is:wip cc:${user} limit:10";
   public static final String DASHBOARD_RECENTLY_CLOSED_QUERY =
-      "is:closed -is:ignored (-is:wip OR owner:self) "
+      "is:closed (-is:wip OR owner:self) "
           + "(owner:${user} OR reviewer:${user} OR assignee:${user} "
           + "OR cc:${user}) -age:4w limit:10";
   public static final String NEW_USER = "owner:${user} limit:1";
@@ -168,6 +167,30 @@
     return Optional.empty();
   }
 
+  public static Optional<Integer> computeChangeNum(String requestedURL, RequestedPage page) {
+    Matcher matcher;
+    switch (page) {
+      case CHANGE:
+        matcher = CHANGE_URL_PATTERN.matcher(requestedURL);
+        break;
+      case DIFF:
+        matcher = DIFF_URL_PATTERN.matcher(requestedURL);
+        break;
+      case DASHBOARD:
+      case PAGE_WITHOUT_PRELOADING:
+      default:
+        return Optional.empty();
+    }
+
+    if (matcher.matches()) {
+      Integer changeNum = Ints.tryParse(matcher.group("changeNum"));
+      if (changeNum != null) {
+        return Optional.of(changeNum);
+      }
+    }
+    return Optional.empty();
+  }
+
   public static List<String> computeDashboardQueryList(Server serverApi) throws RestApiException {
     List<String> queryList = new ArrayList<>();
     queryList.add(SELF_DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY);
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 8e8a9d2..15dcf42 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -99,6 +99,7 @@
 
   private static final String DOC_SERVLET = "DocServlet";
   private static final String FAVICON_SERVLET = "FaviconServlet";
+  private static final String SERVICE_WORKER_SERVLET = "ServiceWorkerServlet";
   private static final String POLYGERRIT_INDEX_SERVLET = "PolyGerritUiIndexServlet";
   private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
 
@@ -167,6 +168,7 @@
     public void configureServlets() {
       serve("/robots.txt").with(named(ROBOTS_TXT_SERVLET));
       serve("/favicon.ico").with(named(FAVICON_SERVLET));
+      serve("/service-worker.js").with(named(SERVICE_WORKER_SERVLET));
     }
 
     @Provides
@@ -201,6 +203,19 @@
       return new SingleFileServlet(cache, webappSourcePath("favicon.ico"), true);
     }
 
+    @Provides
+    @Singleton
+    @Named(SERVICE_WORKER_SERVLET)
+    HttpServlet getServiceWorkerServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new SingleFileServlet(
+            cache, p.warFs.getPath("/polygerrit_ui/workers/service-worker.js"), false);
+      }
+      return new SingleFileServlet(
+          cache, webappSourcePath("polygerrit_ui/workers/service-worker.js"), true);
+    }
+
     private Path webappSourcePath(String name) {
       Paths p = getPaths();
       if (p.unpackedWar != null) {
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index 95b3581..ba1c8bd 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -28,9 +28,11 @@
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:jgit",
+        "//lib:protobuf",
         "//lib/antlr:java-runtime",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/java/com/google/gerrit/index/FieldDef.java b/java/com/google/gerrit/index/FieldDef.java
index 76aa7cc..1c2074b 100644
--- a/java/com/google/gerrit/index/FieldDef.java
+++ b/java/com/google/gerrit/index/FieldDef.java
@@ -21,6 +21,9 @@
 import com.google.common.base.CharMatcher;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import com.google.gerrit.index.SchemaFieldDefs.Setter;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Optional;
@@ -28,11 +31,18 @@
 /**
  * Definition of a field stored in the secondary index.
  *
+ * <p>{@link FieldDef}-s must not be changed once introduced to the codebase. Instead, a new
+ * FieldDef must be added and the old one removed from the schema (in two upgrade steps, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}).
+ *
+ * <p>Note that {@link FieldDef} does not override {@link Object#equals(Object)}. It relies on
+ * instances being singletons so that the default (i.e. reference) comparison works.
+ *
  * @param <I> input type from which documents are created and search results are returned.
  * @param <T> type that should be extracted from the input object when converting to an index
  *     document.
  */
-public final class FieldDef<I, T> {
+public final class FieldDef<I, T> implements SchemaField<I, T> {
   public static FieldDef.Builder<String> exact(String name) {
     return new FieldDef.Builder<>(FieldType.EXACT, name);
   }
@@ -61,17 +71,6 @@
     return new FieldDef.Builder<>(FieldType.TIMESTAMP, name);
   }
 
-  @FunctionalInterface
-  public interface Getter<I, T> {
-    @Nullable
-    T get(I input) throws IOException;
-  }
-
-  @FunctionalInterface
-  public interface Setter<I, T> {
-    void set(I object, T value);
-  }
-
   public static class Builder<T> {
     private final FieldType<T> type;
     private final String name;
@@ -139,16 +138,19 @@
   }
 
   /** Returns name of the field. */
+  @Override
   public String getName() {
     return name;
   }
 
   /** Returns type of the field; for repeatable fields, the inner type, not the iterable type. */
+  @Override
   public FieldType<?> getType() {
     return type;
   }
 
   /** Returns whether the field should be stored in the index. */
+  @Override
   public boolean isStored() {
     return stored;
   }
@@ -159,6 +161,7 @@
    * @param input input object.
    * @return the field value(s) to index.
    */
+  @Override
   @Nullable
   public T get(I input) {
     try {
@@ -177,6 +180,7 @@
    * @return {@code true} if the field was set, {@code false} otherwise
    */
   @SuppressWarnings("unchecked")
+  @Override
   public boolean setIfPossible(I object, StoredValue doc) {
     if (!setter.isPresent()) {
       return false;
@@ -204,6 +208,7 @@
   }
 
   /** Returns whether the field is repeatable. */
+  @Override
   public boolean isRepeatable() {
     return repeatable;
   }
diff --git a/java/com/google/gerrit/index/IndexedField.java b/java/com/google/gerrit/index/IndexedField.java
new file mode 100644
index 0000000..e44f562
--- /dev/null
+++ b/java/com/google/gerrit/index/IndexedField.java
@@ -0,0 +1,515 @@
+// 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.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.reflect.TypeToken;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.converter.ProtoConverter;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import com.google.gerrit.index.SchemaFieldDefs.Setter;
+import com.google.gerrit.proto.Protos;
+import com.google.protobuf.MessageLite;
+import java.io.IOException;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.StreamSupport;
+
+/**
+ * Definition of a field stored in the secondary index.
+ *
+ * <p>Each IndexedField, stored in index, may have multiple {@link SearchSpec} which defines how it
+ * can be searched and how the index tokens are generated.
+ *
+ * <p>Index implementations may choose to store IndexedField and {@link SearchSpec} (search tokens)
+ * separately, however {@link com.google.gerrit.index.query.IndexedQuery} always issues the queries
+ * to {@link SearchSpec}.
+ *
+ * <p>This allows index implementations to store IndexedField once, while enabling multiple
+ * tokenization strategies on the same IndexedField with {@link SearchSpec}
+ *
+ * @param <I> input type from which documents are created and search results are returned.
+ * @param <T> type that should be extracted from the input object when converting to an index
+ *     document.
+ */
+// TODO(mariasavtchouk): revisit the class name after migration is done.
+@SuppressWarnings("serial")
+@AutoValue
+public abstract class IndexedField<I, T> {
+
+  public static final TypeToken<Integer> INTEGER_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<Integer>> ITERABLE_INTEGER_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Long> LONG_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<Long>> ITERABLE_LONG_TYPE = new TypeToken<>() {};
+  public static final TypeToken<String> STRING_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<String>> ITERABLE_STRING_TYPE = new TypeToken<>() {};
+  public static final TypeToken<byte[]> BYTE_ARRAY_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<byte[]>> ITERABLE_BYTE_ARRAY_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Timestamp> TIMESTAMP_TYPE = new TypeToken<>() {};
+
+  // Should not be used directly, only used to check if the proto is stored
+  private static final TypeToken<MessageLite> MESSAGE_TYPE = new TypeToken<>() {};
+
+  public static <I, T> Builder<I, T> builder(String name, TypeToken<T> fieldType) {
+    return new AutoValue_IndexedField.Builder<I, T>()
+        .name(name)
+        .fieldType(fieldType)
+        .stored(false)
+        .required(false);
+  }
+
+  public static <I> Builder<I, Iterable<String>> iterableStringBuilder(String name) {
+    return builder(name, IndexedField.ITERABLE_STRING_TYPE);
+  }
+
+  public static <I> Builder<I, String> stringBuilder(String name) {
+    return builder(name, IndexedField.STRING_TYPE);
+  }
+
+  public static <I> Builder<I, Integer> integerBuilder(String name) {
+    return builder(name, IndexedField.INTEGER_TYPE);
+  }
+
+  public static <I> Builder<I, Long> longBuilder(String name) {
+    return builder(name, IndexedField.LONG_TYPE);
+  }
+
+  public static <I> Builder<I, Iterable<Integer>> iterableIntegerBuilder(String name) {
+    return builder(name, IndexedField.ITERABLE_INTEGER_TYPE);
+  }
+
+  public static <I> Builder<I, Timestamp> timestampBuilder(String name) {
+    return builder(name, IndexedField.TIMESTAMP_TYPE);
+  }
+
+  public static <I> Builder<I, byte[]> byteArrayBuilder(String name) {
+    return builder(name, IndexedField.BYTE_ARRAY_TYPE);
+  }
+
+  public static <I> Builder<I, Iterable<byte[]>> iterableByteArrayBuilder(String name) {
+    return builder(name, IndexedField.ITERABLE_BYTE_ARRAY_TYPE);
+  }
+
+  /**
+   * Defines how {@link IndexedField} can be searched and how the index tokens are generated.
+   *
+   * <p>Multiple {@link SearchSpec} can be defined on single {@link IndexedField}.
+   *
+   * <p>Depending on the implementation, indexes can choose to store {@link IndexedField} and {@link
+   * SearchSpec} separately. The searches are issues to {@link SearchSpec}.
+   */
+  public class SearchSpec implements SchemaField<I, T> {
+    private final String name;
+    private final SearchOption searchOption;
+
+    public SearchSpec(String name, SearchOption searchOption) {
+      checkName(name);
+      this.name = name;
+      this.searchOption = searchOption;
+    }
+
+    @Override
+    public boolean isStored() {
+      return getField().stored();
+    }
+
+    @Override
+    public boolean isRepeatable() {
+      return getField().repeatable();
+    }
+
+    @Override
+    @Nullable
+    public T get(I obj) {
+      return getField().get(obj);
+    }
+
+    @Override
+    public String getName() {
+      return name;
+    }
+
+    @Override
+    public FieldType<?> getType() {
+      SearchOption searchOption = getSearchOption();
+      TypeToken<?> fieldType = getField().fieldType();
+      if (searchOption.equals(SearchOption.STORE_ONLY)) {
+        return FieldType.STORED_ONLY;
+      } else if ((fieldType.equals(IndexedField.INTEGER_TYPE)
+              || fieldType.equals(IndexedField.ITERABLE_INTEGER_TYPE))
+          && searchOption.equals(SearchOption.EXACT)) {
+        return FieldType.INTEGER;
+      } else if (fieldType.equals(IndexedField.INTEGER_TYPE)
+          && searchOption.equals(SearchOption.RANGE)) {
+        return FieldType.INTEGER_RANGE;
+      } else if (fieldType.equals(IndexedField.LONG_TYPE)) {
+        return FieldType.LONG;
+      } else if (fieldType.equals(IndexedField.TIMESTAMP_TYPE)) {
+        return FieldType.TIMESTAMP;
+      } else if (fieldType.equals(IndexedField.STRING_TYPE)
+          || fieldType.equals(IndexedField.ITERABLE_STRING_TYPE)) {
+        if (searchOption.equals(SearchOption.EXACT)) {
+          return FieldType.EXACT;
+        } else if (searchOption.equals(SearchOption.FULL_TEXT)) {
+          return FieldType.FULL_TEXT;
+        } else if (searchOption.equals(SearchOption.PREFIX)) {
+          return FieldType.PREFIX;
+        }
+      }
+      throw new IllegalArgumentException(
+          String.format(
+              "search spec [%s, %s] is not supported on field [%s, %s]",
+              getName(), getSearchOption(), getField().name(), getField().fieldType()));
+    }
+
+    @Override
+    public boolean setIfPossible(I object, StoredValue doc) {
+      return getField().setIfPossible(object, doc);
+    }
+
+    /**
+     * Returns {@link SearchOption} enabled on this field.
+     *
+     * @return {@link SearchOption}
+     */
+    public SearchOption getSearchOption() {
+      return searchOption;
+    }
+
+    /**
+     * Returns {@link IndexedField} on which this spec was created.
+     *
+     * @return original {@link IndexedField} of this spec.
+     */
+    public IndexedField<I, T> getField() {
+      return IndexedField.this;
+    }
+
+    private String checkName(String name) {
+      CharMatcher m = CharMatcher.anyOf("abcdefghijklmnopqrstuvwxyz0123456789_");
+      checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
+      return name;
+    }
+  }
+
+  /**
+   * Adds {@link SearchSpec} to this {@link IndexedField}
+   *
+   * @param name the name to use for in the search.
+   * @param searchOption the tokenization option, enabled by the new {@link SearchSpec}
+   * @return the added {@link SearchSpec}.
+   */
+  public SearchSpec addSearchSpec(String name, SearchOption searchOption) {
+    SearchSpec searchSpec = new SearchSpec(name, searchOption);
+    checkArgument(
+        !searchSpecs.containsKey(searchSpec.getName()),
+        "Can not add search spec %s, because it is already defined on field %s",
+        searchSpec.getName(),
+        name());
+    searchSpecs.put(searchSpec.getName(), searchSpec);
+    return searchSpec;
+  }
+
+  public SearchSpec exact(String name) {
+    return addSearchSpec(name, SearchOption.EXACT);
+  }
+
+  public SearchSpec fullText(String name) {
+    return addSearchSpec(name, SearchOption.FULL_TEXT);
+  }
+
+  public SearchSpec range(String name) {
+    return addSearchSpec(name, SearchOption.RANGE);
+  }
+
+  public SearchSpec integerRange(String name) {
+    checkState(fieldType().equals(INTEGER_TYPE));
+    return addSearchSpec(name, SearchOption.RANGE);
+  }
+
+  public SearchSpec integer(String name) {
+    checkState(fieldType().equals(INTEGER_TYPE) || fieldType().equals(ITERABLE_INTEGER_TYPE));
+    return addSearchSpec(name, SearchOption.EXACT);
+  }
+
+  public SearchSpec longSearch(String name) {
+    checkState(fieldType().equals(LONG_TYPE) || fieldType().equals(ITERABLE_LONG_TYPE));
+    return addSearchSpec(name, SearchOption.EXACT);
+  }
+
+  public SearchSpec prefix(String name) {
+    return addSearchSpec(name, SearchOption.PREFIX);
+  }
+
+  public SearchSpec storedOnly(String name) {
+    checkState(stored());
+    return addSearchSpec(name, SearchOption.STORE_ONLY);
+  }
+
+  public SearchSpec timestamp(String name) {
+    checkState(fieldType().equals(TIMESTAMP_TYPE));
+    return addSearchSpec(name, SearchOption.RANGE);
+  }
+
+  /** A builder for {@link IndexedField}. */
+  @AutoValue.Builder
+  public abstract static class Builder<I, T> {
+
+    public abstract IndexedField.Builder<I, T> name(String name);
+
+    public abstract IndexedField.Builder<I, T> description(Optional<String> description);
+
+    public abstract IndexedField.Builder<I, T> description(String description);
+
+    public abstract Builder<I, T> required(boolean required);
+
+    public Builder<I, T> required() {
+      required(true);
+      return this;
+    }
+
+    /** Allow reading the actual data from the index. */
+    public abstract Builder<I, T> stored(boolean stored);
+
+    public Builder<I, T> stored() {
+      stored(true);
+      return this;
+    }
+
+    abstract Builder<I, T> repeatable(boolean repeatable);
+
+    public abstract Builder<I, T> size(Optional<Integer> value);
+
+    public abstract Builder<I, T> size(Integer value);
+
+    public abstract Builder<I, T> getter(Getter<I, T> getter);
+
+    public abstract Builder<I, T> fieldSetter(Optional<Setter<I, T>> setter);
+
+    abstract TypeToken<T> fieldType();
+
+    public abstract Builder<I, T> fieldType(TypeToken<T> type);
+
+    public abstract Builder<I, T> protoConverter(
+        Optional<ProtoConverter<? extends MessageLite, ?>> value);
+
+    abstract IndexedField<I, T> autoBuild(); // not public
+
+    public final IndexedField<I, T> build() {
+      boolean isRepeatable = fieldType().isSubtypeOf(Iterable.class);
+      repeatable(isRepeatable);
+      IndexedField<I, T> field = autoBuild();
+      checkName(field.name());
+      checkArgument(!field.size().isPresent() || field.size().get() > 0);
+      return field;
+    }
+
+    public final IndexedField<I, T> build(Getter<I, T> getter, Setter<I, T> setter) {
+      return this.getter(getter).fieldSetter(Optional.of(setter)).build();
+    }
+
+    public final IndexedField<I, T> build(
+        Getter<I, T> getter,
+        Setter<I, T> setter,
+        ProtoConverter<? extends MessageLite, ?> protoConverter) {
+      return this.getter(getter)
+          .fieldSetter(Optional.of(setter))
+          .protoConverter(Optional.of(protoConverter))
+          .build();
+    }
+
+    public final IndexedField<I, T> build(Getter<I, T> getter) {
+      return this.getter(getter).fieldSetter(Optional.empty()).build();
+    }
+
+    private static String checkName(String name) {
+      String allowedCharacters = "abcdefghijklmnopqrstuvwxyz0123456789_";
+      CharMatcher m = CharMatcher.anyOf(allowedCharacters + allowedCharacters.toUpperCase());
+      checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
+      return name;
+    }
+  }
+
+  private Map<String, SearchSpec> searchSpecs = new HashMap<>();
+
+  /** The name to store this field under. */
+  public abstract String name();
+
+  /** Optional description of the field data. */
+  public abstract Optional<String> description();
+
+  /** True if this field is mandatory. Default is false. */
+  public abstract boolean required();
+
+  /** Allow reading the actual data from the index. Default is false. */
+  public abstract boolean stored();
+
+  /** True if this field is repeatable. */
+  public abstract boolean repeatable();
+
+  /**
+   * Optional size constrain on the field. The size is not constrained if this property is {@link
+   * Optional#empty()}
+   */
+  public abstract Optional<Integer> size();
+
+  /** See {@link Getter} */
+  public abstract Getter<I, T> getter();
+
+  /** See {@link Setter} */
+  public abstract Optional<Setter<I, T>> fieldSetter();
+
+  /**
+   * The {@link TypeToken} describing the contents of the field. See static constants for the common
+   * supported types.
+   *
+   * @return {@link TypeToken} of this field.
+   */
+  public abstract TypeToken<T> fieldType();
+
+  /** If the {@link #fieldType()} is proto, the converter to use on byte/proto conversions. */
+  public abstract Optional<ProtoConverter<? extends MessageLite, ?>> protoConverter();
+
+  /**
+   * Returns all {@link SearchSpec}, enabled on this field.
+   *
+   * <p>Note: weather or not a search is supported by the index depends on {@link Schema} version.
+   */
+  public ImmutableMap<String, SearchSpec> getSearchSpecs() {
+    return ImmutableMap.copyOf(searchSpecs);
+  }
+
+  /**
+   * Get the field contents from the input object.
+   *
+   * @param input input object.
+   * @return the field value(s) to index.
+   */
+  @Nullable
+  public T get(I input) {
+    try {
+      return getter().get(input);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  public boolean setIfPossible(I object, StoredValue doc) {
+    if (!fieldSetter().isPresent()) {
+      return false;
+    }
+
+    if (this.fieldType().equals(STRING_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asString());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_STRING_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asStrings());
+      return true;
+    } else if (this.fieldType().equals(INTEGER_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asInteger());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_INTEGER_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asIntegers());
+      return true;
+    } else if (this.fieldType().equals(LONG_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asLong());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_LONG_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asLongs());
+      return true;
+    } else if (this.fieldType().equals(BYTE_ARRAY_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asByteArray());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_BYTE_ARRAY_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asByteArrays());
+      return true;
+    } else if (this.fieldType().equals(TIMESTAMP_TYPE)) {
+      checkState(!repeatable(), "can't repeat timestamp values");
+      fieldSetter().get().set(object, (T) doc.asTimestamp());
+      return true;
+    } else if (isProtoType()) {
+      MessageLite proto = doc.asProto();
+      if (proto != null) {
+        fieldSetter().get().set(object, (T) proto);
+        return true;
+      }
+      byte[] bytes = doc.asByteArray();
+      if (bytes != null && protoConverter().isPresent()) {
+        fieldSetter().get().set(object, (T) parseProtoFrom(bytes));
+        return true;
+      }
+    } else if (isProtoIterableType()) {
+      Iterable<MessageLite> protos = doc.asProtos();
+      if (protos != null) {
+        fieldSetter().get().set(object, (T) protos);
+        return true;
+      }
+      Iterable<byte[]> bytes = doc.asByteArrays();
+      if (bytes != null && protoConverter().isPresent()) {
+        fieldSetter().get().set(object, (T) decodeProtos(bytes));
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Returns true if the {@link #fieldType} is a proto message. */
+  public boolean isProtoType() {
+    if (repeatable()) {
+      return false;
+    }
+    return MESSAGE_TYPE.isSupertypeOf(fieldType());
+  }
+
+  /** Returns true if the {@link #fieldType} is a list of proto messages. */
+  public boolean isProtoIterableType() {
+    if (!repeatable()) {
+      return false;
+    }
+    if (!(fieldType().getType() instanceof ParameterizedType)) {
+      return false;
+    }
+    ParameterizedType parameterizedType = (ParameterizedType) fieldType().getType();
+    if (parameterizedType.getActualTypeArguments().length != 1) {
+      return false;
+    }
+    Type type = parameterizedType.getActualTypeArguments()[0];
+    return MESSAGE_TYPE.isSupertypeOf(type);
+  }
+
+  private ImmutableList<MessageLite> decodeProtos(Iterable<byte[]> raw) {
+    return StreamSupport.stream(raw.spliterator(), false)
+        .map(bytes -> parseProtoFrom(bytes))
+        .collect(toImmutableList());
+  }
+
+  private MessageLite parseProtoFrom(byte[] bytes) {
+    return Protos.parseUnchecked(protoConverter().get().getParser(), bytes);
+  }
+}
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 91c3f70..403be35 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -14,67 +14,134 @@
 
 package com.google.gerrit.index;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
 
 /** Specific version of a secondary index schema. */
 public class Schema<T> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static class Builder<T> {
-    private final List<FieldDef<T, ?>> fields = new ArrayList<>();
-    private boolean useLegacyNumericFields;
+    private final List<SchemaField<T, ?>> searchFields = new ArrayList<>();
+    private final List<IndexedField<T, ?>> indexedFields = new ArrayList<>();
+
+    private Optional<Integer> version = Optional.empty();
+
+    public Builder<T> version(int version) {
+      this.version = Optional.of(version);
+      return this;
+    }
 
     public Builder<T> add(Schema<T> schema) {
-      this.fields.addAll(schema.getFields().values());
+      this.indexedFields.addAll(schema.getIndexFields().values());
+      this.searchFields.addAll(schema.getSchemaFields().values());
+      if (!version.isPresent()) {
+        version(schema.getVersion() + 1);
+      }
       return this;
     }
 
     @SafeVarargs
     public final Builder<T> add(FieldDef<T, ?>... fields) {
-      this.fields.addAll(Arrays.asList(fields));
+      return add(ImmutableList.copyOf(fields));
+    }
+
+    public final Builder<T> add(ImmutableList<FieldDef<T, ?>> fields) {
+      this.searchFields.addAll(fields);
       return this;
     }
 
     @SafeVarargs
     public final Builder<T> remove(FieldDef<T, ?>... fields) {
-      this.fields.removeAll(Arrays.asList(fields));
+      this.searchFields.removeAll(Arrays.asList(fields));
       return this;
     }
 
-    public Builder<T> legacyNumericFields(boolean useLegacyNumericFields) {
-      this.useLegacyNumericFields = useLegacyNumericFields;
+    @SafeVarargs
+    public final Builder<T> addSearchSpecs(IndexedField<T, ?>.SearchSpec... searchSpecs) {
+      return addSearchSpecs(ImmutableList.copyOf(searchSpecs));
+    }
+
+    public Builder<T> addSearchSpecs(ImmutableList<IndexedField<T, ?>.SearchSpec> searchSpecs) {
+      for (IndexedField<T, ?>.SearchSpec searchSpec : searchSpecs) {
+        checkArgument(
+            this.indexedFields.contains(searchSpec.getField()),
+            "%s spec can only be added to the schema that contains %s field",
+            searchSpec.getName(),
+            searchSpec.getField().name());
+      }
+      this.searchFields.addAll(searchSpecs);
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> addIndexedFields(IndexedField<T, ?>... fields) {
+      return addIndexedFields(ImmutableList.copyOf(fields));
+    }
+
+    public Builder<T> addIndexedFields(ImmutableList<IndexedField<T, ?>> indexedFields) {
+      this.indexedFields.addAll(indexedFields);
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> remove(IndexedField<T, ?>.SearchSpec... searchSpecs) {
+      this.searchFields.removeAll(Arrays.asList(searchSpecs));
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> remove(IndexedField<T, ?>... indexedFields) {
+      for (IndexedField<T, ?> field : indexedFields) {
+        ImmutableMap<String, ? extends IndexedField<T, ?>.SearchSpec> searchSpecs =
+            field.getSearchSpecs();
+        checkArgument(
+            !searchSpecs.values().stream().anyMatch(this.searchFields::contains),
+            "Field %s can be only removed from schema after all of its searches are removed.",
+            field.name());
+      }
+      this.indexedFields.removeAll(Arrays.asList(indexedFields));
       return this;
     }
 
     public Schema<T> build() {
-      return new Schema<>(useLegacyNumericFields, ImmutableList.copyOf(fields));
+      checkState(version.isPresent());
+      return new Schema<>(
+          version.get(), ImmutableList.copyOf(indexedFields), ImmutableList.copyOf(searchFields));
     }
   }
 
   public static class Values<T> {
-    private final FieldDef<T, ?> field;
+    private final SchemaField<T, ?> field;
     private final Iterable<?> values;
 
-    private Values(FieldDef<T, ?> field, Iterable<?> values) {
+    private Values(SchemaField<T, ?> field, Iterable<?> values) {
       this.field = field;
       this.values = values;
     }
 
-    public FieldDef<T, ?> getField() {
+    public SchemaField<T, ?> getField() {
       return field;
     }
 
@@ -83,59 +150,67 @@
     }
   }
 
-  private static <T> FieldDef<T, ?> checkSame(FieldDef<T, ?> f1, FieldDef<T, ?> f2) {
+  private static <T> SchemaField<T, ?> checkSame(SchemaField<T, ?> f1, SchemaField<T, ?> f2) {
     checkState(f1 == f2, "Mismatched %s fields: %s != %s", f1.getName(), f1, f2);
     return f1;
   }
 
-  private final ImmutableMap<String, FieldDef<T, ?>> fields;
-  private final ImmutableMap<String, FieldDef<T, ?>> storedFields;
-  private final boolean useLegacyNumericFields;
+  private final ImmutableSet<String> storedFields;
+
+  private final ImmutableMap<String, SchemaField<T, ?>> schemaFields;
+  private final ImmutableMap<String, IndexedField<T, ?>> indexedFields;
 
   private int version;
 
-  public Schema(boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
-    this(0, useLegacyNumericFields, fields);
-  }
-
-  public Schema(int version, boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
+  private Schema(
+      int version,
+      ImmutableList<IndexedField<T, ?>> indexedFields,
+      ImmutableList<SchemaField<T, ?>> schemaFields) {
     this.version = version;
-    ImmutableMap.Builder<String, FieldDef<T, ?>> b = ImmutableMap.builder();
-    ImmutableMap.Builder<String, FieldDef<T, ?>> sb = ImmutableMap.builder();
-    for (FieldDef<T, ?> f : fields) {
-      b.put(f.getName(), f);
-      if (f.isStored()) {
-        sb.put(f.getName(), f);
-      }
-    }
-    this.fields = b.build();
-    this.storedFields = sb.build();
-    this.useLegacyNumericFields = useLegacyNumericFields;
+
+    this.indexedFields =
+        indexedFields.stream().collect(toImmutableMap(IndexedField::name, Function.identity()));
+    this.schemaFields =
+        schemaFields.stream().collect(toImmutableMap(SchemaField::getName, Function.identity()));
+
+    Set<String> duplicateKeys =
+        Sets.intersection(this.schemaFields.keySet(), this.indexedFields.keySet());
+    checkArgument(
+        duplicateKeys.isEmpty(),
+        "DuplicateKeys found %s, indexFields:%s, schemaFields: %s",
+        duplicateKeys,
+        this.indexedFields.keySet(),
+        this.schemaFields.keySet());
+    this.storedFields =
+        schemaFields.stream()
+            .filter(SchemaField::isStored)
+            .map(SchemaField::getName)
+            .collect(toImmutableSet());
   }
 
   public final int getVersion() {
     return version;
   }
 
-  public final boolean useLegacyNumericFields() {
-    return useLegacyNumericFields;
-  }
-
   /**
    * Get all fields in this schema.
    *
    * <p>This is primarily useful for iteration. Most callers should prefer one of the helper methods
-   * {@link #getField(FieldDef, FieldDef...)} or {@link #hasField(FieldDef)} to looking up fields by
-   * name
+   * {@link #getField(SchemaField, SchemaField...)} or {@link #hasField(SchemaField)} to looking up
+   * fields by name
    *
    * @return all fields in this schema indexed by name.
    */
-  public final ImmutableMap<String, FieldDef<T, ?>> getFields() {
-    return fields;
+  public final ImmutableMap<String, SchemaField<T, ?>> getSchemaFields() {
+    return ImmutableMap.copyOf(schemaFields);
+  }
+
+  public final ImmutableMap<String, IndexedField<T, ?>> getIndexFields() {
+    return indexedFields;
   }
 
   /** Returns all fields in this schema where {@link FieldDef#isStored()} is true. */
-  public final ImmutableMap<String, FieldDef<T, ?>> getStoredFields() {
+  public final ImmutableSet<String> getStoredFields() {
     return storedFields;
   }
 
@@ -148,13 +223,14 @@
    *     absent if no field matches.
    */
   @SafeVarargs
-  public final Optional<FieldDef<T, ?>> getField(FieldDef<T, ?> first, FieldDef<T, ?>... rest) {
-    FieldDef<T, ?> field = fields.get(first.getName());
+  public final Optional<SchemaField<T, ?>> getField(
+      SchemaField<T, ?> first, SchemaField<T, ?>... rest) {
+    SchemaField<T, ?> field = getSchemaField(first);
     if (field != null) {
       return Optional.of(checkSame(field, first));
     }
-    for (FieldDef<T, ?> f : rest) {
-      field = fields.get(f.getName());
+    for (SchemaField<T, ?> f : rest) {
+      field = getSchemaField(first);
       if (field != null) {
         return Optional.of(checkSame(field, f));
       }
@@ -168,8 +244,8 @@
    * @param field field to look up.
    * @return whether the field is present.
    */
-  public final boolean hasField(FieldDef<T, ?> field) {
-    FieldDef<T, ?> f = fields.get(field.getName());
+  public final boolean hasField(SchemaField<T, ?> field) {
+    SchemaField<T, ?> f = getSchemaField(field);
     if (f == null) {
       return false;
     }
@@ -177,7 +253,20 @@
     return true;
   }
 
-  private Values<T> fieldValues(T obj, FieldDef<T, ?> f, ImmutableSet<String> skipFields) {
+  public final boolean hasField(String fieldName) {
+    return this.getSchemaField(fieldName) != null;
+  }
+
+  private SchemaField<T, ?> getSchemaField(SchemaField<T, ?> field) {
+    return getSchemaField(field.getName());
+  }
+
+  public SchemaField<T, ?> getSchemaField(String fieldName) {
+    return schemaFields.get(fieldName);
+  }
+
+  private @Nullable Values<T> fieldValues(
+      T obj, SchemaField<T, ?> f, ImmutableSet<String> skipFields) {
     if (skipFields.contains(f.getName())) {
       return null;
     }
@@ -215,10 +304,11 @@
    */
   public final Iterable<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
     try {
-      return fields.values().stream()
+      return schemaFields.values().stream()
           .map(f -> fieldValues(obj, f, skipFields))
           .filter(Objects::nonNull)
           .collect(toImmutableList());
+
     } catch (StorageException e) {
       return ImmutableList.of();
     }
@@ -226,10 +316,9 @@
 
   @Override
   public String toString() {
-    return MoreObjects.toStringHelper(this).addValue(fields.keySet()).toString();
-  }
-
-  public void setVersion(int version) {
-    this.version = version;
+    return MoreObjects.toStringHelper(this)
+        .addValue(indexedFields.keySet())
+        .addValue(schemaFields.keySet())
+        .toString();
   }
 }
diff --git a/java/com/google/gerrit/index/SchemaDefinitions.java b/java/com/google/gerrit/index/SchemaDefinitions.java
index e8efd22..4e58d91 100644
--- a/java/com/google/gerrit/index/SchemaDefinitions.java
+++ b/java/com/google/gerrit/index/SchemaDefinitions.java
@@ -42,6 +42,7 @@
     return name;
   }
 
+  /** Returns all schemas sorted by version (ascending). */
   public final ImmutableSortedMap<Integer, Schema<V>> getSchemas() {
     return schemas;
   }
diff --git a/java/com/google/gerrit/index/SchemaFieldDefs.java b/java/com/google/gerrit/index/SchemaFieldDefs.java
new file mode 100644
index 0000000..e0b5dd2
--- /dev/null
+++ b/java/com/google/gerrit/index/SchemaFieldDefs.java
@@ -0,0 +1,103 @@
+// 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.index;
+
+import com.google.gerrit.common.Nullable;
+import java.io.IOException;
+
+/** Interfaces that define properties of fields in {@link Schema}. */
+public class SchemaFieldDefs {
+
+  /**
+   * Definition of a field stored in the secondary index.
+   *
+   * <p>{@link SchemaField}-s must not be changed once introduced to the codebase. Instead, a new
+   * FieldDef must be added and the old one removed from the schema (in two upgrade steps, see
+   * {@code com.google.gerrit.index.IndexUpgradeValidator}).
+   *
+   * @param <I> input type from which documents are created and search results are returned.
+   * @param <T> type that should be extracted from the input object when converting to an index
+   *     document.
+   */
+  public interface SchemaField<T, I> {
+
+    /** Returns whether the field should be stored in the index. */
+    boolean isStored();
+
+    /** Returns whether the field is repeatable. */
+    boolean isRepeatable();
+
+    /**
+     * Get the field contents from the input object.
+     *
+     * @param input input object.
+     * @return the field value(s) to index.
+     */
+    @Nullable
+    I get(T input);
+
+    /** Returns the name of the field. */
+    String getName();
+
+    /**
+     * Returns type of the field; for repeatable fields, the inner type, not the iterable type.
+     * TODO(mariasavtchuk): remove after migrating to the new field formats
+     */
+    FieldType<?> getType();
+
+    /**
+     * Set the field contents back to an object. Used to reconstruct fields from indexed values.
+     * No-op if the field can't be reconstructed.
+     *
+     * @param object input object.
+     * @param doc indexed document
+     * @return {@code true} if the field was set, {@code false} otherwise
+     */
+    boolean setIfPossible(T object, StoredValue doc);
+  }
+
+  /**
+   * Getter to extract value that should be stored in index from the input object.
+   *
+   * <p>This interface allows to specify a method or lambda for populating an index field. Note that
+   * for existing fields, changing the code of either the {@link Getter} implementation or the
+   * method(s) that it calls would invalidate existing index data. Therefore, instead of changing
+   * the semantics of an existing field, a new field must be added using the new semantics from the
+   * start. The old field can be removed in another upgrade step (cf. {@code
+   * com.google.gerrit.index.IndexUpgradeValidator}).
+   *
+   * @param <I> type from which documents are created and search results are returned.
+   * @param <T> type that should be extracted from the input object to an index field.
+   */
+  @FunctionalInterface
+  public interface Getter<I, T> {
+    @Nullable
+    T get(I input) throws IOException;
+  }
+
+  /**
+   * Setter to reconstruct fields from indexed values back to an object.
+   *
+   * <p>See {@link Getter} for restrictions on changing the implementation.
+   *
+   * @param <I> type from which documents are created and search results are returned.
+   * @param <T> type that should be extracted from the input object when converting toto an index
+   *     field.
+   */
+  @FunctionalInterface
+  public interface Setter<I, T> {
+    void set(I object, T value);
+  }
+}
diff --git a/java/com/google/gerrit/index/SchemaUtil.java b/java/com/google/gerrit/index/SchemaUtil.java
index 9599d6a..8f47cf5 100644
--- a/java/com/google/gerrit/index/SchemaUtil.java
+++ b/java/com/google/gerrit/index/SchemaUtil.java
@@ -25,7 +25,6 @@
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.lang.reflect.ParameterizedType;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -49,7 +48,12 @@
             @SuppressWarnings("unchecked")
             Schema<V> schema = (Schema<V>) f.get(null);
             checkArgument(f.getName().startsWith("V"));
-            schema.setVersion(Integer.parseInt(f.getName().substring(1)));
+            int versionName = Integer.parseInt(f.getName().substring(1));
+            checkArgument(
+                versionName == schema.getVersion(),
+                "Schema version %s does not match its name %s",
+                schema.getVersion(),
+                f.getName());
             schemas.put(schema.getVersion(), schema);
           } catch (IllegalAccessException e) {
             throw new IllegalArgumentException(e);
@@ -66,29 +70,56 @@
     return ImmutableSortedMap.copyOf(schemas);
   }
 
-  public static <V> Schema<V> schema(Collection<FieldDef<V, ?>> fields) {
-    return new Schema<>(true, ImmutableList.copyOf(fields));
+  @SafeVarargs
+  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
+    return new Schema.Builder<V>().version(0).add(fields).build();
   }
 
-  public static <V> Schema<V> schema(Schema<V> schema, boolean useLegacyNumericFields) {
-    return new Schema<>(
-        useLegacyNumericFields,
-        new ImmutableList.Builder<FieldDef<V, ?>>().addAll(schema.getFields().values()).build());
+  @SafeVarargs
+  public static <V> Schema<V> schema(int version, FieldDef<V, ?>... fields) {
+    return new Schema.Builder<V>().version(version).add(fields).build();
+  }
+
+  public static <V> Schema<V> schema(int version, ImmutableList<FieldDef<V, ?>> fields) {
+    return new Schema.Builder<V>().version(version).add(fields).build();
   }
 
   @SafeVarargs
   public static <V> Schema<V> schema(Schema<V> schema, FieldDef<V, ?>... moreFields) {
-    return new Schema<>(
-        true,
-        new ImmutableList.Builder<FieldDef<V, ?>>()
-            .addAll(schema.getFields().values())
-            .addAll(ImmutableList.copyOf(moreFields))
-            .build());
+    return new Schema.Builder<V>().add(schema).add(moreFields).build();
   }
 
-  @SafeVarargs
-  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
-    return new Schema<>(true, ImmutableList.copyOf(fields));
+  public static <V> Schema<V> schema(
+      int version,
+      ImmutableList<FieldDef<V, ?>> fieldDefs,
+      ImmutableList<IndexedField<V, ?>> indexedFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return new Schema.Builder<V>()
+        .version(version)
+        .add(fieldDefs)
+        .addIndexedFields(indexedFields)
+        .addSearchSpecs(searchSpecs)
+        .build();
+  }
+
+  public static <V> Schema<V> schema(
+      Schema<V> schema,
+      ImmutableList<FieldDef<V, ?>> fieldDefs,
+      ImmutableList<IndexedField<V, ?>> indexedFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return new Schema.Builder<V>()
+        .add(schema)
+        .add(fieldDefs)
+        .addIndexedFields(indexedFields)
+        .addSearchSpecs(searchSpecs)
+        .build();
+  }
+
+  public static <V> Schema<V> schema(
+      ImmutableList<FieldDef<V, ?>> fieldDefs,
+      ImmutableList<IndexedField<V, ?>> indexFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return schema(/* version= */ 0, fieldDefs, indexFields, searchSpecs);
   }
 
   public static Set<String> getPersonParts(PersonIdent person) {
diff --git a/java/com/google/gerrit/index/SearchOption.java b/java/com/google/gerrit/index/SearchOption.java
new file mode 100644
index 0000000..3fbb68a
--- /dev/null
+++ b/java/com/google/gerrit/index/SearchOption.java
@@ -0,0 +1,29 @@
+// 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.index;
+
+/** Tokenization options enabled on {@link IndexedField}. */
+public enum SearchOption {
+  /** Enables range queries on the field. */
+  RANGE,
+  /** Enables prefix-match search on the field. */
+  PREFIX,
+  /** Enables exact-match search on the field. */
+  EXACT,
+  /** Enables fuzzy-match search on the field. */
+  FULL_TEXT,
+  /** The field can not be searched and is only returned as a payload from the index. */
+  STORE_ONLY,
+}
diff --git a/java/com/google/gerrit/index/StoredValue.java b/java/com/google/gerrit/index/StoredValue.java
index fe790c5..a7e7c26 100644
--- a/java/com/google/gerrit/index/StoredValue.java
+++ b/java/com/google/gerrit/index/StoredValue.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.index;
 
+import com.google.gerrit.common.Nullable;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 
 /**
@@ -47,4 +49,22 @@
 
   /** Returns the {@code byte[]} values of the field. */
   Iterable<byte[]> asByteArrays();
+
+  /**
+   * Returns the {@code MessageLite} value of the field.
+   *
+   * <p>Returns {@code null} if value is not stored as protos (e.g. stored as bytes). {@link
+   * #asByteArray} can be called instead to obtain the value.
+   */
+  @Nullable
+  MessageLite asProto();
+
+  /**
+   * Returns the {@code MessageLite} values of the field.
+   *
+   * <p>Returns {@code null} if value is not stored as protos (e.g. stored as bytes). {@link
+   * #asByteArrays} can be called instead to obtain the value.
+   */
+  @Nullable
+  Iterable<MessageLite> asProtos();
 }
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index c2c8986..3114b4c 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -26,7 +26,12 @@
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 
-/** Index schema for projects. */
+/**
+ * Index schema for projects.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
+ */
 public class ProjectField {
   private static byte[] toRefState(Project project) {
     return RefState.create(RefNames.REFS_CONFIG, project.getConfigRefState())
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 3cc5f9b..0619566 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -19,12 +19,18 @@
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 
-/** Definition of project index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of project index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class ProjectSchemaDefinitions extends SchemaDefinitions<ProjectData> {
 
   @Deprecated
   static final Schema<ProjectData> V1 =
       schema(
+          /* version= */ 1,
           ProjectField.NAME,
           ProjectField.DESCRIPTION,
           ProjectField.PARENT_NAME,
@@ -38,7 +44,10 @@
   @Deprecated static final Schema<ProjectData> V3 = schema(V2);
 
   // Lucene index was changed to add an additional field for sorting.
-  static final Schema<ProjectData> V4 = schema(V3);
+  @Deprecated static final Schema<ProjectData> V4 = schema(V3);
+
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<ProjectData> V5 = schema(V4);
 
   /**
    * Name of the project index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/index/query/FieldBundle.java b/java/com/google/gerrit/index/query/FieldBundle.java
index 6ecb6e6..60881df 100644
--- a/java/com/google/gerrit/index/query/FieldBundle.java
+++ b/java/com/google/gerrit/index/query/FieldBundle.java
@@ -19,7 +19,7 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 /** FieldBundle is an abstraction that allows retrieval of raw values from different sources. */
 public class FieldBundle {
@@ -34,8 +34,8 @@
   /**
    * Get a field's value based on the field definition.
    *
-   * @param fieldDef the definition of the field of which the value should be retrieved. The field
-   *     must be stored and contained in the result set as specified by {@link
+   * @param schemaField the definition of the field of which the value should be retrieved. The
+   *     field must be stored and contained in the result set as specified by {@link
    *     com.google.gerrit.index.QueryOptions}.
    * @param <T> Data type of the returned object based on the field definition
    * @return Either a single element or an Iterable based on the field definition. An empty list is
@@ -44,16 +44,16 @@
    *     check is only enforced on non-repeatable fields.
    */
   @SuppressWarnings("unchecked")
-  public <T> T getValue(FieldDef<?, T> fieldDef) {
-    checkArgument(fieldDef.isStored(), "Field must be stored");
+  public <T> T getValue(SchemaField<?, T> schemaField) {
+    checkArgument(schemaField.isStored(), "Field must be stored");
     checkArgument(
-        fields.containsKey(fieldDef.getName()) || fieldDef.isRepeatable(),
+        fields.containsKey(schemaField.getName()) || schemaField.isRepeatable(),
         "Field %s is not in result set %s",
-        fieldDef.getName(),
+        schemaField.getName(),
         fields.keySet());
 
-    Iterable<Object> result = fields.get(fieldDef.getName());
-    if (fieldDef.isRepeatable()) {
+    Iterable<Object> result = fields.get(schemaField.getName());
+    if (schemaField.isRepeatable()) {
       return (T) result;
     }
     return (T) Iterables.getOnlyElement(result);
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index b65fb96..de81c47 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -21,8 +21,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
 import com.google.common.primitives.Longs;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.Objects;
 import java.util.Set;
 import java.util.stream.StreamSupport;
@@ -37,19 +37,19 @@
    */
   private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_=\n"));
 
-  private final FieldDef<I, ?> def;
+  private final SchemaField<I, ?> def;
 
-  protected IndexPredicate(FieldDef<I, ?> def, String value) {
+  protected IndexPredicate(SchemaField<I, ?> def, String value) {
     super(def.getName(), value);
     this.def = def;
   }
 
-  protected IndexPredicate(FieldDef<I, ?> def, String name, String value) {
+  protected IndexPredicate(SchemaField<I, ?> def, String name, String value) {
     super(name, value);
     this.def = def;
   }
 
-  public FieldDef<I, ?> getField() {
+  public SchemaField<I, ?> getField() {
     return def;
   }
 
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index 21d4c2e..959694b 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -367,7 +367,7 @@
       return requestedFields;
     }
     Index<?, T> index = indexes.getSearchIndex();
-    return index != null ? index.getSchema().getStoredFields().keySet() : ImmutableSet.of();
+    return index != null ? index.getSchema().getStoredFields() : ImmutableSet.of();
   }
 
   /**
diff --git a/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java b/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
index b0a394e..9158a39 100644
--- a/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
+++ b/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
@@ -14,16 +14,24 @@
 
 package com.google.gerrit.index.query;
 
+import com.google.gerrit.common.UsedAt;
+
 public class TooManyTermsInQueryException extends QueryParseException {
   private static final long serialVersionUID = 1L;
 
   private static final String MESSAGE = "too many terms in query";
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public TooManyTermsInQueryException() {
     super(MESSAGE);
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public TooManyTermsInQueryException(Throwable why) {
     super(MESSAGE, why);
   }
+
+  public TooManyTermsInQueryException(int numTerms, int maxConfiguredTerms) {
+    super(MESSAGE + String.format(": %d terms (max = %d)", numTerms, maxConfiguredTerms));
+  }
 }
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index 4241828..50efef0 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -25,10 +25,10 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.query.DataSource;
@@ -165,7 +165,7 @@
         K searchAfter = null;
         for (V result : results) {
           ImmutableListMultimap.Builder<String, Object> fields = ImmutableListMultimap.builder();
-          for (FieldDef<V, ?> field : getSchema().getFields().values()) {
+          for (SchemaField<V, ?> field : getSchema().getSchemaFields().values()) {
             if (field.get(result) == null) {
               continue;
             }
@@ -249,7 +249,7 @@
     @Override
     protected Map<String, Object> docFor(ChangeData value) {
       ImmutableMap.Builder<String, Object> doc = ImmutableMap.builder();
-      for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+      for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
         if (ChangeField.MERGEABLE.getName().equals(field.getName()) && skipMergable) {
           continue;
         }
@@ -267,7 +267,7 @@
           changeDataFactory.create(
               Project.nameKey((String) doc.get(ChangeField.PROJECT.getName())),
               Change.id(Integer.valueOf((String) doc.get(ChangeField.LEGACY_ID_STR.getName()))));
-      for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+      for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
         field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName())));
       }
       return cd;
diff --git a/java/com/google/gerrit/index/testing/BUILD b/java/com/google/gerrit/index/testing/BUILD
index a30eaca..9af2598 100644
--- a/java/com/google/gerrit/index/testing/BUILD
+++ b/java/com/google/gerrit/index/testing/BUILD
@@ -23,5 +23,6 @@
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
+        "//proto:entities_java_proto",
     ],
 )
diff --git a/java/com/google/gerrit/index/testing/FakeStoredValue.java b/java/com/google/gerrit/index/testing/FakeStoredValue.java
index 5091068..133e0ab 100644
--- a/java/com/google/gerrit/index/testing/FakeStoredValue.java
+++ b/java/com/google/gerrit/index/testing/FakeStoredValue.java
@@ -14,15 +14,29 @@
 
 package com.google.gerrit.index.testing;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.index.StoredValue;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 
 /** Bridge to recover fields from the fake index. */
 public class FakeStoredValue implements StoredValue {
   private final Object field;
+  /**
+   * Some index implementations store protos, some convert them to bytes first.
+   *
+   * <p>This property is true if field is stored in proto format.
+   */
+  private final boolean isProto;
 
   public FakeStoredValue(Object field) {
     this.field = field;
+    this.isProto = false;
+  }
+
+  public FakeStoredValue(Object field, boolean isProto) {
+    this.field = field;
+    this.isProto = isProto;
   }
 
   @Override
@@ -69,6 +83,25 @@
   }
 
   @Override
+  @Nullable
+  public MessageLite asProto() {
+    if (isProto) {
+      return (MessageLite) field;
+    }
+    return null;
+  }
+
+  @Override
+  @Nullable
+  @SuppressWarnings("unchecked")
+  public Iterable<MessageLite> asProtos() {
+    if (isProto) {
+      return (Iterable<MessageLite>) field;
+    }
+    return null;
+  }
+
+  @Override
   @SuppressWarnings("unchecked")
   public Iterable<byte[]> asByteArrays() {
     return (Iterable<byte[]>) field;
diff --git a/java/com/google/gerrit/index/testing/TestIndexedFields.java b/java/com/google/gerrit/index/testing/TestIndexedFields.java
new file mode 100644
index 0000000..f80b8a1
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/TestIndexedFields.java
@@ -0,0 +1,209 @@
+// 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.index.testing;
+
+import com.google.common.reflect.TypeToken;
+import com.google.gerrit.entities.converter.ChangeProtoConverter;
+import com.google.gerrit.index.IndexedField;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.index.SchemaFieldDefs.Setter;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Change;
+import com.google.gerrit.proto.Entities.Change_Id;
+import java.io.IOException;
+import java.sql.Timestamp;
+
+/**
+ * Collection of {@link IndexedField}, used in unit tests.
+ *
+ * <p>The list of {@link IndexedField} below are field types, that are currently supported and used
+ * in different index implementations
+ *
+ * <p>They are used in unit tests to make sure these types can be extracted to index and assigned
+ * back to object.
+ */
+public final class TestIndexedFields {
+
+  /** Test input object for {@link IndexedField} */
+  public static class TestIndexedData {
+
+    /** Key that is used to index to identify indexed object */
+    private Object key;
+
+    /** Field value that is extracted from this indexed object to the index document. */
+    private Object testFieldValue;
+
+    public Object getTestField() {
+      return testFieldValue;
+    }
+
+    public void setTestFieldValue(Object testFieldValue) {
+      this.testFieldValue = testFieldValue;
+    }
+
+    public Object getKey() {
+      return key;
+    }
+
+    public void setKey(Object key) {
+      this.key = key;
+    }
+  }
+
+  /** Setter for {@link TestIndexedData} */
+  private static class TestIndexedDataSetter<T> implements Setter<TestIndexedData, T> {
+    @Override
+    public void set(TestIndexedData testIndexedData, T value) {
+      testIndexedData.setTestFieldValue(value);
+    }
+  }
+
+  /** Getter for {@link TestIndexedData} */
+  @SuppressWarnings("unchecked")
+  private static class TestIndexedDataGetter<T> implements Getter<TestIndexedData, T> {
+    @Override
+    public T get(TestIndexedData input) throws IOException {
+      return (T) input.getTestField();
+    }
+  }
+
+  public static <T> TestIndexedDataSetter<T> setter() {
+    return new TestIndexedDataSetter<>();
+  }
+
+  public static <T> TestIndexedDataGetter<T> getter() {
+    return new TestIndexedDataGetter<>();
+  }
+
+  public static final IndexedField<TestIndexedData, Integer> INTEGER_FIELD =
+      IndexedField.<TestIndexedData>integerBuilder("IntegerTestField").build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Integer>.SearchSpec INTEGER_FIELD_SPEC =
+      INTEGER_FIELD.integer("integer_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>> ITERABLE_INTEGER_FIELD =
+      IndexedField.<TestIndexedData>iterableIntegerBuilder("IterableIntegerTestField")
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>>.SearchSpec
+      ITERABLE_INTEGER_FIELD_SPEC = ITERABLE_INTEGER_FIELD.integer("iterable_integer_test");
+
+  public static final IndexedField<TestIndexedData, Integer> INTEGER_RANGE_FIELD =
+      IndexedField.<TestIndexedData>integerBuilder("IntegerRangeTestField")
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+  public static final IndexedField<TestIndexedData, Integer>.SearchSpec INTEGER_RANGE_FIELD_SPEC =
+      INTEGER_RANGE_FIELD.range("integer_range_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>>
+      ITERABLE_INTEGER_RANGE_FIELD =
+          IndexedField.<TestIndexedData>iterableIntegerBuilder("IterableIntegerRangeTestField")
+              .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>>.SearchSpec
+      ITERABLE_INTEGER_RANGE_FIELD_SPEC =
+          ITERABLE_INTEGER_RANGE_FIELD.range("iterable_integer_range_test");
+
+  public static final IndexedField<TestIndexedData, Long> LONG_FIELD =
+      IndexedField.<TestIndexedData>longBuilder("LongTestField").build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Long>.SearchSpec LONG_FIELD_SPEC =
+      LONG_FIELD.longSearch("long_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>> ITERABLE_LONG_FIELD =
+      IndexedField.<TestIndexedData, Iterable<Long>>builder(
+              "IterableLongTestField", IndexedField.ITERABLE_LONG_TYPE)
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>>.SearchSpec
+      ITERABLE_LONG_FIELD_SPEC = ITERABLE_LONG_FIELD.longSearch("iterable_long_test");
+
+  public static final IndexedField<TestIndexedData, Long> LONG_RANGE_FIELD =
+      IndexedField.<TestIndexedData>longBuilder("LongRangeTestField")
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Long>.SearchSpec LONG_RANGE_FIELD_SPEC =
+      LONG_RANGE_FIELD.range("long_range_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>> ITERABLE_LONG_RANGE_FIELD =
+      IndexedField.<TestIndexedData, Iterable<Long>>builder(
+              "IterableLongRangeTestField", IndexedField.ITERABLE_LONG_TYPE)
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>>.SearchSpec
+      ITERABLE_LONG_RANGE_FIELD_SPEC = ITERABLE_LONG_RANGE_FIELD.range("iterable_long_range_test");
+
+  public static final IndexedField<TestIndexedData, Timestamp> TIMESTAMP_FIELD =
+      IndexedField.<TestIndexedData>timestampBuilder("TimestampTestField")
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Timestamp>.SearchSpec TIMESTAMP_FIELD_SPEC =
+      TIMESTAMP_FIELD.timestamp("timestamp_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<String>> ITERABLE_STRING_FIELD =
+      IndexedField.<TestIndexedData>iterableStringBuilder("IterableStringTestField")
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<String>>.SearchSpec
+      ITERABLE_STRING_FIELD_SPEC = ITERABLE_STRING_FIELD.fullText("iterable_test_string");
+
+  public static final IndexedField<TestIndexedData, String> STRING_FIELD =
+      IndexedField.<TestIndexedData>stringBuilder("StringTestField").build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, String>.SearchSpec STRING_FIELD_SPEC =
+      STRING_FIELD.fullText("string_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<byte[]>> ITERABLE_STORED_BYTE_FIELD =
+      IndexedField.<TestIndexedData>iterableByteArrayBuilder("IterableByteTestField")
+          .stored()
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<byte[]>>.SearchSpec
+      ITERABLE_STORED_BYTE_SPEC = ITERABLE_STORED_BYTE_FIELD.storedOnly("iterable_byte_test");
+
+  public static final IndexedField<TestIndexedData, byte[]> STORED_BYTE_FIELD =
+      IndexedField.<TestIndexedData>byteArrayBuilder("ByteTestField")
+          .stored()
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, byte[]>.SearchSpec STORED_BYTE_SPEC =
+      STORED_BYTE_FIELD.storedOnly("byte_test");
+
+  public static final IndexedField<TestIndexedData, Entities.Change> STORED_PROTO_FIELD =
+      IndexedField.<TestIndexedData, Entities.Change>builder(
+              "TestChange", new TypeToken<Entities.Change>() {})
+          .stored()
+          .build(getter(), setter(), ChangeProtoConverter.INSTANCE);
+
+  public static final IndexedField<TestIndexedData, Entities.Change>.SearchSpec
+      STORED_PROTO_FIELD_SPEC = STORED_PROTO_FIELD.storedOnly("test_change");
+
+  public static final IndexedField<TestIndexedData, Iterable<Entities.Change>>
+      ITERABLE_STORED_PROTO_FIELD =
+          IndexedField.<TestIndexedData, Iterable<Entities.Change>>builder(
+                  "IterableTestChange", new TypeToken<Iterable<Entities.Change>>() {})
+              .stored()
+              .build(getter(), setter(), ChangeProtoConverter.INSTANCE);
+
+  public static final IndexedField<TestIndexedData, Iterable<Entities.Change>>.SearchSpec
+      ITERABLE_PROTO_FIELD_SPEC = ITERABLE_STORED_PROTO_FIELD.storedOnly("iterable_test_change");
+
+  public static Change createChangeProto(int id) {
+    return Entities.Change.newBuilder()
+        .setChangeId(Change_Id.newBuilder().setId(id).build())
+        .build();
+  }
+
+  private TestIndexedFields() {}
+}
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 586887b..7b47248 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -33,12 +33,12 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.ListResultSet;
@@ -50,7 +50,6 @@
 import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import java.io.IOException;
 import java.sql.Timestamp;
-import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -66,8 +65,6 @@
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.Field.Store;
 import org.apache.lucene.document.IntPoint;
-import org.apache.lucene.document.LegacyIntField;
-import org.apache.lucene.document.LegacyLongField;
 import org.apache.lucene.document.LongPoint;
 import org.apache.lucene.document.StoredField;
 import org.apache.lucene.document.StringField;
@@ -88,11 +85,10 @@
 import org.apache.lucene.store.Directory;
 
 /** Basic Lucene index implementation. */
-@SuppressWarnings("deprecation")
 public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  static String sortFieldName(FieldDef<?, ?> f) {
+  static String sortFieldName(SchemaField<?, ?> f) {
     return f.getName() + "_SORT";
   }
 
@@ -348,13 +344,9 @@
     if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
       for (Object value : values.getValues()) {
         Integer intValue = (Integer) value;
-        if (schema.useLegacyNumericFields()) {
-          doc.add(new LegacyIntField(name, intValue, store));
-        } else {
-          doc.add(new IntPoint(name, intValue));
-          if (store == Store.YES) {
-            doc.add(new StoredField(name, intValue));
-          }
+        doc.add(new IntPoint(name, intValue));
+        if (store == Store.YES) {
+          doc.add(new StoredField(name, intValue));
         }
       }
     } else if (type == FieldType.LONG) {
@@ -383,22 +375,17 @@
   }
 
   private void addLongField(Document doc, String name, Store store, Long longValue) {
-    if (schema.useLegacyNumericFields()) {
-      doc.add(new LegacyLongField(name, longValue, store));
-    } else {
-      doc.add(new LongPoint(name, longValue));
-      if (store == Store.YES) {
-        doc.add(new StoredField(name, longValue));
-      }
+    doc.add(new LongPoint(name, longValue));
+    if (store == Store.YES) {
+      doc.add(new StoredField(name, longValue));
     }
   }
 
   protected FieldBundle toFieldBundle(Document doc) {
-    Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
     ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
     for (IndexableField field : doc.getFields()) {
-      checkArgument(allFields.containsKey(field.name()), "Unrecognized field " + field.name());
-      FieldType<?> type = allFields.get(field.name()).getType();
+      checkArgument(getSchema().hasField(field.name()), "Unrecognized field " + field.name());
+      FieldType<?> type = getSchema().getSchemaField(field.name()).getType();
       if (type == FieldType.EXACT || type == FieldType.FULL_TEXT || type == FieldType.PREFIX) {
         rawFields.put(field.name(), field.stringValue());
       } else if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
@@ -416,7 +403,7 @@
     return new FieldBundle(rawFields);
   }
 
-  private static Field.Store store(FieldDef<?, ?> f) {
+  private static Field.Store store(SchemaField<?, ?> f) {
     return f.isStored() ? Field.Store.YES : Field.Store.NO;
   }
 
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 475dac4..ce50473 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -15,18 +15,17 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.lucene.LuceneChangeIndex.ID2_SORT_FIELD;
-import static com.google.gerrit.lucene.LuceneChangeIndex.ID_SORT_FIELD;
+import static com.google.gerrit.lucene.LuceneChangeIndex.ID_STR_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.MERGED_ON_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
 import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
@@ -119,13 +118,10 @@
   @Override
   void add(Document doc, Values<ChangeData> values) {
     // Add separate DocValues fields for those fields needed for sorting.
-    FieldDef<ChangeData, ?> f = values.getField();
-    if (f == ChangeField.LEGACY_ID) {
-      int v = (Integer) getOnlyElement(values.getValues());
-      doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
-    } else if (f == ChangeField.LEGACY_ID_STR) {
+    SchemaField<ChangeData, ?> f = values.getField();
+    if (f == ChangeField.LEGACY_ID_STR) {
       String v = (String) getOnlyElement(values.getValues());
-      doc.add(new NumericDocValuesField(ID2_SORT_FIELD, Integer.valueOf(v)));
+      doc.add(new NumericDocValuesField(ID_STR_SORT_FIELD, Integer.valueOf(v)));
     } else if (f == ChangeField.UPDATED) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index c4a5240..2e1771f 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -15,18 +15,18 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.server.index.account.AccountField.FULL_NAME;
-import static com.google.gerrit.server.index.account.AccountField.ID;
-import static com.google.gerrit.server.index.account.AccountField.ID_STR;
-import static com.google.gerrit.server.index.account.AccountField.PREFERRED_EMAIL_EXACT;
+import static com.google.gerrit.server.index.account.AccountField.FULL_NAME_SPEC;
+import static com.google.gerrit.server.index.account.AccountField.ID_FIELD_SPEC;
+import static com.google.gerrit.server.index.account.AccountField.ID_STR_FIELD_SPEC;
+import static com.google.gerrit.server.index.account.AccountField.PREFERRED_EMAIL_EXACT_SPEC;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -50,9 +50,9 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
-import org.apache.lucene.store.RAMDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -60,19 +60,20 @@
     implements AccountIndex {
   private static final String ACCOUNTS = "accounts";
 
-  private static final String FULL_NAME_SORT_FIELD = sortFieldName(FULL_NAME);
-  private static final String EMAIL_SORT_FIELD = sortFieldName(PREFERRED_EMAIL_EXACT);
-  private static final String ID_SORT_FIELD = sortFieldName(ID);
-  private static final String ID2_SORT_FIELD = sortFieldName(ID_STR);
+  private static final String FULL_NAME_SORT_FIELD = sortFieldName(FULL_NAME_SPEC);
+  private static final String EMAIL_SORT_FIELD = sortFieldName(PREFERRED_EMAIL_EXACT_SPEC);
+  private static final String ID_SORT_FIELD = sortFieldName(ID_FIELD_SPEC);
+  private static final String ID2_SORT_FIELD = sortFieldName(ID_STR_FIELD_SPEC);
 
   private static Term idTerm(boolean useLegacyNumericFields, AccountState as) {
     return idTerm(useLegacyNumericFields, as.account().id());
   }
 
   private static Term idTerm(boolean useLegacyNumericFields, Account.Id id) {
-    FieldDef<AccountState, ?> idField = useLegacyNumericFields ? ID : ID_STR;
+    SchemaField<AccountState, ?> idField =
+        useLegacyNumericFields ? ID_FIELD_SPEC : ID_STR_FIELD_SPEC;
     if (useLegacyNumericFields) {
-      return QueryBuilder.intTerm(idField.getName(), id.get());
+      return QueryBuilder.intTerm(idField.getName());
     }
     return QueryBuilder.stringTerm(idField.getName(), Integer.toString(id.get()));
   }
@@ -84,7 +85,7 @@
   private static Directory dir(Schema<AccountState> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
+      return new ByteBuffersDirectory();
     }
     Path indexDir = LuceneVersionManager.getDir(sitePaths, ACCOUNTS, schema);
     return FSDirectory.open(indexDir);
@@ -117,17 +118,17 @@
   @Override
   void add(Document doc, Values<AccountState> values) {
     // Add separate DocValues fields for those fields needed for sorting.
-    FieldDef<AccountState, ?> f = values.getField();
-    if (f == ID) {
+    SchemaField<AccountState, ?> f = values.getField();
+    if (f == ID_FIELD_SPEC) {
       int v = (Integer) getOnlyElement(values.getValues());
       doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
-    } else if (f == ID_STR) {
+    } else if (f == ID_STR_FIELD_SPEC) {
       String v = (String) getOnlyElement(values.getValues());
       doc.add(new NumericDocValuesField(ID2_SORT_FIELD, Integer.valueOf(v)));
-    } else if (f == FULL_NAME) {
+    } else if (f == FULL_NAME_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(FULL_NAME_SORT_FIELD, new BytesRef(value)));
-    } else if (f == PREFERRED_EMAIL_EXACT) {
+    } else if (f == PREFERRED_EMAIL_EXACT_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(EMAIL_SORT_FIELD, new BytesRef(value)));
     }
@@ -137,7 +138,7 @@
   @Override
   public void replace(AccountState as) {
     try {
-      replace(idTerm(getSchema().useLegacyNumericFields(), as), toDocument(as)).get();
+      replace(idTerm(getSchema().hasField(ID_FIELD_SPEC), as), toDocument(as)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -155,7 +156,7 @@
   @Override
   public void delete(Account.Id key) {
     try {
-      delete(idTerm(getSchema().useLegacyNumericFields(), key)).get();
+      delete(idTerm(getSchema().hasField(ID_FIELD_SPEC), key)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -164,15 +165,14 @@
   @Override
   public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
       throws QueryParseException {
-    queryBuilder.getSchema().useLegacyNumericFields();
     return new LuceneQuerySource(
-        opts.filterFields(o -> IndexUtils.accountFields(o, getSchema().useLegacyNumericFields())),
+        opts.filterFields(o -> IndexUtils.accountFields(o, getSchema().hasField(ID_FIELD_SPEC))),
         queryBuilder.toQuery(p),
         getSort());
   }
 
   private Sort getSort() {
-    String idSortField = getSchema().useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
+    String idSortField = getSchema().hasField(ID_FIELD_SPEC) ? ID_SORT_FIELD : ID2_SORT_FIELD;
     return new Sort(
         new SortField(FULL_NAME_SORT_FIELD, SortField.Type.STRING, false),
         new SortField(EMAIL_SORT_FIELD, SortField.Type.STRING, false),
@@ -181,10 +181,11 @@
 
   @Override
   protected AccountState fromDocument(Document doc) {
-    FieldDef<AccountState, ?> idField = getSchema().useLegacyNumericFields() ? ID : ID_STR;
+    SchemaField<AccountState, ?> idField =
+        getSchema().hasField(ID_FIELD_SPEC) ? ID_STR_FIELD_SPEC : ID_STR_FIELD_SPEC;
     Account.Id id =
         Account.id(
-            getSchema().useLegacyNumericFields()
+            getSchema().hasField(ID_FIELD_SPEC)
                 ? doc.getField(idField.getName()).numericValue().intValue()
                 : Integer.valueOf(doc.getField(idField.getName()).stringValue()));
     // Use the AccountCache rather than depending on any stored fields in the document (of which
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index daa921c..3276f25 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -17,7 +17,6 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
@@ -39,10 +38,10 @@
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.PaginationType;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.HasCardinality;
 import com.google.gerrit.index.query.Predicate;
@@ -89,7 +88,7 @@
 import org.apache.lucene.search.SortField;
 import org.apache.lucene.search.TopDocs;
 import org.apache.lucene.search.TopFieldDocs;
-import org.apache.lucene.store.RAMDirectory;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -105,30 +104,39 @@
 
   static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
   static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON);
-  static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
-  static final String ID2_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
+  static final String ID_STR_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
 
   private static final String CHANGES = "changes";
   private static final String CHANGES_OPEN = "open";
   private static final String CHANGES_CLOSED = "closed";
   private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
 
-  @FunctionalInterface
-  interface IdTerm {
-    Term get(String name, int id);
+  /*
+    @FunctionalInterface
+    interface IdTerm {
+      Term get(String name, int id);
+    }
+
+    static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, ChangeData cd) {
+      return idTerm(idTerm, idField, cd.getId());
+    }
+
+    static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, Change.Id id) {
+      return idTerm.get(idField.getName(), id.get());
+    }
+
+    @FunctionalInterface
+    interface ChangeIdExtractor {
+      Change.Id extract(IndexableField f);
+    }
+  */
+
+  static Term idTerm(ChangeData cd) {
+    return idTerm(cd.getVirtualId());
   }
 
-  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, ChangeData cd) {
-    return idTerm(idTerm, idField, cd.getId());
-  }
-
-  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, Change.Id id) {
-    return idTerm.get(idField.getName(), id.get());
-  }
-
-  @FunctionalInterface
-  interface ChangeIdExtractor {
-    Change.Id extract(IndexableField f);
+  static Term idTerm(Change.Id id) {
+    return QueryBuilder.stringTerm(LEGACY_ID_STR.getName(), Integer.toString(id.get()));
   }
 
   private final ListeningExecutorService executor;
@@ -137,12 +145,6 @@
   private final QueryBuilder<ChangeData> queryBuilder;
   private final ChangeSubIndex openIndex;
   private final ChangeSubIndex closedIndex;
-
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final FieldDef<ChangeData, ?> idField;
-  private final String idSortFieldName;
-  private final IdTerm idTerm;
-  private final ChangeIdExtractor extractor;
   private final ImmutableSet<String> skipFields;
 
   @Inject
@@ -173,7 +175,7 @@
           new ChangeSubIndex(
               schema,
               sitePaths,
-              new RAMDirectory(),
+              new ByteBuffersDirectory(),
               "ramOpen",
               skipFields,
               openConfig,
@@ -183,7 +185,7 @@
           new ChangeSubIndex(
               schema,
               sitePaths,
-              new RAMDirectory(),
+              new ByteBuffersDirectory(),
               "ramClosed",
               skipFields,
               closedConfig,
@@ -210,20 +212,6 @@
               searcherFactory,
               autoFlush);
     }
-
-    idField = this.schema.useLegacyNumericFields() ? LEGACY_ID : LEGACY_ID_STR;
-    idSortFieldName = schema.useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
-    idTerm =
-        (name, id) ->
-            this.schema.useLegacyNumericFields()
-                ? QueryBuilder.intTerm(name, id)
-                : QueryBuilder.stringTerm(name, Integer.toString(id));
-    extractor =
-        (f) ->
-            Change.id(
-                this.schema.useLegacyNumericFields()
-                    ? f.numericValue().intValue()
-                    : Integer.valueOf(f.stringValue()));
   }
 
   @Override
@@ -242,7 +230,7 @@
 
   @Override
   public void replace(ChangeData cd) {
-    Term id = LuceneChangeIndex.idTerm(idTerm, idField, cd);
+    Term id = LuceneChangeIndex.idTerm(cd);
     // toDocument is essentially static and doesn't depend on the specific
     // sub-index, so just pick one.
     Document doc = openIndex.toDocument(cd);
@@ -275,9 +263,9 @@
 
   @Override
   public void delete(Change.Id changeId) {
-    Term id = LuceneChangeIndex.idTerm(idTerm, idField, changeId);
+    Term idTerm = LuceneChangeIndex.idTerm(changeId);
     try {
-      Futures.allAsList(openIndex.delete(id), closedIndex.delete(id)).get();
+      Futures.allAsList(openIndex.delete(idTerm), closedIndex.delete(idTerm)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -314,7 +302,7 @@
     return new Sort(
         new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
         new SortField(MERGED_ON_SORT_FIELD, SortField.Type.LONG, true),
-        new SortField(idSortFieldName, SortField.Type.LONG, true));
+        new SortField(ID_STR_SORT_FIELD, SortField.Type.LONG, true));
   }
 
   private class QuerySource implements ChangeDataSource {
@@ -368,7 +356,7 @@
         throw new StorageException("interrupted");
       }
 
-      final Set<String> fields = IndexUtils.changeFields(opts, schema.useLegacyNumericFields());
+      final Set<String> fields = IndexUtils.changeFields(opts);
       return new ChangeDataResults(
           executor.submit(
               new Callable<Results>() {
@@ -391,7 +379,7 @@
       Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex;
 
       try {
-        Results r = doRead(IndexUtils.changeFields(opts, schema.useLegacyNumericFields()));
+        Results r = doRead(IndexUtils.changeFields(opts));
         documents = r.docs;
         searchAfterBySubIndex = r.searchAfterBySubIndex;
       } catch (IOException e) {
@@ -530,7 +518,7 @@
         ImmutableList.Builder<ChangeData> result =
             ImmutableList.builderWithExpectedSize(docs.size());
         for (Document doc : docs) {
-          result.add(toChangeData(fields(doc, fields), fields, idField.getName()));
+          result.add(toChangeData(fields(doc, fields), fields, LEGACY_ID_STR.getName()));
         }
         return result.build();
       } catch (InterruptedException e) {
@@ -577,12 +565,13 @@
     } else {
       IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
 
+      Change.Id id = Change.id(Integer.valueOf(f.stringValue()));
       // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
       IndexableField project = doc.get(PROJECT.getName()).iterator().next();
-      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), extractor.extract(f));
+      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), id);
     }
 
-    for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+    for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
       if (fields.contains(field.getName()) && doc.get(field.getName()) != null) {
         field.setIfPossible(cd, new LuceneStoredValue(doc.get(field.getName())));
       }
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index f7a2248..d475ab7 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -15,16 +15,16 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.server.index.group.GroupField.UUID;
+import static com.google.gerrit.server.index.group.GroupField.UUID_FIELD_SPEC;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -46,9 +46,9 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
-import org.apache.lucene.store.RAMDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -57,14 +57,14 @@
 
   private static final String GROUPS = "groups";
 
-  private static final String UUID_SORT_FIELD = sortFieldName(UUID);
+  private static final String UUID_SORT_FIELD = sortFieldName(UUID_FIELD_SPEC);
 
   private static Term idTerm(InternalGroup group) {
     return idTerm(group.getGroupUUID());
   }
 
   private static Term idTerm(AccountGroup.UUID uuid) {
-    return QueryBuilder.stringTerm(UUID.getName(), uuid.get());
+    return QueryBuilder.stringTerm(UUID_FIELD_SPEC.getName(), uuid.get());
   }
 
   private final GerritIndexWriterConfig indexWriterConfig;
@@ -74,7 +74,7 @@
   private static Directory dir(Schema<?> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
+      return new ByteBuffersDirectory();
     }
     Path indexDir = LuceneVersionManager.getDir(sitePaths, GROUPS, schema);
     return FSDirectory.open(indexDir);
@@ -107,8 +107,8 @@
   @Override
   void add(Document doc, Values<InternalGroup> values) {
     // Add separate DocValues field for the field that is needed for sorting.
-    FieldDef<InternalGroup, ?> f = values.getField();
-    if (f == UUID) {
+    SchemaField<InternalGroup, ?> f = values.getField();
+    if (f == UUID_FIELD_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(UUID_SORT_FIELD, new BytesRef(value)));
     }
@@ -153,7 +153,8 @@
 
   @Override
   protected InternalGroup fromDocument(Document doc) {
-    AccountGroup.UUID uuid = AccountGroup.uuid(doc.getField(UUID.getName()).stringValue());
+    AccountGroup.UUID uuid =
+        AccountGroup.uuid(doc.getField(UUID_FIELD_SPEC.getName()).stringValue());
     // Use the GroupCache rather than depending on any stored fields in the
     // document (of which there shouldn't be any).
     return groupCache.get().get(uuid).orElse(null);
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 11707be..96b22db 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -20,10 +20,10 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.query.DataSource;
@@ -47,9 +47,9 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
-import org.apache.lucene.store.RAMDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -74,7 +74,7 @@
   private static Directory dir(Schema<ProjectData> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
+      return new ByteBuffersDirectory();
     }
     Path indexDir = LuceneVersionManager.getDir(sitePaths, PROJECTS, schema);
     return FSDirectory.open(indexDir);
@@ -107,7 +107,7 @@
   @Override
   void add(Document doc, Values<ProjectData> values) {
     // Add separate DocValues field for the field that is needed for sorting.
-    FieldDef<ProjectData, ?> f = values.getField();
+    SchemaField<ProjectData, ?> f = values.getField();
     if (f == NAME) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(NAME_SORT_FIELD, new BytesRef(value)));
diff --git a/java/com/google/gerrit/lucene/LuceneStoredValue.java b/java/com/google/gerrit/lucene/LuceneStoredValue.java
index efe489b..1f8c039 100644
--- a/java/com/google/gerrit/lucene/LuceneStoredValue.java
+++ b/java/com/google/gerrit/lucene/LuceneStoredValue.java
@@ -18,7 +18,9 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.index.StoredValue;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 import java.util.List;
 import org.apache.lucene.index.IndexableField;
@@ -81,6 +83,20 @@
     return copyAsBytes(field);
   }
 
+  @Override
+  @Nullable
+  public MessageLite asProto() {
+    // Lucene does not store protos
+    return null;
+  }
+
+  @Override
+  @Nullable
+  public Iterable<MessageLite> asProtos() {
+    // Lucene does not store protos
+    return null;
+  }
+
   private static List<byte[]> copyAsBytes(List<IndexableField> fields) {
     return fields.stream()
         .map(
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index e1b56c6..14ad528 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST_NOT;
@@ -39,36 +40,18 @@
 import org.apache.lucene.document.LongPoint;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.search.BooleanQuery;
-import org.apache.lucene.search.LegacyNumericRangeQuery;
 import org.apache.lucene.search.MatchAllDocsQuery;
 import org.apache.lucene.search.PrefixQuery;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.RegexpQuery;
 import org.apache.lucene.search.TermQuery;
 import org.apache.lucene.util.BytesRefBuilder;
-import org.apache.lucene.util.LegacyNumericUtils;
 
-@SuppressWarnings("deprecation")
 public class QueryBuilder<V> {
-  @FunctionalInterface
-  static interface IntTermQuery {
-    Query get(String name, int value);
-  }
-
-  @FunctionalInterface
-  static interface IntRangeQuery {
-    Query get(String name, int min, int max);
-  }
-
-  @FunctionalInterface
-  static interface LongRangeQuery {
-    Query get(String name, long min, long max);
-  }
-
-  static Term intTerm(String name, int value) {
-    BytesRefBuilder builder = new BytesRefBuilder();
-    LegacyNumericUtils.intToPrefixCoded(value, 0, builder);
-    return new Term(name, builder.get());
+  /** @param name field name qparam i key value */
+  static Term intTerm(String name) {
+    checkState(false, "Lucene index implementation removed legacy numeric type");
+    return null;
   }
 
   static Term stringTerm(String name, String value) {
@@ -84,29 +67,9 @@
   private final Schema<V> schema;
   private final org.apache.lucene.util.QueryBuilder queryBuilder;
 
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final IntTermQuery intTermQuery;
-  private final IntRangeQuery intRangeTermQuery;
-  private final LongRangeQuery longRangeQuery;
-
   public QueryBuilder(Schema<V> schema, Analyzer analyzer) {
     this.schema = schema;
     queryBuilder = new org.apache.lucene.util.QueryBuilder(analyzer);
-    intTermQuery =
-        (name, value) ->
-            this.schema.useLegacyNumericFields()
-                ? new TermQuery(intTerm(name, value))
-                : intPoint(name, value);
-    intRangeTermQuery =
-        (name, min, max) ->
-            this.schema.useLegacyNumericFields()
-                ? LegacyNumericRangeQuery.newIntRange(name, min, max, true, true)
-                : IntPoint.newRangeQuery(name, min, max);
-    longRangeQuery =
-        (name, min, max) ->
-            this.schema.useLegacyNumericFields()
-                ? LegacyNumericRangeQuery.newLongRange(name, min, max, true, true)
-                : LongPoint.newRangeQuery(name, min, max);
   }
 
   public Query toQuery(Predicate<V> p) throws QueryParseException {
@@ -209,7 +172,7 @@
     } catch (NumberFormatException e) {
       throw new QueryParseException("not an integer: " + p.getValue(), e);
     }
-    return intTermQuery.get(p.getField().getName(), value);
+    return intPoint(p.getField().getName(), value);
   }
 
   private Query intRangeQuery(IndexPredicate<V> p) throws QueryParseException {
@@ -220,9 +183,9 @@
       int maximum = r.getMaximumValue();
       if (minimum == maximum) {
         // Just fall back to a standard integer query.
-        return intTermQuery.get(name, minimum);
+        return intPoint(name, minimum);
       }
-      return intRangeTermQuery.get(name, minimum, maximum);
+      return IntPoint.newRangeQuery(name, minimum, maximum);
     }
     throw new QueryParseException("not an integer range: " + p);
   }
@@ -230,7 +193,7 @@
   private Query timestampQuery(IndexPredicate<V> p) throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<V> r = (TimestampRangePredicate<V>) p;
-      return longRangeQuery.get(
+      return LongPoint.newRangeQuery(
           r.getField().getName(),
           r.getMinTimestamp().toEpochMilli(),
           r.getMaxTimestamp().toEpochMilli());
@@ -240,7 +203,7 @@
 
   private Query notTimestamp(TimestampRangePredicate<V> r) throws QueryParseException {
     if (r.getMinTimestamp().toEpochMilli() == 0) {
-      return longRangeQuery.get(
+      return LongPoint.newRangeQuery(
           r.getField().getName(), r.getMaxTimestamp().toEpochMilli(), Long.MAX_VALUE);
     }
     throw new QueryParseException("cannot negate: " + r);
diff --git a/java/com/google/gerrit/metrics/BUILD b/java/com/google/gerrit/metrics/BUILD
index 0cb0275..5f4e0c0 100644
--- a/java/com/google/gerrit/metrics/BUILD
+++ b/java/com/google/gerrit/metrics/BUILD
@@ -14,6 +14,7 @@
         "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
     ],
diff --git a/java/com/google/gerrit/metrics/MetricMaker.java b/java/com/google/gerrit/metrics/MetricMaker.java
index 618d421..3f9bab1 100644
--- a/java/com/google/gerrit/metrics/MetricMaker.java
+++ b/java/com/google/gerrit/metrics/MetricMaker.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Supplier;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import java.util.Set;
 
@@ -80,6 +81,7 @@
    * @param desc description of the metric.
    * @return registration handle
    */
+  @CanIgnoreReturnValue
   public <V> RegistrationHandle newConstantMetric(String name, V value, Description desc) {
     desc.setConstant();
 
@@ -110,6 +112,7 @@
    * @param trigger function to compute the value of the metric.
    * @return registration handle
    */
+  @CanIgnoreReturnValue
   public <V> RegistrationHandle newCallbackMetric(
       String name, Class<V> valueClass, Description desc, Supplier<V> trigger) {
     CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
index 9e43a05..f5555b5 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
@@ -35,8 +35,6 @@
 
   @Override
   public long getCurrentThreadAllocatedBytes() {
-    // TODO(ms): call getCurrentThreadAllocatedBytes as soon as this is available in the patched
-    // Java version used by bazel
-    return sys.getThreadAllocatedBytes(Thread.currentThread().getId());
+    return sys.getCurrentThreadAllocatedBytes();
   }
 }
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index c56a8d9..be5fe1a 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -30,7 +30,6 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.Date;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheEditor;
 import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
@@ -61,16 +60,12 @@
     this.allUsers = allUsers.get();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public Account insert(Account.Builder account) throws IOException {
     File path = getPath();
     try (Repository repo = new FileRepository(path);
         ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent ident =
-          new PersonIdent(
-              new GerritPersonIdentProvider(flags.cfg).get(), Date.from(account.registeredOn()));
+          new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), account.registeredOn());
 
       Config accountConfig = new Config();
       AccountProperties.writeToAccountConfig(
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 020705e..f8fcadd 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -40,7 +40,6 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -166,14 +165,10 @@
     return AuditLogFormatter.createBackedBy(ImmutableSet.of(account), ImmutableSet.of(), serverId);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private void commit(Repository repository, GroupConfig groupConfig, Instant groupCreatedOn)
       throws IOException {
     PersonIdent personIdent =
-        new PersonIdent(
-            new GerritPersonIdentProvider(flags.cfg).get(), Timestamp.from(groupCreatedOn));
+        new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), groupCreatedOn);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(repository, personIdent)) {
       groupConfig.commit(metaDataUpdate);
     }
diff --git a/java/com/google/gerrit/pgm/init/InitLabels.java b/java/com/google/gerrit/pgm/init/InitLabels.java
index 3edc732..f862e12 100644
--- a/java/com/google/gerrit/pgm/init/InitLabels.java
+++ b/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -26,7 +26,7 @@
 
 @Singleton
 public class InitLabels implements InitStep {
-  private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+  private static final String KEY_COPY_CONDITION = "copyCondition";
   private static final String KEY_LABEL = "label";
   private static final String KEY_FUNCTION = "function";
   private static final String KEY_VALUE = "value";
@@ -62,7 +62,11 @@
           LABEL_VERIFIED,
           KEY_VALUE,
           Arrays.asList("-1 Fails", "0 No score", "+1 Verified"));
-      cfg.setBoolean(KEY_LABEL, LABEL_VERIFIED, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, true);
+      cfg.setString(
+          KEY_LABEL,
+          LABEL_VERIFIED,
+          KEY_COPY_CONDITION,
+          "changekind:NO_CHANGE OR changekind:NO_CODE_CHANGE");
       allProjectsConfig.save("Configure 'Verified' label");
     }
   }
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 208ed1f..05b50da 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -47,6 +47,7 @@
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.RebaseChangeOp;
@@ -61,15 +62,14 @@
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
 import com.google.gerrit.server.config.SysExecutorModule;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.EventUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
@@ -154,9 +154,8 @@
         .in(SINGLETON);
     bind(Realm.class).to(FakeRealm.class);
     bind(IdentifiedUser.class).toProvider(Providers.of(null));
-    bind(ReplacePatchSetSender.Factory.class).toProvider(Providers.of(null));
+    bind(EmailNewPatchSet.Factory.class).toProvider(Providers.of(null));
     bind(CurrentUser.class).to(InternalUser.class);
-    factory(MergeUtil.Factory.class);
     factory(PatchSetInserter.Factory.class);
     factory(RebaseChangeOp.Factory.class);
 
@@ -223,6 +222,7 @@
     bind(RevisionCreated.class).toInstance(RevisionCreated.DISABLED);
     bind(WorkInProgressStateChanged.class).toInstance(WorkInProgressStateChanged.DISABLED);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
+    bind(AttentionSetObserver.class).toInstance(AttentionSetObserver.DISABLED);
 
     ModuleOverloader.override(
             modules,
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 7a6187d..2be3383 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -104,6 +104,7 @@
         "//lib:servlet-api",
         "//lib:soy",
         "//lib:tukaani-xz",
+        "//lib/auto:auto-factory",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/bouncycastle:bcpkix-neverlink",
@@ -120,6 +121,7 @@
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
+        "//lib/httpcomponents:httpclient",
         "//lib/jsoup",
         "//lib/log:log4j",
         "//lib/lucene:lucene-analyzers-common",
diff --git a/java/com/google/gerrit/server/CommonConverters.java b/java/com/google/gerrit/server/CommonConverters.java
index e7fd1c5..4ad143b 100644
--- a/java/com/google/gerrit/server/CommonConverters.java
+++ b/java/com/google/gerrit/server/CommonConverters.java
@@ -25,14 +25,11 @@
  * static utility methods.
  */
 public class CommonConverters {
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public static GitPerson toGitPerson(PersonIdent ident) {
     GitPerson result = new GitPerson();
     result.name = ident.getName();
     result.email = ident.getEmailAddress();
-    result.setDate(ident.getWhen().toInstant());
+    result.setDate(ident.getWhenAsInstant());
     result.tz = ident.getTimeZoneOffset();
     return result;
   }
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 122e18d..65a81f7 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -50,9 +50,9 @@
 import java.net.SocketAddress;
 import java.net.URL;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Optional;
 import java.util.Set;
-import java.util.TimeZone;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.SystemReader;
 
@@ -427,10 +427,10 @@
   }
 
   public PersonIdent newRefLogIdent() {
-    return newRefLogIdent(Instant.now(), TimeZone.getDefault());
+    return newRefLogIdent(Instant.now(), ZoneId.systemDefault());
   }
 
-  public PersonIdent newRefLogIdent(Instant when, TimeZone tz) {
+  public PersonIdent newRefLogIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
 
     String name = ua.fullName();
@@ -451,21 +451,18 @@
               : ua.preferredEmail();
     }
 
-    return newPersonIdent(name, user, when, tz);
+    return new PersonIdent(name, user, when, zoneId);
   }
 
   private String constructMailAddress(Account ua, String host) {
     return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public PersonIdent newCommitterIdent(PersonIdent ident) {
-    return newCommitterIdent(ident.getWhen().toInstant(), ident.getTimeZone());
+    return newCommitterIdent(ident.getWhenAsInstant(), ident.getZoneId());
   }
 
-  public PersonIdent newCommitterIdent(Instant when, TimeZone tz) {
+  public PersonIdent newCommitterIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
     String name = ua.fullName();
     String email = ua.preferredEmail();
@@ -500,7 +497,7 @@
       }
     }
 
-    return newPersonIdent(name, email, when, tz);
+    return new PersonIdent(name, email, when, zoneId);
   }
 
   @Override
@@ -568,19 +565,4 @@
     }
     return host;
   }
-
-  /**
-   * Create a {@link PersonIdent} from an {@code Instant} and a {@link TimeZone}.
-   *
-   * <p>We use the {@link PersonIdent#PersonIdent(String, String, long, int)} constructor to avoid
-   * doing a conversion to {@code java.util.Date} here. For the {@code int aTZ} argument, which is
-   * the time zone, we do the same computation as in {@link PersonIdent#PersonIdent(String, String,
-   * java.util.Date, TimeZone)} (just instead of getting the epoch millis from {@code
-   * java.util.Date} we get them from {@link Instant}).
-   */
-  // TODO(issue-15517): Drop this method once JGit's PersonIdent class supports Instants
-  private static PersonIdent newPersonIdent(String name, String email, Instant when, TimeZone tz) {
-    return new PersonIdent(
-        name, email, when.toEpochMilli(), tz.getOffset(when.toEpochMilli()) / (60 * 1000));
-  }
 }
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 84afe8c..827c078 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.change.EmailReviewComments;
@@ -32,13 +32,12 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.server.update.PostUpdateContext;
-import com.google.gerrit.server.update.RepoView;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * A {@link BatchUpdateOp} that can be used to publish draft comments
@@ -52,15 +51,14 @@
   private final CommentAdded commentAdded;
   private final CommentsUtil commentsUtil;
   private final EmailReviewComments.Factory email;
-  private final List<LabelVote> labelDelta = new ArrayList<>();
   private final Project.NameKey projectNameKey;
   private final PatchSet.Id psId;
   private final PublishCommentUtil publishCommentUtil;
   private final ChangeMessagesUtil changeMessagesUtil;
 
+  private ObjectId preUpdateMetaId;
   private List<HumanComment> comments = new ArrayList<>();
   private String mailMessage;
-  private IdentifiedUser user;
 
   public interface Factory {
     PublishCommentsOp create(PatchSet.Id psId, Project.NameKey projectNameKey);
@@ -92,7 +90,7 @@
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, UnprocessableEntityException, IOException,
           PatchListNotAvailableException, CommentsRejectedException {
-    user = ctx.getIdentifiedUser();
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     comments = commentsUtil.draftByChangeAuthor(ctx.getNotes(), ctx.getUser().getAccountId());
 
     // PublishCommentsOp should update a separate ChangeUpdate Object than the one used by other ops
@@ -103,7 +101,7 @@
     //   2. Each ChangeUpdate results in 1 commit in NoteDb
     // We do it this way so that the execution results in 2 different commits in NoteDb
     ChangeUpdate changeUpdate = ctx.getDistinctUpdate(psId);
-    publishCommentUtil.publish(ctx, changeUpdate, comments, null);
+    publishCommentUtil.publish(ctx, changeUpdate, comments, /* tag= */ null);
     return insertMessage(changeUpdate);
   }
 
@@ -116,25 +114,15 @@
     PatchSet ps = psUtil.get(changeNotes, psId);
     NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
     if (notify.shouldNotify()) {
-      RepoView repoView;
-      try {
-        repoView = ctx.getRepoView();
-      } catch (IOException ex) {
-        throw new StorageException(
-            String.format("Repository %s not found", ctx.getProject().get()), ex);
-      }
       email
           .create(
-              notify,
-              changeNotes,
+              ctx,
               ps,
-              user,
+              preUpdateMetaId,
               mailMessage,
-              ctx.getWhen(),
               comments,
-              null,
-              labelDelta,
-              repoView)
+              /* patchSetComment= */ null,
+              /* labels= */ ImmutableList.of())
           .sendAsync();
     }
     commentAdded.fire(
@@ -159,7 +147,7 @@
     }
     mailMessage =
         changeMessagesUtil.setChangeMessage(
-            changeUpdate, "Patch Set " + psId.get() + ":" + buf, null);
+            changeUpdate, "Patch Set " + psId.get() + ":" + buf, /* tag= */ null);
     return true;
   }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 95d891e..0f5629e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -37,12 +37,10 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.GitUpdateFailureException;
 import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -110,6 +108,11 @@
     }
   }
 
+  public enum Operation {
+    ADD,
+    REMOVE
+  }
+
   @AutoValue
   public abstract static class StarRef {
     private static final StarRef MISSING =
@@ -158,15 +161,11 @@
   }
 
   public static final String DEFAULT_LABEL = "star";
-  public static final String IGNORE_LABEL = "ignore";
-  public static final ImmutableSortedSet<String> DEFAULT_LABELS =
-      ImmutableSortedSet.of(DEFAULT_LABEL);
 
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated gitRefUpdated;
   private final AllUsersName allUsers;
   private final Provider<PersonIdent> serverIdent;
-  private final ChangeIndexer indexer;
   private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
@@ -175,13 +174,11 @@
       GitReferenceUpdated gitRefUpdated,
       AllUsersName allUsers,
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      ChangeIndexer indexer,
       Provider<InternalChangeQuery> queryProvider) {
     this.repoManager = repoManager;
     this.gitRefUpdated = gitRefUpdated;
     this.allUsers = allUsers;
     this.serverIdent = serverIdent;
-    this.indexer = indexer;
     this.queryProvider = queryProvider;
   }
 
@@ -197,34 +194,27 @@
     }
   }
 
-  public NavigableSet<String> star(
-      Account.Id accountId,
-      Project.NameKey project,
-      Change.Id changeId,
-      Set<String> labelsToAdd,
-      Set<String> labelsToRemove)
+  public void star(Account.Id accountId, Project.NameKey project, Change.Id changeId, Operation op)
       throws IllegalLabelException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       String refName = RefNames.refsStarredChanges(changeId, accountId);
       StarRef old = readLabels(repo, refName);
 
       NavigableSet<String> labels = new TreeSet<>(old.labels());
-      if (labelsToAdd != null) {
-        labels.addAll(labelsToAdd);
-      }
-      if (labelsToRemove != null) {
-        labels.removeAll(labelsToRemove);
+      switch (op) {
+        case ADD:
+          labels.add(DEFAULT_LABEL);
+          break;
+        case REMOVE:
+          labels.remove(DEFAULT_LABEL);
+          break;
       }
 
       if (labels.isEmpty()) {
         deleteRef(repo, refName, old.objectId());
       } else {
-        checkMutuallyExclusiveLabels(labels);
         updateLabels(repo, refName, old.objectId(), labels);
       }
-
-      indexer.index(project, changeId);
-      return Collections.unmodifiableNavigableSet(labels);
     } catch (IOException e) {
       throw new StorageException(
           String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
@@ -349,32 +339,6 @@
     }
   }
 
-  public void ignore(ChangeResource rsrc) throws IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(IGNORE_LABEL),
-        ImmutableSet.of());
-  }
-
-  public void unignore(ChangeResource rsrc) throws IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(),
-        ImmutableSet.of(IGNORE_LABEL));
-  }
-
-  public boolean isIgnoredBy(Change.Id changeId, Account.Id accountId) {
-    return getLabels(accountId, changeId).contains(IGNORE_LABEL);
-  }
-
-  public boolean isIgnored(ChangeResource rsrc) {
-    return isIgnoredBy(rsrc.getChange().getId(), rsrc.getUser().asIdentifiedUser().getAccountId());
-  }
-
   public static StarRef readLabels(Repository repo, String refName) throws IOException {
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
@@ -414,13 +378,6 @@
     }
   }
 
-  private static void checkMutuallyExclusiveLabels(Set<String> labels)
-      throws MutuallyExclusiveLabelsException {
-    if (labels.containsAll(ImmutableSet.of(DEFAULT_LABEL, IGNORE_LABEL))) {
-      throw new MutuallyExclusiveLabelsException(DEFAULT_LABEL, IGNORE_LABEL);
-    }
-  }
-
   private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
     if (labels == null) {
       return;
diff --git a/java/com/google/gerrit/server/account/AccountCache.java b/java/com/google/gerrit/server/account/AccountCache.java
index 54bfa56..5ae7345 100644
--- a/java/com/google/gerrit/server/account/AccountCache.java
+++ b/java/com/google/gerrit/server/account/AccountCache.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.Account;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Caches important (but small) account state to avoid database hits. */
 public interface AccountCache {
@@ -72,4 +75,18 @@
    *     exists or if loading the external ID fails {@link Optional#empty()} is returned
    */
   Optional<AccountState> getByUsername(String username);
+
+  /**
+   * Returns an {@code AccountState} instance for the given account ID at the given {@code metaId}
+   * of {@link com.google.gerrit.entities.RefNames#refsUsers} ref.
+   *
+   * <p>The caller is responsible to ensure the presence of {@code metaId} and the corresponding
+   * meta ref. The method does not populate {@link AccountState#defaultPreferences}.
+   *
+   * @param accountId ID of the account that should be retrieved.
+   * @param metaId the sha1 of commit in {@link com.google.gerrit.entities.RefNames#refsUsers} ref.
+   * @return {@code AccountState} instance for the given account ID at specific sha1 {@code metaId}.
+   */
+  @UsedAt(Project.GOOGLE)
+  AccountState getFromMetaId(Account.Id accountId, ObjectId metaId);
 }
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 1d9150d..66a36f6 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -45,6 +45,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
@@ -106,6 +107,18 @@
   }
 
   @Override
+  public AccountState getFromMetaId(Account.Id id, ObjectId metaId) {
+    try {
+      CachedAccountDetails.Key key = CachedAccountDetails.Key.create(id, metaId);
+
+      CachedAccountDetails accountDetails = accountDetailsCache.get(key);
+      return AccountState.forCachedAccount(accountDetails, CachedPreferences.EMPTY, externalIds);
+    } catch (IOException | ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
   public Map<Account.Id, AccountState> get(Set<Account.Id> accountIds) {
     try {
       try (Repository allUsers = repoManager.openRepository(allUsersName)) {
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 28e881e..4143f77 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -36,7 +36,6 @@
 import java.io.IOException;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -258,9 +257,6 @@
     return c;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
@@ -279,8 +275,8 @@
       }
 
       Instant registeredOn = loadedAccountProperties.get().getRegisteredOn();
-      commit.setAuthor(new PersonIdent(commit.getAuthor(), Date.from(registeredOn)));
-      commit.setCommitter(new PersonIdent(commit.getCommitter(), Date.from(registeredOn)));
+      commit.setAuthor(new PersonIdent(commit.getAuthor(), registeredOn));
+      commit.setCommitter(new PersonIdent(commit.getCommitter(), registeredOn));
     }
 
     saveAccount();
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index f5f9b3d..c80059b 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -176,7 +176,7 @@
       logger.atFine().log(
           "user %s can see own account %d", user.getLoggableName(), otherUser.getId().get());
       return true;
-    } else if (viewAll()) {
+    } else if (canViewAll()) {
       logger.atFine().log(
           "user %s can see account %d (view all accounts = true)",
           user.getLoggableName(), otherUser.getId().get());
@@ -255,7 +255,7 @@
     }
   }
 
-  private boolean viewAll() {
+  public boolean canViewAll() {
     if (viewAll == null) {
       try {
         viewAll = perm.test(GlobalPermission.VIEW_ALL_ACCOUNTS);
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 3ee6365..8d5fea4 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -60,6 +60,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
  * Creates and updates accounts.
@@ -319,17 +320,13 @@
    * @throws IOException if creating the user branch fails due to an IO error
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
       throws IOException, ConfigInvalidException {
     return execute(
             ImmutableList.of(
                 repo -> {
                   AccountConfig accountConfig = read(repo, accountId);
-                  Account account =
-                      accountConfig.getNewAccount(committerIdent.getWhen().toInstant());
+                  Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
                   AccountState accountState = AccountState.forAccount(account);
                   AccountDelta.Builder deltaBuilder = AccountDelta.builder();
                   init.configure(accountState, deltaBuilder);
@@ -513,23 +510,31 @@
         updatedAccounts.size() == 1
             ? Iterables.getOnlyElement(updatedAccounts).message
             : "Batch update for " + updatedAccounts.size() + " accounts";
+    ObjectId oldExternalIdsRevision = externalIdNotes.getRevision();
+    // These update the same ref, so they need to be stacked on top of one another using the same
+    // ExternalIdNotes instance.
+    RevCommit revCommit =
+        commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
+    boolean externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
     for (UpdatedAccount updatedAccount : updatedAccounts) {
+
       // These updates are all for different refs (because batches never update the same account
       // more than once), so there can be multiple commits in the same batch, all with the same base
       // revision in their AccountConfig.
+      // We allow empty commits:
+      // 1) When creating a new account, so that the user branch gets created with an empty commit
+      // when no account properties are set and hence no
+      // 'account.config' file will be created.
+      // 2) When updating "refs/meta/external-ids", so that refs/users/* meta ref is updated too.
+      // This allows to schedule reindexing of account transactionally  on refs/users/* meta
+      // updates.
+      boolean allowEmptyCommit = externalIdsUpdated || updatedAccount.created;
       commitAccountConfig(
           updatedAccount.message,
           allUsersRepo,
           batchRefUpdate,
           updatedAccount.accountConfig,
-          updatedAccount.created /* allowEmptyCommit */);
-      // When creating a new account we must allow empty commits so that the user branch gets
-      // created with an empty commit when no account properties are set and hence no
-      // 'account.config' file will be created.
-
-      // These update the same ref, so they need to be stacked on top of one another using the same
-      // ExternalIdNotes instance.
-      commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
+          allowEmptyCommit);
     }
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
@@ -562,10 +567,10 @@
     }
   }
 
-  private void commitExternalIdUpdates(
+  private RevCommit commitExternalIdUpdates(
       String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) throws IOException {
     try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
-      externalIdNotes.commit(md);
+      return externalIdNotes.commit(md);
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 45f0844..8c3f033 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -113,14 +113,11 @@
     return externalIds.byEmail(email).stream().map(ExternalId::accountId).collect(toImmutableSet());
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public UserIdentity toUserIdentity(PersonIdent who) throws IOException {
     UserIdentity u = new UserIdentity();
     u.setName(who.getName());
     u.setEmail(who.getEmailAddress());
-    u.setDate(who.getWhen().toInstant());
+    u.setDate(who.getWhenAsInstant());
     u.setTimeZone(who.getTimeZoneOffset());
 
     // If only one account has access to this email address, select it
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index f7b0b60..b895834 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.extensions.common.AvatarInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -101,12 +100,8 @@
     Account.Id currentUserId = null;
     if (self.get().isIdentifiedUser()) {
       currentUserId = self.get().getAccountId();
-
-      try {
-        permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+      if (permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
         canModifyAccount = true;
-      } catch (AuthException e) {
-        canModifyAccount = false;
       }
     }
 
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 5bd9bea..1587bc5 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -27,10 +27,16 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
 import com.google.gerrit.server.project.ProjectState;
@@ -49,11 +55,57 @@
 public class UniversalGroupBackend implements GroupBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Field<String> SYSTEM_FIELD =
+      Field.ofString("system", Metadata.Builder::groupSystem).build();
+
   private final PluginSetContext<GroupBackend> backends;
+  private final Counter1<String> handlesCount;
+  private final Counter1<String> getCount;
+  private final Counter2<String, Integer> suggestCount;
+  private final Counter2<String, Boolean> containsCount;
+  private final Counter2<String, Boolean> containsAnyCount;
+  private final Counter2<String, Integer> intersectionCount;
+  private final Counter2<String, Integer> knownGroupsCount;
 
   @Inject
-  UniversalGroupBackend(PluginSetContext<GroupBackend> backends) {
+  UniversalGroupBackend(PluginSetContext<GroupBackend> backends, MetricMaker metricMaker) {
     this.backends = backends;
+    this.handlesCount =
+        metricMaker.newCounter(
+            "group/handles_count", new Description("Calls to GroupBackend.handles"), SYSTEM_FIELD);
+    this.getCount =
+        metricMaker.newCounter(
+            "group/get_count", new Description("Calls to GroupBackend.get"), SYSTEM_FIELD);
+    this.suggestCount =
+        metricMaker.newCounter(
+            "group/suggest_count",
+            new Description("Calls to GroupBackend.suggest"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_suggested", (meta, value) -> {}).build());
+    this.containsCount =
+        metricMaker.newCounter(
+            "group/contains_count",
+            new Description("Calls to GroupMemberships.contains"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains", (meta, value) -> {}).build());
+    this.containsAnyCount =
+        metricMaker.newCounter(
+            "group/contains_any_of_count",
+            new Description("Calls to GroupMemberships.containsAnyOf"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains_any_of", (meta, value) -> {}).build());
+    this.intersectionCount =
+        metricMaker.newCounter(
+            "group/intersection_count",
+            new Description("Calls to GroupMemberships.intersection"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_intersection", (meta, value) -> {}).build());
+    this.knownGroupsCount =
+        metricMaker.newCounter(
+            "group/known_groups_count",
+            new Description("Calls to GroupMemberships.getKnownGroups"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_known_groups", (meta, value) -> {}).build());
   }
 
   @Nullable
@@ -70,7 +122,12 @@
 
   @Override
   public boolean handles(AccountGroup.UUID uuid) {
-    return backend(uuid) != null;
+    GroupBackend b = backend(uuid);
+    if (b == null) {
+      return false;
+    }
+    handlesCount.increment(name(b));
+    return true;
   }
 
   @Override
@@ -83,13 +140,19 @@
       logger.atFine().log("Unknown GroupBackend for UUID: %s", uuid);
       return null;
     }
+    getCount.increment(name(b));
     return b.get(uuid);
   }
 
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
     Set<GroupReference> groups = Sets.newTreeSet(GROUP_REF_NAME_COMPARATOR);
-    backends.runEach(g -> groups.addAll(g.suggest(name, project)));
+    backends.runEach(
+        g -> {
+          Collection<GroupReference> suggestions = g.suggest(name, project);
+          suggestCount.increment(name(g), suggestions.size());
+          groups.addAll(suggestions);
+        });
     return groups;
   }
 
@@ -108,11 +171,11 @@
     }
 
     @Nullable
-    private GroupMembership membership(AccountGroup.UUID uuid) {
+    private Map.Entry<GroupBackend, GroupMembership> membership(AccountGroup.UUID uuid) {
       if (uuid != null) {
         for (Map.Entry<GroupBackend, GroupMembership> m : memberships.entrySet()) {
           if (m.getKey().handles(uuid)) {
-            return m.getValue();
+            return m;
           }
         }
       }
@@ -125,51 +188,57 @@
       if (uuid == null) {
         return false;
       }
-      GroupMembership m = membership(uuid);
+      Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
       if (m == null) {
         return false;
       }
-      return m.contains(uuid);
+      boolean contains = m.getValue().contains(uuid);
+      containsCount.increment(name(m.getKey()), contains);
+      return contains;
     }
 
     @Override
     public boolean containsAnyOf(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           continue;
         }
         lookups.put(m, uuid);
       }
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        GroupMembership m = entry.getKey();
-        Collection<AccountGroup.UUID> ids = entry.getValue();
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackends : lookups.asMap().keySet()) {
+
+        GroupMembership m = groupBackends.getValue();
+        Collection<AccountGroup.UUID> ids = lookups.asMap().get(groupBackends);
         if (ids.size() == 1) {
           if (m.contains(Iterables.getOnlyElement(ids))) {
+            containsAnyCount.increment(name(groupBackends.getKey()), true);
             return true;
           }
         } else if (m.containsAnyOf(ids)) {
+          containsAnyCount.increment(name(groupBackends.getKey()), true);
           return true;
         }
+        // We would have returned if contains was true.
+        containsAnyCount.increment(name(groupBackends.getKey()), false);
       }
       return false;
     }
 
     @Override
     public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           logger.atFine().log("Unknown GroupMembership for UUID: %s", uuid);
           continue;
@@ -177,9 +246,11 @@
         lookups.put(m, uuid);
       }
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        groups.addAll(entry.getKey().intersection(entry.getValue()));
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackend : lookups.asMap().keySet()) {
+        Set<AccountGroup.UUID> intersection =
+            groupBackend.getValue().intersection(lookups.asMap().get(groupBackend));
+        intersectionCount.increment(name(groupBackend.getKey()), intersection.size());
+        groups.addAll(intersection);
       }
       return groups;
     }
@@ -187,8 +258,10 @@
     @Override
     public Set<AccountGroup.UUID> getKnownGroups() {
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (GroupMembership m : memberships.values()) {
-        groups.addAll(m.getKnownGroups());
+      for (Map.Entry<GroupBackend, GroupMembership> entry : memberships.entrySet()) {
+        Set<AccountGroup.UUID> knownGroups = entry.getValue().getKnownGroups();
+        knownGroupsCount.increment(name(entry.getKey()), knownGroups.size());
+        groups.addAll(knownGroups);
       }
       return groups;
     }
@@ -204,6 +277,13 @@
     return false;
   }
 
+  private static String name(GroupBackend backend) {
+    if (backend == null) {
+      return "none";
+    }
+    return backend.getClass().getSimpleName();
+  }
+
   public static class ConfigCheck implements StartupCheck {
     private final Config cfg;
     private final UniversalGroupBackend universalGroupBackend;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 0a51171..1616198 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -135,6 +135,9 @@
   /** Scheme used for GPG public keys. */
   public static final String SCHEME_GPGKEY = "gpgkey";
 
+  /** Scheme for imported accounts from other servers with different GerritServerId */
+  public static final String SCHEME_IMPORTED = "imported";
+
   /** Scheme for external auth used during authentication, e.g. OAuth Token */
   public static final String SCHEME_EXTERNAL = "external";
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index 9f766d0..fe8feac 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -30,7 +30,7 @@
  *
  * <p>All returned collections are unmodifiable.
  */
-interface ExternalIdCache {
+public interface ExternalIdCache {
   Optional<ExternalId> byKey(ExternalId.Key key) throws IOException;
 
   ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index a49061d..1713171 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetApi;
@@ -65,8 +64,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.DynamicOptions;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
@@ -88,7 +85,6 @@
 import com.google.gerrit.server.restapi.change.GetPastAssignees;
 import com.google.gerrit.server.restapi.change.GetPureRevert;
 import com.google.gerrit.server.restapi.change.GetTopic;
-import com.google.gerrit.server.restapi.change.Ignore;
 import com.google.gerrit.server.restapi.change.Index;
 import com.google.gerrit.server.restapi.change.ListChangeComments;
 import com.google.gerrit.server.restapi.change.ListChangeDrafts;
@@ -111,7 +107,6 @@
 import com.google.gerrit.server.restapi.change.SetWorkInProgress;
 import com.google.gerrit.server.restapi.change.SubmittedTogether;
 import com.google.gerrit.server.restapi.change.SuggestChangeReviewers;
-import com.google.gerrit.server.restapi.change.Unignore;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -167,18 +162,15 @@
   private final Provider<ListChangeDrafts> listDraftsProvider;
   private final ChangeEditApiImpl.Factory changeEditApi;
   private final Check check;
-  private final CheckSubmitRequirement checkSubmitRequirement;
+  private final Provider<CheckSubmitRequirement> checkSubmitRequirementProvider;
   private final Index index;
   private final Move move;
   private final PostPrivate postPrivate;
   private final DeletePrivate deletePrivate;
-  private final Ignore ignore;
-  private final Unignore unignore;
   private final SetWorkInProgress setWip;
   private final SetReadyForReview setReady;
   private final PutMessage putMessage;
   private final Provider<GetPureRevert> getPureRevertProvider;
-  private final StarredChangesUtil stars;
   private final DynamicOptionParser dynamicOptionParser;
   private final Injector injector;
   private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
@@ -222,18 +214,15 @@
       Provider<ListChangeDrafts> listDraftsProvider,
       ChangeEditApiImpl.Factory changeEditApi,
       Check check,
-      CheckSubmitRequirement checkSubmitRequirement,
+      Provider<CheckSubmitRequirement> checkSubmitRequirement,
       Index index,
       Move move,
       PostPrivate postPrivate,
       DeletePrivate deletePrivate,
-      Ignore ignore,
-      Unignore unignore,
       SetWorkInProgress setWip,
       SetReadyForReview setReady,
       PutMessage putMessage,
       Provider<GetPureRevert> getPureRevertProvider,
-      StarredChangesUtil stars,
       DynamicOptionParser dynamicOptionParser,
       @Assisted ChangeResource change,
       Injector injector,
@@ -275,18 +264,15 @@
     this.listDraftsProvider = listDraftsProvider;
     this.changeEditApi = changeEditApi;
     this.check = check;
-    this.checkSubmitRequirement = checkSubmitRequirement;
+    this.checkSubmitRequirementProvider = checkSubmitRequirement;
     this.index = index;
     this.move = move;
     this.postPrivate = postPrivate;
     this.deletePrivate = deletePrivate;
-    this.ignore = ignore;
-    this.unignore = unignore;
     this.setWip = setWip;
     this.setReady = setReady;
     this.putMessage = putMessage;
     this.getPureRevertProvider = getPureRevertProvider;
-    this.stars = stars;
     this.dynamicOptionParser = dynamicOptionParser;
     this.change = change;
     this.injector = injector;
@@ -714,10 +700,27 @@
   }
 
   @Override
+  public CheckSubmitRequirementRequest checkSubmitRequirementRequest() {
+    return new CheckSubmitRequirementRequest() {
+      @Override
+      public SubmitRequirementResultInfo get() throws RestApiException {
+        try {
+          CheckSubmitRequirement check = checkSubmitRequirementProvider.get();
+          check.setSrName(this.srName());
+          check.setRefsConfigChangeId(this.getRefsConfigChangeId());
+          return check.apply(change, null).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot check submit requirement", e);
+        }
+      }
+    };
+  }
+
+  @Override
   public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
       throws RestApiException {
     try {
-      return checkSubmitRequirement.apply(change, input).value();
+      return checkSubmitRequirementProvider.get().apply(change, input).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot check submit requirement", e);
     }
@@ -733,30 +736,6 @@
   }
 
   @Override
-  public void ignore(boolean ignore) throws RestApiException {
-    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
-    // StarredChangesUtil.
-    try {
-      if (ignore) {
-        this.ignore.apply(change, new Input());
-      } else {
-        unignore.apply(change, new Input());
-      }
-    } catch (StorageException | IllegalLabelException e) {
-      throw asRestApiException("Cannot ignore change", e);
-    }
-  }
-
-  @Override
-  public boolean ignored() throws RestApiException {
-    try {
-      return stars.isIgnored(change);
-    } catch (StorageException e) {
-      throw asRestApiException("Cannot check if ignored", e);
-    }
-  }
-
-  @Override
   public PureRevertInfo pureRevert() throws RestApiException {
     return pureRevert(null);
   }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 9aa9306..a7931f1 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.api.changes.DraftApi;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.FileApi;
+import com.google.gerrit.extensions.api.changes.GetRelatedOption;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RelatedChangesInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -40,6 +41,7 @@
 import com.google.gerrit.extensions.client.ArchiveFormat;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -64,7 +66,8 @@
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.restapi.change.ApplyFix;
+import com.google.gerrit.server.restapi.change.ApplyProvidedFix;
+import com.google.gerrit.server.restapi.change.ApplyStoredFix;
 import com.google.gerrit.server.restapi.change.CherryPick;
 import com.google.gerrit.server.restapi.change.Comments;
 import com.google.gerrit.server.restapi.change.CreateDraftComment;
@@ -74,7 +77,6 @@
 import com.google.gerrit.server.restapi.change.GetArchive;
 import com.google.gerrit.server.restapi.change.GetCommit;
 import com.google.gerrit.server.restapi.change.GetDescription;
-import com.google.gerrit.server.restapi.change.GetFixPreview;
 import com.google.gerrit.server.restapi.change.GetMergeList;
 import com.google.gerrit.server.restapi.change.GetPatch;
 import com.google.gerrit.server.restapi.change.GetRelated;
@@ -86,6 +88,7 @@
 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.PreviewFix;
 import com.google.gerrit.server.restapi.change.PutDescription;
 import com.google.gerrit.server.restapi.change.Rebase;
 import com.google.gerrit.server.restapi.change.Reviewed;
@@ -132,8 +135,10 @@
   private final ListRobotComments listRobotComments;
   private final ListPortedComments listPortedComments;
   private final ListPortedDrafts listPortedDrafts;
-  private final ApplyFix applyFix;
-  private final GetFixPreview getFixPreview;
+  private final ApplyStoredFix applyStoredFix;
+  private final PreviewFix.Stored previewStoredFix;
+  private final ApplyProvidedFix applyProvidedFix;
+  private final PreviewFix.Provided previewProvidedFix;
   private final Fixes fixes;
   private final ListRevisionDrafts listDrafts;
   private final CreateDraftComment createDraft;
@@ -178,8 +183,10 @@
       ListRobotComments listRobotComments,
       ListPortedComments listPortedComments,
       ListPortedDrafts listPortedDrafts,
-      ApplyFix applyFix,
-      GetFixPreview getFixPreview,
+      ApplyStoredFix applyStoredFix,
+      PreviewFix.Stored previewStoredFix,
+      ApplyProvidedFix applyProvidedFix,
+      PreviewFix.Provided previewProvidedFix,
       Fixes fixes,
       ListRevisionDrafts listDrafts,
       CreateDraftComment createDraft,
@@ -223,8 +230,10 @@
     this.listRobotComments = listRobotComments;
     this.listPortedComments = listPortedComments;
     this.listPortedDrafts = listPortedDrafts;
-    this.applyFix = applyFix;
-    this.getFixPreview = getFixPreview;
+    this.applyStoredFix = applyStoredFix;
+    this.previewStoredFix = previewStoredFix;
+    this.applyProvidedFix = applyProvidedFix;
+    this.previewProvidedFix = previewProvidedFix;
     this.fixes = fixes;
     this.listDrafts = listDrafts;
     this.createDraft = createDraft;
@@ -477,7 +486,16 @@
   @Override
   public EditInfo applyFix(String fixId) throws RestApiException {
     try {
-      return applyFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
+      return applyStoredFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot apply stored fix", e);
+    }
+  }
+
+  @Override
+  public EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException {
+    try {
+      return applyProvidedFix.apply(revision, applyProvidedFixInput).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot apply fix", e);
     }
@@ -486,9 +504,19 @@
   @Override
   public Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException {
     try {
-      return getFixPreview.apply(fixes.parse(revision, IdString.fromDecoded(fixId))).value();
+      return previewStoredFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId))).value();
     } catch (Exception e) {
-      throw asRestApiException("Cannot get fix preview", e);
+      throw asRestApiException("Cannot preview stored fix", e);
+    }
+  }
+
+  @Override
+  public Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
+      throws RestApiException {
+    try {
+      return previewProvidedFix.apply(revision, applyProvidedFixInput).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot preview provided fix", e);
     }
   }
 
@@ -615,7 +643,13 @@
 
   @Override
   public RelatedChangesInfo related() throws RestApiException {
+    return related(EnumSet.noneOf(GetRelatedOption.class));
+  }
+
+  @Override
+  public RelatedChangesInfo related(EnumSet<GetRelatedOption> options) throws RestApiException {
     try {
+      options.forEach(getRelated::addOption);
       return getRelated.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get related changes", e);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 5baed86..3a892bc 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.BatchLabelInput;
@@ -48,6 +49,7 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -78,6 +80,7 @@
 import com.google.gerrit.server.restapi.project.ListBranches;
 import com.google.gerrit.server.restapi.project.ListDashboards;
 import com.google.gerrit.server.restapi.project.ListLabels;
+import com.google.gerrit.server.restapi.project.ListSubmitRequirements;
 import com.google.gerrit.server.restapi.project.ListTags;
 import com.google.gerrit.server.restapi.project.PostLabels;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
@@ -138,8 +141,10 @@
   private final Index index;
   private final IndexChanges indexChanges;
   private final Provider<ListLabels> listLabels;
+  private final Provider<ListSubmitRequirements> listSubmitRequirements;
   private final PostLabels postLabels;
   private final LabelApiImpl.Factory labelApi;
+  private final SubmitRequirementApiImpl.Factory submitRequirementApi;
 
   @AssistedInject
   ProjectApiImpl(
@@ -177,8 +182,10 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -216,8 +223,10 @@
         index,
         indexChanges,
         listLabels,
+        listSubmitRequirements,
         postLabels,
         labelApi,
+        submitRequirementApi,
         null);
   }
 
@@ -257,8 +266,10 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -296,8 +307,10 @@
         index,
         indexChanges,
         listLabels,
+        listSubmitRequirements,
         postLabels,
         labelApi,
+        submitRequirementApi,
         name);
   }
 
@@ -337,8 +350,10 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -376,8 +391,10 @@
     this.index = index;
     this.indexChanges = indexChanges;
     this.listLabels = listLabels;
+    this.listSubmitRequirements = listSubmitRequirements;
     this.postLabels = postLabels;
     this.labelApi = labelApi;
+    this.submitRequirementApi = submitRequirementApi;
   }
 
   @Override
@@ -737,6 +754,20 @@
   }
 
   @Override
+  public ListSubmitRequirementsRequest submitRequirements() {
+    return new ListSubmitRequirementsRequest() {
+      @Override
+      public List<SubmitRequirementInfo> get() throws RestApiException {
+        try {
+          return listSubmitRequirements.get().withInherited(inherited).apply(checkExists()).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list submit requirements", e);
+        }
+      }
+    };
+  }
+
+  @Override
   public LabelApi label(String labelName) throws RestApiException {
     try {
       return labelApi.create(checkExists(), labelName);
@@ -746,6 +777,15 @@
   }
 
   @Override
+  public SubmitRequirementApi submitRequirement(String name) throws RestApiException {
+    try {
+      return submitRequirementApi.create(checkExists(), name);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse submit requirement", e);
+    }
+  }
+
+  @Override
   public void labels(BatchLabelInput input) throws RestApiException {
     try {
       postLabels.apply(checkExists(), input);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsModule.java b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
index 987c71f..9f7e1b4 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectsModule.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
@@ -29,5 +29,6 @@
     factory(CommitApiImpl.Factory.class);
     factory(DashboardApiImpl.Factory.class);
     factory(LabelApiImpl.Factory.class);
+    factory(SubmitRequirementApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
new file mode 100644
index 0000000..aa6ef71
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.restapi.project.CreateSubmitRequirement;
+import com.google.gerrit.server.restapi.project.DeleteSubmitRequirement;
+import com.google.gerrit.server.restapi.project.GetSubmitRequirement;
+import com.google.gerrit.server.restapi.project.SubmitRequirementsCollection;
+import com.google.gerrit.server.restapi.project.UpdateSubmitRequirement;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SubmitRequirementApiImpl implements SubmitRequirementApi {
+  interface Factory {
+    SubmitRequirementApiImpl create(ProjectResource project, String name);
+  }
+
+  private final SubmitRequirementsCollection submitRequirements;
+  private final CreateSubmitRequirement createSubmitRequirement;
+  private final UpdateSubmitRequirement updateSubmitRequirement;
+  private final DeleteSubmitRequirement deleteSubmitRequirement;
+  private final GetSubmitRequirement getSubmitRequirement;
+  private final String name;
+  private final ProjectCache projectCache;
+
+  private ProjectResource project;
+
+  @Inject
+  SubmitRequirementApiImpl(
+      SubmitRequirementsCollection submitRequirements,
+      CreateSubmitRequirement createSubmitRequirement,
+      UpdateSubmitRequirement updateSubmitRequirement,
+      DeleteSubmitRequirement deleteSubmitRequirement,
+      GetSubmitRequirement getSubmitRequirement,
+      ProjectCache projectCache,
+      @Assisted ProjectResource project,
+      @Assisted String name) {
+    this.submitRequirements = submitRequirements;
+    this.createSubmitRequirement = createSubmitRequirement;
+    this.updateSubmitRequirement = updateSubmitRequirement;
+    this.deleteSubmitRequirement = deleteSubmitRequirement;
+    this.getSubmitRequirement = getSubmitRequirement;
+    this.projectCache = projectCache;
+    this.project = project;
+    this.name = name;
+  }
+
+  @Override
+  public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException {
+    try {
+      createSubmitRequirement.apply(project, IdString.fromDecoded(name), input);
+
+      // recreate project resource because project state was updated
+      project =
+          new ProjectResource(
+              projectCache
+                  .get(project.getNameKey())
+                  .orElseThrow(illegalState(project.getNameKey())),
+              project.getUser());
+
+      return this;
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create submit requirement", e);
+    }
+  }
+
+  @Override
+  public SubmitRequirementInfo get() throws RestApiException {
+    try {
+      return getSubmitRequirement.apply(resource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get submit requirement", e);
+    }
+  }
+
+  @Override
+  public SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException {
+    try {
+      return updateSubmitRequirement.apply(resource(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update submit requirement", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteSubmitRequirement.apply(resource(), new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete submit requirement", e);
+    }
+  }
+
+  private SubmitRequirementResource resource() throws RestApiException, PermissionBackendException {
+    return submitRequirements.parse(project, IdString.fromDecoded(name));
+  }
+}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCopier.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
index 31380f4..059445e 100644
--- a/java/com/google/gerrit/server/approval/ApprovalCopier.java
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -14,15 +14,16 @@
 
 package com.google.gerrit.server.approval;
 
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
@@ -32,76 +33,122 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.LabelNormalizer;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.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.gitdiff.ModifiedFile;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.approval.ApprovalContext;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
-import com.google.gerrit.server.query.approval.ListOfFilesUnchangedPredicate;
 import com.google.gerrit.server.util.ManualRequestContext;
 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.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
- * Computes approvals for a given patch set by looking at approvals applied to the given patch set
- * and by additionally 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.
+ * Computes copied approvals for a given patch set.
  *
- * <p>The result of a copy is stored in NoteDb when a new patch set is created.
+ * <p>Approvals are copied if:
+ *
+ * <ul>
+ *   <li>the approval on the previous patch set matches the copy condition of its label
+ *   <li>the approval is not overridden by a current approval on the patch set
+ * </ul>
+ *
+ * <p>Callers should store the copied approvals in NoteDb when a new patch set is created.
  */
 @Singleton
-class ApprovalCopier {
+@VisibleForTesting
+public class ApprovalCopier {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DiffOperations diffOperations;
+  @AutoValue
+  public abstract static class Result {
+    /**
+     * Approvals that have been copied from the previous patch set.
+     *
+     * <p>An approval is copied if:
+     *
+     * <ul>
+     *   <li>the approval on the previous patch set matches the copy condition of its label
+     *   <li>the approval is not overridden by a current approval on the patch set
+     * </ul>
+     */
+    public abstract ImmutableSet<PatchSetApproval> copiedApprovals();
+
+    /**
+     * Approvals on the previous patch set that have not been copied to the patch set.
+     *
+     * <p>These approvals didn't match the copy condition of their labels and hence haven't been
+     * copied.
+     *
+     * <p>Only returns non-copied approvals of the previous patch set. Approvals from earlier patch
+     * sets that were outdated before are not included.
+     */
+    public abstract ImmutableSet<PatchSetApproval> outdatedApprovals();
+
+    static Result empty() {
+      return create(
+          /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
+    }
+
+    @VisibleForTesting
+    public static Result create(
+        ImmutableSet<PatchSetApproval> copiedApprovals,
+        ImmutableSet<PatchSetApproval> outdatedApprovals) {
+      return new AutoValue_ApprovalCopier_Result(copiedApprovals, outdatedApprovals);
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final ChangeKindCache changeKindCache;
+  private final PatchSetUtil psUtil;
   private final LabelNormalizer labelNormalizer;
   private final ApprovalQueryBuilder approvalQueryBuilder;
   private final OneOffRequestContext requestContext;
-  private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
 
   @Inject
   ApprovalCopier(
-      DiffOperations diffOperations,
+      GitRepositoryManager repoManager,
       ProjectCache projectCache,
       ChangeKindCache changeKindCache,
+      PatchSetUtil psUtil,
       LabelNormalizer labelNormalizer,
       ApprovalQueryBuilder approvalQueryBuilder,
-      OneOffRequestContext requestContext,
-      ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate) {
-    this.diffOperations = diffOperations;
+      OneOffRequestContext requestContext) {
+    this.repoManager = repoManager;
     this.projectCache = projectCache;
     this.changeKindCache = changeKindCache;
+    this.psUtil = psUtil;
     this.labelNormalizer = labelNormalizer;
     this.approvalQueryBuilder = approvalQueryBuilder;
     this.requestContext = requestContext;
-    this.listOfFilesUnchangedPredicate = listOfFilesUnchangedPredicate;
   }
 
   /**
-   * Returns all approvals that apply to the given patch set. Honors copied approvals from previous
-   * patch-set.
+   * Returns all copied approvals that apply to the given patch set.
+   *
+   * <p>Approvals are copied if:
+   *
+   * <ul>
+   *   <li>the approval on the previous patch set matches the copy condition of its label
+   *   <li>the approval is not overridden by a current approval on the patch set
+   * </ul>
    */
-  Iterable<PatchSetApproval> forPatchSet(
-      ChangeNotes notes, PatchSet ps, RevWalk rw, Config repoConfig) {
+  @VisibleForTesting
+  public Result forPatchSet(ChangeNotes notes, PatchSet ps, RevWalk rw, Config repoConfig) {
     ProjectState project;
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
@@ -114,226 +161,132 @@
           projectCache
               .get(notes.getProjectName())
               .orElseThrow(illegalState(notes.getProjectName()));
-      Collection<PatchSetApproval> approvals =
-          getForPatchSetWithoutNormalization(notes, project, ps, rw, repoConfig);
-      return labelNormalizer.normalize(notes, approvals).getNormalized();
+      return computeForPatchSet(project.getLabelTypes(), notes, ps, rw, repoConfig);
     }
   }
 
-  private boolean canCopyBasedOnBooleanLabelConfigs(
-      ProjectState project,
-      PatchSetApproval psa,
-      PatchSet.Id psId,
-      ChangeKind kind,
-      boolean isMerge,
-      LabelType type,
-      @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());
-
-    if (type.isCopyMinScore() && type.isMaxNegative(psa)) {
-      logger.atFine().log(
-          "veto approval %s on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyMinScore = true on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.isCopyMaxScore() && type.isMaxPositive(psa)) {
-      logger.atFine().log(
-          "max approval %s on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyMaxScore = true on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.isCopyAnyScore()) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyAnyScore = true on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.getCopyValues().contains(psa.value())) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyValue = %d on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          psa.value(),
-          project.getName());
-      return true;
-    } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
-        && listOfFilesUnchangedPredicate.match(
-            baseVsCurrentDiff, baseVsPriorDiff, priorVsCurrentDiff)) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set "
-              + "copyAllScoresIfListOfFilesDidNotChange = true on "
-              + "project %s and list of files did not change (maybe except a rename, which is "
-              + "still the same file).",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    }
-    switch (kind) {
-      case MERGE_FIRST_PARENT_UPDATE:
-        if (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"
-                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case NO_CODE_CHANGE:
-        if (type.isCopyAllScoresIfNoCodeChange()) {
-          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"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case TRIVIAL_REBASE:
-        if (type.isCopyAllScoresOnTrivialRebase()) {
-          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"
-                  + " copyAllScoresOnTrivialRebase = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case NO_CHANGE:
-        if (type.isCopyAllScoresIfNoChange()) {
-          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"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresOnTrivialRebase()) {
-          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"
-                  + " copyAllScoresOnTrivialRebase = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        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"
-                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresIfNoCodeChange()) {
-          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"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case REWORK:
-      default:
-        logger.atFine().log(
-            "approval %d on label %s of patch set %d of change %d cannot be copied"
-                + " to patch set %d because change kind is %s",
-            psa.value(), psa.label(), n, psa.key().patchSetId().changeId().get(), psId.get(), kind);
-        return false;
-    }
-  }
-
-  private boolean canCopyBasedOnCopyCondition(
+  /**
+   * Returns all follow-up patch sets of the given patch set to which the given approval is
+   * copyable.
+   *
+   * <p>An approval is considered as copyable to a follow-up patch set if it matches the copy rules
+   * of the label and it is copyable to all intermediate follow-up patch sets as well.
+   *
+   * <p>The returned follow-up patch sets are returned in the order of their patch set IDs.
+   *
+   * <p>Note: This method only checks the copy rules to detect if the approval is copyable. There
+   * are other factors, not checked here, that can prevent the copying of the approval to the
+   * returned follow-up patch sets (e.g. if they already have a matching non-copy approval that
+   * prevents the copying).
+   *
+   * @param changeNotes the change notes
+   * @param sourcePatchSet the patch set on which the approval was applied
+   * @param approverId the account ID of the user that applied the approval
+   * @param label the label of the approval that was applied
+   * @param approvalValue the value of the approval that was applied
+   * @return the follow-up patch sets to which the approval is copyable, ordered by patch set ID
+   */
+  public ImmutableList<PatchSet.Id> forApproval(
       ChangeNotes changeNotes,
-      PatchSetApproval psa,
-      PatchSet patchSet,
-      LabelType type,
+      PatchSet sourcePatchSet,
+      Account.Id approverId,
+      String label,
+      short approvalValue)
+      throws IOException {
+    ImmutableList.Builder<PatchSet.Id> targetPatchSetsBuilder = ImmutableList.builder();
+
+    Optional<LabelType> labelType =
+        projectCache
+            .get(changeNotes.getProjectName())
+            .orElseThrow(illegalState(changeNotes.getProjectName()))
+            .getLabelTypes()
+            .byLabel(label);
+    if (!labelType.isPresent()) {
+      // no label type exists for this label, hence this approval cannot be copied
+      return ImmutableList.of();
+    }
+
+    try (Repository repo = repoManager.openRepository(changeNotes.getProjectName());
+        RevWalk revWalk = new RevWalk(repo)) {
+      ImmutableList<PatchSet.Id> followUpPatchSets =
+          changeNotes.getPatchSets().keySet().stream()
+              .filter(psId -> psId.get() > sourcePatchSet.id().get())
+              .collect(toImmutableList());
+      PatchSet priorPatchSet = sourcePatchSet;
+
+      // Iterate over the follow-up patch sets in order to copy the approval from their prior patch
+      // set if possible (copy from PS N-1 to PS N).
+      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
+        PatchSet followUpPatchSet = psUtil.get(changeNotes, followUpPatchSetId);
+        ChangeKind changeKind =
+            changeKindCache.getChangeKind(
+                changeNotes.getProjectName(),
+                revWalk,
+                repo.getConfig(),
+                priorPatchSet.commitId(),
+                followUpPatchSet.commitId());
+        boolean isMerge = isMerge(changeNotes.getProjectName(), revWalk, followUpPatchSet);
+
+        if (canCopy(
+            changeNotes,
+            priorPatchSet.id(),
+            followUpPatchSet,
+            approverId,
+            labelType.get(),
+            approvalValue,
+            changeKind,
+            isMerge,
+            revWalk,
+            repo.getConfig())) {
+          targetPatchSetsBuilder.add(followUpPatchSetId);
+        } else {
+          // The approval is not copyable to this follow-up patch set.
+          // This means it's also not copyable to any further follow-up patch set and we should stop
+          // the loop here.
+          break;
+        }
+        priorPatchSet = followUpPatchSet;
+      }
+    }
+    return targetPatchSetsBuilder.build();
+  }
+
+  private boolean canCopy(
+      ChangeNotes changeNotes,
+      PatchSet.Id sourcePatchSetId,
+      PatchSet targetPatchSet,
+      Account.Id approverId,
+      LabelType labelType,
+      short approvalValue,
       ChangeKind changeKind,
       boolean isMerge,
       RevWalk revWalk,
       Config repoConfig) {
-    if (!type.getCopyCondition().isPresent()) {
+    if (!labelType.getCopyCondition().isPresent()) {
       return false;
     }
     ApprovalContext ctx =
         ApprovalContext.create(
-            changeNotes, psa, patchSet, changeKind, isMerge, revWalk, repoConfig);
+            changeNotes,
+            sourcePatchSetId,
+            approverId,
+            labelType,
+            approvalValue,
+            targetPatchSet,
+            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
       // request (e.g. a group used in this query might not be visible to the person sending this
       // request).
       try (ManualRequestContext ignored = requestContext.open()) {
-        return approvalQueryBuilder.parse(type.getCopyCondition().get()).asMatchable().match(ctx);
+        return approvalQueryBuilder
+            .parse(labelType.getCopyCondition().get())
+            .asMatchable()
+            .match(ctx);
       }
     } catch (QueryParseException e) {
       logger.atWarning().withCause(e).log(
@@ -342,100 +295,102 @@
     }
   }
 
-  private Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
-      ChangeNotes notes, ProjectState project, PatchSet patchSet, RevWalk rw, Config repoConfig) {
-    checkState(
-        project.getNameKey().equals(notes.getProjectName()),
-        "project must match %s, %s",
-        project.getNameKey(),
-        notes.getProjectName());
-
-    PatchSet.Id psId = patchSet.id();
-    // Add approvals on the given patch set to the result
-    Table<String, Account.Id, PatchSetApproval> resultByUser = HashBasedTable.create();
-    ImmutableList<PatchSetApproval> nonCopiedApprovalsForGivenPatchSet =
-        notes.load().getApprovals().get(patchSet.id());
-    nonCopiedApprovalsForGivenPatchSet.forEach(
-        psa -> resultByUser.put(psa.label(), psa.accountId(), psa));
+  private Result computeForPatchSet(
+      LabelTypes labelTypes,
+      ChangeNotes notes,
+      PatchSet targetPatchSet,
+      RevWalk rw,
+      Config repoConfig) {
+    Project.NameKey projectName = notes.getProjectName();
+    PatchSet.Id targetPsId = targetPatchSet.id();
 
     // 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();
+    if (targetPsId.get() == 1) {
+      return Result.empty();
     }
-    Map.Entry<PatchSet.Id, PatchSet> priorPatchSet = notes.load().getPatchSets().lowerEntry(psId);
+    Map.Entry<PatchSet.Id, PatchSet> priorPatchSet =
+        notes.load().getPatchSets().lowerEntry(targetPsId);
     if (priorPatchSet == null) {
-      return resultByUser.values();
+      return Result.empty();
     }
 
-    ImmutableList<PatchSetApproval> priorApprovalsIncludingCopied =
-        notes.load().getApprovalsWithCopied().get(priorPatchSet.getKey());
+    Table<String, Account.Id, PatchSetApproval> currentApprovalsByUser = HashBasedTable.create();
+    ImmutableList<PatchSetApproval> nonCopiedApprovalsForGivenPatchSet =
+        notes.load().getApprovals().onlyNonCopied().get(targetPatchSet.id());
+    nonCopiedApprovalsForGivenPatchSet.forEach(
+        psa -> currentApprovalsByUser.put(psa.label(), psa.accountId(), psa));
+
+    Table<String, Account.Id, PatchSetApproval> copiedApprovalsByUser = HashBasedTable.create();
+    ImmutableSet.Builder<PatchSetApproval> outdatedApprovalsBuilder = ImmutableSet.builder();
+
+    ImmutableList<PatchSetApproval> priorApprovals =
+        notes.load().getApprovals().all().get(priorPatchSet.getKey());
 
     // Add labels from the previous patch set to the result in case the label isn't already there
     // and settings as well as change kind allow copying.
     ChangeKind changeKind =
         changeKindCache.getChangeKind(
-            project.getNameKey(),
+            projectName,
             rw,
             repoConfig,
             priorPatchSet.getValue().commitId(),
-            patchSet.commitId());
-    boolean isMerge = isMerge(project.getNameKey(), rw, patchSet);
+            targetPatchSet.commitId());
+    boolean isMerge = isMerge(projectName, rw, targetPatchSet);
     logger.atFine().log(
         "change kind for patch set %d of change %d against prior patch set %s is %s",
-        patchSet.id().get(),
-        patchSet.id().changeId().get(),
+        targetPatchSet.id().get(),
+        targetPatchSet.id().changeId().get(),
         priorPatchSet.getValue().id().changeId(),
         changeKind);
 
-    Map<String, ModifiedFile> baseVsCurrent = null;
-    Map<String, ModifiedFile> baseVsPrior = null;
-    Map<String, ModifiedFile> priorVsCurrent = null;
-    LabelTypes labelTypes = project.getLabelTypes();
-    for (PatchSetApproval psa : priorApprovalsIncludingCopied) {
-      if (resultByUser.contains(psa.label(), psa.accountId())) {
+    for (PatchSetApproval priorPsa : priorApprovals) {
+      if (priorPsa.value() == 0) {
+        // approvals with a zero vote record the deletion of a vote,
+        // they should neither be copied nor be reported as outdated, hence just skip them
         continue;
       }
-      Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
-      // Only compute modified files if there is a relevant label, since this is expensive.
-      if (baseVsCurrent == null
-          && type.isPresent()
-          && type.get().isCopyAllScoresIfListOfFilesDidNotChange()) {
-        baseVsCurrent = listModifiedFiles(project, patchSet, rw, repoConfig);
-        baseVsPrior = listModifiedFiles(project, priorPatchSet.getValue(), rw, repoConfig);
-        priorVsCurrent =
-            listModifiedFiles(
-                project, priorPatchSet.getValue().commitId(), patchSet.commitId(), rw, repoConfig);
-      }
-      if (!type.isPresent()) {
+
+      Optional<LabelType> labelType = labelTypes.byLabel(priorPsa.labelId());
+      if (!labelType.isPresent()) {
         logger.atFine().log(
             "approval %d on label %s of patch set %d of change %d cannot be copied"
                 + " to patch set %d because the label no longer exists on project %s",
-            psa.value(),
-            psa.label(),
-            psa.key().patchSetId().get(),
-            psa.key().patchSetId().changeId().get(),
-            psId.get(),
-            project.getName());
+            priorPsa.value(),
+            priorPsa.label(),
+            priorPsa.key().patchSetId().get(),
+            priorPsa.key().patchSetId().changeId().get(),
+            targetPsId.get(),
+            projectName);
+        outdatedApprovalsBuilder.add(priorPsa);
         continue;
       }
-      if (!canCopyBasedOnBooleanLabelConfigs(
-              project,
-              psa,
-              patchSet.id(),
-              changeKind,
-              isMerge,
-              type.get(),
-              baseVsCurrent,
-              baseVsPrior,
-              priorVsCurrent)
-          && !canCopyBasedOnCopyCondition(
-              notes, psa, patchSet, type.get(), changeKind, isMerge, rw, repoConfig)) {
+      if (canCopy(
+          notes,
+          priorPsa.patchSetId(),
+          targetPatchSet,
+          priorPsa.accountId(),
+          labelType.get(),
+          priorPsa.value(),
+          changeKind,
+          isMerge,
+          rw,
+          repoConfig)) {
+        if (!currentApprovalsByUser.contains(priorPsa.label(), priorPsa.accountId())) {
+          copiedApprovalsByUser.put(
+              priorPsa.label(),
+              priorPsa.accountId(),
+              priorPsa.copyWithPatchSet(targetPatchSet.id()));
+        }
+      } else {
+        outdatedApprovalsBuilder.add(priorPsa);
         continue;
       }
-      resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(patchSet.id()));
     }
-    return resultByUser.values();
+
+    ImmutableSet<PatchSetApproval> copiedApprovals =
+        labelNormalizer.normalize(notes, copiedApprovalsByUser.values()).getNormalized();
+    return Result.create(copiedApprovals, outdatedApprovalsBuilder.build());
   }
 
   private boolean isMerge(Project.NameKey project, RevWalk rw, PatchSet patchSet) {
@@ -449,58 +404,4 @@
           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, ModifiedFile> listModifiedFiles(
-      ProjectState project, PatchSet ps, RevWalk revWalk, Config repoConfig) {
-    try {
-      Integer parentNum =
-          listOfFilesUnchangedPredicate.isInitialCommit(project.getNameKey(), ps.commitId())
-              ? 0
-              : 1;
-      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"
-              + " votes on labels even if list of files is the same and "
-              + "copyAllIfListOfFilesDidNotChange",
-          ex);
-    }
-  }
-
-  /**
-   * Gets the modified files between two commits corresponding to different patchsets of the same
-   * change.
-   */
-  private Map<String, ModifiedFile> listModifiedFiles(
-      ProjectState project,
-      ObjectId sourceCommit,
-      ObjectId targetCommit,
-      RevWalk revWalk,
-      Config repoConfig) {
-    try {
-      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"
-              + " votes on labels even if list of files is the same and "
-              + "copyAllIfListOfFilesDidNotChange",
-          ex);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index fdcaf69..8014e17 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -15,18 +15,29 @@
 package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
@@ -38,10 +49,13 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.change.LabelNormalizer;
+import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -50,13 +64,20 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.query.approval.UserInPredicate;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -95,22 +116,34 @@
     return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
   }
 
-  private final ApprovalCopier approvalInference;
+  private final AccountCache accountCache;
+  private final String anonymousCowardName;
+  private final ApprovalCopier approvalCopier;
+  private final Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final LabelNormalizer labelNormalizer;
+  private final OneOffRequestContext requestContext;
 
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
-      ApprovalCopier approvalInference,
+      AccountCache accountCache,
+      @AnonymousCowardName String anonymousCowardName,
+      ApprovalCopier approvalCopier,
+      Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
-      LabelNormalizer labelNormalizer) {
-    this.approvalInference = approvalInference;
+      LabelNormalizer labelNormalizer,
+      OneOffRequestContext requestContext) {
+    this.accountCache = accountCache;
+    this.anonymousCowardName = anonymousCowardName;
+    this.approvalCopier = approvalCopier;
+    this.approvalQueryBuilderProvider = approvalQueryBuilderProvider;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.labelNormalizer = labelNormalizer;
+    this.requestContext = requestContext;
   }
 
   /**
@@ -336,26 +369,340 @@
 
   public ListMultimap<PatchSet.Id, PatchSetApproval> byChangeExcludingCopiedApprovals(
       ChangeNotes notes) {
-    return notes.load().getApprovals();
+    return notes.load().getApprovals().onlyNonCopied();
   }
 
   /**
-   * This method should only be used when we want to dynamically compute the approvals. Generally,
-   * the copied approvals are available in {@link ChangeNotes}. However, if the patch-set is just
-   * being created, we need to dynamically compute the approvals so that we can persist them in
-   * storage. The {@link RevWalk} and {@link Config} objects that are being used to create the new
-   * patch-set are required for this method. Here we also add those votes to the provided {@link
-   * ChangeUpdate} object.
+   * Copies approvals to a new patch set.
+   *
+   * <p>Computes the approvals of the prior patch set that should be copied to the new patch set and
+   * stores them in NoteDb.
+   *
+   * <p>For outdated approvals (approvals on the prior patch set which are outdated by the new patch
+   * set and hence not copied) the approvers are added to the attention set since they need to
+   * re-review the change and renew their approvals.
+   *
+   * @param notes the change notes
+   * @param patchSet the newly created patch set
+   * @param revWalk {@link RevWalk} that can see the new patch set revision
+   * @param repoConfig the repo config
+   * @param changeUpdate changeUpdate that is used to persist the copied approvals and update the
+   *     attention set
+   * @return the result of the approval copying
    */
-  public void persistCopiedApprovals(
+  public ApprovalCopier.Result copyApprovalsToNewPatchSet(
       ChangeNotes notes,
       PatchSet patchSet,
       RevWalk revWalk,
       Config repoConfig,
       ChangeUpdate changeUpdate) {
-    approvalInference
-        .forPatchSet(notes, patchSet, revWalk, repoConfig)
-        .forEach(a -> changeUpdate.putCopiedApproval(a));
+    ApprovalCopier.Result approvalCopierResult =
+        approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig);
+    approvalCopierResult.copiedApprovals().forEach(a -> changeUpdate.putCopiedApproval(a));
+
+    if (!notes.getChange().isWorkInProgress()) {
+      // The attention set should not be updated when the change is work-in-progress.
+      addAttentionSetUpdatesForOutdatedApprovals(
+          changeUpdate, approvalCopierResult.outdatedApprovals());
+    }
+
+    return approvalCopierResult;
+  }
+
+  private void addAttentionSetUpdatesForOutdatedApprovals(
+      ChangeUpdate changeUpdate, ImmutableSet<PatchSetApproval> outdatedApprovals) {
+    Set<AttentionSetUpdate> updates = new HashSet<>();
+
+    Multimap<Account.Id, PatchSetApproval> outdatedApprovalsByUser = ArrayListMultimap.create();
+    outdatedApprovals.forEach(psa -> outdatedApprovalsByUser.put(psa.accountId(), psa));
+    for (Map.Entry<Account.Id, Collection<PatchSetApproval>> e :
+        outdatedApprovalsByUser.asMap().entrySet()) {
+      Account.Id approverId = e.getKey();
+      Collection<PatchSetApproval> outdatedUserApprovals = e.getValue();
+
+      String message;
+      if (outdatedUserApprovals.size() == 1) {
+        PatchSetApproval outdatedUserApproval = Iterables.getOnlyElement(outdatedUserApprovals);
+        message =
+            String.format(
+                "Vote got outdated and was removed: %s",
+                LabelVote.create(outdatedUserApproval.label(), outdatedUserApproval.value())
+                    .format());
+      } else {
+        message =
+            String.format(
+                "Votes got outdated and were removed: %s",
+                outdatedUserApprovals.stream()
+                    .map(
+                        outdatedUserApproval ->
+                            LabelVote.create(
+                                    outdatedUserApproval.label(), outdatedUserApproval.value())
+                                .format())
+                    .sorted()
+                    .collect(joining(", ")));
+      }
+
+      updates.add(
+          AttentionSetUpdate.createForWrite(approverId, AttentionSetUpdate.Operation.ADD, message));
+    }
+    changeUpdate.addToPlannedAttentionSetUpdates(updates);
+  }
+
+  public Optional<String> formatApprovalCopierResult(
+      ApprovalCopier.Result approvalCopierResult, LabelTypes labelTypes) {
+    requireNonNull(approvalCopierResult, "approvalCopierResult");
+    requireNonNull(labelTypes, "labelTypes");
+
+    if (approvalCopierResult.copiedApprovals().isEmpty()
+        && approvalCopierResult.outdatedApprovals().isEmpty()) {
+      return Optional.empty();
+    }
+
+    StringBuilder message = new StringBuilder();
+
+    if (!approvalCopierResult.copiedApprovals().isEmpty()) {
+      message.append("Copied Votes:\n");
+      message.append(
+          formatApprovalListWithCopyCondition(approvalCopierResult.copiedApprovals(), labelTypes));
+    }
+    if (!approvalCopierResult.outdatedApprovals().isEmpty()) {
+      if (!approvalCopierResult.copiedApprovals().isEmpty()) {
+        message.append("\n");
+      }
+      message.append("Outdated Votes:\n");
+      message.append(
+          formatApprovalListWithCopyCondition(
+              approvalCopierResult.outdatedApprovals(), labelTypes));
+    }
+
+    return Optional.of(message.toString());
+  }
+
+  /**
+   * Formats the given approvals as a bullet list, each approval with the corresponding copy
+   * condition if available.
+   *
+   * <p>E.g.:
+   *
+   * <pre>
+   * * Code-Review+1, Code-Review+2 (copy condition: "is:MIN")
+   * * Verified+1 (copy condition: "is:MIN")
+   * </pre>
+   *
+   * <p>Entries in the list can have the following formats:
+   *
+   * <ul>
+   *   <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition:
+   *       "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate
+   *       is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
+   *   <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition:
+   *       "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is
+   *       present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001>
+   *       (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label>} (if no copy condition is
+   *       present), e.g.: {@code Code-Review+1, Code-Review+2}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (label type is missing)} (if
+   *       the label type is missing), e.g.: {@code Code-Review+1, Code-Review+2 (label type is
+   *       missing)}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy
+   *       condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is
+   *       present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition:
+   *       "is:FOO")}
+   * </ul>
+   *
+   * @param approvals the approvals that should be formatted
+   * @param labelTypes the label types
+   * @return bullet list with the formatted approvals
+   */
+  private String formatApprovalListWithCopyCondition(
+      ImmutableSet<PatchSetApproval> approvals, LabelTypes labelTypes) {
+    StringBuilder message = new StringBuilder();
+
+    // sort approvals by label vote so that we list them in a deterministic order
+    ImmutableList<PatchSetApproval> approvalsSortedByLabelVote =
+        approvals.stream()
+            .sorted(comparing(psa -> LabelVote.create(psa.label(), psa.value()).format()))
+            .collect(toImmutableList());
+
+    ImmutableListMultimap<String, PatchSetApproval> approvalsByLabel =
+        Multimaps.index(approvalsSortedByLabelVote, PatchSetApproval::label);
+
+    for (Map.Entry<String, Collection<PatchSetApproval>> approvalsByLabelEntry :
+        approvalsByLabel.asMap().entrySet()) {
+      String label = approvalsByLabelEntry.getKey();
+      Collection<PatchSetApproval> approvalsForSameLabel = approvalsByLabelEntry.getValue();
+
+      message.append("* ");
+      if (!labelTypes.byLabel(label).isPresent()) {
+        message
+            .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
+            .append(" (label type is missing)\n");
+        continue;
+      }
+
+      LabelType labelType = labelTypes.byLabel(label).get();
+      if (!labelType.getCopyCondition().isPresent()) {
+        message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)).append("\n");
+        continue;
+      }
+
+      message
+          .append(
+              formatApprovalsWithCopyCondition(
+                  approvalsForSameLabel, labelType.getCopyCondition().get()))
+          .append("\n");
+    }
+
+    return message.toString();
+  }
+
+  /**
+   * Formats the given approvals of the same label with the given copy condition.
+   *
+   * <p>E.g.: {Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
+   *
+   * <p>The following format may be returned:
+   *
+   * <ul>
+   *   <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition:
+   *       "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate
+   *       is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
+   *   <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition:
+   *       "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is
+   *       present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001>
+   *       (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy
+   *       condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is
+   *       present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition:
+   *       "is:FOO")}
+   * </ul>
+   *
+   * @param approvalsForSameLabel the approvals that should be formatted, must be for the same label
+   * @param copyCondition the copy condition of the label
+   * @return the formatted approvals
+   */
+  private String formatApprovalsWithCopyCondition(
+      Collection<PatchSetApproval> approvalsForSameLabel, String copyCondition) {
+    StringBuilder message = new StringBuilder();
+
+    boolean containsUserInPredicate;
+    try {
+      containsUserInPredicate = containsUserInPredicate(copyCondition);
+    } catch (QueryParseException e) {
+      logger.atWarning().withCause(e).log("Non-parsable query condition");
+      message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
+      message.append(String.format(" (non-parseable copy condition: \"%s\")", copyCondition));
+      return message.toString();
+    }
+
+    if (containsUserInPredicate) {
+      // If a UserInPredicate is used (e.g. 'approverin:<group>' or 'uploaderin:<group>') we need to
+      // include the approvers into the change message since they are relevant for the matching. For
+      // example it can happen that the same approval of different users is copied for the one user
+      // but not for the other user (since the one user is a member of the approverin group and the
+      // other user isn't).
+      //
+      // Example:
+      // * label Foo has the copy condition 'is:ANY approverin:123'
+      // * group 123 contains UserA as member, but not UserB
+      // * a change has the following approvals: Foo+1 by UserA and Foo+1 by UserB
+      //
+      // In this case Foo+1 by UserA is copied because UserA is a member of group 123 and the copy
+      // condition matches, while Foo+1 by UserB is not copied because UserB is not a member of
+      // group 123 and the copy condition doesn't match.
+      //
+      // So it can happen that the same approval Foo+1, but by different users, is copied and
+      // outdated at the same time. To allow users to understand that the copying depends on who did
+      // the approval, the approvers must be included into the change message.
+
+      // sort the approvals by their approvers name-email so that the approvers always appear in a
+      // deterministic order
+      ImmutableList<PatchSetApproval> approvalsSortedByLabelVoteAndApprover =
+          approvalsForSameLabel.stream()
+              .sorted(
+                  comparing(
+                          (PatchSetApproval psa) ->
+                              LabelVote.create(psa.label(), psa.value()).format())
+                      .thenComparing(
+                          psa ->
+                              accountCache
+                                  .getEvenIfMissing(psa.accountId())
+                                  .account()
+                                  .getNameEmail(anonymousCowardName)))
+              .collect(toImmutableList());
+
+      ImmutableListMultimap<LabelVote, Account.Id> approversByLabelVote =
+          Multimaps.index(
+                  approvalsSortedByLabelVoteAndApprover,
+                  psa -> LabelVote.create(psa.label(), psa.value()))
+              .entries().stream()
+              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue().accountId()));
+      message.append(
+          approversByLabelVote.asMap().entrySet().stream()
+              .map(
+                  approversByLabelVoteEntry ->
+                      formatLabelVoteWithApprovers(
+                          approversByLabelVoteEntry.getKey(), approversByLabelVoteEntry.getValue()))
+              .collect(joining(", ")));
+    } else {
+      // copy condition doesn't contain a UserInPredicate
+      message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
+    }
+    message.append(String.format(" (copy condition: \"%s\")", copyCondition));
+    return message.toString();
+  }
+
+  private boolean containsUserInPredicate(String copyCondition) throws QueryParseException {
+    // 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
+    // request (e.g. a group used in this query might not be visible to the person sending this
+    // request).
+    try (ManualRequestContext ignored = requestContext.open()) {
+      return approvalQueryBuilderProvider.get().parse(copyCondition).getFlattenedPredicateList()
+          .stream()
+          .anyMatch(UserInPredicate.class::isInstance);
+    }
+  }
+
+  /**
+   * Formats the given approvals as a comma-separated list of label votes.
+   *
+   * <p>E.g.: {@code Code-Review+1, CodeReview+2}
+   *
+   * @param sortedApprovalsForSameLabel the approvals that should be formatted as a comma-separated
+   *     list of label votes, must be sorted
+   * @return the given approvals as a comma-separated list of label votes
+   */
+  private String formatApprovalsAsLabelVotesList(
+      Collection<PatchSetApproval> sortedApprovalsForSameLabel) {
+    return sortedApprovalsForSameLabel.stream()
+        .map(psa -> LabelVote.create(psa.label(), psa.value()))
+        .distinct()
+        .map(LabelVote::format)
+        .collect(joining(", "));
+  }
+
+  /**
+   * Formats the given label vote with a comma-separated list of the given approvers.
+   *
+   * <p>E.g.: {@code Code-Review+1 by <user1-placeholder>, <user2-placeholder>}
+   *
+   * @param labelVote the label vote that should be formatted with a comma-separated list of the
+   *     given approver
+   * @param sortedApprovers the approvers that should be formatted as a comma-separated list for the
+   *     given label vote
+   * @return the given label vote with a comma-separated list of the given approvers
+   */
+  private String formatLabelVoteWithApprovers(
+      LabelVote labelVote, Collection<Account.Id> sortedApprovers) {
+    return new StringBuilder()
+        .append(labelVote.format())
+        .append(" by ")
+        .append(
+            sortedApprovers.stream()
+                .map(AccountTemplateUtil::getAccountTemplate)
+                .collect(joining(", ")))
+        .toString();
   }
 
   /**
@@ -368,7 +715,7 @@
    *     deleted labels.
    */
   public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovalsWithCopied().get(psId);
+    List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovals().all().get(psId);
     return labelNormalizer.normalize(notes, approvalsNotNormalized).getNormalized();
   }
 
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
index 5a71ced..eb3949e 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -43,19 +43,6 @@
         .setIgnoreSelfApproval(proto.getIgnoreSelfApproval())
         .setDefaultValue(Shorts.saturatedCast(proto.getDefaultValue()))
         .setCopyCondition(Strings.emptyToNull(proto.getCopyCondition()))
-        .setCopyAnyScore(proto.getCopyAnyScore())
-        .setCopyMinScore(proto.getCopyMinScore())
-        .setCopyMaxScore(proto.getCopyMaxScore())
-        .setCopyAllScoresIfListOfFilesDidNotChange(
-            proto.getCopyAllScoresIfListOfFilesDidNotChange())
-        .setCopyAllScoresOnMergeFirstParentUpdate(proto.getCopyAllScoresOnMergeFirstParentUpdate())
-        .setCopyAllScoresOnTrivialRebase(proto.getCopyAllScoresOnTrivialRebase())
-        .setCopyAllScoresIfNoCodeChange(proto.getCopyAllScoresIfNoCodeChange())
-        .setCopyAllScoresIfNoChange(proto.getCopyAllScoresIfNoChange())
-        .setCopyValues(
-            proto.getCopyValuesList().stream()
-                .map(Shorts::saturatedCast)
-                .collect(toImmutableList()))
         .setMaxNegative(Shorts.saturatedCast(proto.getMaxNegative()))
         .setMaxPositive(Shorts.saturatedCast(proto.getMaxPositive()))
         .setRefPatterns(proto.getRefPatternsList())
@@ -73,18 +60,6 @@
         .setDescription(autoValue.getDescription().orElse(""))
         .setFunction(FUNCTION_CONVERTER.reverse().convert(autoValue.getFunction()))
         .setCopyCondition(autoValue.getCopyCondition().orElse(""))
-        .setCopyAnyScore(autoValue.isCopyAnyScore())
-        .setCopyMinScore(autoValue.isCopyMinScore())
-        .setCopyMaxScore(autoValue.isCopyMaxScore())
-        .setCopyAllScoresIfListOfFilesDidNotChange(
-            autoValue.isCopyAllScoresIfListOfFilesDidNotChange())
-        .setCopyAllScoresOnMergeFirstParentUpdate(
-            autoValue.isCopyAllScoresOnMergeFirstParentUpdate())
-        .setCopyAllScoresOnTrivialRebase(autoValue.isCopyAllScoresOnTrivialRebase())
-        .setCopyAllScoresIfNoCodeChange(autoValue.isCopyAllScoresIfNoCodeChange())
-        .setCopyAllScoresIfNoChange(autoValue.isCopyAllScoresIfNoChange())
-        .addAllCopyValues(
-            autoValue.getCopyValues().stream().map(c -> (int) c).collect(toImmutableList()))
         .setAllowPostSubmit(autoValue.isAllowPostSubmit())
         .setIgnoreSelfApproval(autoValue.isIgnoreSelfApproval())
         .setDefaultValue(Shorts.saturatedCast(autoValue.getDefaultValue()))
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
index a7a84f7..5aa7a2a 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
@@ -27,6 +27,9 @@
     return StoredCommentLinkInfo.builder(proto.getName())
         .setMatch(emptyToNull(proto.getMatch()))
         .setLink(emptyToNull(proto.getLink()))
+        .setPrefix(emptyToNull(proto.getPrefix()))
+        .setSuffix(emptyToNull(proto.getSuffix()))
+        .setText(emptyToNull(proto.getText()))
         .setHtml(emptyToNull(proto.getHtml()))
         .setEnabled(proto.getEnabled())
         .setOverrideOnly(proto.getOverrideOnly())
@@ -38,6 +41,9 @@
         .setName(autoValue.getName())
         .setMatch(nullToEmpty(autoValue.getMatch()))
         .setLink(nullToEmpty(autoValue.getLink()))
+        .setPrefix(nullToEmpty(autoValue.getPrefix()))
+        .setSuffix(nullToEmpty(autoValue.getSuffix()))
+        .setText(nullToEmpty(autoValue.getText()))
         .setHtml(nullToEmpty(autoValue.getHtml()))
         .setEnabled(Optional.ofNullable(autoValue.getEnabled()).orElse(true))
         .setOverrideOnly(autoValue.getOverrideOnly())
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
index 436fe76..f61e261 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.cache.serialize.entities;
 
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.SubmitRequirementExpression;
@@ -26,11 +28,21 @@
  * SubmitRequirementExpressionResultProto}.
  */
 public class SubmitRequirementExpressionResultSerializer {
+
+  private static final Converter<String, SubmitRequirementExpressionResult.Status>
+      STATUS_CONVERTER = Enums.stringConverter(SubmitRequirementExpressionResult.Status.class);
+
   public static SubmitRequirementExpressionResult deserialize(
       SubmitRequirementExpressionResultProto proto) {
+    SubmitRequirementExpressionResult.Status status;
+    try {
+      status = STATUS_CONVERTER.convert(proto.getStatus());
+    } catch (IllegalArgumentException e) {
+      status = SubmitRequirementExpressionResult.Status.ERROR;
+    }
     return SubmitRequirementExpressionResult.create(
         SubmitRequirementExpression.create(proto.getExpression()),
-        SubmitRequirementExpressionResult.Status.valueOf(proto.getStatus()),
+        status,
         proto.getPassingAtomsList().stream().collect(ImmutableList.toImmutableList()),
         proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()),
         Optional.ofNullable(Strings.emptyToNull(proto.getErrorMessage())));
@@ -40,7 +52,7 @@
       SubmitRequirementExpressionResult r) {
     return SubmitRequirementExpressionResultProto.newBuilder()
         .setExpression(r.expression().expressionString())
-        .setStatus(r.status().name())
+        .setStatus(STATUS_CONVERTER.reverse().convert(r.status()))
         .addAllPassingAtoms(r.passingAtoms())
         .addAllFailingAtoms(r.failingAtoms())
         .setErrorMessage(r.errorMessage().orElse(""))
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index 29bd045..59b32c2 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -40,10 +40,7 @@
 
   private final ChangeCleanupConfig cfg;
   private final Provider<ChangeQueryProcessor> queryProvider;
-  // Provider is needed, because AbandonUtil is singleton, but ChangeQueryBuilder accesses
-  // index collection, that is only provided when multiversion index module is started.
-  // TODO(davido); Remove provider again, when support for legacy numeric fields is removed.
-  private final Provider<ChangeQueryBuilder> queryBuilderProvider;
+  private final ChangeQueryBuilder queryBuilder;
   private final BatchAbandon batchAbandon;
   private final InternalUser internalUser;
 
@@ -52,11 +49,11 @@
       ChangeCleanupConfig cfg,
       InternalUser.Factory internalUserFactory,
       Provider<ChangeQueryProcessor> queryProvider,
-      Provider<ChangeQueryBuilder> queryBuilderProvider,
+      ChangeQueryBuilder queryBuilder,
       BatchAbandon batchAbandon) {
     this.cfg = cfg;
     this.queryProvider = queryProvider;
-    this.queryBuilderProvider = queryBuilderProvider;
+    this.queryBuilder = queryBuilder;
     this.batchAbandon = batchAbandon;
     internalUser = internalUserFactory.create();
   }
@@ -74,11 +71,7 @@
       }
 
       List<ChangeData> changesToAbandon =
-          queryProvider
-              .get()
-              .enforceVisibility(false)
-              .query(queryBuilderProvider.get().parse(query))
-              .entities();
+          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
       ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
           ImmutableListMultimap.builder();
       for (ChangeData cd : changesToAbandon) {
@@ -118,7 +111,7 @@
           queryProvider
               .get()
               .enforceVisibility(false)
-              .query(queryBuilderProvider.get().parse(newQuery))
+              .query(queryBuilder.parse(newQuery))
               .entities();
       if (!changesToAbandon.isEmpty()) {
         validChanges.add(cd);
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index a980c32..ec90bec 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -16,13 +16,11 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -31,18 +29,14 @@
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 
 /** Add a specified user to the attention set. */
 public class AddToAttentionSetOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     AddToAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final MessageIdGenerator messageIdGenerator;
   private final AddToAttentionSetSender.Factory addToAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
@@ -63,14 +57,12 @@
   AddToAttentionSetOp(
       ChangeData.Factory changeDataFactory,
       AddToAttentionSetSender.Factory addToAttentionSetSender,
-      MessageIdGenerator messageIdGenerator,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
     this.addToAttentionSetSender = addToAttentionSetSender;
-    this.messageIdGenerator = messageIdGenerator;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
 
     this.attentionUserId = requireNonNull(attentionUserId, "user");
@@ -93,8 +85,8 @@
 
     change = ctx.getChange();
 
-    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.addToPlannedAttentionSetUpdates(
+    ChangeUpdate changeUpdate = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    changeUpdate.addToPlannedAttentionSetUpdates(
         AttentionSetUpdate.createForWrite(
             attentionUserId, AttentionSetUpdate.Operation.ADD, reason));
     return true;
@@ -105,18 +97,13 @@
     if (!notify) {
       return;
     }
-    try {
-      attentionSetEmailFactory
-          .create(
-              addToAttentionSetSender.create(ctx.getProject(), change.getId()),
-              ctx,
-              change,
-              reason,
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
-              attentionUserId)
-          .sendAsync();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
-    }
+    attentionSetEmailFactory
+        .create(
+            addToAttentionSetSender.create(ctx.getProject(), change.getId()),
+            ctx,
+            change,
+            reason,
+            attentionUserId)
+        .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 6ef7f1e..edaca70 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -467,10 +467,10 @@
     approvalsUtil.addApprovalsForNewPatchSet(
         update, labelTypes, patchSet, ctx.getUser(), approvals);
 
-    // Check if approvals are changing in with this update. If so, add current user to reviewers.
-    // Note that this is done separately as addReviewers is filtering out the change owner as
+    // Check if approvals are changing with this update. If so, add the current user (aka the
+    // approver) as a reviewers because all approvers must also be reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as a
     // reviewer which is needed in several other code paths.
-    // TODO(dborowitz): Still necessary?
     if (!approvals.isEmpty()) {
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 32e40eb..02b0a60 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -235,6 +235,7 @@
   private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
   private final boolean includeMergeable;
   private final boolean lazyLoad;
+  private final boolean cacheQueryResultsByChangeNum;
 
   private AccountLoader accountLoader;
   private FixInput fix;
@@ -274,6 +275,8 @@
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
     this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
     this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
+    this.cacheQueryResultsByChangeNum =
+        cfg.getBoolean("index", "cacheQueryResultsByChangeNum", true);
 
     logger.atFine().log("options = %s", options);
   }
@@ -496,7 +499,7 @@
         // This problem has two sides where 'last in the list' has to be respected:
         // (1) Caching
         // (2) Reusing
-        boolean isCacheable = i != changes.size() - 1;
+        boolean isCacheable = cacheQueryResultsByChangeNum && (i != changes.size() - 1);
         ChangeData cd = changes.get(i);
         ChangeInfo info = cache.get(cd.getId());
         if (info != null && isCacheable) {
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 0116b01..1199be5 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -228,7 +228,7 @@
 
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
     Iterable<PatchSetApproval> approvals;
-    approvals = ctx.getNotes().getApprovalsWithCopied().values();
+    approvals = ctx.getNotes().getApprovals().all().values();
     return Iterables.filter(approvals, psa -> accountId.equals(psa.accountId()));
   }
 
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
new file mode 100644
index 0000000..f6ae6a3
--- /dev/null
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -0,0 +1,241 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.time.Instant;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class EmailNewPatchSet {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    EmailNewPatchSet create(
+        PostUpdateContext postUpdateContext,
+        PatchSet patchSet,
+        String message,
+        ImmutableSet<PatchSetApproval> outdatedApprovals,
+        @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
+        @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId);
+  }
+
+  private final ExecutorService sendEmailExecutor;
+  private final ThreadLocalRequestContext threadLocalRequestContext;
+  private final AsyncSender asyncSender;
+
+  private RequestScopePropagator requestScopePropagator;
+
+  @Inject
+  EmailNewPatchSet(
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      ThreadLocalRequestContext threadLocalRequestContext,
+      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      PatchSetInfoFactory patchSetInfoFactory,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted PostUpdateContext postUpdateContext,
+      @Assisted PatchSet patchSet,
+      @Assisted String message,
+      @Assisted ImmutableSet<PatchSetApproval> outdatedApprovals,
+      @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
+      @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
+      @Assisted ChangeKind changeKind,
+      @Assisted ObjectId preUpdateMetaId) {
+    this.sendEmailExecutor = sendEmailExecutor;
+    this.threadLocalRequestContext = threadLocalRequestContext;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              postUpdateContext.getRepoView(), patchSet.id(), "EmailReplacePatchSet");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    Change.Id changeId = patchSet.id().changeId();
+
+    // Getting the change data from PostUpdateContext retrieves a cached ChangeData
+    // instance. This ChangeData instance has been created when the change was (re)indexed
+    // due to the update, and hence has submit requirement results already cached (since
+    // (re)indexing triggers the evaluation of the submit requirements).
+    Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults =
+        postUpdateContext
+            .getChangeData(postUpdateContext.getProject(), changeId)
+            .submitRequirementsIncludingLegacy();
+    this.asyncSender =
+        new AsyncSender(
+            postUpdateContext.getIdentifiedUser(),
+            replacePatchSetFactory,
+            patchSetInfoFactory,
+            messageId,
+            postUpdateContext.getNotify(changeId),
+            postUpdateContext.getProject(),
+            changeId,
+            patchSet,
+            message,
+            postUpdateContext.getWhen(),
+            outdatedApprovals,
+            reviewers,
+            extraCcs,
+            changeKind,
+            preUpdateMetaId,
+            postUpdateSubmitRequirementResults);
+  }
+
+  public EmailNewPatchSet setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
+    this.requestScopePropagator = requestScopePropagator;
+    return this;
+  }
+
+  public void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        sendEmailExecutor.submit(
+            requestScopePropagator != null
+                ? requestScopePropagator.wrap(asyncSender)
+                : () -> {
+                  RequestContext old = threadLocalRequestContext.setContext(asyncSender);
+                  try {
+                    asyncSender.run();
+                  } finally {
+                    threadLocalRequestContext.setContext(old);
+                  }
+                });
+  }
+
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final IdentifiedUser user;
+    private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+    private final PatchSetInfoFactory patchSetInfoFactory;
+    private final MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Project.NameKey projectName;
+    private final Change.Id changeId;
+    private final PatchSet patchSet;
+    private final String message;
+    private final Instant timestamp;
+    private final ImmutableSet<PatchSetApproval> outdatedApprovals;
+    private final ImmutableSet<Account.Id> reviewers;
+    private final ImmutableSet<Account.Id> extraCcs;
+    private final ChangeKind changeKind;
+    private final ObjectId preUpdateMetaId;
+    private final Map<SubmitRequirement, SubmitRequirementResult>
+        postUpdateSubmitRequirementResults;
+
+    AsyncSender(
+        IdentifiedUser user,
+        ReplacePatchSetSender.Factory replacePatchSetFactory,
+        PatchSetInfoFactory patchSetInfoFactory,
+        MessageId messageId,
+        NotifyResolver.Result notify,
+        Project.NameKey projectName,
+        Change.Id changeId,
+        PatchSet patchSet,
+        String message,
+        Instant timestamp,
+        ImmutableSet<PatchSetApproval> outdatedApprovals,
+        ImmutableSet<Account.Id> reviewers,
+        ImmutableSet<Account.Id> extraCcs,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      this.user = user;
+      this.replacePatchSetFactory = replacePatchSetFactory;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.projectName = projectName;
+      this.changeId = changeId;
+      this.patchSet = patchSet;
+      this.message = message;
+      this.timestamp = timestamp;
+      this.outdatedApprovals = outdatedApprovals;
+      this.reviewers = reviewers;
+      this.extraCcs = extraCcs;
+      this.changeKind = changeKind;
+      this.preUpdateMetaId = preUpdateMetaId;
+      this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
+    }
+
+    @Override
+    public void run() {
+      try {
+        ReplacePatchSetSender emailSender =
+            replacePatchSetFactory.create(
+                projectName,
+                changeId,
+                changeKind,
+                preUpdateMetaId,
+                postUpdateSubmitRequirementResults);
+        emailSender.setFrom(user.getAccountId());
+        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        emailSender.setChangeMessage(message, timestamp);
+        emailSender.setNotify(notify);
+        emailSender.addReviewers(reviewers);
+        emailSender.addExtraCC(extraCcs);
+        emailSender.addOutdatedApproval(outdatedApprovals);
+        emailSender.setMessageId(messageId);
+        emailSender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot send email for new patch set %s", patchSet.id());
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "send-email newpatchset";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user.getRealUser();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index f94e592..a9886c7 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -16,29 +16,38 @@
 
 import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.update.RepoView;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.time.Instant;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
 
-public class EmailReviewComments implements Runnable, RequestContext {
+public class EmailReviewComments {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -47,13 +56,11 @@
     /**
      * Creates handle for sending email
      *
-     * @param notify setting for handling notification.
-     * @param notes change notes.
+     * @param postUpdateContext the post update context from the calling BatchUpdateOp
      * @param patchSet patch set corresponding to the top-level op
-     * @param user user the email should come from.
+     * @param preUpdateMetaId the SHA1 to which the notes branch pointed before the update
      * @param message used by text template only. The contents of this message typically include the
      *     "Patch set N" header and "(M comments)".
-     * @param timestamp timestamp when the comments were added.
      * @param comments inline comments.
      * @param patchSetComment used by HTML template only: some quasi-human-generated text. The
      *     contents should *not* include a "Patch set N" header or "(M comments)" footer, as these
@@ -61,34 +68,17 @@
      * @param labels labels applied as part of this review operation.
      */
     EmailReviewComments create(
-        NotifyResolver.Result notify,
-        ChangeNotes notes,
+        PostUpdateContext postUpdateContext,
         PatchSet patchSet,
-        IdentifiedUser user,
+        ObjectId preUpdateMetaId,
         @Assisted("message") String message,
-        Instant timestamp,
         List<? extends Comment> comments,
-        @Assisted("patchSetComment") String patchSetComment,
-        List<LabelVote> labels,
-        RepoView repoView);
+        @Nullable @Assisted("patchSetComment") String patchSetComment,
+        List<LabelVote> labels);
   }
 
   private final ExecutorService sendEmailsExecutor;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final CommentSender.Factory commentSenderFactory;
-  private final ThreadLocalRequestContext requestContext;
-  private final MessageIdGenerator messageIdGenerator;
-
-  private final NotifyResolver.Result notify;
-  private final ChangeNotes notes;
-  private final PatchSet patchSet;
-  private final IdentifiedUser user;
-  private final String message;
-  private final Instant timestamp;
-  private final List<? extends Comment> comments;
-  private final String patchSetComment;
-  private final List<LabelVote> labels;
-  private final RepoView repoView;
+  private final AsyncSender asyncSender;
 
   @Inject
   EmailReviewComments(
@@ -97,69 +87,151 @@
       CommentSender.Factory commentSenderFactory,
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
-      @Assisted NotifyResolver.Result notify,
-      @Assisted ChangeNotes notes,
+      @Assisted PostUpdateContext postUpdateContext,
       @Assisted PatchSet patchSet,
-      @Assisted IdentifiedUser user,
+      @Assisted ObjectId preUpdateMetaId,
       @Assisted("message") String message,
-      @Assisted Instant timestamp,
       @Assisted List<? extends Comment> comments,
       @Nullable @Assisted("patchSetComment") String patchSetComment,
-      @Assisted List<LabelVote> labels,
-      @Assisted RepoView repoView) {
+      @Assisted List<LabelVote> labels) {
     this.sendEmailsExecutor = executor;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.commentSenderFactory = commentSenderFactory;
-    this.requestContext = requestContext;
-    this.messageIdGenerator = messageIdGenerator;
-    this.notify = notify;
-    this.notes = notes;
-    this.patchSet = patchSet;
-    this.user = user;
-    this.message = message;
-    this.timestamp = timestamp;
-    this.comments = COMMENT_ORDER.sortedCopy(comments);
-    this.patchSetComment = patchSetComment;
-    this.labels = labels;
-    this.repoView = repoView;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              postUpdateContext.getRepoView(), patchSet.id(), "EmailReviewComments");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    Change.Id changeId = patchSet.id().changeId();
+
+    // Getting the change data from PostUpdateContext retrieves a cached ChangeData
+    // instance. This ChangeData instance has been created when the change was (re)indexed
+    // due to the update, and hence has submit requirement results already cached (since
+    // (re)indexing triggers the evaluation of the submit requirements).
+    Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults =
+        postUpdateContext
+            .getChangeData(postUpdateContext.getProject(), changeId)
+            .submitRequirementsIncludingLegacy();
+    this.asyncSender =
+        new AsyncSender(
+            requestContext,
+            commentSenderFactory,
+            patchSetInfoFactory,
+            postUpdateContext.getUser().asIdentifiedUser(),
+            messageId,
+            postUpdateContext.getNotify(changeId),
+            postUpdateContext.getProject(),
+            changeId,
+            patchSet,
+            preUpdateMetaId,
+            message,
+            postUpdateContext.getWhen(),
+            ImmutableList.copyOf(COMMENT_ORDER.sortedCopy(comments)),
+            patchSetComment,
+            ImmutableList.copyOf(labels),
+            postUpdateSubmitRequirementResults);
   }
 
   public void sendAsync() {
     @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSender);
   }
 
-  @Override
-  public void run() {
-    RequestContext old = requestContext.setContext(this);
-    try {
-      CommentSender emailSender =
-          commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
-      emailSender.setFrom(user.getAccountId());
-      emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
-      emailSender.setChangeMessage(message, timestamp);
-      emailSender.setComments(comments);
-      emailSender.setPatchSetComment(patchSetComment);
-      emailSender.setLabels(labels);
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdateAndReason(
-              repoView, patchSet.id(), "EmailReviewComments"));
-      emailSender.send();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
-    } finally {
-      requestContext.setContext(old);
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  // TODO: The passed in Comment class is not thread-safe, replace it with an AutoValue type.
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final ThreadLocalRequestContext requestContext;
+    private final CommentSender.Factory commentSenderFactory;
+    private final PatchSetInfoFactory patchSetInfoFactory;
+    private final IdentifiedUser user;
+    private final MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Project.NameKey projectName;
+    private final Change.Id changeId;
+    private final PatchSet patchSet;
+    private final ObjectId preUpdateMetaId;
+    private final String message;
+    private final Instant timestamp;
+    private final ImmutableList<? extends Comment> comments;
+    @Nullable private final String patchSetComment;
+    private final ImmutableList<LabelVote> labels;
+    private final Map<SubmitRequirement, SubmitRequirementResult>
+        postUpdateSubmitRequirementResults;
+
+    AsyncSender(
+        ThreadLocalRequestContext requestContext,
+        CommentSender.Factory commentSenderFactory,
+        PatchSetInfoFactory patchSetInfoFactory,
+        IdentifiedUser user,
+        MessageId messageId,
+        NotifyResolver.Result notify,
+        Project.NameKey projectName,
+        Change.Id changeId,
+        PatchSet patchSet,
+        ObjectId preUpdateMetaId,
+        String message,
+        Instant timestamp,
+        ImmutableList<? extends Comment> comments,
+        @Nullable String patchSetComment,
+        ImmutableList<LabelVote> labels,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      this.requestContext = requestContext;
+      this.commentSenderFactory = commentSenderFactory;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.user = user;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.projectName = projectName;
+      this.changeId = changeId;
+      this.patchSet = patchSet;
+      this.preUpdateMetaId = preUpdateMetaId;
+      this.message = message;
+      this.timestamp = timestamp;
+      this.comments = comments;
+      this.patchSetComment = patchSetComment;
+      this.labels = labels;
+      this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
     }
-  }
 
-  @Override
-  public String toString() {
-    return "send-email comments";
-  }
+    @Override
+    public void run() {
+      RequestContext old = requestContext.setContext(this);
+      try {
+        CommentSender emailSender =
+            commentSenderFactory.create(
+                projectName, changeId, preUpdateMetaId, postUpdateSubmitRequirementResults);
+        emailSender.setFrom(user.getAccountId());
+        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        emailSender.setChangeMessage(message, timestamp);
+        emailSender.setComments(comments);
+        emailSender.setPatchSetComment(patchSetComment);
+        emailSender.setLabels(labels);
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(messageId);
+        emailSender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
+      } finally {
+        requestContext.setContext(old);
+      }
+    }
 
-  @Override
-  public CurrentUser getUser() {
-    return user.getRealUser();
+    @Override
+    public String toString() {
+      return "send-email comments";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user.getRealUser();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index aeb9db0..79e2054 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
@@ -32,8 +32,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
-import java.util.List;
+import java.util.HashSet;
 import java.util.Optional;
+import java.util.Set;
 
 /**
  * Normalizes votes on labels according to project config.
@@ -49,23 +50,25 @@
   public abstract static class Result {
     @VisibleForTesting
     static Result create(
-        List<PatchSetApproval> unchanged,
-        List<PatchSetApproval> updated,
-        List<PatchSetApproval> deleted) {
+        Set<PatchSetApproval> unchanged,
+        Set<PatchSetApproval> updated,
+        Set<PatchSetApproval> deleted) {
       return new AutoValue_LabelNormalizer_Result(
-          ImmutableList.copyOf(unchanged),
-          ImmutableList.copyOf(updated),
-          ImmutableList.copyOf(deleted));
+          ImmutableSet.copyOf(unchanged),
+          ImmutableSet.copyOf(updated),
+          ImmutableSet.copyOf(deleted));
     }
 
-    public abstract ImmutableList<PatchSetApproval> unchanged();
+    public abstract ImmutableSet<PatchSetApproval> unchanged();
 
-    public abstract ImmutableList<PatchSetApproval> updated();
+    public abstract ImmutableSet<PatchSetApproval> updated();
 
-    public abstract ImmutableList<PatchSetApproval> deleted();
+    public abstract ImmutableSet<PatchSetApproval> deleted();
 
-    public Iterable<PatchSetApproval> getNormalized() {
-      return Iterables.concat(unchanged(), updated());
+    public ImmutableSet<PatchSetApproval> getNormalized() {
+      return Streams.concat(unchanged().stream(), updated().stream())
+          .distinct()
+          .collect(toImmutableSet());
     }
   }
 
@@ -84,9 +87,9 @@
    * @param approvals list of approvals.
    */
   public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals) {
-    List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
+    Set<PatchSetApproval> unchanged = new HashSet<>(approvals.size());
+    Set<PatchSetApproval> updated = new HashSet<>(approvals.size());
+    Set<PatchSetApproval> deleted = new HashSet<>(approvals.size());
     LabelTypes labelTypes =
         projectCache
             .get(notes.getProjectName())
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index fc56e80..f7bec1c0 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -21,11 +21,12 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -33,14 +34,13 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.AutoMerger;
@@ -65,8 +65,6 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class PatchSetInserter implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     PatchSetInserter create(ChangeNotes notes, PatchSet.Id psId, ObjectId commitId);
   }
@@ -74,15 +72,15 @@
   // Injected fields.
   private final PermissionBackend permissionBackend;
   private final PatchSetInfoFactory patchSetInfoFactory;
+  private final ChangeKindCache changeKindCache;
   private final CommitValidators.Factory commitValidatorsFactory;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final EmailNewPatchSet.Factory emailNewPatchSetFactory;
   private final ProjectCache projectCache;
   private final RevisionCreated revisionCreated;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
-  private final MessageIdGenerator messageIdGenerator;
   private final AutoMerger autoMerger;
 
   // Assisted-injected fields.
@@ -111,9 +109,12 @@
   private Change change;
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
+  private ChangeKind changeKind;
   private String mailMessage;
   private ReviewerSet oldReviewers;
   private boolean oldWorkInProgressState;
+  private ApprovalCopier.Result approvalCopierResult;
+  private ObjectId preUpdateMetaId;
 
   @Inject
   public PatchSetInserter(
@@ -121,13 +122,13 @@
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       PatchSetInfoFactory patchSetInfoFactory,
+      ChangeKindCache changeKindCache,
       CommitValidators.Factory commitValidatorsFactory,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      EmailNewPatchSet.Factory emailNewPatchSetFactory,
       PatchSetUtil psUtil,
       RevisionCreated revisionCreated,
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
-      MessageIdGenerator messageIdGenerator,
       AutoMerger autoMerger,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
@@ -136,13 +137,13 @@
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.patchSetInfoFactory = patchSetInfoFactory;
+    this.changeKindCache = changeKindCache;
     this.commitValidatorsFactory = commitValidatorsFactory;
-    this.replacePatchSetFactory = replacePatchSetFactory;
+    this.emailNewPatchSetFactory = emailNewPatchSetFactory;
     this.psUtil = psUtil;
     this.revisionCreated = revisionCreated;
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
-    this.messageIdGenerator = messageIdGenerator;
     this.autoMerger = autoMerger;
 
     this.origNotes = notes;
@@ -238,6 +239,15 @@
       throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
     validate(ctx);
     ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
+
+    changeKind =
+        changeKindCache.getChangeKind(
+            ctx.getProject(),
+            ctx.getRevWalk(),
+            ctx.getRepoView().getConfig(),
+            psUtil.current(origNotes).commitId(),
+            commitId);
+
     Optional<ReceiveCommand> autoMerge =
         autoMerger.createAutoMergeCommitIfNecessary(
             ctx.getRepoView(),
@@ -252,6 +262,7 @@
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, IOException, BadRequestException {
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(psId);
     update.setSubjectForCommit("Create patch set " + psId.get());
@@ -278,12 +289,6 @@
       oldReviewers = approvalsUtil.getReviewers(ctx.getNotes());
     }
 
-    if (message != null) {
-      mailMessage =
-          cmUtil.setChangeMessage(
-              update, message, ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
-    }
-
     oldWorkInProgressState = change.isWorkInProgress();
     if (workInProgress != null) {
       change.setWorkInProgress(workInProgress);
@@ -307,34 +312,68 @@
     }
 
     if (storeCopiedVotes) {
-      approvalsUtil.persistCopiedApprovals(
-          ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
+      approvalCopierResult =
+          approvalsUtil.copyApprovalsToNewPatchSet(
+              ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
     }
 
+    mailMessage = insertChangeMessage(update, ctx);
+
     return true;
   }
 
+  @Nullable
+  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx) {
+    StringBuilder messageBuilder = new StringBuilder();
+    if (message != null) {
+      messageBuilder.append(message);
+    }
+
+    if (approvalCopierResult != null) {
+      approvalsUtil
+          .formatApprovalCopierResult(
+              approvalCopierResult,
+              projectCache
+                  .get(ctx.getProject())
+                  .orElseThrow(illegalState(ctx.getProject()))
+                  .getLabelTypes())
+          .ifPresent(
+              msg -> {
+                if (message != null && !message.endsWith("\n")) {
+                  messageBuilder.append("\n");
+                }
+                messageBuilder.append("\n").append(msg);
+              });
+    }
+
+    String changeMessage = messageBuilder.toString();
+    if (changeMessage.isEmpty()) {
+      return null;
+    }
+
+    return cmUtil.setChangeMessage(
+        update,
+        messageBuilder.toString(),
+        ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
+  }
+
   @Override
   public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (notify.shouldNotify() && sendEmail) {
       requireNonNull(mailMessage);
-      try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setPatchSet(patchSet, patchSetInfo);
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.addReviewers(oldReviewers.byState(REVIEWER));
-        emailSender.addExtraCC(oldReviewers.byState(CC));
-        emailSender.setNotify(notify);
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-        emailSender.send();
-      } catch (Exception err) {
-        logger.atSevere().withCause(err).log(
-            "Cannot send email for new patch set on change %s", change.getId());
-      }
+
+      emailNewPatchSetFactory
+          .create(
+              ctx,
+              patchSet,
+              mailMessage,
+              approvalCopierResult.outdatedApprovals(),
+              oldReviewers.byState(REVIEWER),
+              oldReviewers.byState(CC),
+              changeKind,
+              preUpdateMetaId)
+          .sendAsync();
     }
 
     if (fireRevisionCreated) {
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index a0fa8e9..4de21d6 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
@@ -75,7 +76,7 @@
   }
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeResource.Factory changeResourceFactory;
 
@@ -106,7 +107,7 @@
   @Inject
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       RebaseUtil rebaseUtil,
       ChangeResource.Factory changeResourceFactory,
       IdentifiedUser.GenericFactory identifiedUserFactory,
diff --git a/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index 719578f..b6e3121 100644
--- a/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -237,12 +236,8 @@
 
   private boolean isVisible(PatchSetData psd) throws PermissionBackendException {
     PermissionBackend.WithUser perm = permissionBackend.currentUser();
-    try {
-      perm.change(psd.data()).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      return false;
-    }
-    return projectCache.get(psd.data().project()).map(ProjectState::statePermitsRead).orElse(false);
+    return perm.change(psd.data()).test(ChangePermission.READ)
+        && projectCache.get(psd.data().project()).map(ProjectState::statePermitsRead).orElse(false);
   }
 
   @AutoValue
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 50ee9d4..1d92521 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -16,13 +16,11 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -32,19 +30,15 @@
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.Optional;
 
 /** Remove a specified user from the attention set. */
 public class RemoveFromAttentionSetOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final MessageIdGenerator messageIdGenerator;
   private final RemoveFromAttentionSetSender.Factory removeFromAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
@@ -64,14 +58,12 @@
   @Inject
   RemoveFromAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      MessageIdGenerator messageIdGenerator,
       RemoveFromAttentionSetSender.Factory removeFromAttentionSetSenderFactory,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.messageIdGenerator = messageIdGenerator;
     this.removeFromAttentionSetSender = removeFromAttentionSetSenderFactory;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
@@ -94,8 +86,8 @@
 
     change = ctx.getChange();
 
-    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.addToPlannedAttentionSetUpdates(
+    ChangeUpdate changeUpdate = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    changeUpdate.addToPlannedAttentionSetUpdates(
         AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason));
     return true;
   }
@@ -105,18 +97,13 @@
     if (!notify) {
       return;
     }
-    try {
-      attentionSetEmailFactory
-          .create(
-              removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
-              ctx,
-              change,
-              reason,
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
-              attentionUserId)
-          .sendAsync();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
-    }
+    attentionSetEmailFactory
+        .create(
+            removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
+            ctx,
+            change,
+            reason,
+            attentionUserId)
+        .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index c98fcaa..9580565 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -83,6 +83,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -453,9 +454,13 @@
       this.input = input;
       this.failureType = null;
       result = new ReviewerResult(input.reviewer);
-      // Always silently ignore adding the owner as any type of reviewer on their own change. They
-      // may still be implicitly added as a reviewer if they vote, but not via the reviewer API.
-      this.reviewers = omitOwner(notes, reviewers);
+      if (!state().equals(REMOVED)) {
+        // Always silently ignore adding the owner as any type of reviewer on their own change. They
+        // may still be implicitly added as a reviewer if they vote, but not via the reviewer API.
+        this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ true);
+      } else {
+        this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ false);
+      }
       this.reviewersByEmail =
           reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail);
       this.caller = caller.asIdentifiedUser();
@@ -489,12 +494,18 @@
       this.exactMatchFound = exactMatchFound;
     }
 
-    private ImmutableSet<Account> omitOwner(ChangeNotes notes, Iterable<Account> reviewers) {
-      return reviewers != null
-          ? Streams.stream(reviewers)
-              .filter(account -> !account.id().equals(notes.getChange().getOwner()))
-              .collect(toImmutableSet())
-          : ImmutableSet.of();
+    private ImmutableSet<Account> reviewersAsList(
+        ChangeNotes notes, @Nullable Iterable<Account> reviewers, boolean omitChangeOwner) {
+      if (reviewers == null) {
+        return ImmutableSet.of();
+      }
+
+      Stream<Account> reviewerStream = Streams.stream(reviewers);
+      if (omitChangeOwner) {
+        reviewerStream =
+            reviewerStream.filter(account -> !account.id().equals(notes.getChange().getOwner()));
+      }
+      return reviewerStream.collect(toImmutableSet());
     }
 
     public void gatherResults(ChangeData cd) throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 7d40f06..5469b51 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -57,7 +57,7 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -87,7 +87,7 @@
     RevisionJson create(Iterable<ListChangesOption> options);
   }
 
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final IdentifiedUser.GenericFactory userFactory;
   private final FileInfoJson fileInfoJson;
   private final GpgApiAdapter gpgApi;
@@ -111,7 +111,7 @@
       AnonymousUser anonymous,
       ProjectCache projectCache,
       IdentifiedUser.GenericFactory userFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       FileInfoJson fileInfoJson,
       AccountLoader.Factory accountLoaderFactory,
       DynamicMap<DownloadScheme> downloadSchemes,
diff --git a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
index 96c863e..fcd9e90 100644
--- a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
+++ b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
@@ -65,7 +65,10 @@
       boolean hide) {
     SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
     info.expression = hide ? null : expression.expressionString();
-    info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
+    info.fulfilled =
+        result.status().equals(SubmitRequirementExpressionResult.Status.PASS)
+            || result.status().equals(SubmitRequirementExpressionResult.Status.NOT_EVALUATED);
+    info.status = SubmitRequirementExpressionInfo.Status.valueOf(result.status().name());
     info.passingAtoms = hide ? null : result.passingAtoms();
     info.failingAtoms = hide ? null : result.failingAtoms();
     info.errorMessage = result.errorMessage().isPresent() ? result.errorMessage().get() : null;
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 1409170..04fd1c0 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -19,21 +19,18 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
-import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
 
 /* Set work in progress or ready for review state on a change */
 public class WorkInProgressOp implements BatchUpdateOp {
@@ -61,8 +58,8 @@
   private final WorkInProgressStateChanged stateChanged;
 
   private boolean sendEmail = true;
+  private ObjectId preUpdateMetaId;
   private Change change;
-  private ChangeNotes notes;
   private PatchSet ps;
   private String mailMessage;
 
@@ -88,8 +85,8 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx) {
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     change = ctx.getChange();
-    notes = ctx.getNotes();
     ps = psUtil.get(ctx.getNotes(), change.currentPatchSetId());
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     change.setWorkInProgress(workInProgress);
@@ -131,25 +128,15 @@
         || !sendEmail) {
       return;
     }
-    RepoView repoView;
-    try {
-      repoView = ctx.getRepoView();
-    } catch (IOException ex) {
-      throw new StorageException(
-          String.format("Repository %s not found", ctx.getProject().get()), ex);
-    }
     email
         .create(
-            notify,
-            notes,
+            ctx,
             ps,
-            ctx.getIdentifiedUser(),
-            mailMessage,
-            ctx.getWhen(),
-            ImmutableList.of(),
+            preUpdateMetaId,
             mailMessage,
             ImmutableList.of(),
-            repoView)
+            mailMessage,
+            ImmutableList.of())
         .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 6449155..e5b063b 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.AgreementSignupListener;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
+import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
 import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
@@ -121,6 +122,7 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
@@ -130,11 +132,11 @@
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.events.UserScopedEventListener;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.GitModule;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.NotesBranchUtil;
@@ -173,6 +175,7 @@
 import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
+import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
@@ -188,7 +191,7 @@
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementConfigValidator;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.FileEditsPredicate;
@@ -297,7 +300,7 @@
     factory(ChangeIsVisibleToPredicate.Factory.class);
     factory(DistinctVotersPredicate.Factory.class);
     factory(DeadlineChecker.Factory.class);
-    factory(MergeUtil.Factory.class);
+    factory(EmailNewPatchSet.Factory.class);
     factory(MultiProgressMonitor.Factory.class);
     factory(PatchScriptFactory.Factory.class);
     factory(PatchScriptFactoryForAutoFix.Factory.class);
@@ -312,6 +315,7 @@
     bind(AccountDefaultDisplayName.class).toInstance(accountDefaultDisplayName);
     factory(ProjectOwnerGroupsProvider.Factory.class);
     factory(SubmitRuleEvaluator.Factory.class);
+    factory(DeleteZombieCommentsRefs.Factory.class);
 
     bind(AuthBackend.class).to(UniversalAuthBackend.class).in(SINGLETON);
     DynamicSet.setOf(binder(), AuthBackend.class);
@@ -397,7 +401,7 @@
     DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     DynamicSet.bind(binder(), CommitValidationListener.class)
-        .to(SubmitRequirementExpressionsValidator.class);
+        .to(SubmitRequirementConfigValidator.class);
     DynamicSet.setOf(binder(), CommentValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
@@ -454,6 +458,7 @@
     DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
     DynamicSet.setOf(binder(), OnPostReview.class);
     DynamicMap.mapOf(binder(), AccountTagProvider.class);
+    DynamicSet.setOf(binder(), AttentionSetListener.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
@@ -508,5 +513,7 @@
         .annotatedWith(UniqueAnnotations.create())
         .to(PluginConfigFactory.class);
     DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
+
+    bind(AttentionSetObserver.class);
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIds.java b/java/com/google/gerrit/server/config/GerritImportedServerIds.java
new file mode 100644
index 0000000..c47d3be
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIds.java
@@ -0,0 +1,29 @@
+// 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.config;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * List of ServerIds of the Gerrit data imported from other servers.
+ *
+ * <p>This values correspond to the {@code GerritServerId} of other servers.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface GerritImportedServerIds {}
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
new file mode 100644
index 0000000..2a74833
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
@@ -0,0 +1,37 @@
+// 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.config;
+
+import com.google.common.collect.ImmutableList;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.eclipse.jgit.lib.Config;
+
+public class GerritImportedServerIdsProvider implements Provider<ImmutableList<String>> {
+  public static final String SECTION = "gerrit";
+  public static final String KEY = "importedServerId";
+
+  private final ImmutableList<String> importedIds;
+
+  @Inject
+  public GerritImportedServerIdsProvider(@GerritServerConfig Config cfg) {
+    importedIds = ImmutableList.copyOf(cfg.getStringList(SECTION, null, KEY));
+  }
+
+  @Override
+  public ImmutableList<String> get() {
+    return importedIds;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
index 69d75be..fbdb324 100644
--- a/java/com/google/gerrit/server/config/PreferencesParserUtil.java
+++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -182,6 +182,7 @@
       my.add(new MenuItem("Edits", "#/q/has:edit", null));
       my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
       my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
+      my.add(new MenuItem("All Visible Changes", "#/q/is:visible", null));
       my.add(new MenuItem("Groups", "#/settings/#Groups", null));
     }
     return my;
diff --git a/java/com/google/gerrit/server/config/TrackingFootersProvider.java b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
index 1611da9..778ab4c 100644
--- a/java/com/google/gerrit/server/config/TrackingFootersProvider.java
+++ b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
@@ -32,7 +32,7 @@
 public class TrackingFootersProvider implements Provider<TrackingFooters> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final int MAX_LENGTH = 10;
+  private static final int MAX_LENGTH = 20;
 
   private static final String TRACKING_ID_TAG = "trackingid";
   private static final String FOOTER_TAG = "footer";
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
index 5054da6..04ea438 100644
--- a/java/com/google/gerrit/server/config/UrlFormatter.java
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -83,6 +83,12 @@
     return getWebUrl().map(url -> url + "Documentation/" + page + "#" + anchor);
   }
 
+  /** Returns a URL pointing to a plugin documentation page, at a given named anchor. */
+  default Optional<String> getPluginDocUrl(String pluginName, String page, String anchor) {
+    return getWebUrl()
+        .map(url -> url + "plugins/" + pluginName + "/Documentation/" + page + "#" + anchor);
+  }
+
   /** Returns a REST API URL for a given suffix (eg. "accounts/self/details") */
   default Optional<String> getRestUrl(String suffix) {
     return getWebUrl().map(url -> url + suffix);
diff --git a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index 59ae6f8..7d3ddf1 100644
--- a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -33,9 +33,9 @@
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ScoreDoc;
 import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.IndexOutput;
-import org.apache.lucene.store.RAMDirectory;
 
 @Singleton
 public class QueryDocumentationExecutor {
@@ -100,7 +100,7 @@
   }
 
   protected Directory readIndexDirectory() throws IOException {
-    Directory dir = new RAMDirectory();
+    Directory dir = new ByteBuffersDirectory();
     byte[] buffer = new byte[4096];
     InputStream index = getClass().getResourceAsStream(Constants.INDEX_ZIP);
     if (index == null) {
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 232aa6a..2957d6b 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -53,10 +53,10 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.diff.DiffAlgorithm;
 import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm;
 import org.eclipse.jgit.diff.RawText;
@@ -91,7 +91,7 @@
 @Singleton
 public class ChangeEditModifier {
 
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final Provider<CurrentUser> currentUser;
   private final PermissionBackend permissionBackend;
   private final ChangeEditUtil changeEditUtil;
@@ -110,12 +110,12 @@
       ProjectCache projectCache) {
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
     this.changeEditUtil = changeEditUtil;
     this.patchSetUtil = patchSetUtil;
     this.projectCache = projectCache;
 
-    noteDbEdits = new NoteDbEdits(tz, indexer, currentUser);
+    noteDbEdits = new NoteDbEdits(zoneId, indexer, currentUser);
   }
 
   /**
@@ -519,7 +519,7 @@
 
   private PersonIdent getCommitterIdent(Instant commitTimestamp) {
     IdentifiedUser user = currentUser.get().asIdentifiedUser();
-    return user.newCommitterIdent(commitTimestamp, tz);
+    return user.newCommitterIdent(commitTimestamp, zoneId);
   }
 
   /**
@@ -709,12 +709,12 @@
   }
 
   private static class NoteDbEdits {
-    private final TimeZone tz;
+    private final ZoneId zoneId;
     private final ChangeIndexer indexer;
     private final Provider<CurrentUser> currentUser;
 
-    NoteDbEdits(TimeZone tz, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
-      this.tz = tz;
+    NoteDbEdits(ZoneId zoneId, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
+      this.zoneId = zoneId;
       this.indexer = indexer;
       this.currentUser = currentUser;
     }
@@ -841,7 +841,7 @@
 
     private PersonIdent getRefLogIdent(Instant timestamp) {
       IdentifiedUser user = currentUser.get().asIdentifiedUser();
-      return user.newRefLogIdent(timestamp, tz);
+      return user.newRefLogIdent(timestamp, zoneId);
     }
 
     private void reindex(Change change) {
diff --git a/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java b/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java
new file mode 100644
index 0000000..90ed285
--- /dev/null
+++ b/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+
+public class ProjectHeadUpdatedEvent extends ProjectEvent {
+
+  static final String TYPE = "project-head-updated";
+
+  public String projectName;
+  public String oldHead;
+  public String newHead;
+
+  public ProjectHeadUpdatedEvent() {
+    super(TYPE);
+  }
+
+  @Override
+  public NameKey getProjectNameKey() {
+    return Project.nameKey(projectName);
+  }
+}
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index abacb85..afe2a7c 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
+import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.PrivateStateChangedListener;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
@@ -86,7 +87,8 @@
         ReviewerDeletedListener,
         RevisionCreatedListener,
         TopicEditedListener,
-        VoteDeletedListener {
+        VoteDeletedListener,
+        HeadUpdatedListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static class StreamEventsApiListenerModule extends AbstractModule {
@@ -111,6 +113,7 @@
       DynamicSet.bind(binder(), VoteDeletedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), WorkInProgressStateChangedListener.class)
           .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), HeadUpdatedListener.class).to(StreamEventsApiListener.class);
     }
   }
 
@@ -339,6 +342,16 @@
   }
 
   @Override
+  public void onHeadUpdated(HeadUpdatedListener.Event ev) {
+    ProjectHeadUpdatedEvent event = new ProjectHeadUpdatedEvent();
+    event.projectName = ev.getProjectName();
+    event.oldHead = ev.getOldHeadName();
+    event.newHead = ev.getNewHeadName();
+
+    dispatcher.run(d -> d.postEvent(event.getProjectNameKey(), event));
+  }
+
+  @Override
   public void onHashtagsEdited(HashtagsEditedListener.Event ev) {
     try {
       ChangeNotes notes = getNotes(ev.getChange());
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index ffeb44b..b876341 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -27,13 +27,6 @@
   public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
       "GerritBackendRequestFeature__remove_revision_etag";
 
-  /**
-   * 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_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, UI_FEATURE_SUBMIT_REQUIREMENTS_UI);
diff --git a/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
new file mode 100644
index 0000000..27e0a5e
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.AttentionSetListener;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Helper class to fire an event when an attention set changes. */
+@Singleton
+public class AttentionSetObserver {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PluginSetContext<AttentionSetListener> listeners;
+  private final EventUtil util;
+  private final AccountCache accountCache;
+
+  public static final AttentionSetObserver DISABLED =
+      new AttentionSetObserver() {
+        @Override
+        public void fire(
+            ChangeData changeData,
+            AccountState accountState,
+            AttentionSetUpdate update,
+            Instant when) {}
+      };
+
+  @Inject
+  AttentionSetObserver(
+      PluginSetContext<AttentionSetListener> listeners, EventUtil util, AccountCache accountCache) {
+    this.listeners = listeners;
+    this.util = util;
+    this.accountCache = accountCache;
+  }
+
+  /** Constructor only for DISABLED version of the AttentionSetObserver. */
+  private AttentionSetObserver() {
+    this.listeners = null;
+    this.util = null;
+    this.accountCache = null;
+  }
+
+  /**
+   * Notify all listening plugins
+   *
+   * @param changeData is current data of the change
+   * @param accountState is the initiator of the change
+   * @param update is the update that caused the event
+   * @param when is the time of the event
+   */
+  public void fire(
+      ChangeData changeData, AccountState accountState, AttentionSetUpdate update, Instant when) {
+    if (listeners.isEmpty()) {
+      return;
+    }
+    AccountState target = accountCache.get(update.account()).get();
+
+    HashSet<Integer> added = new HashSet<>();
+    HashSet<Integer> removed = new HashSet<>();
+    switch (update.operation()) {
+      case ADD:
+        added.add(target.account().id().get());
+        break;
+      case REMOVE:
+        removed.add(target.account().id().get());
+        break;
+    }
+
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(changeData), util.accountInfo(accountState), added, removed, when);
+      listeners.runEach(l -> l.onAttentionSetChanged(event));
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Exception while firing AttentionSet changed event");
+    }
+  }
+
+  /** Event to be fired when an attention set changes */
+  public static class Event extends AbstractChangeEvent implements AttentionSetListener.Event {
+    private final Set<Integer> added;
+    private final Set<Integer> removed;
+
+    public Event(
+        ChangeInfo change,
+        AccountInfo editor,
+        Set<Integer> added,
+        Set<Integer> removed,
+        Instant when) {
+      super(change, editor, when, NotifyHandling.ALL);
+      this.added = added;
+      this.removed = removed;
+    }
+
+    @Override
+    public Set<Integer> usersAdded() {
+      return added;
+    }
+
+    @Override
+    public Set<Integer> usersRemoved() {
+      return removed;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 45f7ecb..b669571 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -98,7 +99,7 @@
     return revisionJsonFactory.create(changeOptions).getRevisionInfo(cd, ps);
   }
 
-  public AccountInfo accountInfo(AccountState accountState) {
+  public AccountInfo accountInfo(@Nullable AccountState accountState) {
     if (accountState == null || accountState.account().id() == null) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index deaaff8..d127260 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -58,7 +59,7 @@
       Map<String, Short> oldApprovals,
       NotifyHandling notify,
       String message,
-      AccountState remover,
+      @Nullable AccountState remover,
       Instant when) {
     if (listeners.isEmpty()) {
       return;
@@ -69,8 +70,8 @@
               util.changeInfo(changeData),
               util.revisionInfo(changeData.project(), ps),
               util.accountInfo(reviewer),
-              util.approvals(remover, approvals, when),
-              util.approvals(remover, oldApprovals, when),
+              util.approvals(reviewer, approvals, when),
+              util.approvals(reviewer, oldApprovals, when),
               notify,
               message,
               util.accountInfo(remover),
diff --git a/java/com/google/gerrit/server/fixes/FixCalculator.java b/java/com/google/gerrit/server/fixes/FixCalculator.java
index 9ea628e..df20fbf 100644
--- a/java/com/google/gerrit/server/fixes/FixCalculator.java
+++ b/java/com/google/gerrit/server/fixes/FixCalculator.java
@@ -355,6 +355,11 @@
     }
 
     void processLineToColumn(int to, boolean append) throws IndexOutOfBoundsException {
+      int from = srcPosition.column;
+      if (from > to) {
+        throw new IndexOutOfBoundsException(
+            String.format("The parameter from is greater than to. from: %d, to: %d", from, to));
+      }
       if (to == 0) {
         return;
       }
@@ -366,7 +371,6 @@
           throw new IndexOutOfBoundsException("The processLineToColumn shouldn't add end of line");
         }
       }
-      int from = srcPosition.column;
       int charCount = to - from;
       srcPosition.appendStringWithoutEOLMark(charCount);
       if (append) {
diff --git a/java/com/google/gerrit/server/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
index 9cc754c..e27197c 100644
--- a/java/com/google/gerrit/server/git/BanCommit.java
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -31,8 +31,8 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Constants;
@@ -78,7 +78,7 @@
 
   private final Provider<IdentifiedUser> currentUser;
   private final GitRepositoryManager repoManager;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final PermissionBackend permissionBackend;
   private final NotesBranchUtil.Factory notesBranchUtilFactory;
 
@@ -93,7 +93,7 @@
     this.repoManager = repoManager;
     this.notesBranchUtilFactory = notesBranchUtilFactory;
     this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
   }
 
   /**
@@ -155,7 +155,7 @@
   }
 
   private PersonIdent createPersonIdent() {
-    return currentUser.get().newCommitterIdent(Instant.now(), tz);
+    return currentUser.get().newCommitterIdent(Instant.now(), zoneId);
   }
 
   private static String buildCommitMessage(List<ObjectId> bannedCommits, String reason) {
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index e52c45f..fa46bf4 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -57,6 +58,7 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -220,7 +222,7 @@
 
     PersonIdent committerIdent = serverIdent.get();
     PersonIdent authorIdent =
-        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getTimeZone());
+        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getZoneId());
 
     RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
     revWalk.parseHeaders(parentToCommitToRevert);
@@ -274,6 +276,7 @@
             .create(changeId, revertCommit, notes.getChange().getDest().branch())
             .setTopic(input.topic == null ? changeToRevert.getTopic() : input.topic.trim());
     ins.setMessage("Uploaded patch set 1.");
+    ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
 
     ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
 
@@ -298,6 +301,20 @@
     return changeId;
   }
 
+  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
+      @Nullable Map<String, String> validationOptions) {
+    if (validationOptions == null) {
+      return ImmutableListMultimap.of();
+    }
+
+    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
+        ImmutableListMultimap.builder();
+    validationOptions
+        .entrySet()
+        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
+    return validationOptionsBuilder.build();
+  }
+
   private class NotifyOp implements BatchUpdateOp {
     private final Change change;
     private final ChangeInserter ins;
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index d84ce7b..ae247ad 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -23,6 +23,8 @@
 import static java.util.Comparator.naturalOrder;
 import static java.util.stream.Collectors.joining;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -60,8 +62,6 @@
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.submit.MergeSorter;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
@@ -115,6 +115,7 @@
  * flushing should use {@link ObjectInserter#newReader()}. This is already the default behavior of
  * {@code BatchUpdate}.
  */
+@AutoFactory
 public class MergeUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -135,12 +136,6 @@
     return useRecursiveMerge(cfg) ? MergeStrategy.RECURSIVE : MergeStrategy.RESOLVE;
   }
 
-  public interface Factory {
-    MergeUtil create(ProjectState project);
-
-    MergeUtil create(ProjectState project, boolean useContentMerge);
-  }
-
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final ApprovalsUtil approvalsUtil;
@@ -149,40 +144,38 @@
   private final boolean useRecursiveMerge;
   private final PluggableCommitMessageGenerator commitMessageGenerator;
 
-  @AssistedInject
   MergeUtil(
-      @GerritServerConfig Config serverConfig,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      DynamicItem<UrlFormatter> urlFormatter,
-      ApprovalsUtil approvalsUtil,
-      PluggableCommitMessageGenerator commitMessageGenerator,
-      @Assisted ProjectState project) {
+      @Provided @GerritServerConfig Config serverConfig,
+      @Provided IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Provided DynamicItem<UrlFormatter> urlFormatter,
+      @Provided ApprovalsUtil approvalsUtil,
+      @Provided PluggableCommitMessageGenerator commitMessageGenerator,
+      ProjectState project) {
     this(
         serverConfig,
         identifiedUserFactory,
         urlFormatter,
         approvalsUtil,
-        project,
         commitMessageGenerator,
+        project,
         project.is(BooleanProjectConfig.USE_CONTENT_MERGE));
   }
 
-  @AssistedInject
   MergeUtil(
-      @GerritServerConfig Config serverConfig,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      DynamicItem<UrlFormatter> urlFormatter,
-      ApprovalsUtil approvalsUtil,
-      @Assisted ProjectState project,
-      PluggableCommitMessageGenerator commitMessageGenerator,
-      @Assisted boolean useContentMerge) {
+      @Provided @GerritServerConfig Config serverConfig,
+      @Provided IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Provided DynamicItem<UrlFormatter> urlFormatter,
+      @Provided ApprovalsUtil approvalsUtil,
+      @Provided PluggableCommitMessageGenerator commitMessageGenerator,
+      ProjectState project,
+      boolean useContentMerge) {
     this.identifiedUserFactory = identifiedUserFactory;
     this.urlFormatter = urlFormatter;
     this.approvalsUtil = approvalsUtil;
+    this.commitMessageGenerator = commitMessageGenerator;
     this.project = project;
     this.useContentMerge = useContentMerge;
     this.useRecursiveMerge = useRecursiveMerge(serverConfig);
-    this.commitMessageGenerator = commitMessageGenerator;
   }
 
   public CodeReviewCommit getFirstFastForward(
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 52a34d9..290e1e7 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -249,6 +249,7 @@
    * @param out stream for writing progress messages.
    * @param taskName name of the overall task.
    */
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private MultiProgressMonitor(
       CancellationMetrics cancellationMetrics,
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java
index 3910393..90eadf3 100644
--- a/java/com/google/gerrit/server/git/PureRevertCache.java
+++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -138,13 +138,13 @@
 
   static class Loader extends CacheLoader<PureRevertKeyProto, Boolean> {
     private final GitRepositoryManager repoManager;
-    private final MergeUtil.Factory mergeUtilFactory;
+    private final MergeUtilFactory mergeUtilFactory;
     private final ProjectCache projectCache;
 
     @Inject
     Loader(
         GitRepositoryManager repoManager,
-        MergeUtil.Factory mergeUtilFactory,
+        MergeUtilFactory mergeUtilFactory,
         ProjectCache projectCache) {
       this.repoManager = repoManager;
       this.mergeUtilFactory = mergeUtilFactory;
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index dd5af2c..08849348 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -291,10 +290,7 @@
     receivePack.setPreReceiveHook(asHook());
     receivePack.setPostReceiveHook(lazyPostReceive.create(user, projectName));
 
-    try {
-      projectState.checkStatePermitsRead();
-      this.perm.check(ProjectPermission.READ);
-    } catch (AuthException | ResourceConflictException e) {
+    if (!projectState.statePermitsRead() || !this.perm.test(ProjectPermission.READ)) {
       receivePack.setCheckReferencedObjectsAreReachable(
           receiveConfig.checkReferencedObjectsAreReachable);
     }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index f212384..26ae08a 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -1007,6 +1007,7 @@
             .setIsWorkInProgress(wip)
             .build();
     addMessage(changeFormatter.changeUpdated(input));
+    u.getOutdatedApprovalsMessage().map(msg -> "\n" + msg + "\n").ifPresent(this::addMessage);
   }
 
   private void insertChangesAndPatchSets(
@@ -1253,9 +1254,7 @@
                   + NoteDbPushOption.ALLOW.value());
           return;
         }
-        try {
-          permissionBackend.user(user).check(GlobalPermission.ACCESS_DATABASE);
-        } catch (AuthException e) {
+        if (!permissionBackend.user(user).test(GlobalPermission.ACCESS_DATABASE)) {
           reject(cmd, "NoteDb update requires access database permission");
           return;
         }
@@ -1297,9 +1296,7 @@
   private void validateConfigPush(ReceiveCommand cmd) throws PermissionBackendException {
     try (TraceTimer traceTimer = newTimer("validateConfigPush")) {
       logger.atFine().log("Processing %s command", cmd.getRefName());
-      try {
-        permissions.check(ProjectPermission.WRITE_CONFIG);
-      } catch (AuthException e) {
+      if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
         reject(
             cmd,
             String.format(
@@ -1337,20 +1334,16 @@
             } else {
               if (!oldParent.equals(newParent)) {
                 if (allowProjectOwnersToChangeParent) {
-                  try {
-                    permissionBackend
-                        .user(user)
-                        .project(project.getNameKey())
-                        .check(ProjectPermission.WRITE_CONFIG);
-                  } catch (AuthException e) {
+                  if (!permissionBackend
+                      .user(user)
+                      .project(project.getNameKey())
+                      .test(ProjectPermission.WRITE_CONFIG)) {
                     reject(
                         cmd, "invalid project configuration: only project owners can set parent");
                     return;
                   }
                 } else {
-                  try {
-                    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-                  } catch (AuthException e) {
+                  if (!permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) {
                     reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
                     return;
                   }
@@ -2673,7 +2666,11 @@
 
   private ChangeLookup lookupByChangeKey(RevCommit c, Change.Key key) {
     try (TraceTimer traceTimer = newTimer("lookupByChangeKey")) {
-      return new ChangeLookup(c, key, queryProvider.get().byBranchKey(magicBranch.dest, key));
+      List<ChangeData> byBranchKeyExactMatch =
+          queryProvider.get().byBranchKey(magicBranch.dest, key).stream()
+              .filter(cd -> cd.change().getKey().equals(key))
+              .collect(toList());
+      return new ChangeLookup(c, key, byBranchKeyExactMatch);
     }
   }
 
@@ -2998,9 +2995,7 @@
           return false;
         }
 
-        try {
-          permissions.change(notes).check(ChangePermission.ADD_PATCH_SET);
-        } catch (AuthException no) {
+        if (!permissions.change(notes).test(ChangePermission.ADD_PATCH_SET)) {
           reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
           return false;
         }
@@ -3047,18 +3042,8 @@
       if ((magicBranch.workInProgress || magicBranch.ready)
           && magicBranch.workInProgress != change.isWorkInProgress()
           && !user.getAccountId().equals(change.getOwner())) {
-        boolean hasWriteConfigPermission = false;
-        try {
-          permissions.check(ProjectPermission.WRITE_CONFIG);
-          hasWriteConfigPermission = true;
-        } catch (AuthException e) {
-          // Do nothing.
-        }
-
-        if (!hasWriteConfigPermission) {
-          try {
-            permissions.change(notes).check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
-          } catch (AuthException e1) {
+        if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
+          if (!permissions.change(notes).test(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE)) {
             reject(inputCommand, ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP);
           }
         }
@@ -3187,22 +3172,20 @@
 
         RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
         replaceOp =
-            replaceOpFactory
-                .create(
-                    projectState,
-                    notes.getChange().getDest(),
-                    checkMergedInto,
-                    checkMergedInto ? inputCommand.getNewId().name() : null,
-                    priorPatchSet,
-                    priorCommit,
-                    psId,
-                    newCommit,
-                    info,
-                    groups,
-                    magicBranch,
-                    receivePack.getPushCertificate(),
-                    notes.getChange())
-                .setRequestScopePropagator(requestScopePropagator);
+            replaceOpFactory.create(
+                projectState,
+                notes.getChange(),
+                checkMergedInto,
+                checkMergedInto ? inputCommand.getNewId().name() : null,
+                priorPatchSet,
+                priorCommit,
+                psId,
+                newCommit,
+                info,
+                groups,
+                magicBranch,
+                receivePack.getPushCertificate(),
+                requestScopePropagator);
         bu.addOp(notes.getChangeId(), replaceOp);
         if (progress != null) {
           bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
@@ -3229,9 +3212,14 @@
       }
     }
 
+    @Nullable
     String getRejectMessage() {
       return replaceOp != null ? replaceOp.getRejectMessage() : null;
     }
+
+    Optional<String> getOutdatedApprovalsMessage() {
+      return replaceOp != null ? replaceOp.getOutdatedApprovalsMessage() : Optional.empty();
+    }
   }
 
   private class UpdateGroupsRequest {
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index e7e0e8f..644f82e 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.stream.Collectors.joining;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.common.base.Strings;
@@ -28,7 +29,7 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
@@ -46,24 +47,26 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList;
 import com.google.gerrit.server.change.ReviewerOp;
-import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -75,6 +78,7 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -86,8 +90,6 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -102,7 +104,7 @@
   public interface Factory {
     ReplaceOp create(
         ProjectState projectState,
-        BranchNameKey dest,
+        Change change,
         boolean checkMergedInto,
         @Nullable String mergeResultRevId,
         @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -113,30 +115,29 @@
         List<String> groups,
         @Nullable MagicBranchInput magicBranch,
         @Nullable PushCertificate pushCertificate,
-        Change change);
+        RequestScopePropagator requestScopePropagator);
   }
 
   private static final String CHANGE_IS_CLOSED = "change is closed";
 
+  private final AccountCache accountCache;
   private final AccountResolver accountResolver;
+  private final String anonymousCowardName;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeKindCache changeKindCache;
   private final ChangeMessagesUtil cmUtil;
-  private final ExecutorService sendEmailExecutor;
+  private final EmailNewPatchSet.Factory emailNewPatchSetFactory;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
   private final PatchSetUtil psUtil;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
   private final ReviewerModifier reviewerModifier;
-  private final Change change;
-  private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
 
   private final ProjectState projectState;
-  private final BranchNameKey dest;
+  private final Change change;
   private final boolean checkMergedInto;
   private final String mergeResultRevId;
   private final PatchSet.Id priorPatchSetId;
@@ -146,6 +147,7 @@
   private final PatchSetInfo info;
   private final MagicBranchInput magicBranch;
   private final PushCertificate pushCertificate;
+  private final RequestScopePropagator requestScopePropagator;
   private List<String> groups;
 
   private final Map<String, Short> approvals = new HashMap<>();
@@ -155,15 +157,17 @@
   private PatchSet newPatchSet;
   private ChangeKind changeKind;
   private String mailMessage;
+  private ApprovalCopier.Result approvalCopierResult;
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
-  private RequestScopePropagator requestScopePropagator;
   private ReviewerModificationList reviewerAdditions;
   private MailRecipients oldRecipients;
 
   @Inject
   ReplaceOp(
+      AccountCache accountCache,
       AccountResolver accountResolver,
+      @AnonymousCowardName String anonymousCowardName,
       ApprovalsUtil approvalsUtil,
       ChangeData.Factory changeDataFactory,
       ChangeKindCache changeKindCache,
@@ -172,15 +176,12 @@
       CommentAdded commentAdded,
       MergedByPushOp.Factory mergedByPushOpFactory,
       PatchSetUtil psUtil,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
-      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      EmailNewPatchSet.Factory emailNewPatchSetFactory,
       ReviewerModifier reviewerModifier,
-      Change change,
-      MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
       @Assisted ProjectState projectState,
-      @Assisted BranchNameKey dest,
+      @Assisted Change change,
       @Assisted boolean checkMergedInto,
       @Assisted @Nullable String mergeResultRevId,
       @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -190,8 +191,11 @@
       @Assisted PatchSetInfo info,
       @Assisted List<String> groups,
       @Assisted @Nullable MagicBranchInput magicBranch,
-      @Assisted @Nullable PushCertificate pushCertificate) {
+      @Assisted @Nullable PushCertificate pushCertificate,
+      @Assisted RequestScopePropagator requestScopePropagator) {
+    this.accountCache = accountCache;
     this.accountResolver = accountResolver;
+    this.anonymousCowardName = anonymousCowardName;
     this.approvalsUtil = approvalsUtil;
     this.changeDataFactory = changeDataFactory;
     this.changeKindCache = changeKindCache;
@@ -200,16 +204,13 @@
     this.commentAdded = commentAdded;
     this.mergedByPushOpFactory = mergedByPushOpFactory;
     this.psUtil = psUtil;
-    this.replacePatchSetFactory = replacePatchSetFactory;
     this.projectCache = projectCache;
-    this.sendEmailExecutor = sendEmailExecutor;
+    this.emailNewPatchSetFactory = emailNewPatchSetFactory;
     this.reviewerModifier = reviewerModifier;
-    this.change = change;
-    this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
 
     this.projectState = projectState;
-    this.dest = dest;
+    this.change = change;
     this.checkMergedInto = checkMergedInto;
     this.mergeResultRevId = mergeResultRevId;
     this.priorPatchSetId = priorPatchSetId;
@@ -220,6 +221,7 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
+    this.requestScopePropagator = requestScopePropagator;
   }
 
   @Override
@@ -235,7 +237,7 @@
             commitId);
 
     if (checkMergedInto) {
-      String mergedInto = findMergedInto(ctx, dest.branch(), commit);
+      String mergedInto = findMergedInto(ctx, change.getDest().branch(), commit);
       if (mergedInto != null) {
         mergedByPushOp =
             mergedByPushOpFactory.create(
@@ -338,15 +340,17 @@
     }
     reviewerAdditions.updateChange(ctx, newPatchSet);
 
-    // Check if approvals are changing in with this update. If so, add current user to reviewers.
-    // Note that this is done separately as addReviewers is filtering out the change owner as
+    // Check if approvals are changing with this update. If so, add the current user (aka the
+    // approver) as a reviewers because all approvers must also be reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as a
     // reviewer which is needed in several other code paths.
     if (magicBranch != null && !magicBranch.labels.isEmpty()) {
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
 
-    approvalsUtil.persistCopiedApprovals(
-        ctx.getNotes(), newPatchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
+    approvalCopierResult =
+        approvalsUtil.copyApprovalsToNewPatchSet(
+            ctx.getNotes(), newPatchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
 
     mailMessage = insertChangeMessage(update, ctx, reviewMessage);
     if (mergedByPushOp == null) {
@@ -420,6 +424,15 @@
     if (!Strings.isNullOrEmpty(reviewMessage)) {
       message.append("\n\n").append(reviewMessage);
     }
+    approvalsUtil
+        .formatApprovalCopierResult(approvalCopierResult, projectState.getLabelTypes())
+        .ifPresent(
+            msg -> {
+              if (Strings.isNullOrEmpty(reviewMessage) || !reviewMessage.endsWith("\n")) {
+                message.append("\n");
+              }
+              message.append("\n").append(msg);
+            });
     boolean workInProgress = ctx.getChange().isWorkInProgress();
     if (magicBranch != null && magicBranch.workInProgress) {
       workInProgress = true;
@@ -489,16 +502,28 @@
   @Override
   public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
-    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
-      // TODO(dborowitz): Merge email templates so we only have to send one.
-      Runnable e = new ReplaceEmailTask(ctx);
-      if (requestScopePropagator != null) {
-        @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError = sendEmailExecutor.submit(requestScopePropagator.wrap(e));
-      } else {
-        e.run();
-      }
-    }
+
+    // TODO(dborowitz): Merge email templates so we only have to send one.
+    emailNewPatchSetFactory
+        .create(
+            ctx,
+            newPatchSet,
+            mailMessage,
+            approvalCopierResult.outdatedApprovals(),
+            Streams.concat(
+                    oldRecipients.getReviewers().stream(),
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
+                        .map(PatchSetApproval::accountId))
+                .collect(toImmutableSet()),
+            Streams.concat(
+                    oldRecipients.getCcOnly().stream(),
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs).stream())
+                .collect(toImmutableSet()),
+            changeKind,
+            notes.getMetaId())
+        .setRequestScopePropagator(requestScopePropagator)
+        .sendAsync();
+
     NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
     revisionCreated.fire(
         ctx.getChangeData(notes), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
@@ -512,49 +537,6 @@
     }
   }
 
-  private class ReplaceEmailTask implements Runnable {
-    private final Context ctx;
-
-    private ReplaceEmailTask(Context ctx) {
-      this.ctx = ctx;
-    }
-
-    @Override
-    public void run() {
-      try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
-        emailSender.setFrom(ctx.getAccount().account().id());
-        emailSender.setPatchSet(newPatchSet, info);
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.setNotify(ctx.getNotify(notes.getChangeId()));
-        emailSender.addReviewers(
-            Streams.concat(
-                    oldRecipients.getReviewers().stream(),
-                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
-                        .map(PatchSetApproval::accountId))
-                .collect(toImmutableSet()));
-        emailSender.addExtraCC(
-            Streams.concat(
-                    oldRecipients.getCcOnly().stream(),
-                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs).stream())
-                .collect(toImmutableSet()));
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSetId));
-        // TODO(dborowitz): Support byEmail
-        emailSender.send();
-      } catch (Exception e) {
-        logger.atSevere().withCause(e).log(
-            "Cannot send email for new patch set %s", newPatchSet.id());
-      }
-    }
-
-    @Override
-    public String toString() {
-      return "send-email newpatchset";
-    }
-  }
-
   private void fireApprovalsEvent(PostUpdateContext ctx) {
     if (approvals.isEmpty()) {
       return;
@@ -604,13 +586,42 @@
     return rejectMessage;
   }
 
-  public ReceiveCommand getCommand() {
-    return cmd;
+  public Optional<String> getOutdatedApprovalsMessage() {
+    if (approvalCopierResult == null || approvalCopierResult.outdatedApprovals().isEmpty()) {
+      return Optional.empty();
+    }
+
+    return Optional.of(
+        "The following approvals got outdated and were removed:\n"
+            + approvalCopierResult.outdatedApprovals().stream()
+                .map(
+                    outdatedApproval ->
+                        String.format(
+                            "* %s by %s",
+                            LabelVote.create(outdatedApproval.label(), outdatedApproval.value())
+                                .format(),
+                            getNameFor(outdatedApproval.accountId())))
+                .sorted()
+                .collect(joining("\n")));
   }
 
-  public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
-    this.requestScopePropagator = requestScopePropagator;
-    return this;
+  private String getNameFor(Account.Id accountId) {
+    Optional<Account> account = accountCache.get(accountId).map(AccountState::account);
+    String name = null;
+    if (account.isPresent()) {
+      name = account.get().fullName();
+      if (name == null) {
+        name = account.get().preferredEmail();
+      }
+    }
+    if (name == null) {
+      name = anonymousCowardName + " #" + accountId;
+    }
+    return name;
+  }
+
+  public ReceiveCommand getCommand() {
+    return cmd;
   }
 
   private static String findMergedInto(Context ctx, String first, RevCommit commit) {
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 6118157..999f810 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -49,10 +49,12 @@
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.LabelConfigValidator;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -105,6 +107,7 @@
     private final AccountValidator accountValidator;
     private final ProjectCache projectCache;
     private final ProjectConfig.Factory projectConfigFactory;
+    private final DiffOperations diffOperations;
     private final Config config;
 
     @Inject
@@ -119,7 +122,8 @@
         ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
         AccountValidator accountValidator,
         ProjectCache projectCache,
-        ProjectConfig.Factory projectConfigFactory) {
+        ProjectConfig.Factory projectConfigFactory,
+        DiffOperations diffOperations) {
       this.gerritIdent = gerritIdent;
       this.urlFormatter = urlFormatter;
       this.config = config;
@@ -131,6 +135,7 @@
       this.accountValidator = accountValidator;
       this.projectCache = projectCache;
       this.projectConfigFactory = projectConfigFactory;
+      this.diffOperations = diffOperations;
     }
 
     public CommitValidators forReceiveCommits(
@@ -162,7 +167,8 @@
           .add(new PluginCommitValidationListener(pluginValidators, skipValidation))
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
-          .add(new GroupCommitValidator(allUsers));
+          .add(new GroupCommitValidator(allUsers))
+          .add(new LabelConfigValidator(diffOperations));
       return new CommitValidators(validators.build());
     }
 
@@ -191,7 +197,8 @@
           .add(new PluginCommitValidationListener(pluginValidators))
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
-          .add(new GroupCommitValidator(allUsers));
+          .add(new GroupCommitValidator(allUsers))
+          .add(new LabelConfigValidator(diffOperations));
       return new CommitValidators(validators.build());
     }
 
@@ -401,11 +408,17 @@
         sshPort = 22;
       }
 
+      // TODO(15944): Remove once both SFTP/SCP protocol are supported.
+      //
+      // In newer versions of OpenSSH, the default hook installation command will fail with a
+      // cryptic error because the scp binary defaults to a different protocol.
+      String scpFlagHint = "(for OpenSSH >= 9.0 you need to add the flag '-O' to the scp command)";
+
       String sshHook =
           String.format(
               "gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
               sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
-      return String.format("  %s\nor, for http(s):\n  %s", sshHook, httpHook);
+      return String.format("  %s\n%s\nor, for http(s):\n  %s", sshHook, scpFlagHint, httpHook);
     }
   }
 
@@ -551,10 +564,10 @@
         return Collections.emptyList();
       }
       try {
-        perm.check(RefPermission.MERGE);
-        return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException("you are not allowed to upload merges", e);
+        if (perm.test(RefPermission.MERGE)) {
+          return Collections.emptyList();
+        }
+        throw new CommitValidationException("you are not allowed to upload merges");
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check MERGE");
         throw new CommitValidationException("internal auth error");
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index 85d232e..9ac3c89 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -157,8 +157,10 @@
             && !refEvent.command.getRefName().equals(RefNames.REFS_USERS_DEFAULT)) {
           if (refEvent.command.getType().equals(ReceiveCommand.Type.CREATE)) {
             try {
-              perm.check(GlobalPermission.ACCESS_DATABASE);
-            } catch (AuthException | PermissionBackendException e) {
+              if (!perm.test(GlobalPermission.ACCESS_DATABASE)) {
+                throw new ValidationException("Not allowed to create user branch.");
+              }
+            } catch (PermissionBackendException e) {
               throw new ValidationException("Not allowed to create user branch.", e);
             }
             if (Account.Id.fromRef(refEvent.command.getRefName()) == null) {
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
index 3f7ef2c..4c1f69b 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogReader.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -139,9 +139,6 @@
     return result.stream().map(AccountGroupByIdAudit.Builder::build).collect(toImmutableList());
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private Optional<ParsedCommit> parse(AccountGroup.UUID uuid, RevCommit c) {
     Optional<Account.Id> authorId = NoteDbUtil.parseIdent(c.getAuthorIdent());
     if (!authorId.isPresent()) {
@@ -169,7 +166,7 @@
     return Optional.of(
         new AutoValue_AuditLogReader_ParsedCommit(
             authorId.get(),
-            c.getAuthorIdent().getWhen().toInstant(),
+            c.getAuthorIdent().getWhenAsInstant(),
             ImmutableList.copyOf(addedMembers),
             ImmutableList.copyOf(removedMembers),
             ImmutableList.copyOf(addedSubgroups),
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index 71cc08c..4f2c049 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -37,7 +37,6 @@
 import java.io.IOException;
 import java.time.Instant;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.Optional;
 import java.util.function.Function;
 import java.util.regex.Pattern;
@@ -300,9 +299,6 @@
     return c;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
@@ -321,8 +317,8 @@
     Instant commitTimestamp =
         TimeUtil.truncateToSecond(
             groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::now));
-    commit.setAuthor(new PersonIdent(commit.getAuthor(), Date.from(commitTimestamp)));
-    commit.setCommitter(new PersonIdent(commit.getCommitter(), Date.from(commitTimestamp)));
+    commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
+    commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
 
     InternalGroup updatedGroup = updateGroup(commitTimestamp);
 
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index ee8dfc8..80cc463 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -15,21 +15,18 @@
 package com.google.gerrit.server.index;
 
 import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.project.ProjectField;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.io.IOException;
 import java.util.Set;
@@ -65,17 +62,17 @@
    */
   public static Set<String> accountFields(Set<String> fields, boolean useLegacyNumericFields) {
     String idFieldName =
-        useLegacyNumericFields ? AccountField.ID.getName() : AccountField.ID_STR.getName();
+        useLegacyNumericFields
+            ? AccountField.ID_FIELD_SPEC.getName()
+            : AccountField.ID_STR_FIELD_SPEC.getName();
     return fields.contains(idFieldName) ? fields : Sets.union(fields, ImmutableSet.of(idFieldName));
   }
 
   /**
    * Returns a sanitized set of fields for change index queries by removing fields that the current
-   * index version doesn't support and accounting for numeric vs. string primary keys. The primary
-   * key situation is temporary and should be removed after the migration is done.
+   * index version doesn't support.
    */
-  public static Set<String> changeFields(QueryOptions opts, boolean useLegacyNumericFields) {
-    FieldDef<ChangeData, ?> idField = useLegacyNumericFields ? LEGACY_ID : LEGACY_ID_STR;
+  public static Set<String> changeFields(QueryOptions opts) {
     // Ensure we request enough fields to construct a ChangeData. We need both
     // change ID and project, which can either come via the Change field or
     // separate fields.
@@ -84,10 +81,10 @@
       // A Change is always sufficient.
       return fs;
     }
-    if (fs.contains(PROJECT.getName()) && fs.contains(idField.getName())) {
+    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID_STR.getName())) {
       return fs;
     }
-    return Sets.union(fs, ImmutableSet.of(idField.getName(), PROJECT.getName()));
+    return Sets.union(fs, ImmutableSet.of(LEGACY_ID_STR.getName(), PROJECT.getName()));
   }
 
   /**
@@ -97,9 +94,9 @@
    */
   public static Set<String> groupFields(QueryOptions opts) {
     Set<String> fs = opts.fields();
-    return fs.contains(GroupField.UUID.getName())
+    return fs.contains(GroupField.UUID_FIELD_SPEC.getName())
         ? fs
-        : Sets.union(fs, ImmutableSet.of(GroupField.UUID.getName()));
+        : Sets.union(fs, ImmutableSet.of(GroupField.UUID_FIELD_SPEC.getName()));
   }
 
   /** Returns a index-friendly representation of a {@link CurrentUser} to be used in queries. */
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index 416b175..c802205 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -14,11 +14,6 @@
 
 package com.google.gerrit.server.index.account;
 
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.integer;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
-import static com.google.gerrit.index.FieldDef.timestamp;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.FluentIterable;
@@ -26,7 +21,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.server.account.AccountState;
@@ -41,13 +36,31 @@
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
-/** Secondary index schemas for accounts. */
+/**
+ * Secondary index schemas for accounts.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
+ */
 public class AccountField {
-  public static final FieldDef<AccountState, Integer> ID =
-      integer("id").stored().build(a -> a.account().id().get());
 
-  public static final FieldDef<AccountState, String> ID_STR =
-      exact("id_str").stored().build(a -> String.valueOf(a.account().id().get()));
+  public static final IndexedField<AccountState, Integer> ID_FIELD =
+      IndexedField.<AccountState>integerBuilder("Id")
+          .stored()
+          .required()
+          .build(a -> a.account().id().get());
+
+  public static final IndexedField<AccountState, Integer>.SearchSpec ID_FIELD_SPEC =
+      ID_FIELD.integer("id");
+
+  public static final IndexedField<AccountState, String> ID_STR_FIELD =
+      IndexedField.<AccountState>stringBuilder("IdStr")
+          .stored()
+          .required()
+          .build(a -> String.valueOf(a.account().id().get()));
+
+  public static final IndexedField<AccountState, String>.SearchSpec ID_STR_FIELD_SPEC =
+      ID_STR_FIELD.exact("id_str");
 
   /**
    * External IDs.
@@ -55,9 +68,13 @@
    * <p>This field includes secondary emails. Use this field only if the current user is allowed to
    * see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT} capability).
    */
-  public static final FieldDef<AccountState, Iterable<String>> EXTERNAL_ID =
-      exact("external_id")
-          .buildRepeatable(a -> Iterables.transform(a.externalIds(), id -> id.key().get()));
+  public static final IndexedField<AccountState, Iterable<String>> EXTERNAL_ID_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("ExternalId")
+          .required()
+          .build(a -> Iterables.transform(a.externalIds(), id -> id.key().get()));
+
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec
+      EXTERNAL_ID_FIELD_SPEC = EXTERNAL_ID_FIELD.exact("external_id");
 
   /**
    * Fuzzy prefix match on name and email parts.
@@ -66,37 +83,59 @@
    * is allowed to see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT}
    * capability).
    *
-   * <p>Use the {@link AccountField#NAME_PART_NO_SECONDARY_EMAIL} if the current user can't see
+   * <p>Use the {@link AccountField#NAME_PART_NO_SECONDARY_EMAIL_SPEC} if the current user can't see
    * secondary emails.
    */
-  public static final FieldDef<AccountState, Iterable<String>> NAME_PART =
-      prefix("name")
-          .buildRepeatable(
-              a -> getNameParts(a, Iterables.transform(a.externalIds(), ExternalId::email)));
+  public static final IndexedField<AccountState, Iterable<String>> NAME_PART_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("FullNameAndAllEmailsParts")
+          .description("Full name, all linked emails and their parts (split at special characters)")
+          .required()
+          .build(a -> getNameParts(a, Iterables.transform(a.externalIds(), ExternalId::email)));
+
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+      NAME_PART_FIELD.prefix("name");
 
   /**
    * Fuzzy prefix match on name and preferred email parts. Parts of secondary emails are not
    * included.
    */
-  public static final FieldDef<AccountState, Iterable<String>> NAME_PART_NO_SECONDARY_EMAIL =
-      prefix("name2")
-          .buildRepeatable(a -> getNameParts(a, Arrays.asList(a.account().preferredEmail())));
+  public static final IndexedField<AccountState, Iterable<String>>
+      NAME_PART_NO_SECONDARY_EMAIL_FIELD =
+          IndexedField.<AccountState>iterableStringBuilder("FullNameAndPreferredEmailParts")
+              .description(
+                  "Full name, preferred emails and its parts (split at special characters)")
+              .required()
+              .build(a -> getNameParts(a, Arrays.asList(a.account().preferredEmail())));
 
-  public static final FieldDef<AccountState, String> FULL_NAME =
-      exact("full_name").build(a -> a.account().fullName());
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec
+      NAME_PART_NO_SECONDARY_EMAIL_SPEC = NAME_PART_NO_SECONDARY_EMAIL_FIELD.prefix("name2");
 
-  public static final FieldDef<AccountState, String> ACTIVE =
-      exact("inactive").build(a -> a.account().isActive() ? "1" : "0");
+  public static final IndexedField<AccountState, String> FULL_NAME_FIELD =
+      IndexedField.<AccountState>stringBuilder("FullName")
+          .required()
+          .build(a -> a.account().fullName());
 
+  public static final IndexedField<AccountState, String>.SearchSpec FULL_NAME_SPEC =
+      FULL_NAME_FIELD.exact("full_name");
+
+  public static final IndexedField<AccountState, String> ACTIVE_FIELD =
+      IndexedField.<AccountState>stringBuilder("Active")
+          .required()
+          .build(a -> a.account().isActive() ? "1" : "0");
+
+  public static final IndexedField<AccountState, String>.SearchSpec ACTIVE_FIELD_SPEC =
+      ACTIVE_FIELD.exact("inactive");
   /**
    * All emails (preferred email + secondary emails). Use this field only if the current user is
    * allowed to see secondary emails (requires the 'Modify Account' capability).
    *
-   * <p>Use the {@link AccountField#PREFERRED_EMAIL} if the current user can't see secondary emails.
+   * <p>Use the {@link AccountField#PREFERRED_EMAIL_LOWER_CASE_SPEC} if the current user can't see
+   * secondary emails.
    */
-  public static final FieldDef<AccountState, Iterable<String>> EMAIL =
-      prefix("email")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, Iterable<String>> EMAIL_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("Email")
+          .required()
+          .build(
               a ->
                   FluentIterable.from(a.externalIds())
                       .transform(ExternalId::email)
@@ -105,42 +144,66 @@
                       .transform(String::toLowerCase)
                       .toSet());
 
-  public static final FieldDef<AccountState, String> PREFERRED_EMAIL =
-      prefix("preferredemail")
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec EMAIL_SPEC =
+      EMAIL_FIELD.prefix("email");
+
+  public static final IndexedField<AccountState, String> PREFERRED_EMAIL_LOWER_CASE_FIELD =
+      IndexedField.<AccountState>stringBuilder("PreferredEmailLowerCase")
           .build(
               a -> {
                 String preferredEmail = a.account().preferredEmail();
                 return preferredEmail != null ? preferredEmail.toLowerCase() : null;
               });
 
-  public static final FieldDef<AccountState, String> PREFERRED_EMAIL_EXACT =
-      exact("preferredemail_exact").build(a -> a.account().preferredEmail());
+  public static final IndexedField<AccountState, String>.SearchSpec
+      PREFERRED_EMAIL_LOWER_CASE_SPEC = PREFERRED_EMAIL_LOWER_CASE_FIELD.prefix("preferredemail");
+
+  public static final IndexedField<AccountState, String> PREFERRED_EMAIL_EXACT_FIELD =
+      IndexedField.<AccountState>stringBuilder("PreferredEmail")
+          .build(a -> a.account().preferredEmail());
+
+  public static final IndexedField<AccountState, String>.SearchSpec PREFERRED_EMAIL_EXACT_SPEC =
+      PREFERRED_EMAIL_EXACT_FIELD.exact("preferredemail_exact");
 
   // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
-  public static final FieldDef<AccountState, Timestamp> REGISTERED =
-      timestamp("registered").build(a -> Timestamp.from(a.account().registeredOn()));
+  public static final IndexedField<AccountState, Timestamp> REGISTERED_FIELD =
+      IndexedField.<AccountState>timestampBuilder("Registered")
+          .required()
+          .build(a -> Timestamp.from(a.account().registeredOn()));
 
-  public static final FieldDef<AccountState, String> USERNAME =
-      exact("username").build(a -> a.userName().map(String::toLowerCase).orElse(""));
+  public static final IndexedField<AccountState, Timestamp>.SearchSpec REGISTERED_SPEC =
+      REGISTERED_FIELD.timestamp("registered");
 
-  public static final FieldDef<AccountState, Iterable<String>> WATCHED_PROJECT =
-      exact("watchedproject")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, String> USERNAME_FIELD =
+      IndexedField.<AccountState>stringBuilder("Username")
+          .build(a -> a.userName().map(String::toLowerCase).orElse(""));
+
+  public static final IndexedField<AccountState, String>.SearchSpec USERNAME_SPEC =
+      USERNAME_FIELD.exact("username");
+
+  public static final IndexedField<AccountState, Iterable<String>> WATCHED_PROJECT_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("WatchedProject")
+          .build(
               a ->
                   FluentIterable.from(a.projectWatches().keySet())
                       .transform(k -> k.project().get())
                       .toSet());
 
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec WATCHED_PROJECT_SPEC =
+      WATCHED_PROJECT_FIELD.exact("watchedproject");
+
   /**
    * All values of all refs that were used in the course of indexing this document, except the
    * refs/meta/external-ids notes branch which is handled specially (see {@link
-   * #EXTERNAL_ID_STATE}).
+   * #EXTERNAL_ID_STATE_SPEC}).
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
    */
-  public static final FieldDef<AccountState, Iterable<byte[]>> REF_STATE =
-      storedOnly("ref_state")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, Iterable<byte[]>> REF_STATE_FIELD =
+      IndexedField.<AccountState>iterableByteArrayBuilder("RefState")
+          .stored()
+          .required()
+          .build(
               a -> {
                 if (a.account().metaId() == null) {
                   return ImmutableList.of();
@@ -157,21 +220,29 @@
                         .toByteArray(new AllUsersName(AllUsersNameProvider.DEFAULT)));
               });
 
+  public static final IndexedField<AccountState, Iterable<byte[]>>.SearchSpec REF_STATE_SPEC =
+      REF_STATE_FIELD.storedOnly("ref_state");
+
   /**
    * All note values of all external IDs that were used in the course of indexing this document.
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code [hex sha of external ID]:[hex sha of
    * note blob]}, or with other words {@code [note ID]:[note data ID]}.
    */
-  public static final FieldDef<AccountState, Iterable<byte[]>> EXTERNAL_ID_STATE =
-      storedOnly("external_id_state")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, Iterable<byte[]>> EXTERNAL_ID_STATE_FIELD =
+      IndexedField.<AccountState>iterableByteArrayBuilder("ExternalIdState")
+          .stored()
+          .required()
+          .build(
               a ->
                   a.externalIds().stream()
                       .filter(e -> e.blobId() != null)
                       .map(ExternalId::toByteArray)
                       .collect(toSet()));
 
+  public static final IndexedField<AccountState, Iterable<byte[]>>.SearchSpec
+      EXTERNAL_ID_STATE_SPEC = EXTERNAL_ID_STATE_FIELD.storedOnly("external_id_state");
+
   private static final Set<String> getNameParts(AccountState a, Iterable<String> emails) {
     String fullName = a.account().fullName();
     Set<String> parts = SchemaUtil.getNameParts(fullName, emails);
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
index 8b95f7b..94dfbf1 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
@@ -58,19 +58,19 @@
   public void validateMaxTermsInQuery(Predicate<AccountState> predicate)
       throws QueryParseException {
     MutableInteger leafTerms = new MutableInteger();
-    validateMaxTermsInQuery(predicate, leafTerms);
+    countLeafTerms(predicate, leafTerms);
+    if (leafTerms.value > config.maxTerms()) {
+      throw new TooManyTermsInQueryException(leafTerms.value, config.maxTerms());
+    }
   }
 
-  private void validateMaxTermsInQuery(Predicate<AccountState> predicate, MutableInteger leafTerms)
-      throws TooManyTermsInQueryException {
-    if (!(predicate instanceof IndexPredicate)) {
-      if (++leafTerms.value > config.maxTerms()) {
-        throw new TooManyTermsInQueryException();
-      }
+  private void countLeafTerms(Predicate<AccountState> predicate, MutableInteger leafTerms) {
+    if (predicate instanceof IndexPredicate) {
+      ++leafTerms.value;
     }
 
     for (Predicate<AccountState> childPredicate : predicate.getChildren()) {
-      validateMaxTermsInQuery(childPredicate, leafTerms);
+      countLeafTerms(childPredicate, leafTerms);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 5de3ba4..31fbf36 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -16,35 +16,55 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.account.AccountState;
 
-/** Definition of account index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of account index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class AccountSchemaDefinitions extends SchemaDefinitions<AccountState> {
+
   @Deprecated
-  static final Schema<AccountState> V4 =
+  static final Schema<AccountState> V8 =
       schema(
-          AccountField.ACTIVE,
-          AccountField.EMAIL,
-          AccountField.EXTERNAL_ID,
-          AccountField.FULL_NAME,
-          AccountField.ID,
-          AccountField.NAME_PART,
-          AccountField.REGISTERED,
-          AccountField.USERNAME,
-          AccountField.WATCHED_PROJECT);
-
-  @Deprecated static final Schema<AccountState> V5 = schema(V4, AccountField.PREFERRED_EMAIL);
-
-  @Deprecated
-  static final Schema<AccountState> V6 =
-      schema(V5, AccountField.REF_STATE, AccountField.EXTERNAL_ID_STATE);
-
-  @Deprecated static final Schema<AccountState> V7 = schema(V6, AccountField.PREFERRED_EMAIL_EXACT);
-
-  @Deprecated
-  static final Schema<AccountState> V8 = schema(V7, AccountField.NAME_PART_NO_SECONDARY_EMAIL);
+          /* version= */ 8,
+          ImmutableList.of(),
+          ImmutableList.of(
+              AccountField.ID_FIELD,
+              AccountField.ACTIVE_FIELD,
+              AccountField.EMAIL_FIELD,
+              AccountField.EXTERNAL_ID_FIELD,
+              AccountField.EXTERNAL_ID_STATE_FIELD,
+              AccountField.FULL_NAME_FIELD,
+              AccountField.NAME_PART_FIELD,
+              AccountField.NAME_PART_NO_SECONDARY_EMAIL_FIELD,
+              AccountField.PREFERRED_EMAIL_EXACT_FIELD,
+              AccountField.PREFERRED_EMAIL_LOWER_CASE_FIELD,
+              AccountField.REF_STATE_FIELD,
+              AccountField.REGISTERED_FIELD,
+              AccountField.USERNAME_FIELD,
+              AccountField.WATCHED_PROJECT_FIELD),
+          ImmutableList.<IndexedField<AccountState, ?>.SearchSpec>of(
+              AccountField.ID_FIELD_SPEC,
+              AccountField.ACTIVE_FIELD_SPEC,
+              AccountField.EMAIL_SPEC,
+              AccountField.EXTERNAL_ID_FIELD_SPEC,
+              AccountField.EXTERNAL_ID_STATE_SPEC,
+              AccountField.FULL_NAME_SPEC,
+              AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC,
+              AccountField.NAME_PART_SPEC,
+              AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC,
+              AccountField.PREFERRED_EMAIL_EXACT_SPEC,
+              AccountField.REF_STATE_SPEC,
+              AccountField.REGISTERED_SPEC,
+              AccountField.USERNAME_SPEC,
+              AccountField.WATCHED_PROJECT_SPEC));
 
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<AccountState> V9 = schema(V8);
@@ -55,14 +75,19 @@
   // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
   // to offer fast single- and multi-dimensional numeric range. As the consequense, integer
   // document id type is replaced with string document id type.
+  @Deprecated
   static final Schema<AccountState> V11 =
       new Schema.Builder<AccountState>()
           .add(V10)
-          .remove(AccountField.ID)
-          .add(AccountField.ID_STR)
-          .legacyNumericFields(false)
+          .remove(AccountField.ID_FIELD_SPEC)
+          .remove(AccountField.ID_FIELD)
+          .addIndexedFields(AccountField.ID_STR_FIELD)
+          .addSearchSpecs(AccountField.ID_STR_FIELD_SPEC)
           .build();
 
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<AccountState> V12 = schema(V11);
+
   /**
    * Name of the account index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
index 50fdcde..699dfbe 100644
--- a/java/com/google/gerrit/server/index/account/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -58,15 +58,15 @@
 public class StalenessChecker {
   public static final ImmutableSet<String> FIELDS =
       ImmutableSet.of(
-          AccountField.ID.getName(),
-          AccountField.REF_STATE.getName(),
-          AccountField.EXTERNAL_ID_STATE.getName());
+          AccountField.ID_FIELD_SPEC.getName(),
+          AccountField.REF_STATE_SPEC.getName(),
+          AccountField.EXTERNAL_ID_STATE_SPEC.getName());
 
   public static final ImmutableSet<String> FIELDS2 =
       ImmutableSet.of(
-          AccountField.ID_STR.getName(),
-          AccountField.REF_STATE.getName(),
-          AccountField.EXTERNAL_ID_STATE.getName());
+          AccountField.ID_STR_FIELD_SPEC.getName(),
+          AccountField.REF_STATE_SPEC.getName(),
+          AccountField.EXTERNAL_ID_STATE_SPEC.getName());
 
   private final AccountIndexCollection indexes;
   private final GitRepositoryManager repoManager;
@@ -94,13 +94,13 @@
       // No index; caller couldn't do anything if it is stale.
       return StalenessCheckResult.notStale();
     }
-    if (!i.getSchema().hasField(AccountField.REF_STATE)
-        || !i.getSchema().hasField(AccountField.EXTERNAL_ID_STATE)) {
+    if (!i.getSchema().hasField(AccountField.REF_STATE_SPEC)
+        || !i.getSchema().hasField(AccountField.EXTERNAL_ID_STATE_SPEC)) {
       // Index version not new enough for this check.
       return StalenessCheckResult.notStale();
     }
 
-    boolean useLegacyNumericFields = i.getSchema().useLegacyNumericFields();
+    boolean useLegacyNumericFields = i.getSchema().hasField(AccountField.ID_FIELD_SPEC);
     ImmutableSet<String> fields = useLegacyNumericFields ? FIELDS : FIELDS2;
     Optional<FieldBundle> result =
         i.getRaw(
@@ -121,8 +121,9 @@
       }
     }
 
-    for (Map.Entry<Project.NameKey, RefState> e :
-        RefState.parseStates(result.get().getValue(AccountField.REF_STATE)).entries()) {
+    Iterable<byte[]> refStates =
+        result.get().<Iterable<byte[]>>getValue(AccountField.REF_STATE_SPEC);
+    for (Map.Entry<Project.NameKey, RefState> e : RefState.parseStates(refStates).entries()) {
       // Custom All-Users repository names are not indexed. Instead, the default name is used.
       // Therefore, defer to the currently configured All-Users name.
       Project.NameKey repoName =
@@ -137,8 +138,10 @@
     }
 
     Set<ExternalId> extIds = externalIds.byAccount(id);
+
     ListMultimap<ObjectId, ObjectId> extIdStates =
-        parseExternalIdStates(result.get().getValue(AccountField.EXTERNAL_ID_STATE));
+        parseExternalIdStates(
+            result.get().<Iterable<byte[]>>getValue(AccountField.EXTERNAL_ID_STATE_SPEC));
     if (extIdStates.size() != extIds.size()) {
       return StalenessCheckResult.stale(
           "External IDs of the account were modified since the account was indexed. (%s != %s)",
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index ee272b7..f1b0b96 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -63,6 +63,7 @@
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.RefState;
+import com.google.gerrit.index.SchemaFieldDefs;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.proto.Protos;
@@ -107,6 +108,9 @@
  *
  * <p>Field names are all lowercase alphanumeric plus underscore; index implementations may create
  * unambiguous derived field names containing other characters.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
  */
 public class ChangeField {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -123,11 +127,8 @@
 
   // TODO: Rename LEGACY_ID to NUMERIC_ID
   /** Legacy change ID. */
-  public static final FieldDef<ChangeData, Integer> LEGACY_ID =
-      integer("legacy_id").stored().build(cd -> cd.getId().get());
-
   public static final FieldDef<ChangeData, String> LEGACY_ID_STR =
-      exact("legacy_id_str").stored().build(cd -> String.valueOf(cd.getId().get()));
+      exact("legacy_id_str").stored().build(cd -> String.valueOf(cd.getVirtualId().get()));
 
   /** Newer style Change-Id key. */
   public static final FieldDef<ChangeData, String> ID =
@@ -1395,7 +1396,7 @@
     return converter.fromProto(message);
   }
 
-  private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
+  private static <T> SchemaFieldDefs.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
     return in -> in.change() != null ? func.apply(in.change()) : null;
   }
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 05c5c77..6fc2665 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -30,8 +30,6 @@
 
   @Override
   default Predicate<ChangeData> keyPredicate(Change.Id id) {
-    return getSchema().useLegacyNumericFields()
-        ? ChangePredicates.id(id)
-        : ChangePredicates.idStr(id);
+    return ChangePredicates.idStr(id);
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 8b75872..05fb780 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -19,13 +19,14 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexRewriter;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.AndCardinalPredicate;
 import com.google.gerrit.index.query.AndPredicate;
 import com.google.gerrit.index.query.HasCardinality;
@@ -88,7 +89,7 @@
     return s != null ? s : EnumSet.allOf(Change.Status.class);
   }
 
-  private static EnumSet<Change.Status> extractStatus(Predicate<ChangeData> in) {
+  private static @Nullable EnumSet<Change.Status> extractStatus(Predicate<ChangeData> in) {
     if (in instanceof ChangeStatusPredicate) {
       Status status = ((ChangeStatusPredicate) in).getStatus();
       return status != null ? EnumSet.of(status) : null;
@@ -161,6 +162,9 @@
 
     MutableInteger leafTerms = new MutableInteger();
     Predicate<ChangeData> out = rewriteImpl(in, index, opts, leafTerms);
+    if (leafTerms.value > config.maxTerms()) {
+      throw new TooManyTermsInQueryException(leafTerms.value, config.maxTerms());
+    }
     if (isSameInstance(in, out) || out instanceof IndexPredicate) {
       return new IndexedChangeQuery(index, out, opts);
     } else if (out == null /* cannot rewrite */) {
@@ -189,9 +193,7 @@
       throws QueryParseException {
     in = IsSubmittablePredicate.rewrite(in);
     if (isIndexPredicate(in, index)) {
-      if (++leafTerms.value > config.maxTerms()) {
-        throw new TooManyTermsInQueryException();
-      }
+      ++leafTerms.value;
       return in;
     } else if (in instanceof LimitPredicate) {
       // Replace any limits with the limit provided by the caller. The caller
@@ -249,9 +251,9 @@
     }
     IndexPredicate<ChangeData> p = (IndexPredicate<ChangeData>) in;
 
-    FieldDef<ChangeData, ?> def = p.getField();
+    SchemaField<ChangeData, ?> field = p.getField();
     Schema<ChangeData> schema = index.getSchema();
-    return schema.hasField(def);
+    return schema.hasField(field);
   }
 
   private Predicate<ChangeData> partitionChildren(
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 8f68904..6849831 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -43,10 +43,8 @@
 import com.google.inject.OutOfScopeException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
@@ -184,21 +182,6 @@
   }
 
   /**
-   * Start indexing multiple changes in parallel.
-   *
-   * @param ids changes to index.
-   * @return future for completing indexing of all changes.
-   */
-  public ListenableFuture<List<ChangeData>> indexAsync(
-      Project.NameKey project, Collection<Change.Id> ids) {
-    List<ListenableFuture<ChangeData>> futures = new ArrayList<>(ids.size());
-    for (Change.Id id : ids) {
-      futures.add(indexAsync(project, id));
-    }
-    return Futures.allAsList(futures);
-  }
-
-  /**
    * Synchronously index a change, then check if the index is stale due to a race condition.
    *
    * @param cd change to index.
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 0a06735..6116f5a 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -20,21 +20,34 @@
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.query.change.ChangeData;
 
-/** Definition of change index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of change index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
+  /** Added new field {@link ChangeField#IS_SUBMITTABLE} based on submit requirements. */
   @Deprecated
-  static final Schema<ChangeData> V55 =
+  static final Schema<ChangeData> V74 =
       schema(
+          /* version= */ 74,
           ChangeField.ADDED,
           ChangeField.APPROVAL,
           ChangeField.ASSIGNEE,
+          ChangeField.ATTENTION_SET_FULL,
+          ChangeField.ATTENTION_SET_USERS,
+          ChangeField.ATTENTION_SET_USERS_COUNT,
           ChangeField.AUTHOR,
           ChangeField.CHANGE,
+          ChangeField.CHERRY_PICK,
+          ChangeField.CHERRY_PICK_OF_CHANGE,
+          ChangeField.CHERRY_PICK_OF_PATCHSET,
           ChangeField.COMMENT,
           ChangeField.COMMENTBY,
           ChangeField.COMMIT,
-          ChangeField.COMMITTER,
           ChangeField.COMMIT_MESSAGE,
+          ChangeField.COMMITTER,
           ChangeField.DELETED,
           ChangeField.DELTA,
           ChangeField.DIRECTORY,
@@ -47,14 +60,19 @@
           ChangeField.EXTENSION,
           ChangeField.FILE_PART,
           ChangeField.FOOTER,
+          ChangeField.FUZZY_HASHTAG,
           ChangeField.FUZZY_TOPIC,
           ChangeField.GROUP,
           ChangeField.HASHTAG,
           ChangeField.HASHTAG_CASE_AWARE,
           ChangeField.ID,
+          ChangeField.IS_PURE_REVERT,
+          ChangeField.IS_SUBMITTABLE,
           ChangeField.LABEL,
-          ChangeField.LEGACY_ID,
+          ChangeField.LEGACY_ID_STR,
+          ChangeField.MERGE,
           ChangeField.MERGEABLE,
+          ChangeField.MERGED_ON,
           ChangeField.ONLY_EXTENSIONS,
           ChangeField.OWNER,
           ChangeField.PATCH_SET,
@@ -77,131 +95,18 @@
           ChangeField.STATUS,
           ChangeField.STORED_SUBMIT_RECORD_LENIENT,
           ChangeField.STORED_SUBMIT_RECORD_STRICT,
+          ChangeField.STORED_SUBMIT_REQUIREMENTS,
           ChangeField.SUBMISSIONID,
           ChangeField.SUBMIT_RECORD,
+          ChangeField.SUBMIT_RULE_RESULT,
           ChangeField.TOTAL_COMMENT_COUNT,
           ChangeField.TR,
           ChangeField.UNRESOLVED_COMMENT_COUNT,
           ChangeField.UPDATED,
+          ChangeField.UPLOADER,
           ChangeField.WIP);
 
   /**
-   * The computation of the {@link ChangeField#EXTENSION} field is changed, hence reindexing is
-   * required.
-   */
-  @Deprecated static final Schema<ChangeData> V56 = schema(V55);
-
-  /**
-   * New numeric types: use dimensional points using the k-d tree geo-spatial data structure to
-   * offer fast single- and multi-dimensional numeric range. As the consequense, {@link
-   * ChangeField#LEGACY_ID} is replaced with {@link ChangeField#LEGACY_ID_STR}.
-   */
-  @Deprecated
-  static final Schema<ChangeData> V57 =
-      new Schema.Builder<ChangeData>()
-          .add(V56)
-          .remove(ChangeField.LEGACY_ID)
-          .add(ChangeField.LEGACY_ID_STR)
-          .legacyNumericFields(false)
-          .build();
-
-  /**
-   * Added new fields {@link ChangeField#CHERRY_PICK_OF_CHANGE} and {@link
-   * ChangeField#CHERRY_PICK_OF_PATCHSET}.
-   */
-  @Deprecated
-  static final Schema<ChangeData> V58 =
-      new Schema.Builder<ChangeData>()
-          .add(V57)
-          .add(ChangeField.CHERRY_PICK_OF_CHANGE)
-          .add(ChangeField.CHERRY_PICK_OF_PATCHSET)
-          .build();
-
-  /**
-   * Added new fields {@link ChangeField#ATTENTION_SET_USERS} and {@link
-   * ChangeField#ATTENTION_SET_FULL}.
-   */
-  @Deprecated
-  static final Schema<ChangeData> V59 =
-      new Schema.Builder<ChangeData>()
-          .add(V58)
-          .add(ChangeField.ATTENTION_SET_USERS)
-          .add(ChangeField.ATTENTION_SET_FULL)
-          .build();
-
-  /** Added new fields {@link ChangeField#MERGE} */
-  @Deprecated
-  static final Schema<ChangeData> V60 =
-      new Schema.Builder<ChangeData>().add(V59).add(ChangeField.MERGE).build();
-
-  /** Added new field {@link ChangeField#MERGED_ON} */
-  @Deprecated
-  static final Schema<ChangeData> V61 =
-      new Schema.Builder<ChangeData>().add(V60).add(ChangeField.MERGED_ON).build();
-
-  /** Added new field {@link ChangeField#FUZZY_HASHTAG} */
-  @Deprecated
-  static final Schema<ChangeData> V62 =
-      new Schema.Builder<ChangeData>().add(V61).add(ChangeField.FUZZY_HASHTAG).build();
-
-  /**
-   * The computation of the {@link ChangeField#DIRECTORY} field is changed, hence reindexing is
-   * required.
-   */
-  @Deprecated static final Schema<ChangeData> V63 = schema(V62, false);
-
-  /** Added support for MIN/MAX/ANY for {@link ChangeField#LABEL} */
-  @Deprecated static final Schema<ChangeData> V64 = schema(V63, false);
-
-  /** Added new field for submit requirements. */
-  @Deprecated
-  static final Schema<ChangeData> V65 =
-      new Schema.Builder<ChangeData>().add(V64).add(ChangeField.STORED_SUBMIT_REQUIREMENTS).build();
-
-  /**
-   * The computation of {@link ChangeField#LABEL} has changed: We added the non_uploader arg to the
-   * label field.
-   */
-  @Deprecated static final Schema<ChangeData> V66 = schema(V65, false);
-
-  /** Updated submit records: store the rule name that created the submit record. */
-  @Deprecated static final Schema<ChangeData> V67 = schema(V66, false);
-
-  /** Added new field {@link ChangeField#SUBMIT_RULE_RESULT}. */
-  @Deprecated
-  static final Schema<ChangeData> V68 =
-      new Schema.Builder<ChangeData>().add(V67).add(ChangeField.SUBMIT_RULE_RESULT).build();
-
-  /** Added new field {@link ChangeField#CHERRY_PICK}. */
-  @Deprecated
-  static final Schema<ChangeData> V69 =
-      new Schema.Builder<ChangeData>().add(V68).add(ChangeField.CHERRY_PICK).build();
-
-  /** Added new field {@link ChangeField#ATTENTION_SET_USERS_COUNT}. */
-  @Deprecated
-  static final Schema<ChangeData> V70 =
-      new Schema.Builder<ChangeData>().add(V69).add(ChangeField.ATTENTION_SET_USERS_COUNT).build();
-
-  /** Added new field {@link ChangeField#UPLOADER}. */
-  @Deprecated
-  static final Schema<ChangeData> V71 =
-      new Schema.Builder<ChangeData>().add(V70).add(ChangeField.UPLOADER).build();
-
-  /** Added new field {@link ChangeField#IS_PURE_REVERT}. */
-  @Deprecated
-  static final Schema<ChangeData> V72 =
-      new Schema.Builder<ChangeData>().add(V71).add(ChangeField.IS_PURE_REVERT).build();
-
-  @Deprecated
-  /** Added new "count=$count" argument to the {@link ChangeField#LABEL} operator. */
-  static final Schema<ChangeData> V73 = schema(V72, false);
-
-  @Deprecated
-  /** Added new field {@link ChangeField#IS_SUBMITTABLE} based on submit requirements. */
-  static final Schema<ChangeData> V74 =
-      new Schema.Builder<ChangeData>().add(V73).add(ChangeField.IS_SUBMITTABLE).build();
-
-  /**
    * Added new field {@link ChangeField#PREFIX_HASHTAG} and {@link ChangeField#PREFIX_TOPIC} to
    * allow easier search for topics.
    */
@@ -219,9 +124,19 @@
       new Schema.Builder<ChangeData>().add(V75).add(ChangeField.FOOTER_NAME).build();
 
   /** Added new field {@link ChangeField#COMMIT_MESSAGE_EXACT}. */
+  @Deprecated
   static final Schema<ChangeData> V77 =
       new Schema.Builder<ChangeData>().add(V76).add(ChangeField.COMMIT_MESSAGE_EXACT).build();
 
+  // Upgrade Lucene to 7.x requires reindexing.
+  @Deprecated static final Schema<ChangeData> V78 = schema(V77);
+
+  /** Remove draft and star fields. */
+  static final Schema<ChangeData> V79 =
+      new Schema.Builder<ChangeData>()
+          .add(V78)
+          .remove(ChangeField.DRAFTBY, ChangeField.STAR, ChangeField.STARBY)
+          .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/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 458f4a4..861a5fa 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -90,11 +90,13 @@
     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);
+          if (ref.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
             break;
           }
+          Account.Id accountId = Account.Id.fromRef(ref.getRefName());
+          if (accountId != null) {
+            indexer.get().index(accountId);
+          }
         }
       }
       // The update is in All-Users and not on refs/meta/config. So it's not a change. Return early.
diff --git a/java/com/google/gerrit/server/index/group/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
index af74514..7a26f31 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -15,76 +15,125 @@
 package com.google.gerrit.server.index.group;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.integer;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
-import static com.google.gerrit.index.FieldDef.timestamp;
 
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.git.ObjectIds;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.SchemaUtil;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
 
-/** Secondary index schemas for groups. */
+/**
+ * Secondary index schemas for groups.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
+ */
 public class GroupField {
   /** Legacy group ID. */
-  public static final FieldDef<InternalGroup, Integer> ID =
-      integer("id").build(g -> g.getId().get());
+  public static final IndexedField<InternalGroup, Integer> ID_FIELD =
+      IndexedField.<InternalGroup>integerBuilder("Id").required().build(g -> g.getId().get());
+
+  public static final IndexedField<InternalGroup, Integer>.SearchSpec ID_FIELD_SPEC =
+      ID_FIELD.integer("id");
 
   /** Group UUID. */
-  public static final FieldDef<InternalGroup, String> UUID =
-      exact("uuid").stored().build(g -> g.getGroupUUID().get());
+  public static final IndexedField<InternalGroup, String> UUID_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("UUID")
+          .required()
+          .stored()
+          .build(g -> g.getGroupUUID().get());
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec UUID_FIELD_SPEC =
+      UUID_FIELD.exact("uuid");
 
   /** Group owner UUID. */
-  public static final FieldDef<InternalGroup, String> OWNER_UUID =
-      exact("owner_uuid").build(g -> g.getOwnerGroupUUID().get());
+  public static final IndexedField<InternalGroup, String> OWNER_UUID_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("OwnerUUID")
+          .required()
+          .build(g -> g.getOwnerGroupUUID().get());
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec OWNER_UUID_SPEC =
+      OWNER_UUID_FIELD.exact("owner_uuid");
 
   /** 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 -> Timestamp.from(internalGroup.getCreatedOn()));
+  public static final IndexedField<InternalGroup, Timestamp> CREATED_ON_FIELD =
+      IndexedField.<InternalGroup>timestampBuilder("CreatedOn")
+          .required()
+          .build(internalGroup -> Timestamp.from(internalGroup.getCreatedOn()));
+
+  public static final IndexedField<InternalGroup, Timestamp>.SearchSpec CREATED_ON_SPEC =
+      CREATED_ON_FIELD.timestamp("created_on");
 
   /** Group name. */
-  public static final FieldDef<InternalGroup, String> NAME =
-      exact("name").build(InternalGroup::getName);
+  public static final IndexedField<InternalGroup, String> NAME_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("Name")
+          .required()
+          .size(200)
+          .build(InternalGroup::getName);
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec NAME_SPEC =
+      NAME_FIELD.exact("name");
 
   /** Prefix match on group name parts. */
-  public static final FieldDef<InternalGroup, Iterable<String>> NAME_PART =
-      prefix("name_part").buildRepeatable(g -> SchemaUtil.getNameParts(g.getName()));
+  public static final IndexedField<InternalGroup, Iterable<String>> NAME_PART_FIELD =
+      IndexedField.<InternalGroup>iterableStringBuilder("NamePart")
+          .required()
+          .size(200)
+          .build(g -> SchemaUtil.getNameParts(g.getName()));
+
+  public static final IndexedField<InternalGroup, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+      NAME_PART_FIELD.prefix("name_part");
 
   /** Group description. */
-  public static final FieldDef<InternalGroup, String> DESCRIPTION =
-      fullText("description").build(InternalGroup::getDescription);
+  public static final IndexedField<InternalGroup, String> DESCRIPTION_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("Description").build(InternalGroup::getDescription);
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec DESCRIPTION_SPEC =
+      DESCRIPTION_FIELD.fullText("description");
 
   /** Whether the group is visible to all users. */
-  public static final FieldDef<InternalGroup, String> IS_VISIBLE_TO_ALL =
-      exact("is_visible_to_all").build(g -> g.isVisibleToAll() ? "1" : "0");
+  public static final IndexedField<InternalGroup, String> IS_VISIBLE_TO_ALL_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("IsVisibleToAll")
+          .required()
+          .size(1)
+          .build(g -> g.isVisibleToAll() ? "1" : "0");
 
-  public static final FieldDef<InternalGroup, Iterable<Integer>> MEMBER =
-      integer("member")
-          .buildRepeatable(
-              g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
+  public static final IndexedField<InternalGroup, String>.SearchSpec IS_VISIBLE_TO_ALL_SPEC =
+      IS_VISIBLE_TO_ALL_FIELD.exact("is_visible_to_all");
 
-  public static final FieldDef<InternalGroup, Iterable<String>> SUBGROUP =
-      exact("subgroup")
-          .buildRepeatable(
+  public static final IndexedField<InternalGroup, Iterable<Integer>> MEMBER_FIELD =
+      IndexedField.<InternalGroup>iterableIntegerBuilder("Member")
+          .build(g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
+
+  public static final IndexedField<InternalGroup, Iterable<Integer>>.SearchSpec MEMBER_SPEC =
+      MEMBER_FIELD.integer("member");
+
+  public static final IndexedField<InternalGroup, Iterable<String>> SUBGROUP_FIELD =
+      IndexedField.<InternalGroup>iterableStringBuilder("Subgroup")
+          .build(
               g ->
                   g.getSubgroups().stream().map(AccountGroup.UUID::get).collect(toImmutableList()));
 
+  public static final IndexedField<InternalGroup, Iterable<String>>.SearchSpec SUBGROUP_SPEC =
+      SUBGROUP_FIELD.exact("subgroup");
+
   /** ObjectId of HEAD:refs/groups/<UUID>. */
-  public static final FieldDef<InternalGroup, byte[]> REF_STATE =
-      storedOnly("ref_state")
+  public static final IndexedField<InternalGroup, byte[]> REF_STATE_FIELD =
+      IndexedField.<InternalGroup>byteArrayBuilder("RefState")
+          .stored()
+          .required()
           .build(
               g -> {
                 byte[] a = new byte[ObjectIds.STR_LEN];
                 MoreObjects.firstNonNull(g.getRefState(), ObjectId.zeroId()).copyTo(a, 0);
                 return a;
               });
+
+  public static final IndexedField<InternalGroup, byte[]>.SearchSpec REF_STATE_SPEC =
+      REF_STATE_FIELD.storedOnly("ref_state");
 }
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index c4d8952..26f9e96 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -16,29 +16,48 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 
-/** Definition of group index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of group index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
   @Deprecated
-  static final Schema<InternalGroup> V2 =
+  static final Schema<InternalGroup> V5 =
       schema(
-          GroupField.DESCRIPTION,
-          GroupField.ID,
-          GroupField.IS_VISIBLE_TO_ALL,
-          GroupField.NAME,
-          GroupField.NAME_PART,
-          GroupField.OWNER_UUID,
-          GroupField.UUID);
-
-  @Deprecated static final Schema<InternalGroup> V3 = schema(V2, GroupField.CREATED_ON);
-
-  @Deprecated
-  static final Schema<InternalGroup> V4 = schema(V3, GroupField.MEMBER, GroupField.SUBGROUP);
-
-  @Deprecated static final Schema<InternalGroup> V5 = schema(V4, GroupField.REF_STATE);
+          /* version= */ 5,
+          ImmutableList.of(),
+          ImmutableList.of(
+              GroupField.CREATED_ON_FIELD,
+              GroupField.DESCRIPTION_FIELD,
+              GroupField.ID_FIELD,
+              GroupField.IS_VISIBLE_TO_ALL_FIELD,
+              GroupField.MEMBER_FIELD,
+              GroupField.NAME_FIELD,
+              GroupField.NAME_PART_FIELD,
+              GroupField.OWNER_UUID_FIELD,
+              GroupField.REF_STATE_FIELD,
+              GroupField.SUBGROUP_FIELD,
+              GroupField.UUID_FIELD),
+          ImmutableList.<IndexedField<InternalGroup, ?>.SearchSpec>of(
+              GroupField.CREATED_ON_SPEC,
+              GroupField.DESCRIPTION_SPEC,
+              GroupField.ID_FIELD_SPEC,
+              GroupField.IS_VISIBLE_TO_ALL_SPEC,
+              GroupField.MEMBER_SPEC,
+              GroupField.NAME_SPEC,
+              GroupField.NAME_PART_SPEC,
+              GroupField.OWNER_UUID_SPEC,
+              GroupField.REF_STATE_SPEC,
+              GroupField.SUBGROUP_SPEC,
+              GroupField.UUID_FIELD_SPEC));
 
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<InternalGroup> V6 = schema(V5);
@@ -48,7 +67,10 @@
 
   // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
   // to offer fast single- and multi-dimensional numeric range.
-  static final Schema<InternalGroup> V8 = schema(V7, false);
+  @Deprecated static final Schema<InternalGroup> V8 = schema(V7);
+
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<InternalGroup> V9 = schema(V8);
 
   /** Singleton instance of the schema definitions. This is one per JVM. */
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
index 8b0f1f8..7013e27 100644
--- a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -36,9 +36,9 @@
   public static QueryOptions createOptions(
       IndexConfig config, int start, int pageSize, int limit, Set<String> fields) {
     // Always include GroupField.UUID since it is needed to load the group from NoteDb.
-    if (!fields.contains(GroupField.UUID.getName())) {
+    if (!fields.contains(GroupField.UUID_FIELD_SPEC.getName())) {
       fields = new HashSet<>(fields);
-      fields.add(GroupField.UUID.getName());
+      fields.add(GroupField.UUID_FIELD_SPEC.getName());
     }
     return QueryOptions.create(config, start, pageSize, limit, fields);
   }
diff --git a/java/com/google/gerrit/server/index/group/StalenessChecker.java b/java/com/google/gerrit/server/index/group/StalenessChecker.java
index 4e992cb..72370bb 100644
--- a/java/com/google/gerrit/server/index/group/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/group/StalenessChecker.java
@@ -39,7 +39,7 @@
 @Singleton
 public class StalenessChecker {
   public static final ImmutableSet<String> FIELDS =
-      ImmutableSet.of(GroupField.UUID.getName(), GroupField.REF_STATE.getName());
+      ImmutableSet.of(GroupField.UUID_FIELD_SPEC.getName(), GroupField.REF_STATE_SPEC.getName());
 
   private final GroupIndexCollection indexes;
   private final GitRepositoryManager repoManager;
@@ -84,7 +84,8 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
       ObjectId head = ref == null ? ObjectId.zeroId() : ref.getObjectId();
-      ObjectId idFromIndex = ObjectId.fromString(result.get().getValue(GroupField.REF_STATE), 0);
+      ObjectId idFromIndex =
+          ObjectId.fromString(result.get().getValue(GroupField.REF_STATE_SPEC), 0);
       if (head.equals(idFromIndex)) {
         return StalenessCheckResult.notStale();
       }
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 5cd0e98..b433e9f 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -102,6 +102,9 @@
   /** The name of a group. */
   public abstract Optional<String> groupName();
 
+  /** The group system being queried. */
+  public abstract Optional<String> groupSystem();
+
   /** The UUID of a group. */
   public abstract Optional<String> groupUuid();
 
@@ -328,6 +331,8 @@
 
     public abstract Builder groupName(@Nullable String groupName);
 
+    public abstract Builder groupSystem(@Nullable String groupSystem);
+
     public abstract Builder groupUuid(@Nullable String groupUuid);
 
     public abstract Builder httpStatus(int httpStatus);
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 0fc89ba..e362c4b 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -366,16 +366,13 @@
       // Send email notifications
       outgoingMailFactory
           .create(
-              ctx.getNotify(notes.getChangeId()),
-              notes,
+              ctx,
               patchSet,
-              ctx.getUser().asIdentifiedUser(),
+              notes.getMetaId(),
               mailMessage,
-              ctx.getWhen(),
               comments,
               patchSetComment,
-              ImmutableList.of(),
-              ctx.getRepoView())
+              ImmutableList.of())
           .sendAsync();
       // Get previous approvals from this user
       Map<String, Short> approvals = new HashMap<>();
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
index 3ac610d..d8b20ba 100644
--- a/java/com/google/gerrit/server/mail/send/AbandonedSender.java
+++ b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
@@ -41,7 +41,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ABANDONED_CHANGES);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
index b13bcf6..f9ef199 100644
--- a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
@@ -30,7 +30,7 @@
   @Inject
   public AddToAttentionSetSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, project, changeId);
+    super(args, "addToAttentionSet", project, changeId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
index 8f898a8..d1ee4ee 100644
--- a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
@@ -11,6 +11,7 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Account;
@@ -23,8 +24,9 @@
   private Account.Id attentionSetUser;
   private String reason;
 
-  public AttentionSetSender(EmailArguments args, Project.NameKey project, Change.Id changeId) {
-    super(args, "addToAttentionSet", ChangeEmail.newChangeData(args, project, changeId));
+  public AttentionSetSender(
+      EmailArguments args, String messageClass, Project.NameKey project, Change.Id changeId) {
+    super(args, messageClass, ChangeEmail.newChangeData(args, project, changeId));
   }
 
   @Override
@@ -34,7 +36,6 @@
     ccAllApprovals();
     bccStarredBy();
     ccExistingReviewers();
-    removeUsersThatIgnoredTheChange();
   }
 
   public void setAttentionSetUser(Account.Id attentionSetUser) {
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 288ccf8..8be5548 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 
 import com.google.common.base.Splitter;
@@ -54,6 +54,8 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.text.MessageFormat;
 import java.time.Instant;
 import java.util.Collection;
@@ -65,6 +67,7 @@
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.stream.Collectors;
+import org.apache.http.client.utils.URIBuilder;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.JGitText;
@@ -83,6 +86,11 @@
     return ea.changeDataFactory.create(project, id);
   }
 
+  protected static ChangeData newChangeData(
+      EmailArguments ea, Project.NameKey project, Change.Id id, ObjectId metaId) {
+    return ea.changeDataFactory.create(ea.changeNotesFactory.createChecked(project, id, metaId));
+  }
+
   private final Set<Account.Id> currentAttentionSet;
   protected final Change change;
   protected final ChangeData changeData;
@@ -245,13 +253,22 @@
         .reduce(0, Integer::sum);
   }
 
-  /** Get a link to the change; null if the server doesn't know its own address. */
+  /**
+   * Get a link to the change; null if the server doesn't know its own address or if the address is
+   * malformed. The link will contain a usp parameter set to "email" to inform the frontend on
+   * clickthroughs where the link came from.
+   */
   @Nullable
   public String getChangeUrl() {
-    return args.urlFormatter
-        .get()
-        .getChangeViewUrl(change.getProject(), change.getId())
-        .orElse(null);
+    Optional<String> changeUrl =
+        args.urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId());
+    if (!changeUrl.isPresent()) return null;
+    try {
+      URI uri = new URIBuilder(changeUrl.get()).addParameter("usp", "email").build();
+      return uri.toString();
+    } catch (URISyntaxException e) {
+      return null;
+    }
   }
 
   public String getChangeMessageThreadId() {
@@ -375,14 +392,6 @@
     }
   }
 
-  protected void removeUsersThatIgnoredTheChange() {
-    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
-      if (e.getValue().contains(StarredChangesUtil.IGNORE_LABEL)) {
-        args.accountCache.get(e.getKey()).ifPresent(a -> removeUser(a.account()));
-      }
-    }
-  }
-
   @Override
   protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
     if (!NotifyHandling.ALL.equals(notify.handling())) {
@@ -458,12 +467,7 @@
     if (!projectState.statePermitsRead()) {
       return false;
     }
-    try {
-      args.permissionBackend.absentUser(to).change(changeData).check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
   }
 
   /** Find all users who are authors of any part of this change. */
@@ -559,7 +563,7 @@
       // We need names rather than account ids / emails to make it user readable.
       soyContext.put(
           "attentionSet",
-          currentAttentionSet.stream().map(this::getNameFor).collect(toImmutableSet()));
+          currentAttentionSet.stream().map(this::getNameFor).sorted().collect(toImmutableList()));
     }
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index ce5438b..3c821cc 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -14,11 +14,16 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.FilenameComparator;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -28,6 +33,8 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.NoSuchEntityException;
 import com.google.gerrit.exceptions.StorageException;
@@ -55,6 +62,7 @@
 import java.util.Optional;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
 /** Send comments, after the author of them hit used Publish Comments in the UI. */
@@ -64,7 +72,11 @@
 
   public interface Factory {
 
-    CommentSender create(Project.NameKey project, Change.Id changeId);
+    CommentSender create(
+        Project.NameKey project,
+        Change.Id changeId,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
   }
 
   private class FileCommentGroup {
@@ -106,11 +118,14 @@
   }
 
   private List<? extends Comment> inlineComments = Collections.emptyList();
-  private String patchSetComment;
-  private List<LabelVote> labels = Collections.emptyList();
+  @Nullable private String patchSetComment;
+  private ImmutableList<LabelVote> labels = ImmutableList.of();
   private final CommentsUtil commentsUtil;
   private final boolean incomingEmailEnabled;
   private final String replyToAddress;
+  private final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
+      preUpdateSubmitRequirementResultsSupplier;
+  private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
   @Inject
   public CommentSender(
@@ -118,24 +133,35 @@
       CommentsUtil commentsUtil,
       @GerritServerConfig Config cfg,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId) {
+      @Assisted Change.Id changeId,
+      @Assisted ObjectId preUpdateMetaId,
+      @Assisted
+          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
     super(args, "comment", newChangeData(args, project, changeId));
     this.commentsUtil = commentsUtil;
     this.incomingEmailEnabled =
         cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
             > Protocol.NONE.ordinal();
     this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
+    this.preUpdateSubmitRequirementResultsSupplier =
+        Suppliers.memoize(
+            () ->
+                // Triggers an (expensive) evaluation of the submit requirements. This is OK since
+                // all callers sent this email asynchronously, see EmailReviewComments.
+                newChangeData(args, project, changeId, preUpdateMetaId)
+                    .submitRequirementsIncludingLegacy());
+    this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
   }
 
   public void setComments(List<? extends Comment> comments) {
     inlineComments = comments;
   }
 
-  public void setPatchSetComment(String comment) {
+  public void setPatchSetComment(@Nullable String comment) {
     this.patchSetComment = comment;
   }
 
-  public void setLabels(List<LabelVote> labels) {
+  public void setLabels(ImmutableList<LabelVote> labels) {
     this.labels = labels;
   }
 
@@ -150,7 +176,6 @@
       bccStarredBy();
       includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
     }
-    removeUsersThatIgnoredTheChange();
 
     // Add header that enables identifying comments on parsed email.
     // Grouping is currently done by timestamp.
@@ -506,6 +531,15 @@
     soyContext.put(
         "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
 
+    if (isChangeNoLongerSubmittable()) {
+      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      soyContext.put(
+          "oldSubmitRequirements",
+          formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
+      soyContext.put(
+          "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
+    }
+
     footers.add(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp());
     footers.add(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No"));
     footers.add(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes"));
@@ -515,6 +549,59 @@
     }
   }
 
+  /**
+   * Checks whether the change is no longer submittable.
+   *
+   * @return {@code true} if the change has been submittable before the update and is no longer
+   *     submittable after the update has been applied, otherwise {@code false}
+   */
+  private boolean isChangeNoLongerSubmittable() {
+    boolean isSubmittablePreUpdate =
+        preUpdateSubmitRequirementResultsSupplier.get().values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s before the update is %s",
+        change.getId(), isSubmittablePreUpdate);
+    if (!isSubmittablePreUpdate) {
+      return false;
+    }
+
+    boolean isSubmittablePostUpdate =
+        postUpdateSubmitRequirementResults.values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s after the update is %s",
+        change.getId(), isSubmittablePostUpdate);
+    return !isSubmittablePostUpdate;
+  }
+
+  private ImmutableList<String> formatUnsatisfiedSubmitRequirements() {
+    return postUpdateSubmitRequirementResults.entrySet().stream()
+        .filter(e -> SubmitRequirementResult.Status.UNSATISFIED.equals(e.getValue().status()))
+        .map(Map.Entry::getKey)
+        .map(SubmitRequirement::name)
+        .sorted()
+        .collect(toImmutableList());
+  }
+
+  private static ImmutableList<String> formatSubmitRequirments(
+      Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
+    return submitRequirementResults.entrySet().stream()
+        .map(
+            e -> {
+              if (e.getValue().errorMessage().isPresent()) {
+                return String.format(
+                    "%s: %s (%s)",
+                    e.getKey().name(),
+                    e.getValue().status().name(),
+                    e.getValue().errorMessage().get());
+              }
+              return String.format("%s: %s", e.getKey().name(), e.getValue().status().name());
+            })
+        .sorted()
+        .collect(toImmutableList());
+  }
+
   private String getLine(PatchFile fileInfo, short side, int lineNbr) {
     try {
       return fileInfo.getLine(side, lineNbr);
@@ -535,8 +622,8 @@
     }
   }
 
-  private List<Map<String, Object>> getLabelVoteSoyData(List<LabelVote> votes) {
-    List<Map<String, Object>> result = new ArrayList<>();
+  private ImmutableList<Map<String, Object>> getLabelVoteSoyData(ImmutableList<LabelVote> votes) {
+    ImmutableList.Builder<Map<String, Object>> result = ImmutableList.builder();
     for (LabelVote vote : votes) {
       Map<String, Object> data = new HashMap<>();
       data.put("label", vote.label());
@@ -546,7 +633,7 @@
       data.put("value", (int) vote.value());
       result.add(data);
     }
-    return result;
+    return result.build();
   }
 
   private String getCommentTimestamp() {
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index 0de0dbe..70676e3 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -63,7 +63,6 @@
     includeWatchers(NotifyType.ALL_COMMENTS);
     reviewers.stream().forEach(r -> add(RecipientType.TO, r));
     addByEmail(RecipientType.TO, reviewersByEmail);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
index 77efbf8..f71cc00 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
@@ -41,7 +41,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index cec857d..693c669 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -62,7 +62,6 @@
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
     includeWatchers(NotifyType.SUBMITTED_CHANGES);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
index b187f9c..dcf3b6c 100644
--- a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
@@ -37,6 +37,5 @@
     super.init();
 
     ccExistingReviewers();
-    removeUsersThatIgnoredTheChange();
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 001de52..e899fc5 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -116,7 +116,7 @@
       names.add(getNameFor(id));
     }
     for (Address address : removedByEmailReviewers) {
-      names.add(address.name());
+      names.add(address.toString());
     }
     return names;
   }
diff --git a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
index 6762b7d..5242bfb 100644
--- a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
@@ -30,7 +30,7 @@
   @Inject
   public RemoveFromAttentionSetSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, project, changeId);
+    super(args, "removeFromAttentionSet", project, changeId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 9516b9f..0d32dd5 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -14,34 +14,89 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Send notice of new patch sets for reviewers. */
 public class ReplacePatchSetSender extends ReplyToChangeSender {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
-    ReplacePatchSetSender create(Project.NameKey project, Change.Id changeId);
+    ReplacePatchSetSender create(
+        Project.NameKey project,
+        Change.Id changeId,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
   }
 
   private final Set<Account.Id> reviewers = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
+  private final ChangeKind changeKind;
+  private final Set<PatchSetApproval> outdatedApprovals = new HashSet<>();
+  private final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
+      preUpdateSubmitRequirementResultsSupplier;
+  private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
   @Inject
   public ReplacePatchSetSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+      EmailArguments args,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id changeId,
+      @Assisted ChangeKind changeKind,
+      @Assisted ObjectId preUpdateMetaId,
+      @Assisted
+          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
     super(args, "newpatchset", newChangeData(args, project, changeId));
+    this.changeKind = changeKind;
+
+    this.preUpdateSubmitRequirementResultsSupplier =
+        Suppliers.memoize(
+            () ->
+                // Triggers an (expensive) evaluation of the submit requirements. This is OK since
+                // all callers sent this email asynchronously, see EmailNewPatchSet.
+                newChangeData(args, project, changeId, preUpdateMetaId)
+                    .submitRequirementsIncludingLegacy());
+
+    this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
+  }
+
+  @Override
+  protected boolean shouldSendMessage() {
+    if (!isChangeNoLongerSubmittable() && changeKind.isTrivialRebase()) {
+      logger.atFine().log(
+          "skip email because new patch set is a trivial rebase that didn't make the change"
+              + " non-submittable");
+      return false;
+    }
+
+    return super.shouldSendMessage();
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
@@ -52,6 +107,12 @@
     extraCC.addAll(cc);
   }
 
+  public void addOutdatedApproval(@Nullable Collection<PatchSetApproval> outdatedApprovals) {
+    if (outdatedApprovals != null) {
+      this.outdatedApprovals.addAll(outdatedApprovals);
+    }
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -71,7 +132,6 @@
     }
     bccStarredBy();
     includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
@@ -82,7 +142,7 @@
     }
   }
 
-  public List<String> getReviewerNames() {
+  public ImmutableList<String> getReviewerNames() {
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
       if (id.equals(fromId)) {
@@ -93,12 +153,87 @@
     if (names.isEmpty()) {
       return null;
     }
-    return names;
+    return names.stream().sorted().collect(toImmutableList());
+  }
+
+  private ImmutableList<String> formatOutdatedApprovals() {
+    return outdatedApprovals.stream()
+        .map(
+            outdatedApproval ->
+                String.format(
+                    "%s by %s",
+                    LabelVote.create(outdatedApproval.label(), outdatedApproval.value()).format(),
+                    getNameFor(outdatedApproval.accountId())))
+        .sorted()
+        .collect(toImmutableList());
   }
 
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContextEmailData.put("reviewerNames", getReviewerNames());
+    soyContextEmailData.put("outdatedApprovals", formatOutdatedApprovals());
+
+    if (isChangeNoLongerSubmittable()) {
+      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      soyContext.put(
+          "oldSubmitRequirements",
+          formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
+      soyContext.put(
+          "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
+    }
+  }
+
+  /**
+   * Checks whether the change is no longer submittable.
+   *
+   * @return {@code true} if the change has been submittable before the update and is no longer
+   *     submittable after the update has been applied, otherwise {@code false}
+   */
+  private boolean isChangeNoLongerSubmittable() {
+    boolean isSubmittablePreUpdate =
+        preUpdateSubmitRequirementResultsSupplier.get().values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s before the update is %s",
+        change.getId(), isSubmittablePreUpdate);
+    if (!isSubmittablePreUpdate) {
+      return false;
+    }
+
+    boolean isSubmittablePostUpdate =
+        postUpdateSubmitRequirementResults.values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s after the update is %s",
+        change.getId(), isSubmittablePostUpdate);
+    return !isSubmittablePostUpdate;
+  }
+
+  private ImmutableList<String> formatUnsatisfiedSubmitRequirements() {
+    return postUpdateSubmitRequirementResults.entrySet().stream()
+        .filter(e -> SubmitRequirementResult.Status.UNSATISFIED.equals(e.getValue().status()))
+        .map(Map.Entry::getKey)
+        .map(SubmitRequirement::name)
+        .sorted()
+        .collect(toImmutableList());
+  }
+
+  private static ImmutableList<String> formatSubmitRequirments(
+      Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
+    return submitRequirementResults.entrySet().stream()
+        .map(
+            e -> {
+              if (e.getValue().errorMessage().isPresent()) {
+                return String.format(
+                    "%s: %s (%s)",
+                    e.getKey().name(),
+                    e.getValue().status().name(),
+                    e.getValue().errorMessage().get());
+              }
+              return String.format("%s: %s", e.getKey().name(), e.getValue().status().name());
+            })
+        .sorted()
+        .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RestoredSender.java b/java/com/google/gerrit/server/mail/send/RestoredSender.java
index ffe70cf..e37d8f9 100644
--- a/java/com/google/gerrit/server/mail/send/RestoredSender.java
+++ b/java/com/google/gerrit/server/mail/send/RestoredSender.java
@@ -41,7 +41,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/RevertedSender.java b/java/com/google/gerrit/server/mail/send/RevertedSender.java
index c11529b..1d7223d 100644
--- a/java/com/google/gerrit/server/mail/send/RevertedSender.java
+++ b/java/com/google/gerrit/server/mail/send/RevertedSender.java
@@ -40,7 +40,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 0a721cf..c06cc1e 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -385,7 +385,7 @@
     try (QuotedPrintableOutputStream qp = new QuotedPrintableOutputStream(s, false)) {
       qp.write(input.getBytes(UTF_8));
     }
-    return s.toString();
+    return s.toString(UTF_8);
   }
 
   private static void setMissingHeader(Map<String, EmailHeader> hdrs, String name, String value) {
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 158972f..93f29f6 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -17,6 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Change;
@@ -24,6 +25,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritImportedServerIds;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
@@ -51,6 +53,7 @@
     public final AllUsersName allUsers;
     public final NoteDbMetrics metrics;
     public final String serverId;
+    public final ImmutableList<String> importedServerIds;
 
     // Providers required to avoid dependency cycles.
 
@@ -64,7 +67,8 @@
         ChangeNoteJson changeNoteJson,
         NoteDbMetrics metrics,
         Provider<ChangeNotesCache> cache,
-        @GerritServerId String serverId) {
+        @GerritServerId String serverId,
+        @GerritImportedServerIds ImmutableList<String> importedServerIds) {
       this.failOnLoadForTest = new AtomicBoolean();
       this.repoManager = repoManager;
       this.allUsers = allUsers;
@@ -72,6 +76,7 @@
       this.metrics = metrics;
       this.cache = cache;
       this.serverId = serverId;
+      this.importedServerIds = importedServerIds;
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 7efda47..e6f1622 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.InternalUser;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.Date;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -56,9 +55,6 @@
   private ObjectId result;
   boolean rootOnly;
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   AbstractChangeUpdate(
       ChangeNotes notes,
       CurrentUser user,
@@ -66,7 +62,7 @@
       ChangeNoteUtil noteUtil,
       Instant when) {
     this.noteUtil = noteUtil;
-    this.serverIdent = new PersonIdent(serverIdent, Date.from(when));
+    this.serverIdent = new PersonIdent(serverIdent, when);
     this.notes = notes;
     this.change = notes.getChange();
     this.accountId = accountId(user);
@@ -76,9 +72,6 @@
     this.when = when;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   AbstractChangeUpdate(
       ChangeNoteUtil noteUtil,
       PersonIdent serverIdent,
@@ -92,7 +85,7 @@
         (notes != null && change == null) || (notes == null && change != null),
         "exactly one of notes or change required");
     this.noteUtil = noteUtil;
-    this.serverIdent = new PersonIdent(serverIdent, Date.from(when));
+    this.serverIdent = new PersonIdent(serverIdent, when);
     this.notes = notes;
     this.change = change != null ? change : notes.getChange();
     this.accountId = accountId;
@@ -213,9 +206,6 @@
    *     deleted.
    * @throws IOException if a lower-level error occurred.
    */
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr) throws IOException {
     if (isEmpty()) {
       return null;
@@ -236,7 +226,7 @@
       return null; // Impl is a no-op.
     }
     cb.setAuthor(authorIdent);
-    cb.setCommitter(new PersonIdent(serverIdent, Date.from(when)));
+    cb.setCommitter(new PersonIdent(serverIdent, when));
     setParentCommit(cb, curr);
     if (cb.getTreeId() == null) {
       if (curr.equals(z)) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 5d19205..73161d7 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -92,6 +92,7 @@
   private List<HumanComment> put = new ArrayList<>();
   private Map<Key, DeleteReason> delete = new HashMap<>();
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private ChangeDraftUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 0f1d362c..de401ac 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -16,6 +16,8 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.EntitiesAdapterFactory;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.json.EnumTypeAdapterFactory;
 import com.google.gerrit.json.OptionalSubmitRequirementExpressionResultAdapterFactory;
 import com.google.gerrit.json.OptionalTypeAdapter;
@@ -65,6 +67,9 @@
             new OptionalBooleanAdapter().nullSafe())
         .registerTypeAdapterFactory(new OptionalSubmitRequirementExpressionResultAdapterFactory())
         .registerTypeAdapter(ObjectId.class, new ObjectIdAdapter())
+        .registerTypeAdapter(
+            SubmitRequirementExpressionResult.Status.class,
+            new SubmitRequirementExpressionResultStatusAdapter())
         .setPrettyPrinting()
         .create();
   }
@@ -158,4 +163,32 @@
       return builder.build();
     }
   }
+
+  /**
+   * A Json type adapter for the Status enum in {@link SubmitRequirementExpressionResult}. This
+   * adapter is able to parse unrecognized values. Unrecognized values are converted to the value
+   * "ERROR" The adapter is needed to ensure forward compatibility since we want to add more values
+   * to this enum. We do that to ensure safer rollout in distributed setups where some tasks are
+   * updated before others. We make sure that tasks running the old binaries are still able to parse
+   * values written by tasks running the new binaries.
+   *
+   * <p>TODO(ghareeb): Remove this adapter.
+   */
+  static class SubmitRequirementExpressionResultStatusAdapter
+      extends TypeAdapter<SubmitRequirementExpressionResult.Status> {
+    @Override
+    public void write(JsonWriter jsonWriter, Status status) throws IOException {
+      jsonWriter.value(status.name());
+    }
+
+    @Override
+    public Status read(JsonReader jsonReader) throws IOException {
+      String val = jsonReader.nextString();
+      try {
+        return SubmitRequirementExpressionResult.Status.valueOf(val);
+      } catch (IllegalArgumentException e) {
+        return SubmitRequirementExpressionResult.Status.ERROR;
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index e9d2f4c..a30cfe0 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -24,7 +24,6 @@
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import java.time.Instant;
-import java.util.Date;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -100,16 +99,13 @@
    * Returns a {@link PersonIdent} that contains the account ID, but not the user's name or email
    * address.
    */
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public PersonIdent newAccountIdIdent(
       Account.Id accountId, Instant when, PersonIdent serverIdent) {
     return new PersonIdent(
         getAccountIdAsUsername(accountId),
         getAccountIdAsEmailAddress(accountId),
-        Date.from(when),
-        serverIdent.getTimeZone());
+        when,
+        serverIdent.getZoneId());
   }
 
   /** Returns the string {@code "Gerrit User " + accountId}, to pseudonymize user names. */
@@ -384,18 +380,26 @@
    *   <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>
+   *
+   * <p>Footer example for removal: Copied-Label: -<LABEL> <Gerrit Account>,<Gerrit Real Account>
+   *
+   * <ul>
+   *   <li><Gerrit Real Account> is also optional, if it was not set.
+   * </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);
+          ParsedPatchSetApproval.builder().footerLine(labelLine);
 
-      int tagStart = labelLine.indexOf(":\"");
-      int uuidStart = labelLine.indexOf(", ");
+      boolean isRemoval = labelLine.startsWith("-");
+      rawPatchSetApproval.isRemoval(isRemoval);
+      int labelStart = isRemoval ? 1 : 0;
+      int uuidStart = isRemoval ? -1 : labelLine.indexOf(", ");
+      int tagStart = isRemoval ? -1 : labelLine.indexOf(":\"");
+
+      checkFooter(!isRemoval || uuidStart == -1, FOOTER_LABEL, labelLine);
 
       // Weird tag that contains uuid delimiter. The uuid is actually not present.
       if (tagStart != -1 && uuidStart > tagStart) {
@@ -408,7 +412,8 @@
           FOOTER_COPIED_LABEL,
           labelLine);
 
-      String labelVoteStr = labelLine.substring(0, uuidStart != -1 ? uuidStart : identitiesStart);
+      String labelVoteStr =
+          labelLine.substring(labelStart, uuidStart != -1 ? uuidStart : identitiesStart);
       rawPatchSetApproval.labelVote(labelVoteStr);
       if (uuidStart != -1) {
         String uuid = labelLine.substring(uuidStart + 2, identitiesStart);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 3095cd2..a342686 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static java.util.Comparator.comparing;
@@ -47,6 +46,7 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApprovals;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
@@ -90,9 +90,6 @@
   static final Ordering<PatchSetApproval> PSA_BY_TIME =
       Ordering.from(comparing(PatchSetApproval::granted));
 
-  public static final Ordering<ChangeMessage> MESSAGE_BY_TIME =
-      Ordering.from(comparing(ChangeMessage::getWrittenOn));
-
   @FormatMethod
   public static ConfigInvalidException parseException(
       Change.Id changeId, String fmt, Object... args) {
@@ -142,15 +139,6 @@
     }
 
     public ChangeNotes createChecked(
-        Repository repo,
-        Project.NameKey project,
-        Change.Id changeId,
-        @Nullable ObjectId metaRevId) {
-      Change change = newChange(project, changeId);
-      return new ChangeNotes(args, change, true, null, metaRevId).load(repo);
-    }
-
-    public ChangeNotes createChecked(
         Project.NameKey project, Change.Id changeId, @Nullable ObjectId metaRevId) {
       Change change = newChange(project, changeId);
       return new ChangeNotes(args, change, true, null, metaRevId).load();
@@ -390,8 +378,7 @@
   // Lazy defensive copies of mutable ReviewDb types, to avoid polluting the
   // ChangeNotesCache from handlers.
   private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
-  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
-  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvalsWithCopied;
+  private PatchSetApprovals approvals;
   private ImmutableSet<Comment.Key> commentKeys;
 
   public ChangeNotes(
@@ -419,6 +406,10 @@
     return state.metaId();
   }
 
+  public String getServerId() {
+    return state.serverId();
+  }
+
   public ImmutableSortedMap<PatchSet.Id, PatchSet> getPatchSets() {
     if (patchSets == null) {
       ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
@@ -429,28 +420,14 @@
     return patchSets;
   }
 
-  /**
-   * Gets the approvals, not including the copied approvals. To get copied approvals as well, use
-   * {@link #getApprovalsWithCopied}, or use {@code ApprovalInference}.
-   */
-  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
+  /** Gets the approvals of all patch sets. */
+  public PatchSetApprovals getApprovals() {
     if (approvals == null) {
-      approvals =
-          state.approvals().stream()
-              .filter(e -> !e.getValue().copied())
-              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue()));
+      approvals = PatchSetApprovals.create(ImmutableListMultimap.copyOf(state.approvals()));
     }
     return approvals;
   }
 
-  /** Gets all approvals, including copied approvals. */
-  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovalsWithCopied() {
-    if (approvalsWithCopied == null) {
-      approvalsWithCopied = ImmutableListMultimap.copyOf(state.approvals());
-    }
-    return approvalsWithCopied;
-  }
-
   public ReviewerSet getReviewers() {
     return state.reviewers();
   }
@@ -682,7 +659,9 @@
      * be to bump the cache version, but that would invalidate all persistent cache entries, what we
      * rather try to avoid.
      */
-    if (!Strings.isNullOrEmpty(stateServerId) && !args.serverId.equals(stateServerId)) {
+    if (!Strings.isNullOrEmpty(stateServerId)
+        && !args.serverId.equals(stateServerId)
+        && !args.importedServerIds.contains(stateServerId)) {
       throw new InvalidServerIdException(args.serverId, stateServerId);
     }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 40bf6e5..0f2c877 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
@@ -61,7 +62,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(4)
+            .version(5)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
@@ -366,7 +367,13 @@
           "Load change notes for change %s of project %s", key.changeId(), key.project());
       ChangeNotesParser parser =
           new ChangeNotesParser(
-              key.changeId(), key.id(), walkSupplier.get(), args.changeNoteJson, args.metrics);
+              key.changeId(),
+              key.id(),
+              walkSupplier.get(),
+              args.changeNoteJson,
+              args.metrics,
+              args.serverId,
+              externalIdCache);
       ChangeNotesState result = parser.parseAll();
       // This assignment only happens if call() was actually called, which only
       // happens when Cache#get(K, Callable<V>) incurs a cache miss.
@@ -377,11 +384,16 @@
 
   private final Cache<Key, ChangeNotesState> cache;
   private final Args args;
+  private final ExternalIdCache externalIdCache;
 
   @Inject
-  ChangeNotesCache(@Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache, Args args) {
+  ChangeNotesCache(
+      @Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache,
+      Args args,
+      ExternalIdCache externalIdCache) {
     this.cache = cache;
     this.args = args;
+    this.externalIdCache = externalIdCache;
   }
 
   Value get(
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 1d8ec82..f2a659d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -79,6 +79,7 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.notedb.ChangeNoteUtil.ParsedPatchSetApproval;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.LabelVote;
@@ -199,18 +200,24 @@
   // the latest record unsets the field).
   private Optional<PatchSet.Id> cherryPickOf;
   private Instant mergedOn;
+  private final ExternalIdCache externalIdCache;
+  private final String gerritServerId;
 
   ChangeNotesParser(
       Change.Id changeId,
       ObjectId tip,
       ChangeNotesRevWalk walk,
       ChangeNoteJson changeNoteJson,
-      NoteDbMetrics metrics) {
+      NoteDbMetrics metrics,
+      String gerritServerId,
+      ExternalIdCache externalIdCache) {
     this.id = changeId;
     this.tip = tip;
     this.walk = walk;
     this.changeNoteJson = changeNoteJson;
     this.metrics = metrics;
+    this.externalIdCache = externalIdCache;
+    this.gerritServerId = gerritServerId;
     approvals = new LinkedHashMap<>();
     bufferedApprovals = new ArrayList<>();
     reviewers = HashBasedTable.create();
@@ -948,9 +955,15 @@
       realAccountId = parseIdent(realIdent);
     }
 
-    LabelVote l;
+    LabelVote labelVote;
     try {
-      l = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
+      if (!parsedPatchSetApproval.isRemoval()) {
+        labelVote = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
+      } else {
+        String labelName = parsedPatchSetApproval.labelVote();
+        LabelType.checkNameInternal(labelName);
+        labelVote = LabelVote.create(labelName, (short) 0);
+      }
     } catch (IllegalArgumentException e) {
       ConfigInvalidException pe =
           parseException(
@@ -961,9 +974,9 @@
 
     PatchSetApproval.Builder psa =
         PatchSetApproval.builder()
-            .key(PatchSetApproval.key(psId, accountId, LabelId.create(l.label())))
+            .key(PatchSetApproval.key(psId, accountId, LabelId.create(labelVote.label())))
             .uuid(parsedPatchSetApproval.uuid().map(PatchSetApproval::uuid))
-            .value(l.value())
+            .value(labelVote.value())
             .granted(ts)
             .tag(parsedPatchSetApproval.tag())
             .copied(true);
@@ -1270,11 +1283,8 @@
    * @param commit the commit to return commit time.
    * @return the timestamp when the commit was applied.
    */
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private Instant getCommitTimestamp(ChangeNotesCommit commit) {
-    return commit.getCommitterIdent().getWhen().toInstant();
+    return commit.getCommitterIdent().getWhenAsInstant();
   }
 
   private void pruneReviewers() {
@@ -1403,7 +1413,7 @@
   }
 
   private Account.Id parseIdent(PersonIdent ident) throws ConfigInvalidException {
-    return NoteDbUtil.parseIdent(ident)
+    return NoteDbUtil.parseIdent(ident, gerritServerId, externalIdCache)
         .orElseThrow(
             () -> parseException("cannot retrieve account id: %s", ident.getEmailAddress()));
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 8f352cb..62c734b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -52,10 +52,12 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
 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;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
@@ -141,7 +143,7 @@
   private final ServiceUserClassifier serviceUserClassifier;
   private final PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator;
 
-  private final Table<String, Account.Id, Optional<Short>> approvals;
+  private final Table<String, Account.Id, Optional<PatchSetApproval>> 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<>();
@@ -181,6 +183,10 @@
   private DeleteChangeMessageRewriter deleteChangeMessageRewriter;
   private List<SubmitRequirementResult> submitRequirementResults;
 
+  private ImmutableList.Builder<AttentionSetUpdate> attentionSetUpdatesBuilder =
+      ImmutableList.builder();
+
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private ChangeUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -214,7 +220,7 @@
         noteUtil);
   }
 
-  private static Table<String, Account.Id, Optional<Short>> approvals(
+  private static Table<String, Account.Id, Optional<PatchSetApproval>> approvals(
       Comparator<String> nameComparator) {
     return TreeBasedTable.create(nameComparator, naturalOrder());
   }
@@ -281,7 +287,18 @@
   }
 
   public void putApprovalFor(Account.Id reviewer, String label, short value) {
-    approvals.put(label, reviewer, Optional.of(value));
+    PatchSetApproval psa =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(getPatchSetId(), reviewer, LabelId.create(label)))
+            .value(value)
+            .granted(when)
+            .uuid(patchSetApprovalUuidGenerator.get(getPatchSetId(), reviewer, label, value, when))
+            .build();
+    approvals.put(label, reviewer, Optional.of(psa));
+  }
+
+  public ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> getApprovals() {
+    return ImmutableTable.copyOf(approvals);
   }
 
   void removeApproval(String label) {
@@ -301,6 +318,23 @@
     copiedApprovals.add(copiedPatchSetApproval);
   }
 
+  public void removeCopiedApprovalFor(
+      @Nullable Account.Id realUserId, Account.Id reviewerId, String label) {
+    PatchSetApproval.Builder psaBuilder =
+        PatchSetApproval.builder()
+            .copied(true)
+            .key(PatchSetApproval.key(getPatchSetId(), reviewerId, LabelId.create(label)))
+            .value(0)
+            .uuid(Optional.empty())
+            .granted(when);
+
+    if (realUserId != null) {
+      psaBuilder.realAccountId(realUserId);
+    }
+
+    copiedApprovals.add(psaBuilder.build());
+  }
+
   public void merge(SubmissionId submissionId, Iterable<SubmitRecord> submitRecords) {
     this.status = Change.Status.MERGED;
     this.submissionId = submissionId.toString();
@@ -426,12 +460,21 @@
   }
 
   /**
-   * All updates must have a timestamp of null since we use the commit's timestamp. There also must
-   * not be multiple updates for a single user. Only the first update takes place because of the
-   * different priorities: e.g, if we want to add someone to the attention set but also want to
-   * remove someone from the attention set, we should ensure to add/remove that user based on the
-   * priority of the addition and removal. If most importantly we want to remove the user, then we
-   * must first create the removal, and the addition will not take effect.
+   * Adds attention set updates that should be stored in NoteDb.
+   *
+   * <p>If invoked multiple times with attention set updates for the same user, only the attention
+   * set update of the first invocation is stored for this user and further attention set updates
+   * for this user are silently ignored. This means if callers invoke this method multiple times
+   * with attention set updates for the same user, they must ensure that the first call is being
+   * done with the attention set update that should take precedence.
+   *
+   * @param updates Attention set updates that should be performed. The updates must not have any
+   *     timestamp set ({@link AttentionSetUpdate#timestamp()} must return {@code null}). This is
+   *     because the timestamp of all performed updates is always the timestamp of when the NoteDb
+   *     commit is created. Each of the provided updates must be for a different user, if there are
+   *     multiple updates for the same user the update is rejected.
+   * @throws IllegalArgumentException thrown if any of the provided updates has a timestamp set, or
+   *     if the provided set of updates contains multiple updates for the same user
    */
   public void addToPlannedAttentionSetUpdates(Set<AttentionSetUpdate> updates) {
     if (updates == null || updates.isEmpty() || ignoreFurtherAttentionSetUpdates) {
@@ -463,6 +506,10 @@
     addToPlannedAttentionSetUpdates(ImmutableSet.of(update));
   }
 
+  public ImmutableList<AttentionSetUpdate> getAttentionSetUpdates() {
+    return attentionSetUpdatesBuilder.build();
+  }
+
   public void setAssignee(Account.Id assignee) {
     checkArgument(assignee != null, "use removeAssignee");
     this.assignee = Optional.of(assignee);
@@ -750,8 +797,8 @@
       addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
     }
 
-    for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
-      addLabelFooter(msg, c, patchSetId);
+    for (Table.Cell<String, Account.Id, Optional<PatchSetApproval>> c : approvals.cellSet()) {
+      addLabelFooter(msg, c);
     }
     for (PatchSetApproval patchSetApproval : copiedApprovals) {
       addCopiedLabelFooter(msg, patchSetApproval);
@@ -839,7 +886,7 @@
   }
 
   private void addLabelFooter(
-      StringBuilder msg, Cell<String, Account.Id, Optional<Short>> c, PatchSet.Id patchSetId) {
+      StringBuilder msg, Cell<String, Account.Id, Optional<PatchSetApproval>> c) {
     addFooter(msg, FOOTER_LABEL);
     String label = c.getRowKey();
     Account.Id reviewerId = c.getColumnKey();
@@ -850,10 +897,10 @@
       // Since vote removals do not need to be referenced, e.g. by the copy approvals, they do not
       // require a UUID.
     } else {
-      short value = c.getValue().get();
-      msg.append(LabelVote.create(label, c.getValue().get()).formatWithEquals());
+      short value = c.getValue().get().value();
+      msg.append(LabelVote.create(label, value).formatWithEquals());
       msg.append(", ");
-      msg.append(patchSetApprovalUuidGenerator.get(patchSetId, reviewerId, label, value, when));
+      msg.append(c.getValue().get().uuid().get());
     }
     if (!reviewerId.equals(getAccountId())) {
       noteUtil.appendAccountIdIdentString(msg.append(' '), reviewerId);
@@ -863,7 +910,20 @@
 
   private void addCopiedLabelFooter(StringBuilder msg, PatchSetApproval patchSetApproval) {
     if (patchSetApproval.value() == 0) {
-      // Can only happen if we removed a vote. There is no need to persist removed votes.
+      addFooter(msg, FOOTER_COPIED_LABEL);
+
+      // Mark the copied approval as deleted.
+      msg.append('-').append(patchSetApproval.label());
+
+      noteUtil.appendAccountIdIdentString(msg.append(' '), patchSetApproval.accountId());
+
+      // In the non-copied labels, we don't need to pass the real account id since it's already
+      // in FOOTER_REAL_USER. Here, we want to retain the original real account id.
+      if (!patchSetApproval.realAccountId().equals(patchSetApproval.accountId())) {
+        noteUtil.appendAccountIdIdentString(msg.append(","), patchSetApproval.realAccountId());
+      }
+
+      msg.append('\n');
       return;
     }
     addFooter(msg, FOOTER_COPIED_LABEL);
@@ -880,7 +940,7 @@
 
     // In the non-copied labels, we don't need to pass the real account id since it's already
     // in FOOTER_REAL_USER. Here, we want to retain the original real account id.
-    if (patchSetApproval.realAccountId() != null) {
+    if (!patchSetApproval.realAccountId().equals(patchSetApproval.accountId())) {
       noteUtil.appendAccountIdIdentString(msg.append(","), patchSetApproval.realAccountId());
     }
 
@@ -913,11 +973,13 @@
       // be submitted or when the caller is a robot.
       return;
     }
+
+    Set<AttentionSetUpdate> updates = new HashSet<>();
     Set<Account.Id> currentReviewers =
         getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER);
-    Set<AttentionSetUpdate> updates = new HashSet<>();
     for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) {
       Account.Id reviewerId = reviewer.getKey();
+
       ReviewerStateInternal reviewerState = reviewer.getValue();
       // Only add new reviewers to the attention set. Also, don't add the owner because the owner
       // can only be a "dummy" reviewer for legacy reasons.
@@ -1023,6 +1085,7 @@
       }
 
       addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
+      attentionSetUpdatesBuilder.add(attentionSetUpdate);
       hasUpdates = true;
     }
     return hasUpdates;
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index 534da0d..da20475 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -23,6 +23,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_PATTERN;
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_REGEX;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
@@ -355,7 +356,7 @@
   private ImmutableSet<AccountState> collectAccounts(ChangeNotes changeNotes) {
     Set<Account.Id> accounts = new HashSet<>();
     accounts.add(changeNotes.getChange().getOwner());
-    for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().values()) {
+    for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().all().values()) {
       if (patchSetApproval.accountId() != null) {
         accounts.add(patchSetApproval.accountId());
       }
@@ -577,12 +578,9 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private boolean verifyPersonIdent(PersonIdent newIdent, PersonIdent originalIdent) {
     return newIdent.getTimeZoneOffset() == originalIdent.getTimeZoneOffset()
-        && newIdent.getWhen().getTime() == originalIdent.getWhen().getTime()
+        && newIdent.getWhenAsInstant().equals(originalIdent.getWhenAsInstant())
         && newIdent.getEmailAddress().equals(originalIdent.getEmailAddress());
   }
 
@@ -1251,7 +1249,7 @@
       fmt.setContext(0);
       fmt.format(diff, oldBody, newBody);
       fmt.flush();
-      return out.toString();
+      return out.toString(UTF_8);
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 28436db..c8d93f8 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -15,19 +15,40 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.entities.RefNames.REFS_DRAFT_COMMENTS;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Sets.SetView;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
+import com.google.gerrit.server.util.time.TimeUtil;
 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.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import java.util.function.Consumer;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -39,16 +60,21 @@
  * href="https://gerrit-review.googlesource.com/c/gerrit/+/246233">
  * https://gerrit-review.googlesource.com/c/gerrit/+/246233 </a>
  *
- * <p>An earlier bug in the deletion of draft comments {@code
- * refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain in Git
- * and not get deleted. These refs point to an empty tree.
+ * <p>The implementation has two cases for detecting zombie drafts:
+ *
+ * <ul>
+ *   <li>An earlier bug in the deletion of draft comments {@code
+ *       refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain
+ *       in Git and not get deleted. These refs point to an empty tree. We delete such refs.
+ *   <li>Inspecting all draft-comment refs. Check for each draft if there exists a published comment
+ *       with the same UUID. These comments are called zombie drafts. If the program is run in
+ *       {@link #dryRun} mode, the zombie draft IDs will only be logged for tracking, otherwise they
+ *       will also be deleted.
+ * </uL>
  */
 public class DeleteZombieCommentsRefs {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final String EMPTY_TREE_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
-  private static final String DRAFT_REFS_PREFIX = "refs/draft-comments";
-
   // Number of refs deleted at once in a batch ref-update.
   // Log progress after deleting every CHUNK_SIZE refs
   private static final int CHUNK_SIZE = 3000;
@@ -56,19 +82,73 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final int cleanupPercentage;
-  private Repository allUsersRepo;
+
+  /**
+   * Run the logic in dry run mode only. That is, detected zombie drafts will be logged only but not
+   * deleted. Creators of this class can use {@link Factory#create(int, boolean)} to specify the dry
+   * run mode. If {@link Factory#create(int)} is used, the dry run mode will be set to its default:
+   * true.
+   */
+  private final boolean dryRun;
+
   private final Consumer<String> uiConsumer;
+  @Nullable private final DraftCommentNotes.Factory draftNotesFactory;
+  @Nullable private final ChangeNotes.Factory changeNotesFactory;
+  @Nullable private final CommentsUtil commentsUtil;
+  @Nullable private final ChangeUpdate.Factory changeUpdateFactory;
+  @Nullable private final IdentifiedUser.GenericFactory userFactory;
 
   public interface Factory {
     DeleteZombieCommentsRefs create(int cleanupPercentage);
+
+    DeleteZombieCommentsRefs create(int cleanupPercentage, boolean dryRun);
   }
 
-  @Inject
+  @AssistedInject
   public DeleteZombieCommentsRefs(
       AllUsersName allUsers,
       GitRepositoryManager repoManager,
+      ChangeNotes.Factory changeNotesFactory,
+      DraftCommentNotes.Factory draftNotesFactory,
+      CommentsUtil commentsUtil,
+      ChangeUpdate.Factory changeUpdateFactory,
+      IdentifiedUser.GenericFactory userFactory,
       @Assisted Integer cleanupPercentage) {
-    this(allUsers, repoManager, cleanupPercentage, (msg) -> {});
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        /* dryRun= */ true,
+        (msg) -> {},
+        changeNotesFactory,
+        draftNotesFactory,
+        commentsUtil,
+        changeUpdateFactory,
+        userFactory);
+  }
+
+  @AssistedInject
+  public DeleteZombieCommentsRefs(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      ChangeNotes.Factory changeNotesFactory,
+      DraftCommentNotes.Factory draftNotesFactory,
+      CommentsUtil commentsUtil,
+      ChangeUpdate.Factory changeUpdateFactory,
+      IdentifiedUser.GenericFactory userFactory,
+      @Assisted Integer cleanupPercentage,
+      @Assisted boolean dryRun) {
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        dryRun,
+        (msg) -> {},
+        changeNotesFactory,
+        draftNotesFactory,
+        commentsUtil,
+        changeUpdateFactory,
+        userFactory);
   }
 
   public DeleteZombieCommentsRefs(
@@ -76,43 +156,252 @@
       GitRepositoryManager repoManager,
       Integer cleanupPercentage,
       Consumer<String> uiConsumer) {
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        /* dryRun= */ false,
+        uiConsumer,
+        null,
+        null,
+        null,
+        null,
+        null);
+  }
+
+  private DeleteZombieCommentsRefs(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      Integer cleanupPercentage,
+      boolean dryRun,
+      Consumer<String> uiConsumer,
+      @Nullable ChangeNotes.Factory changeNotesFactory,
+      @Nullable DraftCommentNotes.Factory draftNotesFactory,
+      @Nullable CommentsUtil commentsUtil,
+      @Nullable ChangeUpdate.Factory changeUpdateFactory,
+      @Nullable IdentifiedUser.GenericFactory userFactory) {
     this.allUsers = allUsers;
     this.repoManager = repoManager;
     this.cleanupPercentage = (cleanupPercentage == null) ? 100 : cleanupPercentage;
+    this.dryRun = dryRun;
     this.uiConsumer = uiConsumer;
+    this.draftNotesFactory = draftNotesFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.commentsUtil = commentsUtil;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.userFactory = userFactory;
   }
 
   public void execute() throws IOException {
-    allUsersRepo = repoManager.openRepository(allUsers);
-
-    List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(DRAFT_REFS_PREFIX);
-    List<Ref> zombieRefs = filterZombieRefs(draftRefs);
-
-    logInfo(
-        String.format(
-            "Found a total of %d zombie draft refs in %s repo.",
-            zombieRefs.size(), allUsers.get()));
-
-    logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
-    zombieRefs =
-        zombieRefs.stream()
-            .filter(ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
-            .collect(toImmutableList());
-    logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
-
-    long zombieRefsCnt = zombieRefs.size();
-    long deletedRefsCnt = 0;
-    long startTime = System.currentTimeMillis();
-
-    for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
-      deleteBatchZombieRefs(refsBatch);
-      long elapsed = (System.currentTimeMillis() - startTime) / 1000;
-      deletedRefsCnt += refsBatch.size();
-      logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+    deleteDraftRefsThatPointToEmptyTree();
+    if (draftNotesFactory != null) {
+      deleteDraftCommentsThatAreAlsoPublished();
     }
   }
 
-  private void deleteBatchZombieRefs(List<Ref> refsBatch) throws IOException {
+  private void deleteDraftRefsThatPointToEmptyTree() throws IOException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
+      List<Ref> zombieRefs = filterZombieRefs(allUsersRepo, draftRefs);
+
+      logInfo(
+          String.format(
+              "Found a total of %d zombie draft refs in %s repo.",
+              zombieRefs.size(), allUsers.get()));
+
+      logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
+      zombieRefs =
+          zombieRefs.stream()
+              .filter(
+                  ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
+              .collect(toImmutableList());
+      logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
+
+      if (dryRun) {
+        logInfo(
+            "Running in dry run mode. Skipping deletion of draft refs pointing to an empty tree.");
+        return;
+      }
+
+      long zombieRefsCnt = zombieRefs.size();
+      long deletedRefsCnt = 0;
+      long startTime = System.currentTimeMillis();
+
+      for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
+        deleteBatchZombieRefs(allUsersRepo, refsBatch);
+        long elapsed = (System.currentTimeMillis() - startTime) / 1000;
+        deletedRefsCnt += refsBatch.size();
+        logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+      }
+    }
+  }
+
+  /**
+   * Iterates over all draft refs in All-Users repository. For each draft ref, checks if there
+   * exists a published comment with the same UUID and deletes the draft ref if that's the case
+   * because it is a zombie draft.
+   *
+   * @return the number of detected and deleted zombie draft comments.
+   */
+  @VisibleForTesting
+  public int deleteDraftCommentsThatAreAlsoPublished() throws IOException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      Timestamp earliestZombieTs = null;
+      Timestamp latestZombieTs = null;
+      int numZombies = 0;
+      List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
+      // Filter the number of draft refs to be processed according to the cleanup percentage.
+      draftRefs =
+          draftRefs.stream()
+              .filter(
+                  ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
+              .collect(toImmutableList());
+      Set<ChangeUserIDsPair> visitedSet = new HashSet<>();
+      ImmutableSet<Change.Id> changeIds =
+          draftRefs.stream()
+              .map(d -> Change.Id.fromAllUsersRef(d.getName()))
+              .collect(ImmutableSet.toImmutableSet());
+      Map<Change.Id, Project.NameKey> changeProjectMap = mapChangeIdsToProjects(changeIds);
+      for (Ref draftRef : draftRefs) {
+        try {
+          Change.Id changeId = Change.Id.fromAllUsersRef(draftRef.getName());
+          Account.Id accountId = Account.Id.fromRef(draftRef.getName());
+          ChangeUserIDsPair changeUserIDsPair = ChangeUserIDsPair.create(changeId, accountId);
+          if (!visitedSet.add(changeUserIDsPair)) {
+            continue;
+          }
+          if (!changeProjectMap.containsKey(changeId)) {
+            logger.atWarning().log(
+                "Could not find a project associated with change ID %s. Skipping draft ref %s.",
+                changeId, draftRef.getName());
+            continue;
+          }
+          DraftCommentNotes draftNotes = draftNotesFactory.create(changeId, accountId).load();
+          ChangeNotes notes =
+              changeNotesFactory.createChecked(changeProjectMap.get(changeId), changeId);
+          List<HumanComment> drafts = draftNotes.getComments().values().asList();
+          List<HumanComment> published = commentsUtil.publishedHumanCommentsByChange(notes);
+          Set<String> publishedIds = toUuid(published);
+          List<HumanComment> zombieDrafts =
+              drafts.stream()
+                  .filter(draft -> publishedIds.contains(draft.key.uuid))
+                  .collect(Collectors.toList());
+          for (HumanComment zombieDraft : zombieDrafts) {
+            earliestZombieTs = getEarlierTs(earliestZombieTs, zombieDraft.writtenOn);
+            latestZombieTs = getLaterTs(latestZombieTs, zombieDraft.writtenOn);
+          }
+          zombieDrafts.forEach(
+              zombieDraft ->
+                  logger.atWarning().log(
+                      "Draft comment with uuid '%s' of change %s, account %s, written on %s,"
+                          + " is a zombie draft that is already published.",
+                      zombieDraft.key.uuid, changeId, accountId, zombieDraft.writtenOn));
+          if (!zombieDrafts.isEmpty() && !dryRun) {
+            deleteZombieComments(accountId, notes, zombieDrafts);
+          }
+          numZombies += zombieDrafts.size();
+        } catch (Exception e) {
+          logger.atWarning().withCause(e).log("Failed to process ref %s", draftRef.getName());
+        }
+      }
+      if (numZombies > 0) {
+        logger.atWarning().log(
+            "Detected %d additional zombie drafts (earliest at %s, latest at %s).",
+            numZombies, earliestZombieTs, latestZombieTs);
+      }
+      return numZombies;
+    }
+  }
+
+  @AutoValue
+  abstract static class ChangeUserIDsPair {
+    abstract Change.Id changeId();
+
+    abstract Account.Id accountId();
+
+    static ChangeUserIDsPair create(Change.Id changeId, Account.Id accountId) {
+      return new AutoValue_DeleteZombieCommentsRefs_ChangeUserIDsPair(changeId, accountId);
+    }
+  }
+
+  /**
+   * Accepts a list of draft (zombie) comments for the same change and delete them by executing a
+   * {@link ChangeUpdate} on NoteDb. The update is executed using the user account who created this
+   * draft.
+   */
+  private void deleteZombieComments(
+      Account.Id accountId, ChangeNotes changeNotes, List<HumanComment> draftsToDelete)
+      throws IOException {
+    if (changeUpdateFactory == null || userFactory == null) {
+      return;
+    }
+    ChangeUpdate changeUpdate =
+        changeUpdateFactory.create(changeNotes, userFactory.create(accountId), TimeUtil.now());
+    draftsToDelete.forEach(c -> changeUpdate.deleteComment(c));
+    changeUpdate.commit();
+    logger.atInfo().log(
+        "Deleted zombie draft comments with UUIDs %s",
+        draftsToDelete.stream().map(d -> d.key.uuid).collect(Collectors.toList()));
+  }
+
+  /**
+   * Map each change ID to its associated project.
+   *
+   * <p>When doing a ref scan of draft refs
+   * "refs/draft-comments/$change_id_short/$change_id/$user_id" we don't know which project this
+   * draft comment is associated with. The project name is needed to load published comments for the
+   * change, hence we map each change ID to its project here by scanning through the change meta ref
+   * of the change ID in all projects.
+   */
+  private Map<Change.Id, Project.NameKey> mapChangeIdsToProjects(
+      ImmutableSet<Change.Id> changeIds) {
+    Map<Change.Id, Project.NameKey> result = new HashMap<>();
+    for (Project.NameKey project : repoManager.list()) {
+      try (Repository repo = repoManager.openRepository(project)) {
+        SetView<Change.Id> unmappedChangeIds = Sets.difference(changeIds, result.keySet());
+        for (Change.Id changeId : unmappedChangeIds) {
+          Ref ref = repo.getRefDatabase().exactRef(RefNames.changeMetaRef(changeId));
+          if (ref != null) {
+            result.put(changeId, project);
+          }
+        }
+      } catch (Exception e) {
+        logger.atWarning().withCause(e).log("Failed to open repository for project '%s'.", project);
+      }
+      if (changeIds.size() == result.size()) {
+        // We do not need to scan the remaining repositories
+        break;
+      }
+    }
+    if (result.size() != changeIds.size()) {
+      logger.atWarning().log(
+          "Failed to associate the following change Ids to a project: %s",
+          Sets.difference(changeIds, result.keySet()));
+    }
+    return result;
+  }
+
+  /** Map the list of input comments to their UUIDs. */
+  private Set<String> toUuid(List<HumanComment> in) {
+    return in.stream().map(c -> c.key.uuid).collect(Collectors.toSet());
+  }
+
+  private Timestamp getEarlierTs(@Nullable Timestamp t1, Timestamp t2) {
+    if (t1 == null) {
+      return t2;
+    }
+    return t1.before(t2) ? t1 : t2;
+  }
+
+  private Timestamp getLaterTs(@Nullable Timestamp t1, Timestamp t2) {
+    if (t1 == null) {
+      return t2;
+    }
+    return t1.after(t2) ? t1 : t2;
+  }
+
+  private void deleteBatchZombieRefs(Repository allUsersRepo, List<Ref> refsBatch)
+      throws IOException {
     List<ReceiveCommand> deleteCommands =
         refsBatch.stream()
             .map(
@@ -126,18 +415,19 @@
     RefUpdateUtil.executeChecked(bru, allUsersRepo);
   }
 
-  private List<Ref> filterZombieRefs(List<Ref> allDraftRefs) throws IOException {
+  private List<Ref> filterZombieRefs(Repository allUsersRepo, List<Ref> allDraftRefs)
+      throws IOException {
     List<Ref> zombieRefs = new ArrayList<>((int) (allDraftRefs.size() * 0.5));
     for (Ref ref : allDraftRefs) {
-      if (isZombieRef(ref)) {
+      if (isZombieRef(allUsersRepo, ref)) {
         zombieRefs.add(ref);
       }
     }
     return zombieRefs;
   }
 
-  private boolean isZombieRef(Ref ref) throws IOException {
-    return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getName().equals(EMPTY_TREE_ID);
+  private boolean isZombieRef(Repository allUsersRepo, Ref ref) throws IOException {
+    return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getId().equals(EMPTY_TREE_ID);
   }
 
   private void logInfo(String message) {
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 94e11c8..ad1f4c5 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -18,14 +18,18 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableListMultimap.flatteningToImmutableListMultimap;
 import static com.google.gerrit.server.logging.TraceContext.newTimer;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectChangeKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.RefUpdateUtil;
@@ -73,7 +77,7 @@
 public class NoteDbUpdateManager implements AutoCloseable {
   private static final int MAX_UPDATES_DEFAULT = 1000;
   /** Limits the number of patch sets that can be created. Can be overridden in the config. */
-  private static final int MAX_PATCH_SETS_DEFAULT = 1500;
+  private static final int MAX_PATCH_SETS_DEFAULT = 1000;
 
   public interface Factory {
     NoteDbUpdateManager create(Project.NameKey projectName);
@@ -353,6 +357,14 @@
     }
   }
 
+  public ImmutableListMultimap<ProjectChangeKey, AttentionSetUpdate> attentionSetUpdates() {
+    return this.changeUpdates.values().stream()
+        .collect(
+            flatteningToImmutableListMultimap(
+                cu -> ProjectChangeKey.create(cu.getProjectName(), cu.getId()),
+                cu -> cu.getAttentionSetUpdates().stream()));
+  }
+
   private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
       throws IOException {
     if (or == null || or.cmds.isEmpty()) {
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUtil.java b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
index 396e29b..64bf430 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUtil.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
@@ -20,8 +20,12 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
+import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.GitDateFormatter;
 import org.eclipse.jgit.util.GitDateFormatter.Format;
@@ -48,6 +52,45 @@
     return Optional.empty();
   }
 
+  /**
+   * Returns an AccountId for the given email address and the current serverId. Reverse lookup the
+   * AccountId using the ExternalIdCache if the account has a foreign serverId.
+   *
+   * @param ident the accountId@serverId identity
+   * @param serverId the Gerrit's serverId
+   * @param externalIdCache reference to the cache for looking up the external ids
+   * @return a defined accountId if the account was found, {@link Account#UNKNOWN_ACCOUNT_ID} if the
+   *     lookup via external-id did not return any account, or an empty value if the identity was
+   *     malformed.
+   * @throws ConfigInvalidException when the lookup of the external-id failed
+   */
+  public static Optional<Account.Id> parseIdent(
+      PersonIdent ident, String serverId, ExternalIdCache externalIdCache)
+      throws ConfigInvalidException {
+    String email = ident.getEmailAddress();
+    int at = email.indexOf('@');
+    if (at >= 0) {
+      Integer id = Ints.tryParse(email.substring(0, at));
+      String accountServerId = email.substring(at + 1);
+      if (id != null) {
+        if (accountServerId.equals(serverId)) {
+          return Optional.of(Account.id(id));
+        }
+
+        ExternalId.Key extIdKey = ExternalId.Key.create(ExternalId.SCHEME_IMPORTED, email, false);
+        try {
+          return externalIdCache
+              .byKey(extIdKey)
+              .map(ExternalId::accountId)
+              .or(() -> Optional.of(Account.UNKNOWN_ACCOUNT_ID));
+        } catch (IOException e) {
+          throw new ConfigInvalidException("Unable to lookup external id from cache", e);
+        }
+      }
+    }
+    return Optional.empty();
+  }
+
   public static String extractHostPartFromPersonIdent(PersonIdent ident) {
     String email = ident.getEmailAddress();
     int at = email.indexOf('@');
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index edf5bd3..7f067f5 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -69,6 +69,7 @@
 
   private List<RobotComment> put = new ArrayList<>();
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private RobotCommentUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -81,6 +82,7 @@
     super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
   }
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private RobotCommentUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
diff --git a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
index a815f57..96d3080 100644
--- a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
+++ b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
@@ -40,6 +40,8 @@
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(6);
   private static final FieldDescriptor SR_FORCED_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(7);
+  private static final FieldDescriptor SR_HIDDEN_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(8);
 
   @Override
   public SubmitRequirementResultProto toProto(SubmitRequirementResult r) {
@@ -53,6 +55,9 @@
     if (r.forced().isPresent()) {
       builder.setForced(r.forced().get());
     }
+    if (r.hidden().isPresent()) {
+      builder.setHidden(r.hidden().get());
+    }
     if (r.applicabilityExpressionResult().isPresent()) {
       builder.setApplicabilityExpressionResult(
           SubmitRequirementExpressionResultSerializer.serialize(
@@ -84,6 +89,9 @@
     if (proto.hasField(SR_FORCED_FIELD)) {
       builder.forced(Optional.of(proto.getForced()));
     }
+    if (proto.hasField(SR_HIDDEN_FIELD)) {
+      builder.hidden(Optional.of(proto.getHidden()));
+    }
     if (proto.hasField(SR_APPLICABILITY_EXPR_RESULT_FIELD)) {
       builder.applicabilityExpressionResult(
           Optional.of(
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index f29d1c1..1f4720d 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -169,7 +169,9 @@
       return Optional.empty();
     }
 
-    if (repoView.getRef(RefNames.refsCacheAutomerge(maybeMergeCommit.name())).isPresent()) {
+    String automergeRef = RefNames.refsCacheAutomerge(maybeMergeCommit.name());
+    logger.atFine().log("AutoMerge ref=%s, mergeCommit=%s", automergeRef, maybeMergeCommit.name());
+    if (repoView.getRef(automergeRef).isPresent()) {
       logger.atFine().log("AutoMerge alredy exists");
       return Optional.empty();
     }
@@ -178,7 +180,7 @@
         new ReceiveCommand(
             ObjectId.zeroId(),
             createAutoMergeCommit(repoView, rw, ins, maybeMergeCommit),
-            RefNames.refsCacheAutomerge(maybeMergeCommit.name())));
+            automergeRef));
   }
 
   /**
@@ -250,6 +252,7 @@
               merge.getParent(1),
               m.getMergeResults());
     }
+    logger.atFine().log("AutoMerge treeId=%s", treeId.name());
 
     rw.parseHeaders(merge);
     // For maximum stability, choose a single ident using the committer time of
@@ -268,16 +271,20 @@
       cb.addParentId(p);
     }
 
+    ObjectId commitId = ins.insert(cb);
+    logger.atFine().log("AutoMerge commitId=%s", commitId.name());
+    ins.flush();
+
     if (ins instanceof InMemoryInserter) {
       // When using an InMemoryInserter we need to read back the values from that inserter because
       // they are not available.
       try (ObjectReader tmpReader = ins.newReader();
           RevWalk tmpRw = new RevWalk(tmpReader)) {
-        return tmpRw.parseCommit(ins.insert(cb));
+        return tmpRw.parseCommit(commitId);
       }
     }
 
-    return rw.parseCommit(ins.insert(cb));
+    return rw.parseCommit(commitId);
   }
 
   private static class NonFlushingWrapper extends ObjectInserter.Filter {
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
index dcd3e85..56a01b9 100644
--- a/java/com/google/gerrit/server/patch/BaseCommitUtil.java
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
@@ -31,7 +30,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
@@ -141,37 +139,7 @@
     ObjectId autoMergeId =
         autoMerger.createAutoMergeCommit(new RepoView(repo, rw, ins), rw, ins, mergeCommit);
     ins.flush();
-    return updateRef(repo, rw, refName, autoMergeId, mergeCommit);
-  }
-
-  private static RevCommit updateRef(
-      Repository repo, RevWalk rw, String refName, ObjectId autoMergeId, RevCommit merge)
-      throws IOException {
-    RefUpdate ru = repo.updateRef(refName);
-    ru.setNewObjectId(autoMergeId);
-    ru.disableRefLog();
-    switch (ru.forceUpdate()) {
-      case FAST_FORWARD:
-      case FORCED:
-      case NEW:
-      case NO_CHANGE:
-        return rw.parseCommit(autoMergeId);
-      case LOCK_FAILURE:
-        throw new LockFailureException(
-            String.format("Failed to create auto-merge of %s", merge.name()), ru);
-      case IO_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      case RENAMED:
-      default:
-        throw new IOException(
-            String.format(
-                "Failed to create auto-merge of %s: Cannot write %s (%s)",
-                merge.name(), refName, ru.getResult()));
-    }
+    return rw.parseCommit(autoMergeId);
   }
 
   private ObjectInserter newInserter(Repository repo) {
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 02f125a..3baa3b1 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -175,10 +175,8 @@
       throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException {
 
-    try {
-      permissionBackend.user(currentUser).change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new NoSuchChangeException(changeId, e);
+    if (!permissionBackend.user(currentUser).change(notes).test(ChangePermission.READ)) {
+      throw new NoSuchChangeException(changeId);
     }
 
     if (!projectCache
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java b/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
index accd2bd..348e244 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
@@ -97,10 +97,8 @@
       throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceNotFoundException {
 
-    try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new NoSuchChangeException(changeId, e);
+    if (!permissionBackend.currentUser().change(notes).test(ChangePermission.READ)) {
+      throw new NoSuchChangeException(changeId);
     }
 
     if (!projectCache
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 2385a70..7562b49 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -294,7 +294,7 @@
     ProjectState projectState =
         projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
     PatchSet.Id maxPatchSetId = PatchSet.id(notes.getChangeId(), 1);
-    for (PatchSetApproval patchSetApproval : notes.getApprovals().values()) {
+    for (PatchSetApproval patchSetApproval : notes.getApprovals().onlyNonCopied().values()) {
       if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
         continue;
       }
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
index a464235..62d66c0 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.patch.gitdiff;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache.ModifiedFileProto;
@@ -84,6 +86,9 @@
   enum Serializer implements CacheSerializer<ModifiedFile> {
     INSTANCE;
 
+    private static final Converter<String, ChangeType> CHANGE_TYPE_CONVERTER =
+        Enums.stringConverter(ChangeType.class);
+
     private static final FieldDescriptor oldPathDescriptor =
         ModifiedFileProto.getDescriptor().findFieldByNumber(2);
 
@@ -97,7 +102,7 @@
 
     public ModifiedFileProto toProto(ModifiedFile modifiedFile) {
       ModifiedFileProto.Builder builder = ModifiedFileProto.newBuilder();
-      builder.setChangeType(modifiedFile.changeType().toString());
+      builder.setChangeType(CHANGE_TYPE_CONVERTER.reverse().convert(modifiedFile.changeType()));
       if (modifiedFile.oldPath().isPresent()) {
         builder.setOldPath(modifiedFile.oldPath().get());
       }
@@ -115,7 +120,7 @@
 
     public ModifiedFile fromProto(ModifiedFileProto modifiedFileProto) {
       ModifiedFile.Builder builder = ModifiedFile.builder();
-      builder.changeType(ChangeType.valueOf(modifiedFileProto.getChangeType()));
+      builder.changeType(CHANGE_TYPE_CONVERTER.convert(modifiedFileProto.getChangeType()));
 
       if (modifiedFileProto.hasField(oldPathDescriptor)) {
         builder.oldPath(Optional.of(modifiedFileProto.getOldPath()));
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index 22ec328..d0d024c 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -18,6 +18,8 @@
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Patch;
@@ -232,6 +234,15 @@
   public enum Serializer implements CacheSerializer<GitFileDiff> {
     INSTANCE;
 
+    private static final Converter<String, Patch.FileMode> FILE_MODE_CONVERTER =
+        Enums.stringConverter(Patch.FileMode.class);
+
+    private static final Converter<String, Patch.ChangeType> CHANGE_TYPE_CONVERTER =
+        Enums.stringConverter(Patch.ChangeType.class);
+
+    private static final Converter<String, Patch.PatchType> PATCH_TYPE_CONVERTER =
+        Enums.stringConverter(Patch.PatchType.class);
+
     private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
         GitFileDiffProto.getDescriptor().findFieldByNumber(3);
 
@@ -258,7 +269,7 @@
               .setFileHeader(gitFileDiff.fileHeader())
               .setOldId(idConverter.toByteString(gitFileDiff.oldId().toObjectId()))
               .setNewId(idConverter.toByteString(gitFileDiff.newId().toObjectId()))
-              .setChangeType(gitFileDiff.changeType().name());
+              .setChangeType(CHANGE_TYPE_CONVERTER.reverse().convert(gitFileDiff.changeType()));
       gitFileDiff
           .edits()
           .forEach(
@@ -276,13 +287,13 @@
         builder.setNewPath(gitFileDiff.newPath().get());
       }
       if (gitFileDiff.oldMode().isPresent()) {
-        builder.setOldMode(gitFileDiff.oldMode().get().name());
+        builder.setOldMode(FILE_MODE_CONVERTER.reverse().convert(gitFileDiff.oldMode().get()));
       }
       if (gitFileDiff.newMode().isPresent()) {
-        builder.setNewMode(gitFileDiff.newMode().get().name());
+        builder.setNewMode(FILE_MODE_CONVERTER.reverse().convert(gitFileDiff.newMode().get()));
       }
       if (gitFileDiff.patchType().isPresent()) {
-        builder.setPatchType(gitFileDiff.patchType().get().name());
+        builder.setPatchType(PATCH_TYPE_CONVERTER.reverse().convert(gitFileDiff.patchType().get()));
       }
       if (gitFileDiff.negative().isPresent()) {
         builder.setNegative(gitFileDiff.negative().get());
@@ -303,7 +314,7 @@
           .fileHeader(proto.getFileHeader())
           .oldId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getOldId())))
           .newId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getNewId())))
-          .changeType(ChangeType.valueOf(proto.getChangeType()));
+          .changeType(CHANGE_TYPE_CONVERTER.convert(proto.getChangeType()));
 
       if (proto.hasField(OLD_PATH_DESCRIPTOR)) {
         builder.oldPath(Optional.of(proto.getOldPath()));
@@ -312,13 +323,13 @@
         builder.newPath(Optional.of(proto.getNewPath()));
       }
       if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
-        builder.oldMode(Optional.of(Patch.FileMode.valueOf(proto.getOldMode())));
+        builder.oldMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getOldMode())));
       }
       if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
-        builder.newMode(Optional.of(Patch.FileMode.valueOf(proto.getNewMode())));
+        builder.newMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getNewMode())));
       }
       if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
-        builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
+        builder.patchType(Optional.of(PATCH_TYPE_CONVERTER.convert(proto.getPatchType())));
       }
       if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
         builder.negative(Optional.of(proto.getNegative()));
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
index 2d80614..2e18e93 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -104,6 +106,12 @@
   public enum Serializer implements CacheSerializer<GitFileDiffCacheKey> {
     INSTANCE;
 
+    private static final Converter<String, DiffAlgorithm> DIFF_ALGORITHM_CONVERTER =
+        Enums.stringConverter(DiffAlgorithm.class);
+
+    private static final Converter<String, Whitespace> WHITESPACE_CONVERTER =
+        Enums.stringConverter(Whitespace.class);
+
     @Override
     public byte[] serialize(GitFileDiffCacheKey key) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -114,8 +122,8 @@
               .setBTree(idConverter.toByteString(key.newTree()))
               .setFilePath(key.newFilePath())
               .setRenameScore(key.renameScore())
-              .setDiffAlgorithm(key.diffAlgorithm().name())
-              .setWhitepsace(key.whitespace().name())
+              .setDiffAlgorithm(DIFF_ALGORITHM_CONVERTER.reverse().convert(key.diffAlgorithm()))
+              .setWhitepsace(WHITESPACE_CONVERTER.reverse().convert(key.whitespace()))
               .setUseTimeout(key.useTimeout())
               .build());
     }
@@ -130,8 +138,8 @@
           .newTree(idConverter.fromByteString(proto.getBTree()))
           .newFilePath(proto.getFilePath())
           .renameScore(proto.getRenameScore())
-          .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
-          .whitespace(Whitespace.valueOf(proto.getWhitepsace()))
+          .diffAlgorithm(DIFF_ALGORITHM_CONVERTER.convert(proto.getDiffAlgorithm()))
+          .whitespace(WHITESPACE_CONVERTER.convert(proto.getWhitepsace()))
           .useTimeout(proto.getUseTimeout())
           .build();
     }
diff --git a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
index 506d292..12a7841 100644
--- a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -130,11 +130,10 @@
                   notesResult -> {
                     if (!notesResult.error().isPresent()) {
                       return changeDataFactory.create(notesResult.notes());
-                    } else {
-                      logger.atWarning().withCause(notesResult.error().get()).log(
-                          "Unable to load ChangeNotes for %s", notesResult.id());
-                      return null;
                     }
+                    logger.atWarning().withCause(notesResult.error().get()).log(
+                        "Unable to load ChangeNotes for %s", notesResult.id());
+                    return null;
                   })
               .filter(Objects::nonNull);
     } catch (IOException e) {
diff --git a/java/com/google/gerrit/server/project/LabelConfigValidator.java b/java/com/google/gerrit/server/project/LabelConfigValidator.java
new file mode 100644
index 0000000..3e5ff6b
--- /dev/null
+++ b/java/com/google/gerrit/server/project/LabelConfigValidator.java
@@ -0,0 +1,357 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Validates modifications to label configurations in the {@code project.config} file that is stored
+ * in {@code refs/meta/config}.
+ *
+ * <p>Rejects setting/changing deprecated fields that are no longer supported (fields {@code
+ * copyAnyScore}, {@code copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange},
+ * {@code copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
+ * copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
+ * copyValue}).
+ *
+ * <p>Updates that unset the deprecated fields or that don't touch them are allowed.
+ */
+@Singleton
+public class LabelConfigValidator implements CommitValidationListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @VisibleForTesting public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
+
+  @VisibleForTesting public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
+
+  @VisibleForTesting public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
+
+  @VisibleForTesting public static final String KEY_COPY_VALUE = "copyValue";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
+      "copyAllScoresOnMergeFirstParentUpdate";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
+      "copyAllScoresIfListOfFilesDidNotChange";
+
+  // Map of deprecated boolean flags to the predicates that should be used in the copy condition
+  // instead.
+  private static final ImmutableMap<String, String> DEPRECATED_FLAGS =
+      ImmutableMap.<String, String>builder()
+          .put(KEY_COPY_ANY_SCORE, "is:ANY")
+          .put(KEY_COPY_MIN_SCORE, "is:MIN")
+          .put(KEY_COPY_MAX_SCORE, "is:MAX")
+          .put(KEY_COPY_ALL_SCORES_IF_NO_CHANGE, "changekind:" + ChangeKind.NO_CHANGE.name())
+          .put(
+              KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+              "changekind:" + ChangeKind.NO_CODE_CHANGE.name())
+          .put(
+              KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+              "changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name())
+          .put(
+              KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+              "changekind:" + ChangeKind.TRIVIAL_REBASE.name())
+          .put(KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE, "has:unchanged-files")
+          .build();
+
+  private final DiffOperations diffOperations;
+
+  @Inject
+  public LabelConfigValidator(DiffOperations diffOperations) {
+    this.diffOperations = diffOperations;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    try {
+      if (!receiveEvent.refName.equals(RefNames.REFS_CONFIG)
+          || !isFileChanged(receiveEvent, ProjectConfig.PROJECT_CONFIG)) {
+        // The project.config file in refs/meta/config was not modified, hence we do not need to do
+        // any validation and can return early.
+        return ImmutableList.of();
+      }
+
+      ImmutableList.Builder<CommitValidationMessage> validationMessageBuilder =
+          ImmutableList.builder();
+
+      // Load the new config
+      Config newConfig;
+      try {
+        newConfig = loadNewConfig(receiveEvent);
+      } catch (ConfigInvalidException e) {
+        // The current config is invalid, hence we cannot inspect the delta.
+        // Rejecting invalid configs is not the responsibility of this validator, hence ignore this
+        // exception here.
+        logger.atWarning().log(
+            "cannot inspect the project config, because parsing %s from revision %s"
+                + " in project %s failed: %s",
+            ProjectConfig.PROJECT_CONFIG,
+            receiveEvent.commit.name(),
+            receiveEvent.getProjectNameKey(),
+            e.getMessage());
+        return ImmutableList.of();
+      }
+
+      // Load the old config
+      Optional<Config> oldConfig = loadOldConfig(receiveEvent);
+
+      for (String labelName : newConfig.getSubsections(ProjectConfig.LABEL)) {
+        for (String deprecatedFlag : DEPRECATED_FLAGS.keySet()) {
+          if (flagChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName, deprecatedFlag)) {
+            validationMessageBuilder.add(
+                new CommitValidationMessage(
+                    String.format(
+                        "Parameter '%s.%s.%s' is deprecated and cannot be set,"
+                            + " use '%s' in '%s.%s.%s' instead.",
+                        ProjectConfig.LABEL,
+                        labelName,
+                        deprecatedFlag,
+                        DEPRECATED_FLAGS.get(deprecatedFlag),
+                        ProjectConfig.LABEL,
+                        labelName,
+                        ProjectConfig.KEY_COPY_CONDITION),
+                    ValidationMessage.Type.ERROR));
+          }
+        }
+
+        if (copyValuesChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName)) {
+          validationMessageBuilder.add(
+              new CommitValidationMessage(
+                  String.format(
+                      "Parameter '%s.%s.%s' is deprecated and cannot be set,"
+                          + " use 'is:<copy-value>' in '%s.%s.%s' instead.",
+                      ProjectConfig.LABEL,
+                      labelName,
+                      KEY_COPY_VALUE,
+                      ProjectConfig.LABEL,
+                      labelName,
+                      ProjectConfig.KEY_COPY_CONDITION),
+                  ValidationMessage.Type.ERROR));
+        }
+
+        // Ban modifying label functions to any blocking function value
+        if (flagChangedOrNewlySet(
+            newConfig, oldConfig.orElse(null), labelName, ProjectConfig.KEY_FUNCTION)) {
+          String fnName =
+              newConfig.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
+          Optional<LabelFunction> labelFn = LabelFunction.parse(fnName);
+          if (labelFn.isPresent() && !isLabelFunctionAllowed(labelFn.get())) {
+            validationMessageBuilder.add(
+                new CommitValidationMessage(
+                    String.format(
+                        "Value '%s' of '%s.%s.%s' is not allowed and cannot be set."
+                            + " Label functions can only be set to {%s, %s, %s}."
+                            + " Use submit requirements instead of label functions.",
+                        fnName,
+                        ProjectConfig.LABEL,
+                        labelName,
+                        ProjectConfig.KEY_FUNCTION,
+                        LabelFunction.NO_BLOCK,
+                        LabelFunction.NO_OP,
+                        LabelFunction.PATCH_SET_LOCK),
+                    ValidationMessage.Type.ERROR));
+          }
+        }
+
+        // Ban deletions of label functions as well since the default is MaxWithBlock
+        if (flagDeleted(newConfig, oldConfig.orElse(null), labelName, ProjectConfig.KEY_FUNCTION)) {
+          validationMessageBuilder.add(
+              new CommitValidationMessage(
+                  String.format(
+                      "Cannot delete '%s.%s.%s'."
+                          + " Label functions can only be set to {%s, %s, %s}."
+                          + " Use submit requirements instead of label functions.",
+                      ProjectConfig.LABEL,
+                      labelName,
+                      ProjectConfig.KEY_FUNCTION,
+                      LabelFunction.NO_BLOCK,
+                      LabelFunction.NO_OP,
+                      LabelFunction.PATCH_SET_LOCK),
+                  ValidationMessage.Type.ERROR));
+        }
+      }
+
+      ImmutableList<CommitValidationMessage> validationMessages = validationMessageBuilder.build();
+      if (!validationMessages.isEmpty()) {
+        throw new CommitValidationException(
+            String.format(
+                "invalid %s file in revision %s",
+                ProjectConfig.PROJECT_CONFIG, receiveEvent.commit.getName()),
+            validationMessages);
+      }
+      return ImmutableList.of();
+    } catch (IOException | DiffNotAvailableException e) {
+      String errorMessage =
+          String.format(
+              "failed to validate file %s for revision %s in ref %s of project %s",
+              ProjectConfig.PROJECT_CONFIG,
+              receiveEvent.commit.getName(),
+              RefNames.REFS_CONFIG,
+              receiveEvent.getProjectNameKey());
+      logger.atSevere().withCause(e).log("%s", errorMessage);
+      throw new CommitValidationException(errorMessage, e);
+    }
+  }
+
+  /**
+   * Whether the given file was changed in the given revision.
+   *
+   * @param receiveEvent the receive event
+   * @param fileName the name of the file
+   */
+  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+      throws DiffNotAvailableException {
+    Map<String, FileDiffOutput> fileDiffOutputs;
+    if (receiveEvent.commit.getParentCount() > 0) {
+      // normal commit with one parent: use listModifiedFilesAgainstParent with parentNum = 1 to
+      // compare against the only parent (using parentNum = 0 to compare against the default parent
+      // would also work)
+      // merge commit with 2 or more parents: must use listModifiedFilesAgainstParent with parentNum
+      // = 1 to compare against the first parent (using parentNum = 0 would compare against the
+      // auto-merge)
+      fileDiffOutputs =
+          diffOperations.listModifiedFilesAgainstParent(
+              receiveEvent.getProjectNameKey(), receiveEvent.commit, 1, DiffOptions.DEFAULTS);
+    } else {
+      // initial commit: must use listModifiedFilesAgainstParent with parentNum = 0
+      fileDiffOutputs =
+          diffOperations.listModifiedFilesAgainstParent(
+              receiveEvent.getProjectNameKey(),
+              receiveEvent.commit,
+              /* parentNum=*/ 0,
+              DiffOptions.DEFAULTS);
+    }
+    return fileDiffOutputs.keySet().contains(fileName);
+  }
+
+  private Config loadNewConfig(CommitReceivedEvent receiveEvent)
+      throws IOException, ConfigInvalidException {
+    ProjectLevelConfig.Bare bareConfig = new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    bareConfig.load(receiveEvent.project.getNameKey(), receiveEvent.revWalk, receiveEvent.commit);
+    return bareConfig.getConfig();
+  }
+
+  private Optional<Config> loadOldConfig(CommitReceivedEvent receiveEvent) throws IOException {
+    if (receiveEvent.commit.getParentCount() == 0) {
+      // initial commit, an old config doesn't exist
+      return Optional.empty();
+    }
+
+    try {
+      ProjectLevelConfig.Bare bareConfig =
+          new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+      bareConfig.load(
+          receiveEvent.project.getNameKey(),
+          receiveEvent.revWalk,
+          receiveEvent.commit.getParent(0));
+      return Optional.of(bareConfig.getConfig());
+    } catch (ConfigInvalidException e) {
+      // the old config is not parseable, treat this the same way as if an old config didn't exist
+      // so that all parameters in the new config are validated
+      logger.atWarning().log(
+          "cannot inspect the old project config, because parsing %s from parent revision %s"
+              + " in project %s failed: %s",
+          ProjectConfig.PROJECT_CONFIG,
+          receiveEvent.commit.name(),
+          receiveEvent.getProjectNameKey(),
+          e.getMessage());
+      return Optional.empty();
+    }
+  }
+
+  private static boolean flagChangedOrNewlySet(
+      Config newConfig, @Nullable Config oldConfig, String labelName, String key) {
+    if (oldConfig == null) {
+      return newConfig.getNames(ProjectConfig.LABEL, labelName).contains(key);
+    }
+
+    // Use getString rather than getBoolean so that we do not have to deal with values that cannot
+    // be parsed as a boolean.
+    String oldValue = oldConfig.getString(ProjectConfig.LABEL, labelName, key);
+    String newValue = newConfig.getString(ProjectConfig.LABEL, labelName, key);
+    return newValue != null && !newValue.equals(oldValue);
+  }
+
+  private static boolean flagDeleted(
+      Config newConfig, @Nullable Config oldConfig, String labelName, String key) {
+    if (oldConfig == null) {
+      return false;
+    }
+    String oldValue = oldConfig.getString(ProjectConfig.LABEL, labelName, key);
+    String newValue = newConfig.getString(ProjectConfig.LABEL, labelName, key);
+    return oldValue != null && newValue == null;
+  }
+
+  private static boolean copyValuesChangedOrNewlySet(
+      Config newConfig, @Nullable Config oldConfig, String labelName) {
+    if (oldConfig == null) {
+      return newConfig.getNames(ProjectConfig.LABEL, labelName).contains(KEY_COPY_VALUE);
+    }
+
+    // Ignore the order in which the copy values are defined in the new and old config, since the
+    // order doesn't matter for this parameter.
+    ImmutableSet<String> oldValues =
+        ImmutableSet.copyOf(
+            oldConfig.getStringList(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE));
+    ImmutableSet<String> newValues =
+        ImmutableSet.copyOf(
+            newConfig.getStringList(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE));
+    return !newValues.isEmpty() && !Sets.difference(newValues, oldValues).isEmpty();
+  }
+
+  private static boolean isLabelFunctionAllowed(LabelFunction labelFunction) {
+    return labelFunction.equals(LabelFunction.NO_BLOCK)
+        || labelFunction.equals(LabelFunction.NO_OP)
+        || labelFunction.equals(LabelFunction.PATCH_SET_LOCK);
+  }
+}
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 71ea12b..235eb34 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -34,17 +34,6 @@
     label.branches = labelType.getRefPatterns() != null ? labelType.getRefPatterns() : null;
     label.canOverride = toBoolean(labelType.isCanOverride());
     label.copyCondition = labelType.getCopyCondition().orElse(null);
-    label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
-    label.copyMinScore = toBoolean(labelType.isCopyMinScore());
-    label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
-    label.copyAllScoresIfListOfFilesDidNotChange =
-        toBoolean(labelType.isCopyAllScoresIfListOfFilesDidNotChange());
-    label.copyAllScoresIfNoChange = toBoolean(labelType.isCopyAllScoresIfNoChange());
-    label.copyAllScoresIfNoCodeChange = toBoolean(labelType.isCopyAllScoresIfNoCodeChange());
-    label.copyAllScoresOnTrivialRebase = toBoolean(labelType.isCopyAllScoresOnTrivialRebase());
-    label.copyAllScoresOnMergeFirstParentUpdate =
-        toBoolean(labelType.isCopyAllScoresOnMergeFirstParentUpdate());
-    label.copyValues = labelType.getCopyValues().isEmpty() ? null : labelType.getCopyValues();
     label.allowPostSubmit = toBoolean(labelType.isAllowPostSubmit());
     label.ignoreSelfApproval = toBoolean(labelType.isIgnoreSelfApproval());
     return label;
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index d816d84..47b0a53 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -109,20 +109,9 @@
   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";
   public static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
   public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
-  public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
   public static final String KEY_COPY_CONDITION = "copyCondition";
-  public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
-  public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
-      "copyAllScoresIfListOfFilesDidNotChange";
-  public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
-      "copyAllScoresOnMergeFirstParentUpdate";
-  public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
-  public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
-  public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
-  public static final String KEY_COPY_VALUE = "copyValue";
   public static final String KEY_VALUE = "value";
   public static final String KEY_CAN_OVERRIDE = "canOverride";
   public static final String KEY_BRANCH = "branch";
@@ -144,6 +133,9 @@
   public static final String KEY_MATCH = "match";
   private static final String KEY_HTML = "html";
   public static final String KEY_LINK = "link";
+  public static final String KEY_PREFIX = "prefix";
+  public static final String KEY_SUFFIX = "suffix";
+  public static final String KEY_TEXT = "text";
   public static final String KEY_ENABLED = "enabled";
 
   public static final String PROJECT_CONFIG = "project.config";
@@ -339,6 +331,10 @@
     }
 
     String link = cfg.getString(COMMENTLINK, name, KEY_LINK);
+    String linkPrefix = cfg.getString(COMMENTLINK, name, KEY_PREFIX);
+    String linkSuffix = cfg.getString(COMMENTLINK, name, KEY_SUFFIX);
+    String linkText = cfg.getString(COMMENTLINK, name, KEY_TEXT);
+
     String html = cfg.getString(COMMENTLINK, name, KEY_HTML);
     boolean hasHtml = !Strings.isNullOrEmpty(html);
 
@@ -363,6 +359,9 @@
     return StoredCommentLinkInfo.builder(name)
         .setMatch(match)
         .setLink(link)
+        .setPrefix(linkPrefix)
+        .setSuffix(linkSuffix)
+        .setText(linkText)
         .setHtml(html)
         .setEnabled(enabled)
         .setOverrideOnly(false)
@@ -550,6 +549,7 @@
     return submitRequirementSections;
   }
 
+  /** Adds or replaces the given {@link SubmitRequirement} in this config. */
   public void upsertSubmitRequirement(SubmitRequirement requirement) {
     submitRequirementSections.put(requirement.name(), requirement);
   }
@@ -1018,7 +1018,7 @@
         continue;
       }
 
-      // The expressions are validated in SubmitRequirementExpressionsValidator.
+      // The expressions are validated in SubmitRequirementConfigValidator.
 
       SubmitRequirement submitRequirement =
           SubmitRequirement.builder()
@@ -1135,62 +1135,6 @@
           rc.getBoolean(LABEL, name, KEY_ALLOW_POST_SUBMIT, LabelType.DEF_ALLOW_POST_SUBMIT));
       label.setIgnoreSelfApproval(
           rc.getBoolean(LABEL, name, KEY_IGNORE_SELF_APPROVAL, LabelType.DEF_IGNORE_SELF_APPROVAL));
-      label.setCopyAnyScore(
-          rc.getBoolean(LABEL, name, KEY_COPY_ANY_SCORE, LabelType.DEF_COPY_ANY_SCORE));
-      label.setCopyMinScore(
-          rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE, LabelType.DEF_COPY_MIN_SCORE));
-      label.setCopyMaxScore(
-          rc.getBoolean(LABEL, name, KEY_COPY_MAX_SCORE, LabelType.DEF_COPY_MAX_SCORE));
-      label.setCopyAllScoresIfListOfFilesDidNotChange(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
-              LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE));
-      label.setCopyAllScoresOnMergeFirstParentUpdate(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
-              LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE));
-      label.setCopyAllScoresOnTrivialRebase(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
-              LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE));
-      label.setCopyAllScoresIfNoCodeChange(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
-              LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE));
-      label.setCopyAllScoresIfNoChange(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
-              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)) {
-            error(
-                String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name));
-          }
-        } catch (IllegalArgumentException notValue) {
-          error(
-              String.format(
-                  "Invalid %s \"%s\" for label \"%s\": %s",
-                  KEY_COPY_VALUE, value, name, notValue.getMessage()));
-        }
-      }
-      label.setCopyValues(copyValues);
       label.setCanOverride(
           rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE, LabelType.DEF_CAN_OVERRIDE));
       List<String> refPatterns = getStringListOrNull(rc, LABEL, name, KEY_BRANCH);
@@ -1443,6 +1387,15 @@
         if (!Strings.isNullOrEmpty(cm.getLink())) {
           rc.setString(COMMENTLINK, cm.getName(), KEY_LINK, cm.getLink());
         }
+        if (!Strings.isNullOrEmpty(cm.getPrefix())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_PREFIX, cm.getPrefix());
+        }
+        if (!Strings.isNullOrEmpty(cm.getSuffix())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_SUFFIX, cm.getSuffix());
+        }
+        if (!Strings.isNullOrEmpty(cm.getText())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_TEXT, cm.getText());
+        }
         if (cm.getEnabled() != null && !cm.getEnabled()) {
           rc.setBoolean(COMMENTLINK, cm.getName(), KEY_ENABLED, cm.getEnabled());
         }
@@ -1654,67 +1607,6 @@
           label.isIgnoreSelfApproval(),
           LabelType.DEF_IGNORE_SELF_APPROVAL);
       setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ANY_SCORE,
-          label.isCopyAnyScore(),
-          LabelType.DEF_COPY_ANY_SCORE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_MIN_SCORE,
-          label.isCopyMinScore(),
-          LabelType.DEF_COPY_MIN_SCORE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_MAX_SCORE,
-          label.isCopyMaxScore(),
-          LabelType.DEF_COPY_MAX_SCORE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
-          label.isCopyAllScoresOnTrivialRebase(),
-          LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
-          label.isCopyAllScoresIfNoCodeChange(),
-          LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
-          label.isCopyAllScoresIfNoChange(),
-          LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
-          label.isCopyAllScoresIfListOfFilesDidNotChange(),
-          LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
-          label.isCopyAllScoresOnMergeFirstParentUpdate(),
-          LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
-      rc.setStringList(
-          LABEL,
-          name,
-          KEY_COPY_VALUE,
-          label.getCopyValues().stream().map(LabelValue::formatValue).collect(toList()));
-      setBooleanConfigKey(
           rc, LABEL, name, KEY_CAN_OVERRIDE, label.isCanOverride(), LabelType.DEF_CAN_OVERRIDE);
       List<String> values = new ArrayList<>(label.getValues().size());
       for (LabelValue value : label.getValues()) {
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
index 797756b..e86ad41 100644
--- a/java/com/google/gerrit/server/project/RefUtil.java
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -18,11 +18,9 @@
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.common.collect.Iterables;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import java.io.IOException;
 import java.util.Collections;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -37,37 +35,28 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 public class RefUtil {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private RefUtil() {}
 
-  public static ObjectId parseBaseRevision(
-      Repository repo, Project.NameKey projectName, String baseRevision)
-      throws InvalidRevisionException {
+  public static ObjectId parseBaseRevision(Repository repo, String baseRevision)
+      throws UnprocessableEntityException, IOException {
     try {
       ObjectId revid = repo.resolve(baseRevision);
       if (revid == null) {
-        throw new InvalidRevisionException(baseRevision);
+        throw new UnprocessableEntityException(
+            String.format("base revision \"%s\" not found", baseRevision));
       }
       return revid;
-    } catch (IOException err) {
-      logger.atSevere().withCause(err).log(
-          "Cannot resolve \"%s\" in project \"%s\"", baseRevision, projectName.get());
-      throw new InvalidRevisionException(baseRevision);
-    } catch (RevisionSyntaxException err) {
-      throw new InvalidRevisionException(baseRevision, err);
+    } catch (RevisionSyntaxException e) {
+      throw new UnprocessableEntityException(
+          String.format("base revision \"%s\" is invalid", baseRevision), e);
     }
   }
 
-  public static RevWalk verifyConnected(Repository repo, ObjectId revid)
-      throws InvalidRevisionException {
+  public static RevWalk verifyConnected(Repository repo, ObjectId baseRevision)
+      throws BadRequestException, UnprocessableEntityException, IOException {
     try {
       ObjectWalk rw = new ObjectWalk(repo);
-      try {
-        rw.markStart(rw.parseCommit(revid));
-      } catch (IncorrectObjectTypeException err) {
-        throw new InvalidRevisionException(revid.name(), err);
-      }
+      rw.markStart(rw.parseCommit(baseRevision));
       RefDatabase refDb = repo.getRefDatabase();
       Iterable<Ref> refs =
           Iterables.concat(
@@ -85,12 +74,12 @@
       }
       rw.checkConnectivity();
       return rw;
-    } catch (IncorrectObjectTypeException | MissingObjectException err) {
-      throw new InvalidRevisionException(revid.name(), err);
-    } catch (IOException err) {
-      logger.atSevere().withCause(err).log(
-          "Repository \"%s\" may be corrupt; suggest running git fsck", repo.getDirectory());
-      throw new InvalidRevisionException(revid.name());
+    } catch (IncorrectObjectTypeException e) {
+      throw new BadRequestException(
+          String.format("base revision \"%s\" is not a commit", baseRevision.name()), e);
+    } catch (MissingObjectException e) {
+      throw new UnprocessableEntityException(
+          String.format("base revision \"%s\" not found", baseRevision.name()), e);
     }
   }
 
@@ -119,19 +108,4 @@
     }
     return result;
   }
-
-  /** Error indicating the revision is invalid as supplied. */
-  public static class InvalidRevisionException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    public static final String MESSAGE = "Invalid Revision";
-
-    InvalidRevisionException(@Nullable String invalidRevision) {
-      super(MESSAGE + ": " + invalidRevision);
-    }
-
-    InvalidRevisionException(@Nullable String invalidRevision, Throwable why) {
-      super(MESSAGE + ": " + invalidRevision, why);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
new file mode 100644
index 0000000..6366a14
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * Validates the expressions of submit requirements in {@code project.config}.
+ *
+ * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
+ * ProjectConfig#loadSubmitRequirementSections(Config)}.
+ *
+ * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
+ * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
+ * {@link ProjectConfig} is cached in the project cache).
+ */
+public class SubmitRequirementConfigValidator implements CommitValidationListener {
+  private final DiffOperations diffOperations;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+  @Inject
+  SubmitRequirementConfigValidator(
+      DiffOperations diffOperations,
+      ProjectConfig.Factory projectConfigFactory,
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+    this.diffOperations = diffOperations;
+    this.projectConfigFactory = projectConfigFactory;
+    this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
+      throws CommitValidationException {
+    try {
+      if (!event.refName.equals(RefNames.REFS_CONFIG)
+          || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
+        // the project.config file in refs/meta/config was not modified, hence we do not need to
+        // validate the submit requirements in it
+        return ImmutableList.of();
+      }
+
+      ProjectConfig projectConfig = getProjectConfig(event);
+      ImmutableList.Builder<String> validationMsgsBuilder = ImmutableList.builder();
+      for (SubmitRequirement submitRequirement :
+          projectConfig.getSubmitRequirementSections().values()) {
+        validationMsgsBuilder.addAll(
+            submitRequirementExpressionsValidator.validateExpressions(submitRequirement));
+      }
+      ImmutableList<String> validationMsgs = validationMsgsBuilder.build();
+      if (!validationMsgs.isEmpty()) {
+        throw new CommitValidationException(
+            String.format(
+                "invalid submit requirement expressions in %s (revision = %s)",
+                ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision().name()),
+            new ImmutableList.Builder<CommitValidationMessage>()
+                .add(
+                    new CommitValidationMessage(
+                        "Invalid project configuration", ValidationMessage.Type.ERROR))
+                .addAll(
+                    validationMsgs.stream()
+                        .map(m -> toCommitValidationMessage(m))
+                        .collect(Collectors.toList()))
+                .build());
+      }
+      return ImmutableList.of();
+    } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
+      throw new CommitValidationException(
+          String.format(
+              "failed to validate submit requirement expressions in %s for revision %s in ref %s"
+                  + " of project %s",
+              ProjectConfig.PROJECT_CONFIG,
+              event.commit.getName(),
+              RefNames.REFS_CONFIG,
+              event.project.getNameKey()),
+          e);
+    }
+  }
+
+  private static CommitValidationMessage toCommitValidationMessage(String message) {
+    return new CommitValidationMessage(message, ValidationMessage.Type.ERROR);
+  }
+
+  /**
+   * Whether the given file was changed in the given revision.
+   *
+   * @param receiveEvent the receive event
+   * @param fileName the name of the file
+   */
+  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+      throws DiffNotAvailableException {
+    return diffOperations
+        .listModifiedFilesAgainstParent(
+            receiveEvent.project.getNameKey(),
+            receiveEvent.commit,
+            /* parentNum=*/ 0,
+            DiffOptions.DEFAULTS)
+        .keySet().stream()
+        .anyMatch(fileName::equals);
+  }
+
+  private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
+      throws IOException, ConfigInvalidException {
+    ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
+    projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
+    return projectConfig;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
index 8717581..f2e4ff8 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
@@ -15,144 +15,59 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.git.validators.CommitValidationException;
-import com.google.gerrit.server.git.validators.CommitValidationListener;
-import com.google.gerrit.server.git.validators.CommitValidationMessage;
-import com.google.gerrit.server.git.validators.ValidationMessage;
-import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.DiffOptions;
 import com.google.inject.Inject;
-import java.io.IOException;
+import com.google.inject.Singleton;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
-/**
- * Validates the expressions of submit requirements in {@code project.config}.
- *
- * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
- * ProjectConfig#loadSubmitRequirementSections(Config)}.
- *
- * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
- * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
- * {@link ProjectConfig} is cached in the project cache).
- */
-public class SubmitRequirementExpressionsValidator implements CommitValidationListener {
-  private final DiffOperations diffOperations;
-  private final ProjectConfig.Factory projectConfigFactory;
+@Singleton
+public class SubmitRequirementExpressionsValidator {
   private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
 
   @Inject
-  SubmitRequirementExpressionsValidator(
-      DiffOperations diffOperations,
-      ProjectConfig.Factory projectConfigFactory,
-      SubmitRequirementsEvaluator submitRequirementsEvaluator) {
-    this.diffOperations = diffOperations;
-    this.projectConfigFactory = projectConfigFactory;
+  SubmitRequirementExpressionsValidator(SubmitRequirementsEvaluator submitRequirementsEvaluator) {
     this.submitRequirementsEvaluator = submitRequirementsEvaluator;
   }
 
-  @Override
-  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
-      throws CommitValidationException {
-    try {
-      if (!event.refName.equals(RefNames.REFS_CONFIG)
-          || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
-        // the project.config file in refs/meta/config was not modified, hence we do not need to
-        // validate the submit requirements in it
-        return ImmutableList.of();
-      }
-
-      ProjectConfig projectConfig = getProjectConfig(event);
-      ImmutableList<CommitValidationMessage> validationMessages =
-          validateSubmitRequirementExpressions(
-              projectConfig.getSubmitRequirementSections().values());
-      if (!validationMessages.isEmpty()) {
-        throw new CommitValidationException(
-            String.format(
-                "invalid submit requirement expressions in %s (revision = %s)",
-                ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision()),
-            validationMessages);
-      }
-      return ImmutableList.of();
-    } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
-      throw new CommitValidationException(
-          String.format(
-              "failed to validate submit requirement expressions in %s for revision %s in ref %s"
-                  + " of project %s",
-              ProjectConfig.PROJECT_CONFIG,
-              event.commit.getName(),
-              RefNames.REFS_CONFIG,
-              event.project.getNameKey()),
-          e);
-    }
-  }
-
   /**
-   * Whether the given file was changed in the given revision.
+   * Validates the query expressions on the input {@code submitRequirement}.
    *
-   * @param receiveEvent the receive event
-   * @param fileName the name of the file
+   * @return list of string containing the error messages resulting from the validation. The list is
+   *     empty if the "submit requirement" is valid.
    */
-  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
-      throws DiffNotAvailableException {
-    return diffOperations
-        .listModifiedFilesAgainstParent(
-            receiveEvent.project.getNameKey(),
-            receiveEvent.commit,
-            /* parentNum=*/ 0,
-            DiffOptions.DEFAULTS)
-        .keySet().stream()
-        .anyMatch(fileName::equals);
-  }
-
-  private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
-      throws IOException, ConfigInvalidException {
-    ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
-    projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
-    return projectConfig;
-  }
-
-  private ImmutableList<CommitValidationMessage> validateSubmitRequirementExpressions(
-      Collection<SubmitRequirement> submitRequirements) {
-    List<CommitValidationMessage> validationMessages = new ArrayList<>();
-    for (SubmitRequirement submitRequirement : submitRequirements) {
-      validateSubmitRequirementExpression(
-          validationMessages,
-          submitRequirement,
-          submitRequirement.submittabilityExpression(),
-          ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
-      submitRequirement
-          .applicabilityExpression()
-          .ifPresent(
-              expression ->
-                  validateSubmitRequirementExpression(
-                      validationMessages,
-                      submitRequirement,
-                      expression,
-                      ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
-      submitRequirement
-          .overrideExpression()
-          .ifPresent(
-              expression ->
-                  validateSubmitRequirementExpression(
-                      validationMessages,
-                      submitRequirement,
-                      expression,
-                      ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
-    }
+  public ImmutableList<String> validateExpressions(SubmitRequirement submitRequirement) {
+    List<String> validationMessages = new ArrayList<>();
+    validateSubmitRequirementExpression(
+        validationMessages,
+        submitRequirement,
+        submitRequirement.submittabilityExpression(),
+        ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+    submitRequirement
+        .applicabilityExpression()
+        .ifPresent(
+            expression ->
+                validateSubmitRequirementExpression(
+                    validationMessages,
+                    submitRequirement,
+                    expression,
+                    ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
+    submitRequirement
+        .overrideExpression()
+        .ifPresent(
+            expression ->
+                validateSubmitRequirementExpression(
+                    validationMessages,
+                    submitRequirement,
+                    expression,
+                    ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
     return ImmutableList.copyOf(validationMessages);
   }
 
   private void validateSubmitRequirementExpression(
-      List<CommitValidationMessage> validationMessages,
+      List<String> validationMessages,
       SubmitRequirement submitRequirement,
       SubmitRequirementExpression expression,
       String configKey) {
@@ -160,23 +75,19 @@
       submitRequirementsEvaluator.validateExpression(expression);
     } catch (QueryParseException e) {
       if (validationMessages.isEmpty()) {
-        validationMessages.add(
-            new CommitValidationMessage(
-                "Invalid project configuration", ValidationMessage.Type.ERROR));
+        validationMessages.add("Invalid project configuration");
       }
       validationMessages.add(
-          new CommitValidationMessage(
-              String.format(
-                  "  %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
-                      + " invalid: %s",
-                  ProjectConfig.PROJECT_CONFIG,
-                  expression.expressionString(),
-                  submitRequirement.name(),
-                  ProjectConfig.SUBMIT_REQUIREMENT,
-                  submitRequirement.name(),
-                  configKey,
-                  e.getMessage()),
-              ValidationMessage.Type.ERROR));
+          String.format(
+              "  %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                  + " invalid: %s",
+              ProjectConfig.PROJECT_CONFIG,
+              expression.expressionString(),
+              submitRequirement.name(),
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              submitRequirement.name(),
+              configKey,
+              e.getMessage()));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementJson.java b/java/com/google/gerrit/server/project/SubmitRequirementJson.java
new file mode 100644
index 0000000..5593ff4
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementJson.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.inject.Singleton;
+
+/** Converts a {@link SubmitRequirement} to a {@link SubmitRequirementInfo}. */
+@Singleton
+public class SubmitRequirementJson {
+  public static SubmitRequirementInfo format(SubmitRequirement sr) {
+    SubmitRequirementInfo info = new SubmitRequirementInfo();
+    info.name = sr.name();
+    info.description = sr.description().orElse(null);
+    if (sr.applicabilityExpression().isPresent()) {
+      info.applicabilityExpression = sr.applicabilityExpression().get().expressionString();
+    }
+    if (sr.overrideExpression().isPresent()) {
+      info.overrideExpression = sr.overrideExpression().get().expressionString();
+    }
+    info.submittabilityExpression = sr.submittabilityExpression().expressionString();
+    info.allowOverrideInChildProjects = sr.allowOverrideInChildProjects();
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementResource.java b/java/com/google/gerrit/server/project/SubmitRequirementResource.java
new file mode 100644
index 0000000..d075cd7
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementResource.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class SubmitRequirementResource implements RestResource {
+  public static final TypeLiteral<RestView<SubmitRequirementResource>> SUBMIT_REQUIREMENT_KIND =
+      new TypeLiteral<>() {};
+
+  private final ProjectResource project;
+  private final SubmitRequirement submitRequirement;
+
+  public SubmitRequirementResource(ProjectResource project, SubmitRequirement submitRequirement) {
+    this.project = project;
+    this.submitRequirement = submitRequirement;
+  }
+
+  public ProjectResource getProject() {
+    return project;
+  }
+
+  public SubmitRequirement getSubmitRequirement() {
+    return submitRequirement;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index 1361122..39ba8b4 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -103,6 +104,7 @@
     return result.build();
   }
 
+  @VisibleForTesting
   static List<SubmitRequirementResult> createResult(
       SubmitRecord record, List<LabelType> labelTypes, ObjectId psCommitId, boolean isForced) {
     List<SubmitRequirementResult> results;
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
index df836e0..b3278c9 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -28,11 +28,8 @@
    * from the project config of the project containing the change as well as parent projects.
    *
    * @param cd change data corresponding to a specific gerrit change
-   * @param includeLegacy if set to true, evaluate legacy {@link
-   *     com.google.gerrit.entities.SubmitRecord}s and convert them to submit requirements.
    */
-  ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
-      ChangeData cd, boolean includeLegacy);
+  ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(ChangeData cd);
 
   /** Evaluate a single {@link SubmitRequirement} using change data. */
   SubmitRequirementResult evaluateRequirement(SubmitRequirement sr, ChangeData cd);
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index c57fe27..d749fd3 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -46,7 +46,6 @@
   private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
   private final ProjectCache projectCache;
   private final PluginSetContext<SubmitRequirement> globalSubmitRequirements;
-  private final SubmitRequirementsUtil submitRequirementsUtil;
   private final OneOffRequestContext requestContext;
 
   public static Module module() {
@@ -65,12 +64,10 @@
       Provider<SubmitRequirementChangeQueryBuilder> queryBuilder,
       ProjectCache projectCache,
       PluginSetContext<SubmitRequirement> globalSubmitRequirements,
-      SubmitRequirementsUtil submitRequirementsUtil,
       OneOffRequestContext requestContext) {
     this.queryBuilder = queryBuilder;
     this.projectCache = projectCache;
     this.globalSubmitRequirements = globalSubmitRequirements;
-    this.submitRequirementsUtil = submitRequirementsUtil;
     this.requestContext = requestContext;
   }
 
@@ -82,16 +79,8 @@
 
   @Override
   public ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
-      ChangeData cd, boolean includeLegacy) {
-    ImmutableMap<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements =
-        getRequirements(cd);
-    if (!includeLegacy) {
-      return projectConfigRequirements;
-    }
-    Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
-        SubmitRequirementsAdapter.getLegacyRequirements(cd);
-    return submitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
-        projectConfigRequirements, legacyReqs, cd);
+      ChangeData cd) {
+    return getRequirements(cd);
   }
 
   @Override
@@ -105,8 +94,14 @@
           sr.applicabilityExpression().isPresent()
               ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
               : Optional.empty();
-      Optional<SubmitRequirementExpressionResult> submittabilityResult = Optional.empty();
-      Optional<SubmitRequirementExpressionResult> overrideResult = Optional.empty();
+      Optional<SubmitRequirementExpressionResult> submittabilityResult =
+          Optional.of(
+              SubmitRequirementExpressionResult.notEvaluated(sr.submittabilityExpression()));
+      Optional<SubmitRequirementExpressionResult> overrideResult =
+          sr.overrideExpression().isPresent()
+              ? Optional.of(
+                  SubmitRequirementExpressionResult.notEvaluated(sr.overrideExpression().get()))
+              : Optional.empty();
       if (!sr.applicabilityExpression().isPresent()
           || SubmitRequirementResult.assertPass(applicabilityResult)) {
         submittabilityResult = Optional.of(evaluateExpression(sr.submittabilityExpression(), cd));
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
index c234c8c..e54e5af 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -15,9 +15,10 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
-import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
@@ -29,6 +30,7 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Set;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 /**
@@ -38,12 +40,18 @@
 @Singleton
 public class SubmitRequirementsUtil {
 
+  /**
+   * Submit requirement name can only contain alphanumeric characters or hyphen. Name cannot start
+   * with a hyphen or number.
+   */
+  private static final Pattern SUBMIT_REQ_NAME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9\\-]*");
+
   @Singleton
   static class Metrics {
-    final Counter2<String, String> submitRequirementsMatchingWithLegacy;
-    final Counter2<String, String> submitRequirementsMismatchingWithLegacy;
-    final Counter2<String, String> legacyNotInSrs;
-    final Counter2<String, String> srsNotInLegacy;
+    final Counter1<String> submitRequirementsMatchingWithLegacy;
+    final Counter1<String> submitRequirementsMismatchingWithLegacy;
+    final Counter1<String> legacyNotInSrs;
+    final Counter1<String> srsNotInLegacy;
 
     @Inject
     Metrics(MetricMaker metricMaker) {
@@ -57,7 +65,6 @@
                           + "w.r.t. change submittability.")
                   .setRate()
                   .setUnit("count"),
-              Field.ofString("project", Metadata.Builder::projectName).build(),
               Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
                   .description("Submit requirement name")
                   .build());
@@ -71,7 +78,6 @@
                           + "w.r.t. change submittability.")
                   .setRate()
                   .setUnit("count"),
-              Field.ofString("project", Metadata.Builder::projectName).build(),
               Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
                   .description("Submit requirement name")
                   .build());
@@ -83,7 +89,6 @@
                           + "but not a project config requirement with the same name for a change.")
                   .setRate()
                   .setUnit("count"),
-              Field.ofString("project", Metadata.Builder::projectName).build(),
               Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
                   .description("Submit requirement name")
                   .build());
@@ -95,7 +100,6 @@
                           + "result but not a legacy requirement with the same name for a change.")
                   .setRate()
                   .setUnit("count"),
-              Field.ofString("project", Metadata.Builder::projectName).build(),
               Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
                   .description("Submit requirement name")
                   .build());
@@ -134,6 +138,10 @@
     result.putAll(projectConfigRequirements);
     Map<String, SubmitRequirementResult> requirementsByName =
         projectConfigRequirements.entrySet().stream()
+            // filter out legacy entries as a safety guard for duplicate entries
+            // (projectConfigRequirements should not contain legacy entries)
+            // TODO(ghareeb): remove the filter statement
+            .filter(entry -> !entry.getValue().isLegacy())
             .collect(Collectors.toMap(sr -> sr.getKey().name().toLowerCase(), sr -> sr.getValue()));
     for (Map.Entry<SubmitRequirement, SubmitRequirementResult> legacy :
         legacyRequirements.entrySet()) {
@@ -145,7 +153,7 @@
       if (projectConfigResult == null) {
         result.put(legacy.getKey(), legacy.getValue());
         if (shouldReportMetric(cd)) {
-          metrics.legacyNotInSrs.increment(cd.project().get(), srName);
+          metrics.legacyNotInSrs.increment(srName);
         }
         continue;
       }
@@ -154,14 +162,14 @@
         // 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);
+          metrics.submitRequirementsMatchingWithLegacy.increment(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);
+        metrics.submitRequirementsMismatchingWithLegacy.increment(srName);
       }
       result.put(legacy.getKey(), legacy.getValue());
     }
@@ -172,13 +180,27 @@
             .collect(Collectors.toSet());
     for (String projectConfigSrName : requirementsByName.keySet()) {
       if (!legacyNames.contains(projectConfigSrName) && shouldReportMetric(cd)) {
-        metrics.srsNotInLegacy.increment(cd.project().get(), projectConfigSrName);
+        metrics.srsNotInLegacy.increment(projectConfigSrName);
       }
     }
 
     return ImmutableMap.copyOf(result);
   }
 
+  /** Validates the name of submit requirements. */
+  public static void validateName(@Nullable String name) throws IllegalArgumentException {
+    if (name == null || name.isEmpty()) {
+      throw new IllegalArgumentException("Empty submit requirement name");
+    }
+    if (!SUBMIT_REQ_NAME_PATTERN.matcher(name).matches()) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Illegal submit requirement name \"%s\". Name can only consist of "
+                  + "alphanumeric characters and '-'. Name cannot start with '-' or number.",
+              name));
+    }
+  }
+
   private static boolean shouldReportMetric(ChangeData cd) {
     // We only care about recording differences in old and new requirements for open changes
     // that did not have their data retrieved from the (potentially stale) change index.
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 8f94089..433abe6 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -18,8 +18,8 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
@@ -31,7 +31,8 @@
 /** Utility class to create predicates for account index queries. */
 public class AccountPredicates {
   public static boolean hasActive(Predicate<AccountState> p) {
-    return QueryBuilder.find(p, AccountPredicate.class, AccountField.ACTIVE.getName()) != null;
+    return QueryBuilder.find(p, AccountPredicate.class, AccountField.ACTIVE_FIELD_SPEC.getName())
+        != null;
   }
 
   public static Predicate<AccountState> andActive(Predicate<AccountState> p) {
@@ -49,11 +50,11 @@
     if (canSeeSecondaryEmails) {
       preds.add(equalsNameIncludingSecondaryEmails(query));
     } else {
-      if (schema.hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL)) {
+      if (schema.hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC)) {
         preds.add(equalsName(query));
       } else {
         preds.add(AccountPredicates.fullName(query));
-        if (schema.hasField(AccountField.PREFERRED_EMAIL)) {
+        if (schema.hasField(AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC)) {
           preds.add(AccountPredicates.preferredEmail(query));
         }
       }
@@ -66,63 +67,67 @@
 
   public static Predicate<AccountState> id(Schema<AccountState> schema, Account.Id accountId) {
     return new AccountPredicate(
-        schema.useLegacyNumericFields() ? AccountField.ID : AccountField.ID_STR,
+        schema.hasField(AccountField.ID_FIELD_SPEC)
+            ? AccountField.ID_FIELD_SPEC
+            : AccountField.ID_STR_FIELD_SPEC,
         AccountQueryBuilder.FIELD_ACCOUNT,
         accountId.toString());
   }
 
   public static Predicate<AccountState> emailIncludingSecondaryEmails(String email) {
     return new AccountPredicate(
-        AccountField.EMAIL, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
+        AccountField.EMAIL_SPEC, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
   }
 
   public static Predicate<AccountState> preferredEmail(String email) {
     return new AccountPredicate(
-        AccountField.PREFERRED_EMAIL,
+        AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC,
         AccountQueryBuilder.FIELD_PREFERRED_EMAIL,
         email.toLowerCase());
   }
 
   public static Predicate<AccountState> preferredEmailExact(String email) {
     return new AccountPredicate(
-        AccountField.PREFERRED_EMAIL_EXACT, AccountQueryBuilder.FIELD_PREFERRED_EMAIL_EXACT, email);
+        AccountField.PREFERRED_EMAIL_EXACT_SPEC,
+        AccountQueryBuilder.FIELD_PREFERRED_EMAIL_EXACT,
+        email);
   }
 
   public static Predicate<AccountState> equalsNameIncludingSecondaryEmails(String name) {
     return new AccountPredicate(
-        AccountField.NAME_PART, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
+        AccountField.NAME_PART_SPEC, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
   }
 
   public static Predicate<AccountState> equalsName(String name) {
     return new AccountPredicate(
-        AccountField.NAME_PART_NO_SECONDARY_EMAIL,
+        AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC,
         AccountQueryBuilder.FIELD_NAME,
         name.toLowerCase());
   }
 
   public static Predicate<AccountState> externalIdIncludingSecondaryEmails(String externalId) {
-    return new AccountPredicate(AccountField.EXTERNAL_ID, externalId);
+    return new AccountPredicate(AccountField.EXTERNAL_ID_FIELD_SPEC, externalId);
   }
 
   public static Predicate<AccountState> fullName(String fullName) {
-    return new AccountPredicate(AccountField.FULL_NAME, fullName);
+    return new AccountPredicate(AccountField.FULL_NAME_SPEC, fullName);
   }
 
   public static Predicate<AccountState> isActive() {
-    return new AccountPredicate(AccountField.ACTIVE, "1");
+    return new AccountPredicate(AccountField.ACTIVE_FIELD_SPEC, "1");
   }
 
   public static Predicate<AccountState> isNotActive() {
-    return new AccountPredicate(AccountField.ACTIVE, "0");
+    return new AccountPredicate(AccountField.ACTIVE_FIELD_SPEC, "0");
   }
 
   public static Predicate<AccountState> username(String username) {
     return new AccountPredicate(
-        AccountField.USERNAME, AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
+        AccountField.USERNAME_SPEC, AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
   }
 
   public static Predicate<AccountState> watchedProject(Project.NameKey project) {
-    return new AccountPredicate(AccountField.WATCHED_PROJECT, project.get());
+    return new AccountPredicate(AccountField.WATCHED_PROJECT_SPEC, project.get());
   }
 
   public static Predicate<AccountState> cansee(
@@ -132,11 +137,11 @@
 
   /** Predicate that is mapped to a field in the account index. */
   static class AccountPredicate extends IndexPredicate<AccountState> {
-    AccountPredicate(FieldDef<AccountState, ?> def, String value) {
+    AccountPredicate(SchemaField<AccountState, ?> def, String value) {
       super(def, value);
     }
 
-    AccountPredicate(FieldDef<AccountState, ?> def, String name, String value) {
+    AccountPredicate(SchemaField<AccountState, ?> def, String name, String value) {
       super(def, name, value);
     }
   }
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 6ab51c5..ed950c8 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.LimitPredicate;
@@ -121,15 +120,12 @@
       throw error(String.format("change %s not found", change));
     }
 
-    try {
-      args.permissionBackend
-          .user(args.getUser())
-          .change(changeNotes.get())
-          .check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw error(String.format("change %s not found", change), e);
+    if (!args.permissionBackend
+        .user(args.getUser())
+        .change(changeNotes.get())
+        .test(ChangePermission.READ)) {
+      throw error(String.format("change %s not found", change));
     }
-
     return AccountPredicates.cansee(args, changeNotes.get());
   }
 
@@ -140,7 +136,7 @@
       return AccountPredicates.emailIncludingSecondaryEmails(email);
     }
 
-    if (args.schema().hasField(AccountField.PREFERRED_EMAIL)) {
+    if (args.schema().hasField(AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC)) {
       return AccountPredicates.preferredEmail(email);
     }
 
@@ -174,7 +170,7 @@
       return AccountPredicates.equalsNameIncludingSecondaryEmails(name);
     }
 
-    if (args.schema().hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL)) {
+    if (args.schema().hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC)) {
       return AccountPredicates.equalsName(name);
     }
 
@@ -219,12 +215,7 @@
   }
 
   private boolean canSeeSecondaryEmails() throws PermissionBackendException, QueryParseException {
-    try {
-      args.permissionBackend.user(args.getUser()).check(GlobalPermission.MODIFY_ACCOUNT);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return args.permissionBackend.user(args.getUser()).test(GlobalPermission.MODIFY_ACCOUNT);
   }
 
   private boolean checkedCanSeeSecondaryEmails() {
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index a0a9f71..98a12d5 100644
--- a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -25,9 +25,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.InternalQuery;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -151,16 +151,16 @@
     return query(AccountPredicates.watchedProject(project));
   }
 
-  private boolean hasField(FieldDef<AccountState, ?> field) {
+  private boolean hasField(SchemaField<AccountState, ?> field) {
     Schema<AccountState> s = schema();
     return (s != null && s.hasField(field));
   }
 
   private boolean hasPreferredEmail() {
-    return hasField(AccountField.PREFERRED_EMAIL);
+    return hasField(AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC);
   }
 
   private boolean hasPreferredEmailExact() {
-    return hasField(AccountField.PREFERRED_EMAIL_EXACT);
+    return hasField(AccountField.PREFERRED_EMAIL_EXACT_SPEC);
   }
 }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
index 6e433c5..901c51f 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalContext.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -17,8 +17,9 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
-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;
@@ -27,15 +28,21 @@
 /** Entity representing all required information to match predicates for copying approvals. */
 @AutoValue
 public abstract class ApprovalContext {
-  /** Approval on the source patch set to be copied. */
-  public abstract PatchSetApproval patchSetApproval();
+  public abstract PatchSet.Id sourcePatchSetId();
+
+  public abstract Account.Id approverId();
+
+  public abstract LabelType labelType();
+
+  /** Value of the approval on the source patch set to be copied. */
+  public abstract short approvalValue();
 
   /**
    * Target change and patch set for the approval. This must be used instead of getting the PatchSet
    * from {@link #changeNotes()} because it is possible we are now creating the patch-set, so it
    * doesn't exist in changeNotes yet.
    */
-  public abstract PatchSet target();
+  public abstract PatchSet targetPatchSet();
 
   /** {@link ChangeNotes} of the change in question. */
   public abstract ChangeNotes changeNotes();
@@ -54,17 +61,20 @@
 
   public static ApprovalContext create(
       ChangeNotes changeNotes,
-      PatchSetApproval psa,
-      PatchSet patchSet,
+      PatchSet.Id sourcePatchSetId,
+      Account.Id approverId,
+      LabelType labelType,
+      short approvalValue,
+      PatchSet targetPatchSet,
       ChangeKind changeKind,
       boolean isMerge,
       RevWalk revWalk,
       Config repoConfig) {
     checkState(
-        psa.patchSetId().changeId().equals(patchSet.id().changeId()),
+        sourcePatchSetId.changeId().equals(targetPatchSet.id().changeId()),
         "approval and target must be the same change. got: %s, %s",
-        psa.patchSetId(),
-        patchSet.id());
+        sourcePatchSetId,
+        targetPatchSet.id());
     // TODO(ekempin): Use checkState to verify that psa.patchSetId().get() + 1 == id.get() so that
     // it's ensured that approvals are only copied to the next consecutive patch set. To add back
     // this verification https://gerrit-review.googlesource.com/c/gerrit/+/312633 can be reverted.
@@ -72,6 +82,15 @@
     // 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, isMerge, revWalk, repoConfig);
+        sourcePatchSetId,
+        approverId,
+        labelType,
+        approvalValue,
+        targetPatchSet,
+        changeNotes,
+        changeKind,
+        isMerge,
+        revWalk,
+        repoConfig);
   }
 }
diff --git a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
index f519b16..daf437b 100644
--- a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
@@ -32,39 +32,7 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
-    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));
+    return ctx.changeKind().matches(changeKind, ctx.isMerge());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java b/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java
index 1f36f8a..3021534 100644
--- a/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java
@@ -27,7 +27,7 @@
 
   @Override
   public boolean match(ApprovalContext approvalContext) {
-    return votingValue == approvalContext.patchSetApproval().value();
+    return votingValue == approvalContext.approvalValue();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
index 2a72c49..958011c 100644
--- a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
@@ -52,9 +52,8 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
-    PatchSet targetPatchSet = ctx.target();
-    PatchSet sourcePatchSet =
-        ctx.changeNotes().getPatchSets().get(ctx.patchSetApproval().patchSetId());
+    PatchSet targetPatchSet = ctx.targetPatchSet();
+    PatchSet sourcePatchSet = ctx.changeNotes().getPatchSets().get(ctx.sourcePatchSetId());
 
     Integer parentNum =
         isInitialCommit(ctx.changeNotes().getProjectName(), targetPatchSet.commitId()) ? 0 : 1;
@@ -87,8 +86,7 @@
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't copy"
-              + " votes on labels even if list of files is the same and "
-              + "copyAllIfListOfFilesDidNotChange",
+              + " votes on labels even if list of files is the same",
           ex);
     }
   }
diff --git a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
index 326620d..98471da 100644
--- a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
+++ b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
@@ -14,16 +14,12 @@
 
 package com.google.gerrit.server.query.approval;
 
-import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.LabelType;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import java.util.Objects;
-import java.util.Optional;
 
 /** Predicate that matches patch set approvals we want to copy based on the value. */
 public class MagicValuePredicate extends ApprovalPredicate {
@@ -48,36 +44,20 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
-    Optional<LabelType> lt =
-        getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId());
     short pValue;
     switch (value) {
       case ANY:
         return true;
       case MIN:
-        if (!lt.isPresent()) {
-          return false;
-        }
-        pValue = lt.get().getMaxNegative();
+        pValue = ctx.labelType().getMaxNegative();
         break;
       case MAX:
-        if (!lt.isPresent()) {
-          return false;
-        }
-        pValue = lt.get().getMaxPositive();
+        pValue = ctx.labelType().getMaxPositive();
         break;
       default:
         throw new IllegalArgumentException("unrecognized label value: " + value);
     }
-    return pValue == ctx.patchSetApproval().value();
-  }
-
-  private Optional<LabelType> getLabelType(Project.NameKey project, LabelId labelId) {
-    return projectCache
-        .get(project)
-        .orElseThrow(() -> new IllegalStateException(project + " absent"))
-        .getLabelTypes()
-        .byLabel(labelId);
+    return pValue == ctx.approvalValue();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/approval/UserInPredicate.java b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
index 2aef703..fda2014 100644
--- a/java/com/google/gerrit/server/query/approval/UserInPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
@@ -53,10 +53,10 @@
   public boolean match(ApprovalContext ctx) {
     Account.Id accountId;
     if (field == Field.UPLOADER) {
-      PatchSet patchSet = ctx.target();
+      PatchSet patchSet = ctx.targetPatchSet();
       accountId = patchSet.uploader();
     } else if (field == Field.APPROVER) {
-      accountId = ctx.patchSetApproval().accountId();
+      accountId = ctx.approverId();
     } else {
       throw new IllegalStateException("unknown field in group membership check: " + field);
     }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index ad422bc..8ab9786 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -74,9 +74,10 @@
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.RobotCommentNotes;
 import com.google.gerrit.server.patch.DiffSummary;
@@ -262,15 +263,65 @@
    * <p>Attempting to lazy load data will fail with NPEs. Callers may consider manually setting
    * fields that can be set.
    *
+   * @param project project name
    * @param id change ID
+   * @param currentPatchSetId current patchset number
+   * @param commitId commit SHA1 of the current patchset
    * @return instance for testing.
    */
   public static ChangeData createForTest(
       Project.NameKey project, Change.Id id, int currentPatchSetId, ObjectId commitId) {
+    return createForTest(project, id, currentPatchSetId, commitId, null, null, null);
+  }
+
+  /**
+   * Create an instance for testing only.
+   *
+   * <p>Attempting to lazy load data will fail with NPEs. Callers may consider manually setting
+   * fields that can be set.
+   *
+   * @param project project name
+   * @param id change ID
+   * @param currentPatchSetId current patchset number
+   * @param commitId commit SHA1 of the current patchset
+   * @param serverId Gerrit server id
+   * @param virtualIdAlgo algorithm for virtualising the Change number
+   * @param changeNotes notes associated with the Change
+   * @return instance for testing.
+   */
+  public static ChangeData createForTest(
+      Project.NameKey project,
+      Change.Id id,
+      int currentPatchSetId,
+      ObjectId commitId,
+      @Nullable String serverId,
+      @Nullable ChangeNumberVirtualIdAlgorithm virtualIdAlgo,
+      @Nullable ChangeNotes changeNotes) {
     ChangeData cd =
         new ChangeData(
-            null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, project, id, null, null);
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            serverId,
+            virtualIdAlgo,
+            project,
+            id,
+            null,
+            changeNotes);
     cd.currentPatchSet =
         PatchSet.builder()
             .id(PatchSet.id(id, currentPatchSetId))
@@ -289,7 +340,7 @@
   private final ChangeNotes.Factory notesFactory;
   private final CommentsUtil commentsUtil;
   private final GitRepositoryManager repoManager;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final MergeabilityCache mergeabilityCache;
   private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
@@ -361,6 +412,9 @@
   private Optional<Instant> mergedOn;
   private ImmutableSetMultimap<NameKey, RefState> refStates;
   private ImmutableList<byte[]> refStatePatterns;
+  private String gerritServerId;
+  private String changeServerId;
+  private ChangeNumberVirtualIdAlgorithm virtualIdFunc;
 
   @Inject
   private ChangeData(
@@ -371,7 +425,7 @@
       ChangeNotes.Factory notesFactory,
       CommentsUtil commentsUtil,
       GitRepositoryManager repoManager,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       MergeabilityCache mergeabilityCache,
       PatchListCache patchListCache,
       PatchSetUtil psUtil,
@@ -381,6 +435,8 @@
       SubmitRequirementsEvaluator submitRequirementsEvaluator,
       SubmitRequirementsUtil submitRequirementsUtil,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
+      @GerritServerId String gerritServerId,
+      ChangeNumberVirtualIdAlgorithm virtualIdFunc,
       @Assisted Project.NameKey project,
       @Assisted Change.Id id,
       @Assisted @Nullable Change change,
@@ -408,6 +464,10 @@
 
     this.change = change;
     this.notes = notes;
+
+    this.changeServerId = notes == null ? null : notes.getServerId();
+    this.gerritServerId = gerritServerId;
+    this.virtualIdFunc = virtualIdFunc;
   }
 
   /**
@@ -528,6 +588,14 @@
     return legacyId;
   }
 
+  public Change.Id getVirtualId() {
+    if (virtualIdFunc == null || changeServerId == null || changeServerId.equals(gerritServerId)) {
+      return legacyId;
+    }
+
+    return Change.id(virtualIdFunc.apply(changeServerId, legacyId.get()));
+  }
+
   public Project.NameKey project() {
     return project;
   }
@@ -558,6 +626,7 @@
       throw new StorageException("Unable to load change " + legacyId, e);
     }
     change = notes.getChange();
+    changeServerId = notes.getServerId();
     setPatchSets(null);
     return change;
   }
@@ -942,7 +1011,7 @@
   }
 
   /**
-   * Similar to {@link #submitRequirements}, except that it also converts submit records resulting
+   * Similar to {@link #submitRequirements()}, except that it also converts submit records resulting
    * from the evaluation of legacy submit rules to submit requirements.
    */
   public Map<SubmitRequirement, SubmitRequirementResult> submitRequirementsIncludingLegacy() {
@@ -969,8 +1038,7 @@
       Change c = change();
       if (c == null || !c.isClosed()) {
         // Open changes: Evaluate submit requirements online.
-        submitRequirements =
-            submitRequirementsEvaluator.evaluateAllRequirements(this, /* includeLegacy= */ false);
+        submitRequirements = submitRequirementsEvaluator.evaluateAllRequirements(this);
         return submitRequirements;
       }
       // Closed changes: Load submit requirement results from NoteDb.
@@ -1194,7 +1262,7 @@
     this.stars = ImmutableListMultimap.copyOf(stars);
   }
 
-  public ImmutableMap<Account.Id, StarRef> starRefs() {
+  private ImmutableMap<Account.Id, StarRef> starRefs() {
     if (starRefs == null) {
       if (!lazyload()) {
         return ImmutableMap.of();
@@ -1275,7 +1343,6 @@
                     edit.getRowKey(), edit.getColumnKey().changeId(), edit.getColumnKey()),
                 edit.getValue()));
       }
-      starRefs().values().forEach(r -> result.put(allUsersName, RefState.of(r.ref())));
 
       // TODO: instantiating the notes is too much. We don't want to parse NoteDb, we just want the
       // refs.
@@ -1283,14 +1350,6 @@
       notes().getRobotComments(); // Force loading robot comments.
       RobotCommentNotes robotNotes = notes().getRobotCommentNotes();
       result.put(project, RefState.create(robotNotes.getRefName(), robotNotes.getMetaId()));
-      draftRefs()
-          .entrySet()
-          .forEach(
-              r ->
-                  result.put(
-                      allUsersName,
-                      RefState.create(
-                          RefNames.refsDraftComments(getId(), r.getKey()), r.getValue())));
 
       refStates = result.build();
     }
diff --git a/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
new file mode 100644
index 0000000..726a376
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+
+/**
+ * Dictionary-based encoding algorithm for combining a serverId/legacyChangeNum into a virtual
+ * numeric id
+ *
+ * <p>TODO: To be reverted on master and stable-3.8
+ */
+@Singleton
+public class ChangeNumberBitmapMaskAlgorithm implements ChangeNumberVirtualIdAlgorithm {
+  /*
+   * Bit-wise masks for representing the Change's VirtualId as combination of ServerId + ChangeNum:
+   */
+  private static final int CHANGE_NUM_BIT_LEN = 28; // Allows up to 268M changes
+  private static final int LEGACY_ID_BIT_MASK = (1 << CHANGE_NUM_BIT_LEN) - 1;
+  private static final int SERVER_ID_BIT_LEN =
+      Integer.BYTES * 8 - CHANGE_NUM_BIT_LEN; // Allows up to 64 ServerIds
+
+  private final ImmutableMap<String, Integer> serverIdCodes;
+
+  @Inject
+  public ChangeNumberBitmapMaskAlgorithm(
+      @GerritImportedServerIds ImmutableList<String> importedServerIds) {
+    if (importedServerIds.size() >= 1 << SERVER_ID_BIT_LEN) {
+      throw new ProvisionException(
+          String.format(
+              "Too many imported GerritServerIds (%d) to fit into the Change virtual id",
+              importedServerIds.size()));
+    }
+    ImmutableMap.Builder<String, Integer> serverIdCodesBuilder = new ImmutableMap.Builder<>();
+    for (int i = 0; i < importedServerIds.size(); i++) {
+      serverIdCodesBuilder.put(importedServerIds.get(i), i + 1);
+    }
+
+    serverIdCodes = serverIdCodesBuilder.build();
+  }
+
+  @Override
+  public int apply(String changeServerId, int changeNum) {
+    if ((changeNum & LEGACY_ID_BIT_MASK) != changeNum) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Change number %d is too large to be converted into a virtual id", changeNum));
+    }
+
+    Integer encodedServerId = serverIdCodes.get(changeServerId);
+    if (encodedServerId == null) {
+      throw new IllegalArgumentException(
+          String.format("ServerId %s is not part of the GerritImportedServerIds", changeServerId));
+    }
+    int virtualId = (changeNum & LEGACY_ID_BIT_MASK) | (encodedServerId << CHANGE_NUM_BIT_LEN);
+
+    return virtualId;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java b/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java
new file mode 100644
index 0000000..ab21705
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.inject.ImplementedBy;
+
+/**
+ * Algorithm for encoding a serverId/legacyChangeNum into a virtual numeric id
+ *
+ * <p>TODO: To be reverted on master and stable-3.8
+ */
+@ImplementedBy(ChangeNumberBitmapMaskAlgorithm.class)
+public interface ChangeNumberVirtualIdAlgorithm {
+
+  /**
+   * Convert a serverId/legacyChangeNum tuple into a virtual numeric id
+   *
+   * @param serverId Gerrit serverId
+   * @param legacyChangeNum legacy change number
+   * @return virtual id which combines serverId and legacyChangeNum together
+   */
+  int apply(String serverId, int legacyChangeNum);
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 259239b..5f9abc3 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -81,11 +81,7 @@
    * 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(
-      boolean computeFromAllUsersRepository, CommentsUtil commentsUtil, Account.Id id) {
-    if (!computeFromAllUsersRepository) {
-      return new ChangeIndexCardinalPredicate(ChangeField.DRAFTBY, id.toString(), 20);
-    }
+  public static Predicate<ChangeData> draftBy(CommentsUtil commentsUtil, Account.Id id) {
     Set<Predicate<ChangeData>> changeIdPredicates =
         commentsUtil.getChangesWithDrafts(id).stream()
             .map(ChangePredicates::idStr)
@@ -100,13 +96,7 @@
    * 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);
-    }
+      StarredChangesUtil starredChangesUtil, Account.Id id, String label) {
     Set<Predicate<ChangeData>> starredChanges =
         starredChangesUtil.byAccountId(id, label).stream()
             .map(ChangePredicates::idStr)
@@ -136,15 +126,6 @@
    * Returns a predicate that matches the change with the provided {@link
    * com.google.gerrit.entities.Change.Id}.
    */
-  public static Predicate<ChangeData> id(Change.Id id) {
-    return new ChangeIndexCardinalPredicate(
-        ChangeField.LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString(), 1);
-  }
-
-  /**
-   * Returns a predicate that matches the change with the provided {@link
-   * com.google.gerrit.entities.Change.Id}.
-   */
   public static Predicate<ChangeData> idStr(Change.Id id) {
     return new ChangeIndexCardinalPredicate(
         ChangeField.LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString(), 1);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index c494024..1c6235b 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -17,7 +17,6 @@
 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;
@@ -562,9 +561,7 @@
     if (PAT_LEGACY_ID.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
       if (id != null) {
-        return args.getSchema().useLegacyNumericFields()
-            ? ChangePredicates.id(Change.id(id))
-            : ChangePredicates.idStr(Change.id(id));
+        return ChangePredicates.idStr(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
       return ChangePredicates.idPrefix(parseChangeId(query));
@@ -736,10 +733,6 @@
       return new IsSubmittablePredicate();
     }
 
-    if ("ignored".equalsIgnoreCase(value)) {
-      return ignoredBySelf();
-    }
-
     if ("started".equalsIgnoreCase(value)) {
       checkFieldAvailable(ChangeField.STARTED, "is:started");
       return new BooleanPredicate(ChangeField.STARTED);
@@ -1144,41 +1137,13 @@
     return ChangePredicates.message(text);
   }
 
-  @Operator
-  public Predicate<ChangeData> star(String label) throws QueryParseException {
-    if ("ignore".equalsIgnoreCase(label)) {
-      return ignoredBySelf();
-    }
-    if ("star".equalsIgnoreCase(label)) {
-      return starredBySelf();
-    }
-    throw new IllegalArgumentException();
-  }
-
-  private Predicate<ChangeData> ignoredBySelf() throws QueryParseException {
-    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 ChangePredicates.starBy(
-        args.experimentFeatures.isFeatureEnabled(
-            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
-        args.starredChangesUtil,
-        self(),
-        StarredChangesUtil.DEFAULT_LABEL);
+        args.starredChangesUtil, self(), StarredChangesUtil.DEFAULT_LABEL);
   }
 
   private Predicate<ChangeData> draftBySelf() throws QueryParseException {
-    return ChangePredicates.draftBy(
-        args.experimentFeatures.isFeatureEnabled(
-            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
-        args.commentsUtil,
-        self());
+    return ChangePredicates.draftBy(args.commentsUtil, self());
   }
 
   @Operator
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index f95dbb0..fc4c1d0 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -89,11 +89,7 @@
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
     and.add(ChangePredicates.project(c.getProject()));
     and.add(ChangePredicates.ref(c.getDest().branch()));
-    and.add(
-        Predicate.not(
-            args.getSchema().useLegacyNumericFields()
-                ? ChangePredicates.id(c.getId())
-                : ChangePredicates.idStr(c.getId())));
+    and.add(Predicate.not(ChangePredicates.idStr(c.getId())));
     and.add(Predicate.or(filePredicates));
 
     ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index e7b25fb..99c1ca1 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -56,11 +56,6 @@
  * holding on to a single instance.
  */
 public class InternalChangeQuery extends InternalQuery<ChangeData, InternalChangeQuery> {
-  @FunctionalInterface
-  static interface ChangeIdPredicateFactory {
-    Predicate<ChangeData> create(Change.Id id);
-  }
-
   private static Predicate<ChangeData> ref(BranchNameKey branch) {
     return ChangePredicates.ref(branch.branch());
   }
@@ -84,9 +79,6 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory notesFactory;
 
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final ChangeIdPredicateFactory predicateFactory;
-
   @Inject
   InternalChangeQuery(
       ChangeQueryProcessor queryProcessor,
@@ -97,11 +89,6 @@
     super(queryProcessor, indexes, indexConfig);
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
-    predicateFactory =
-        (id) ->
-            schema().useLegacyNumericFields()
-                ? ChangePredicates.id(id)
-                : ChangePredicates.idStr(id);
   }
 
   public List<ChangeData> byKey(Change.Key key) {
@@ -113,13 +100,13 @@
   }
 
   public List<ChangeData> byLegacyChangeId(Change.Id id) {
-    return query(predicateFactory.create(id));
+    return query(ChangePredicates.idStr(id));
   }
 
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
-      preds.add(predicateFactory.create(id));
+      preds.add(ChangePredicates.idStr(id));
     }
     return query(or(preds));
   }
diff --git a/java/com/google/gerrit/server/query/change/StarPredicate.java b/java/com/google/gerrit/server/query/change/StarPredicate.java
deleted file mode 100644
index 548ab29..0000000
--- a/java/com/google/gerrit/server/query/change/StarPredicate.java
+++ /dev/null
@@ -1,46 +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.query.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.index.query.HasCardinality;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class StarPredicate extends ChangeIndexPredicate implements HasCardinality {
-  protected final Account.Id accountId;
-  protected final String label;
-
-  public StarPredicate(Account.Id accountId, String label) {
-    super(ChangeField.STAR, StarredChangesUtil.StarField.create(accountId, label).toString());
-    this.accountId = accountId;
-    this.label = label;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.stars().get(accountId).contains(label);
-  }
-
-  @Override
-  public int getCardinality() {
-    return 10;
-  }
-
-  @Override
-  public String toString() {
-    return ChangeQueryBuilder.FIELD_STAR + ":" + label;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 8a2dc8d..e742cba 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.group.GroupField;
@@ -26,51 +26,51 @@
 /** Utility class to create predicates for group index queries. */
 public class GroupPredicates {
   public static Predicate<InternalGroup> id(AccountGroup.Id groupId) {
-    return new GroupPredicate(GroupField.ID, groupId.toString());
+    return new GroupPredicate(GroupField.ID_FIELD_SPEC, groupId.toString());
   }
 
   public static Predicate<InternalGroup> uuid(AccountGroup.UUID uuid) {
-    return new GroupPredicate(GroupField.UUID, GroupQueryBuilder.FIELD_UUID, uuid.get());
+    return new GroupPredicate(GroupField.UUID_FIELD_SPEC, GroupQueryBuilder.FIELD_UUID, uuid.get());
   }
 
   public static Predicate<InternalGroup> description(String description) {
     return new GroupPredicate(
-        GroupField.DESCRIPTION, GroupQueryBuilder.FIELD_DESCRIPTION, description);
+        GroupField.DESCRIPTION_SPEC, GroupQueryBuilder.FIELD_DESCRIPTION, description);
   }
 
   public static Predicate<InternalGroup> inname(String name) {
     return new GroupPredicate(
-        GroupField.NAME_PART, GroupQueryBuilder.FIELD_INNAME, name.toLowerCase(Locale.US));
+        GroupField.NAME_PART_SPEC, GroupQueryBuilder.FIELD_INNAME, name.toLowerCase(Locale.US));
   }
 
   public static Predicate<InternalGroup> name(String name) {
-    return new GroupPredicate(GroupField.NAME, name);
+    return new GroupPredicate(GroupField.NAME_SPEC, name);
   }
 
   public static Predicate<InternalGroup> owner(AccountGroup.UUID ownerUuid) {
     return new GroupPredicate(
-        GroupField.OWNER_UUID, GroupQueryBuilder.FIELD_OWNER, ownerUuid.get());
+        GroupField.OWNER_UUID_SPEC, GroupQueryBuilder.FIELD_OWNER, ownerUuid.get());
   }
 
   public static Predicate<InternalGroup> isVisibleToAll() {
-    return new GroupPredicate(GroupField.IS_VISIBLE_TO_ALL, "1");
+    return new GroupPredicate(GroupField.IS_VISIBLE_TO_ALL_SPEC, "1");
   }
 
   public static Predicate<InternalGroup> member(Account.Id memberId) {
-    return new GroupPredicate(GroupField.MEMBER, memberId.toString());
+    return new GroupPredicate(GroupField.MEMBER_SPEC, memberId.toString());
   }
 
   public static Predicate<InternalGroup> subgroup(AccountGroup.UUID subgroupUuid) {
-    return new GroupPredicate(GroupField.SUBGROUP, subgroupUuid.get());
+    return new GroupPredicate(GroupField.SUBGROUP_SPEC, subgroupUuid.get());
   }
 
   /** Predicate that is mapped to a field in the group index. */
   static class GroupPredicate extends IndexPredicate<InternalGroup> {
-    GroupPredicate(FieldDef<InternalGroup, ?> def, String value) {
+    GroupPredicate(SchemaField<InternalGroup, ?> def, String value) {
       super(def, value);
     }
 
-    GroupPredicate(FieldDef<InternalGroup, ?> def, String name, String value) {
+    GroupPredicate(SchemaField<InternalGroup, ?> def, String name, String value) {
       super(def, name, value);
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 2566b72..62da2f2 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -31,6 +31,7 @@
         "//lib:jgit",
         "//lib:servlet-api",
         "//lib/antlr:java-runtime",
+        "//lib/auto:auto-factory",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:compress",
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index fbd99eb..e35ffdb 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -15,7 +15,6 @@
 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;
@@ -40,7 +39,6 @@
 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;
@@ -70,37 +68,34 @@
 
   private final Provider<CurrentUser> userProvider;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final Provider<ChangeQueryBuilder> queryBuilderProvider;
+  private final ChangeQueryBuilder queryBuilder;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeJson.Factory changeJsonFactory;
   private final Provider<CommentJson> commentJsonProvider;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final ExperimentFeatures experimentFeatures;
 
   @Inject
   DeleteDraftComments(
       Provider<CurrentUser> userProvider,
       BatchUpdate.Factory batchUpdateFactory,
-      Provider<ChangeQueryBuilder> queryBuilderProvider,
+      ChangeQueryBuilder queryBuilder,
       Provider<InternalChangeQuery> queryProvider,
       ChangeData.Factory changeDataFactory,
       ChangeJson.Factory changeJsonFactory,
       Provider<CommentJson> commentJsonProvider,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      ExperimentFeatures experimentFeatures) {
+      PatchSetUtil psUtil) {
     this.userProvider = userProvider;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.queryBuilderProvider = queryBuilderProvider;
+    this.queryBuilder = queryBuilder;
     this.queryProvider = queryProvider;
     this.changeDataFactory = changeDataFactory;
     this.changeJsonFactory = changeJsonFactory;
     this.commentJsonProvider = commentJsonProvider;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.experimentFeatures = experimentFeatures;
   }
 
   @Override
@@ -151,17 +146,12 @@
 
   private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
       throws BadRequestException {
-    Predicate<ChangeData> hasDraft =
-        ChangePredicates.draftBy(
-            experimentFeatures.isFeatureEnabled(
-                GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
-            commentsUtil,
-            accountId);
+    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(commentsUtil, accountId);
     if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
       return hasDraft;
     }
     try {
-      return Predicate.and(hasDraft, queryBuilderProvider.get().parse(input.query));
+      return Predicate.and(hasDraft, queryBuilder.parse(input.query));
     } catch (QueryParseException e) {
       throw new BadRequestException("Invalid query: " + e.getMessage(), e);
     }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
index f73f00a..e09e48f 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.mail.send.DeleteKeySender;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -74,10 +75,14 @@
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
-    IdentifiedUser user = rsrc.getUser();
-    authorizedKeys.deleteKey(user.getAccountId(), rsrc.getSshKey().seq());
+    return apply(rsrc.getUser(), rsrc.getSshKey());
+  }
+
+  public Response<?> apply(IdentifiedUser user, AccountSshKey sshKey)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    authorizedKeys.deleteKey(user.getAccountId(), sshKey.seq());
     try {
-      deleteKeySenderFactory.create(user, rsrc.getSshKey()).send();
+      deleteKeySenderFactory.create(user, sshKey).send();
     } catch (EmailException e) {
       logger.atSevere().withCause(e).log(
           "Cannot send SSH key deletion message to %s", user.getAccount().preferredEmail());
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
index db6ad48..eb2be10 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAgreements.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -87,10 +87,8 @@
 
     IdentifiedUser user = self.get().asIdentifiedUser();
     if (user != resource.getUser()) {
-      try {
-        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-      } catch (AuthException e) {
-        throw new AuthException("not allowed to get contributor agreements", e);
+      if (!permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) {
+        throw new AuthException("not allowed to get contributor agreements");
       }
     }
 
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index c671562..30534b5 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -202,6 +202,9 @@
     }
 
     if (start != null) {
+      if (start < 0) {
+        throw new BadRequestException("'start' parameter cannot be less than zero");
+      }
       queryProcessor.setStart(start);
     }
 
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index 39c1fef..12abf3d 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -134,8 +134,7 @@
             self.get().getAccountId(),
             change.getProject(),
             change.getId(),
-            StarredChangesUtil.DEFAULT_LABELS,
-            null);
+            StarredChangesUtil.Operation.ADD);
       } catch (MutuallyExclusiveLabelsException e) {
         throw new ResourceConflictException(e.getMessage());
       } catch (IllegalLabelException e) {
@@ -186,8 +185,7 @@
           self.get().getAccountId(),
           rsrc.getChange().getProject(),
           rsrc.getChange().getId(),
-          null,
-          StarredChangesUtil.DEFAULT_LABELS);
+          StarredChangesUtil.Operation.REMOVE);
       return Response.none();
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index a21431e..03d383f 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -81,13 +81,11 @@
           String.format(
               "%s is a robot, and robots can't be added to the attention set.", input.user));
     }
-    try {
-      permissionBackend
-          .absentUser(attentionUserId)
-          .change(changeResource.getNotes())
-          .check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new AuthException("read not permitted for " + attentionUserId, e);
+    if (!permissionBackend
+        .absentUser(attentionUserId)
+        .change(changeResource.getNotes())
+        .test(ChangePermission.READ)) {
+      throw new AuthException("read not permitted for " + attentionUserId);
     }
 
     try (BatchUpdate bu =
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java
similarity index 73%
copy from java/com/google/gerrit/server/restapi/change/ApplyFix.java
copy to java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java
index a1c51e8..a55ef84b 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 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.
@@ -16,17 +16,18 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.gerrit.entities.Comment.Range;
+import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
 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;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.change.FixResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditJson;
@@ -34,6 +35,7 @@
 import com.google.gerrit.server.edit.CommitModification;
 import com.google.gerrit.server.fixes.FixReplacementInterpreter;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -41,11 +43,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Repository;
 
+/** Applies a fix that is provided as part of the request body. */
 @Singleton
-public class ApplyFix implements RestModifyView<FixResource, Input> {
-
+public class ApplyProvidedFix implements RestModifyView<RevisionResource, ApplyProvidedFixInput> {
   private final GitRepositoryManager gitRepositoryManager;
   private final FixReplacementInterpreter fixReplacementInterpreter;
   private final ChangeEditModifier changeEditModifier;
@@ -53,7 +57,7 @@
   private final ProjectCache projectCache;
 
   @Inject
-  public ApplyFix(
+  public ApplyProvidedFix(
       GitRepositoryManager gitRepositoryManager,
       FixReplacementInterpreter fixReplacementInterpreter,
       ChangeEditModifier changeEditModifier,
@@ -67,21 +71,35 @@
   }
 
   @Override
-  public Response<EditInfo> apply(FixResource fixResource, Input nothing)
+  public Response<EditInfo> apply(
+      RevisionResource revisionResource, ApplyProvidedFixInput applyProvidedFixInput)
       throws AuthException, BadRequestException, ResourceConflictException, IOException,
           ResourceNotFoundException, PermissionBackendException {
-    RevisionResource revisionResource = fixResource.getRevisionResource();
+    if (applyProvidedFixInput == null) {
+      throw new BadRequestException("applyProvidedFixInput is required");
+    }
+    if (applyProvidedFixInput.fixReplacementInfos == null) {
+      throw new BadRequestException("applyProvidedFixInput.fixReplacementInfos is required");
+    }
     Project.NameKey project = revisionResource.getProject();
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
     PatchSet patchSet = revisionResource.getPatchSet();
 
+    ChangeNotes changeNotes = revisionResource.getNotes();
+
+    List<FixReplacement> fixReplacements =
+        applyProvidedFixInput.fixReplacementInfos.stream()
+            .map(fix -> new FixReplacement(fix.path, new Range(fix.range), fix.replacement))
+            .collect(Collectors.toList());
+
     try (Repository repository = gitRepositoryManager.openRepository(project)) {
       CommitModification commitModification =
           fixReplacementInterpreter.toCommitModification(
-              repository, projectState, patchSet.commitId(), fixResource.getFixReplacements());
+              repository, projectState, patchSet.commitId(), fixReplacements);
       ChangeEdit changeEdit =
           changeEditModifier.combineWithModifiedPatchSetTree(
-              repository, revisionResource.getNotes(), patchSet, commitModification);
+              repository, changeNotes, patchSet, commitModification);
+
       return Response.ok(changeEditJson.toEditInfo(changeEdit, false));
     } catch (InvalidChangeOperationException e) {
       throw new ResourceConflictException(e.getMessage());
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyStoredFix.java
similarity index 97%
rename from java/com/google/gerrit/server/restapi/change/ApplyFix.java
rename to java/com/google/gerrit/server/restapi/change/ApplyStoredFix.java
index a1c51e8..2d87dcf 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyStoredFix.java
@@ -44,7 +44,7 @@
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class ApplyFix implements RestModifyView<FixResource, Input> {
+public class ApplyStoredFix implements RestModifyView<FixResource, Input> {
 
   private final GitRepositoryManager gitRepositoryManager;
   private final FixReplacementInterpreter fixReplacementInterpreter;
@@ -53,7 +53,7 @@
   private final ProjectCache projectCache;
 
   @Inject
-  public ApplyFix(
+  public ApplyStoredFix(
       GitRepositoryManager gitRepositoryManager,
       FixReplacementInterpreter fixReplacementInterpreter,
       ChangeEditModifier changeEditModifier,
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index e09f2f4..718759a 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -118,8 +118,6 @@
     post(CHANGE_KIND, "private").to(PostPrivate.class);
     post(CHANGE_KIND, "private.delete").to(DeletePrivateByPost.class);
     delete(CHANGE_KIND, "private").to(DeletePrivate.class);
-    put(CHANGE_KIND, "ignore").to(Ignore.class);
-    put(CHANGE_KIND, "unignore").to(Unignore.class);
     post(CHANGE_KIND, "wip").to(SetWorkInProgress.class);
     post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
     put(CHANGE_KIND, "message").to(PutMessage.class);
@@ -170,8 +168,10 @@
     child(REVISION_KIND, "robotcomments").to(RobotComments.class);
     get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
     child(REVISION_KIND, "fixes").to(Fixes.class);
-    post(FIX_KIND, "apply").to(ApplyFix.class);
-    get(FIX_KIND, "preview").to(GetFixPreview.class);
+    post(FIX_KIND, "apply").to(ApplyStoredFix.class);
+    get(FIX_KIND, "preview").to(PreviewFix.Stored.class);
+    post(REVISION_KIND, "fix:apply").to(ApplyProvidedFix.class);
+    post(REVISION_KIND, "fix:preview").to(PreviewFix.Provided.class);
 
     get(REVISION_KIND, "ported_comments").to(ListPortedComments.class);
     get(REVISION_KIND, "ported_drafts").to(ListPortedDrafts.class);
@@ -210,9 +210,12 @@
     factory(DeleteChangeOp.Factory.class);
     factory(DeleteReviewerByEmailOp.Factory.class);
     factory(DeleteReviewerOp.Factory.class);
+    factory(DeleteVoteOp.Factory.class);
     factory(EmailReviewComments.Factory.class);
     factory(PatchSetInserter.Factory.class);
     factory(AddReviewersOp.Factory.class);
+    factory(PostReviewOp.Factory.class);
+    factory(PreviewFix.Factory.class);
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
     factory(SetAssigneeOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java b/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
index 55b234c..e5c47a7a 100644
--- a/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
@@ -14,39 +14,102 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.SubmitRequirementsJson;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map.Entry;
 import java.util.Optional;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
 
 /**
  * A rest view to evaluate (test) a {@link com.google.gerrit.entities.SubmitRequirement} on a given
- * change.
+ * change. The submit requirement can be supplied in one of two ways:
  *
- * <p>TODO(ghareeb): Can this class be made singleton?
+ * <p>1) Using the {@link SubmitRequirementInput}.
+ *
+ * <p>2) From a change to the {@link RefNames#REFS_CONFIG} branch and the name of the
+ * submit-requirement.
  */
 public class CheckSubmitRequirement
     implements RestModifyView<ChangeResource, SubmitRequirementInput> {
   private final SubmitRequirementsEvaluator evaluator;
 
+  @Option(name = "--sr-name")
+  private String srName;
+
+  @Option(name = "--refs-config-change-id")
+  private String refsConfigChangeId;
+
+  private final GitRepositoryManager repoManager;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangesCollection changesCollection;
+  private final ChangeNotes.Factory changeNotesFactory;
+
+  public void setSrName(String srName) {
+    this.srName = srName;
+  }
+
+  public void setRefsConfigChangeId(String refsConfigChangeId) {
+    this.refsConfigChangeId = refsConfigChangeId;
+  }
+
   @Inject
-  public CheckSubmitRequirement(SubmitRequirementsEvaluator evaluator) {
+  public CheckSubmitRequirement(
+      SubmitRequirementsEvaluator evaluator,
+      GitRepositoryManager repoManager,
+      ProjectConfig.Factory projectConfigFactory,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangesCollection changesCollection) {
     this.evaluator = evaluator;
+    this.repoManager = repoManager;
+    this.projectConfigFactory = projectConfigFactory;
+    this.changeDataFactory = changeDataFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changesCollection = changesCollection;
   }
 
   @Override
   public Response<SubmitRequirementResultInfo> apply(
-      ChangeResource resource, SubmitRequirementInput input) throws BadRequestException {
-    SubmitRequirement requirement = createSubmitRequirement(input);
+      ChangeResource resource, SubmitRequirementInput input)
+      throws IOException, PermissionBackendException, RestApiException {
+    if ((srName == null || refsConfigChangeId == null)
+        && !(srName == null && refsConfigChangeId == null)) {
+      throw new BadRequestException(
+          "Both 'sr-name' and 'refs-config-change-id' parameters must be set");
+    }
+    SubmitRequirement requirement =
+        srName != null && refsConfigChangeId != null
+            ? createSubmitRequirementFromRequestParams()
+            : createSubmitRequirement(input);
     SubmitRequirementResult res =
         evaluator.evaluateRequirement(requirement, resource.getChangeData());
     return Response.ok(SubmitRequirementsJson.toInfo(requirement, res));
@@ -67,6 +130,57 @@
         .build();
   }
 
+  /**
+   * Loads the submit-requirement identified by the name {@link #srName} from the latest patch-set
+   * of the change with ID {@link #refsConfigChangeId}.
+   *
+   * @return a {@link SubmitRequirement} entity.
+   * @throws BadRequestException If {@link #refsConfigChangeId} is a non-existent change or not in
+   *     the {@link RefNames#REFS_CONFIG} branch, if the submit-requirement with name {@link
+   *     #srName} does not exist or if the server failed to load the project due to other
+   *     exceptions.
+   */
+  private SubmitRequirement createSubmitRequirementFromRequestParams()
+      throws IOException, PermissionBackendException, RestApiException {
+    ChangeResource refsConfigChange;
+    try {
+      refsConfigChange =
+          changesCollection.parse(
+              TopLevelResource.INSTANCE, IdString.fromDecoded(refsConfigChangeId));
+    } catch (ResourceNotFoundException e) {
+      throw new BadRequestException(
+          String.format("Change '%s' does not exist", refsConfigChangeId), e);
+    }
+    ChangeNotes notes = changeNotesFactory.createCheckedUsingIndexLookup(refsConfigChange.getId());
+    ChangeData changeData = changeDataFactory.create(notes);
+    try (Repository git = repoManager.openRepository(changeData.project())) {
+      if (!changeData.change().getDest().branch().equals(RefNames.REFS_CONFIG)) {
+        throw new BadRequestException(
+            String.format("Change '%s' is not in refs/meta/config branch.", refsConfigChangeId));
+      }
+      ObjectId revisionId = changeData.currentPatchSet().commitId();
+      ProjectConfig cfg = projectConfigFactory.create(changeData.project());
+      try {
+        cfg.load(git, revisionId);
+      } catch (ConfigInvalidException e) {
+        throw new ResourceConflictException(
+            String.format(
+                "Failed to load project config for change '%s' from revision '%s'",
+                refsConfigChangeId, revisionId),
+            e);
+      }
+      List<Entry<String, SubmitRequirement>> submitRequirements =
+          cfg.getSubmitRequirementSections().entrySet().stream()
+              .filter(entry -> entry.getKey().equals(srName))
+              .collect(Collectors.toList());
+      if (submitRequirements.isEmpty()) {
+        throw new BadRequestException(
+            String.format("No submit requirement matching name '%s'", srName));
+      }
+      return Iterables.getOnlyElement(submitRequirements).getValue();
+    }
+  }
+
   private void validateSubmitRequirementInput(SubmitRequirementInput input)
       throws BadRequestException {
     if (input.name == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 6a25095..66f8be7 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
@@ -69,11 +70,11 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -103,12 +104,12 @@
   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;
   private final SetCherryPickOp.Factory setCherryPickOfFactory;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ProjectCache projectCache;
   private final ApprovalsUtil approvalsUtil;
@@ -125,7 +126,7 @@
       ChangeInserter.Factory changeInserterFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       SetCherryPickOp.Factory setCherryPickOfFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       ChangeNotes.Factory changeNotesFactory,
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
@@ -134,7 +135,7 @@
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.user = user;
     this.changeInserterFactory = changeInserterFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
@@ -306,7 +307,7 @@
       CodeReviewCommit cherryPickCommit;
       ProjectState projectState =
           projectCache.get(dest.project()).orElseThrow(noSuchProject(dest.project()));
-      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverTimeZone);
+      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverZoneId);
 
       try {
         MergeUtil mergeUtil;
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 6a637b3..760d99d 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -63,6 +63,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -85,11 +86,10 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Collections;
-import java.util.Date;
 import java.util.List;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -117,7 +117,7 @@
   private final String anonymousCowardName;
   private final GitRepositoryManager gitManager;
   private final Sequences seq;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final ProjectsCollection projectsCollection;
@@ -127,7 +127,7 @@
   private final ChangeFinder changeFinder;
   private final Provider<InternalChangeQuery> queryProvider;
   private final PatchSetUtil psUtil;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final SubmitType submitType;
   private final NotifyResolver notifyResolver;
   private final ContributorAgreementsChecker contributorAgreements;
@@ -150,14 +150,14 @@
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
       @GerritServerConfig Config config,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       NotifyResolver notifyResolver,
       ContributorAgreementsChecker contributorAgreements) {
     this.updateFactory = updateFactory;
     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;
@@ -309,10 +309,8 @@
       Project.NameKey project, String refName, @Nullable AccountInput author)
       throws ResourceNotFoundException, AuthException, PermissionBackendException {
     PermissionBackend.ForRef forRef = permissionBackend.currentUser().project(project).ref(refName);
-    try {
-      forRef.check(RefPermission.READ);
-    } catch (AuthException e) {
-      throw new ResourceNotFoundException(String.format("ref %s not found", refName), e);
+    if (!forRef.test(RefPermission.READ)) {
+      throw new ResourceNotFoundException(String.format("ref %s not found", refName));
     }
     forRef.check(RefPermission.CREATE_CHANGE);
     if (author != null) {
@@ -320,9 +318,6 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private ChangeInfo createNewChange(
       ChangeInput input,
       IdentifiedUser me,
@@ -359,12 +354,11 @@
 
       Instant now = TimeUtil.now();
 
-      PersonIdent committer = me.newCommitterIdent(now, serverTimeZone);
+      PersonIdent committer = me.newCommitterIdent(now, serverZoneId);
       PersonIdent author =
           input.author == null
               ? committer
-              : new PersonIdent(
-                  input.author.name, input.author.email, Date.from(now), serverTimeZone);
+              : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
 
       String commitMessage = getCommitMessage(input.subject, me);
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 651bf7b..4b66cdc 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -51,6 +51,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -68,9 +69,8 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.Date;
+import java.time.ZoneId;
 import java.util.List;
-import java.util.TimeZone;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -85,11 +85,11 @@
   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;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final ProjectCache projectCache;
   private final ChangeFinder changeFinder;
@@ -104,7 +104,7 @@
       Provider<CurrentUser> user,
       ChangeJson.Factory json,
       PatchSetUtil psUtil,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       ProjectCache projectCache,
       ChangeFinder changeFinder,
@@ -112,7 +112,7 @@
     this.updateFactory = updateFactory;
     this.gitManager = gitManager;
     this.commits = commits;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.user = user;
     this.jsonFactory = json;
     this.psUtil = psUtil;
@@ -123,9 +123,6 @@
     this.permissionBackend = permissionBackend;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc, MergePatchSetInput in)
       throws IOException, RestApiException, UpdateException, PermissionBackendException {
@@ -184,8 +181,8 @@
       IdentifiedUser me = user.get().asIdentifiedUser();
       PersonIdent author =
           in.author == null
-              ? me.newCommitterIdent(now, serverTimeZone)
-              : new PersonIdent(in.author.name, in.author.email, Date.from(now), serverTimeZone);
+              ? me.newCommitterIdent(now, serverZoneId)
+              : new PersonIdent(in.author.name, in.author.email, now, serverZoneId);
       CodeReviewCommit newCommit =
           createMergeCommit(
               in,
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 208cecf..9fa3160 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -15,102 +15,51 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelTypes;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
-import com.google.gerrit.server.extensions.events.VoteDeleted;
-import com.google.gerrit.server.mail.send.DeleteVoteSender;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.AccountTemplateUtil;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class DeleteVote implements RestModifyView<VoteResource, DeleteVoteInput> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final BatchUpdate.Factory updateFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeMessagesUtil cmUtil;
-  private final VoteDeleted voteDeleted;
-  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
   private final NotifyResolver notifyResolver;
-  private final RemoveReviewerControl removeReviewerControl;
-  private final ProjectCache projectCache;
-  private final MessageIdGenerator messageIdGenerator;
+
   private final AddToAttentionSetOp.Factory attentionSetOpFactory;
   private final Provider<CurrentUser> currentUserProvider;
+  private final DeleteVoteOp.Factory deleteVoteOpFactory;
 
   @Inject
   DeleteVote(
       BatchUpdate.Factory updateFactory,
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ChangeMessagesUtil cmUtil,
-      VoteDeleted voteDeleted,
-      DeleteVoteSender.Factory deleteVoteSenderFactory,
       NotifyResolver notifyResolver,
-      RemoveReviewerControl removeReviewerControl,
-      ProjectCache projectCache,
-      MessageIdGenerator messageIdGenerator,
       AddToAttentionSetOp.Factory attentionSetOpFactory,
-      Provider<CurrentUser> currentUserProvider) {
+      Provider<CurrentUser> currentUserProvider,
+      DeleteVoteOp.Factory deleteVoteOpFactory) {
     this.updateFactory = updateFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.cmUtil = cmUtil;
-    this.voteDeleted = voteDeleted;
-    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
     this.notifyResolver = notifyResolver;
-    this.removeReviewerControl = removeReviewerControl;
-    this.projectCache = projectCache;
-    this.messageIdGenerator = messageIdGenerator;
     this.attentionSetOpFactory = attentionSetOpFactory;
     this.currentUserProvider = currentUserProvider;
+    this.deleteVoteOpFactory = deleteVoteOpFactory;
   }
 
   @Override
@@ -140,13 +89,12 @@
               firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
       bu.addOp(
           change.getId(),
-          new Op(
-              projectCache
-                  .get(r.getChange().getProject())
-                  .orElseThrow(illegalState(r.getChange().getProject())),
+          deleteVoteOpFactory.create(
+              r.getChange().getProject(),
               r.getReviewerUser().state(),
               rsrc.getLabel(),
-              input));
+              input,
+              true));
       if (!input.ignoreAutomaticAttentionSetRules
           && !r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
         bu.addOp(
@@ -164,109 +112,4 @@
 
     return Response.none();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final ProjectState projectState;
-    private final AccountState accountState;
-    private final String label;
-    private final DeleteVoteInput input;
-
-    private String mailMessage;
-    private Change change;
-    private PatchSet ps;
-    private Map<String, Short> newApprovals = new HashMap<>();
-    private Map<String, Short> oldApprovals = new HashMap<>();
-
-    private Op(
-        ProjectState projectState, AccountState accountState, String label, DeleteVoteInput input) {
-      this.projectState = projectState;
-      this.accountState = accountState;
-      this.label = label;
-      this.input = input;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
-      change = ctx.getChange();
-      PatchSet.Id psId = change.currentPatchSetId();
-      ps = psUtil.current(ctx.getNotes());
-
-      boolean found = false;
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-
-      Account.Id accountId = accountState.account().id();
-
-      for (PatchSetApproval a : approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, accountId)) {
-        if (!labelTypes.byLabel(a.labelId()).isPresent()) {
-          continue; // Ignore undefined labels.
-        } else if (!a.label().equals(label)) {
-          // Populate map for non-matching labels, needed by VoteDeleted.
-          newApprovals.put(a.label(), a.value());
-          continue;
-        } else {
-          try {
-            removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-          } catch (AuthException e) {
-            throw new AuthException("delete vote not permitted", e);
-          }
-        }
-        // Set the approval to 0 if vote is being removed.
-        newApprovals.put(a.label(), (short) 0);
-        found = true;
-
-        // Set old value, as required by VoteDeleted.
-        oldApprovals.put(a.label(), a.value());
-        break;
-      }
-      if (!found) {
-        throw new ResourceNotFoundException();
-      }
-
-      ctx.getUpdate(psId).removeApprovalFor(accountId, label);
-
-      StringBuilder msg = new StringBuilder();
-      msg.append("Removed ");
-      LabelVote.appendTo(msg, label, requireNonNull(oldApprovals.get(label)));
-      msg.append(" by ").append(AccountTemplateUtil.getAccountTemplate(accountId)).append("\n");
-      mailMessage =
-          cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
-      return true;
-    }
-
-    @Override
-    public void postUpdate(PostUpdateContext ctx) {
-      if (mailMessage == null) {
-        return;
-      }
-
-      IdentifiedUser user = ctx.getIdentifiedUser();
-      try {
-        NotifyResolver.Result notify = ctx.getNotify(change.getId());
-        if (notify.shouldNotify()) {
-          ReplyToChangeSender emailSender =
-              deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
-          emailSender.setFrom(user.getAccountId());
-          emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-          emailSender.setNotify(notify);
-          emailSender.setMessageId(
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-          emailSender.send();
-        }
-      } catch (Exception e) {
-        logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
-      }
-
-      voteDeleted.fire(
-          ctx.getChangeData(change),
-          ps,
-          accountState,
-          newApprovals,
-          oldApprovals,
-          input.notify,
-          mailMessage,
-          user.state(),
-          ctx.getWhen());
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
new file mode 100644
index 0000000..432f0da
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -0,0 +1,214 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.extensions.events.VoteDeleted;
+import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.RemoveReviewerControl;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Updates the storage to delete vote(s). */
+public class DeleteVoteOp implements BatchUpdateOp {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /** Factory to create {@link DeleteVoteOp} instances. */
+  public interface Factory {
+    DeleteVoteOp create(
+        Project.NameKey projectState,
+        AccountState reviewerToDeleteVoteFor,
+        String label,
+        DeleteVoteInput input,
+        boolean enforcePermissions);
+  }
+
+  private final Project.NameKey projectName;
+  private final AccountState reviewerToDeleteVoteFor;
+
+  private final ProjectCache projectCache;
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final VoteDeleted voteDeleted;
+  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
+
+  private final RemoveReviewerControl removeReviewerControl;
+  private final MessageIdGenerator messageIdGenerator;
+
+  private final String label;
+  private final DeleteVoteInput input;
+  private final boolean enforcePermissions;
+
+  private String mailMessage;
+  private Change change;
+  private PatchSet ps;
+  private Map<String, Short> newApprovals = new HashMap<>();
+  private Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  public DeleteVoteOp(
+      ProjectCache projectCache,
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      VoteDeleted voteDeleted,
+      DeleteVoteSender.Factory deleteVoteSenderFactory,
+      RemoveReviewerControl removeReviewerControl,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted Project.NameKey projectName,
+      @Assisted AccountState reviewerToDeleteVoteFor,
+      @Assisted String label,
+      @Assisted DeleteVoteInput input,
+      @Assisted boolean enforcePermissions) {
+    this.projectCache = projectCache;
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.voteDeleted = voteDeleted;
+    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+    this.removeReviewerControl = removeReviewerControl;
+    this.messageIdGenerator = messageIdGenerator;
+
+    this.projectName = projectName;
+    this.reviewerToDeleteVoteFor = reviewerToDeleteVoteFor;
+    this.label = label;
+    this.input = input;
+    this.enforcePermissions = enforcePermissions;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
+    change = ctx.getChange();
+    PatchSet.Id psId = change.currentPatchSetId();
+    ps = psUtil.current(ctx.getNotes());
+
+    boolean found = false;
+    LabelTypes labelTypes =
+        projectCache
+            .get(projectName)
+            .orElseThrow(illegalState(projectName))
+            .getLabelTypes(ctx.getNotes());
+
+    Account.Id accountId = reviewerToDeleteVoteFor.account().id();
+
+    for (PatchSetApproval a : approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, accountId)) {
+      if (!labelTypes.byLabel(a.labelId()).isPresent()) {
+        continue; // Ignore undefined labels.
+      } else if (!a.label().equals(label)) {
+        // Populate map for non-matching labels, needed by VoteDeleted.
+        newApprovals.put(a.label(), a.value());
+        continue;
+      } else if (enforcePermissions) {
+        // For regular users, check if they are allowed to remove the vote.
+        try {
+          removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
+        } catch (AuthException e) {
+          throw new AuthException("delete vote not permitted", e);
+        }
+      }
+      // Set the approval to 0 if vote is being removed.
+      newApprovals.put(a.label(), (short) 0);
+      found = true;
+
+      // Set old value, as required by VoteDeleted.
+      oldApprovals.put(a.label(), a.value());
+      break;
+    }
+    if (!found) {
+      throw new ResourceNotFoundException();
+    }
+
+    ctx.getUpdate(psId).removeApprovalFor(accountId, label);
+
+    StringBuilder msg = new StringBuilder();
+    msg.append("Removed ");
+    LabelVote.appendTo(msg, label, requireNonNull(oldApprovals.get(label)));
+    msg.append(" by ").append(AccountTemplateUtil.getAccountTemplate(accountId));
+    if (input.reason != null) {
+      msg.append("\n\n" + input.reason);
+    }
+    msg.append("\n");
+    mailMessage = cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(PostUpdateContext ctx) {
+    if (mailMessage == null) {
+      return;
+    }
+
+    CurrentUser user = ctx.getUser();
+    try {
+      NotifyResolver.Result notify = ctx.getNotify(change.getId());
+      if (notify.shouldNotify()) {
+        ReplyToChangeSender emailSender =
+            deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
+        if (user.isIdentifiedUser()) {
+          emailSender.setFrom(user.getAccountId());
+        }
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+        emailSender.send();
+      }
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
+    }
+    voteDeleted.fire(
+        ctx.getChangeData(change),
+        ps,
+        reviewerToDeleteVoteFor,
+        newApprovals,
+        oldApprovals,
+        input.notify,
+        mailMessage,
+        user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null,
+        ctx.getWhen());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
deleted file mode 100644
index 95e26a23..0000000
--- a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
+++ /dev/null
@@ -1,148 +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.restapi.change;
-
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static java.util.stream.Collectors.groupingBy;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.FixReplacement;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.common.DiffInfo;
-import com.google.gerrit.extensions.common.DiffWebLinkInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.change.FixResource;
-import com.google.gerrit.server.diff.DiffInfoCreator;
-import com.google.gerrit.server.diff.DiffSide;
-import com.google.gerrit.server.diff.DiffWebLinksProvider;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LargeObjectException;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.lib.Repository;
-
-@Singleton
-public class GetFixPreview implements RestReadView<FixResource> {
-
-  private final ProjectCache projectCache;
-  private final GitRepositoryManager repoManager;
-  private final PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory;
-
-  @Inject
-  GetFixPreview(
-      ProjectCache projectCache,
-      GitRepositoryManager repoManager,
-      PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory) {
-    this.projectCache = projectCache;
-    this.repoManager = repoManager;
-    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
-  }
-
-  @Override
-  public Response<Map<String, DiffInfo>> apply(FixResource resource)
-      throws PermissionBackendException, ResourceNotFoundException, ResourceConflictException,
-          AuthException, IOException, InvalidChangeOperationException {
-    Map<String, DiffInfo> result = new HashMap<>();
-    PatchSet patchSet = resource.getRevisionResource().getPatchSet();
-    ChangeNotes notes = resource.getRevisionResource().getNotes();
-    Change change = notes.getChange();
-    ProjectState state =
-        projectCache.get(change.getProject()).orElseThrow(illegalState(change.getProject()));
-    Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
-        resource.getFixReplacements().stream()
-            .collect(groupingBy(fixReplacement -> fixReplacement.path));
-    try {
-      try (Repository git = repoManager.openRepository(notes.getProjectName())) {
-        for (Map.Entry<String, List<FixReplacement>> entry :
-            fixReplacementsPerFilePath.entrySet()) {
-          String fileName = entry.getKey();
-          DiffInfo diffInfo =
-              getFixPreviewForSingleFile(
-                  git, patchSet, state, notes, fileName, ImmutableList.copyOf(entry.getValue()));
-          result.put(fileName, diffInfo);
-        }
-      }
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-    } catch (LargeObjectException e) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    }
-    return Response.ok(result);
-  }
-
-  private DiffInfo getFixPreviewForSingleFile(
-      Repository git,
-      PatchSet patchSet,
-      ProjectState state,
-      ChangeNotes notes,
-      String fileName,
-      ImmutableList<FixReplacement> fixReplacements)
-      throws PermissionBackendException, AuthException, LargeObjectException,
-          InvalidChangeOperationException, IOException, ResourceNotFoundException {
-    PatchScriptFactoryForAutoFix psf =
-        patchScriptFactoryFactory.create(
-            git, notes, fileName, patchSet, fixReplacements, DiffPreferencesInfo.defaults());
-    PatchScript ps = psf.call();
-
-    DiffSide sideA =
-        DiffSide.create(
-            ps.getFileInfoA(),
-            MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
-            DiffSide.Type.SIDE_A);
-    DiffSide sideB = DiffSide.create(ps.getFileInfoB(), ps.getNewName(), DiffSide.Type.SIDE_B);
-
-    DiffInfoCreator diffInfoCreator =
-        new DiffInfoCreator(state, new DiffWebLinksProviderImpl(), true);
-    return diffInfoCreator.create(ps, sideA, sideB);
-  }
-
-  private static class DiffWebLinksProviderImpl implements DiffWebLinksProvider {
-
-    @Override
-    public ImmutableList<DiffWebLinkInfo> getDiffLinks() {
-      return ImmutableList.of();
-    }
-
-    @Override
-    public ImmutableList<WebLinkInfo> getEditWebLinks() {
-      return ImmutableList.of();
-    }
-
-    @Override
-    public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type fileInfoType) {
-      return ImmutableList.of();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
index 7a1808b..6471a62 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRelated.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRelated.java
@@ -21,6 +21,8 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.api.changes.GetRelatedOption;
 import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.RelatedChangesInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -35,19 +37,19 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.kohsuke.args4j.Option;
 
-@Singleton
 public class GetRelated implements RestReadView<RevisionResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ChangeData.Factory changeDataFactory;
   private final GetRelatedChangesUtil getRelatedChangesUtil;
+  private boolean computeSubmittable = false;
 
   @Inject
   GetRelated(ChangeData.Factory changeDataFactory, GetRelatedChangesUtil getRelatedChangesUtil) {
@@ -55,6 +57,15 @@
     this.getRelatedChangesUtil = getRelatedChangesUtil;
   }
 
+  @Option(name = "-o", usage = "Options")
+  public void addOption(GetRelatedOption option) {
+    if (option == GetRelatedOption.SUBMITTABLE) {
+      computeSubmittable = true;
+    } else {
+      throw new IllegalArgumentException("option not recognized: " + option);
+    }
+  }
+
   @Override
   public Response<RelatedChangesInfo> apply(RevisionResource rsrc)
       throws IOException, NoSuchProjectException, PermissionBackendException {
@@ -86,7 +97,7 @@
       } else {
         commit = d.commit();
       }
-      result.add(newChangeAndCommit(rsrc.getProject(), d.data().change(), ps, commit));
+      result.add(newChangeAndCommit(rsrc.getProject(), d.data(), ps, commit));
     }
 
     if (result.size() == 1) {
@@ -98,11 +109,12 @@
     return ImmutableList.copyOf(result);
   }
 
-  static RelatedChangeAndCommitInfo newChangeAndCommit(
-      Project.NameKey project, @Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
+  private RelatedChangeAndCommitInfo newChangeAndCommit(
+      Project.NameKey project, ChangeData cd, @Nullable PatchSet ps, RevCommit c) {
     RelatedChangeAndCommitInfo info = new RelatedChangeAndCommitInfo();
     info.project = project.get();
 
+    Change change = cd.change();
     if (change != null) {
       info.changeId = change.getKey().get();
       info._changeNumber = change.getChangeId();
@@ -110,6 +122,7 @@
       PatchSet.Id curr = change.currentPatchSetId();
       info._currentRevisionNumber = curr != null ? curr.get() : null;
       info.status = ChangeUtil.status(change).toUpperCase(Locale.US);
+      info.submittable = computeSubmittable ? submittable(cd) : null;
     }
 
     info.commit = new CommitInfo();
@@ -124,4 +137,9 @@
     info.commit.subject = c.getShortMessage();
     return info;
   }
+
+  private static boolean submittable(ChangeData cd) {
+    return cd.submitRequirementsIncludingLegacy().values().stream()
+        .allMatch(SubmitRequirementResult::fulfilled);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Ignore.java b/java/com/google/gerrit/server/restapi/change/Ignore.java
deleted file mode 100644
index a049e54..0000000
--- a/java/com/google/gerrit/server/restapi/change/Ignore.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// 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.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class Ignore implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final StarredChangesUtil stars;
-
-  @Inject
-  Ignore(StarredChangesUtil stars) {
-    this.stars = stars;
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Ignore")
-        .setTitle("Ignore the change")
-        .setVisible(canIgnore(rsrc));
-  }
-
-  @Override
-  public Response<String> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, IllegalLabelException {
-    try {
-      if (rsrc.isUserOwner()) {
-        throw new BadRequestException("cannot ignore own change");
-      }
-
-      if (!isIgnored(rsrc)) {
-        stars.ignore(rsrc);
-      }
-      return Response.ok();
-    } catch (MutuallyExclusiveLabelsException e) {
-      throw new ResourceConflictException(e.getMessage());
-    }
-  }
-
-  private boolean canIgnore(ChangeResource rsrc) {
-    return !rsrc.isUserOwner() && !isIgnored(rsrc);
-  }
-
-  private boolean isIgnored(ChangeResource rsrc) {
-    try {
-      return stars.isIgnored(rsrc);
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("failed to check ignored star");
-    }
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index 7683ab7..8aa2554 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -61,7 +61,7 @@
 
   private final GitRepositoryManager gitManager;
   private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeIndexer indexer;
   private final MergeabilityCache cache;
@@ -71,7 +71,7 @@
   Mergeable(
       GitRepositoryManager gitManager,
       ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       ChangeData.Factory changeDataFactory,
       ChangeIndexer indexer,
       MergeabilityCache cache,
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 900b9e5..c1b36d7 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -141,12 +141,9 @@
     // discussion in
     // https://gerrit-review.googlesource.com/c/gerrit/+/129171
     // Only administrators are allowed to keep all labels at their own risk.
-    try {
-      if (input.keepAllVotes) {
-        permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
-      }
-    } catch (AuthException denied) {
-      throw new AuthException("move is not permitted with keepAllVotes option", denied);
+    if (input.keepAllVotes
+        && !permissionBackend.user(caller).test(GlobalPermission.ADMINISTRATE_SERVER)) {
+      throw new AuthException("move is not permitted with keepAllVotes option");
     }
 
     // Move requires abandoning this change, and creating a new change.
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 4605d7c..7a6ac0d 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -15,23 +15,17 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.groupingBy;
-import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
@@ -44,16 +38,11 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.FixReplacement;
-import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.entities.RobotComment;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -75,29 +64,20 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.extensions.validators.CommentForValidation;
-import com.google.gerrit.extensions.validators.CommentValidationContext;
-import com.google.gerrit.extensions.validators.CommentValidationFailure;
-import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.ModifyReviewersEmail;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerModifier;
@@ -106,12 +86,10 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -121,26 +99,17 @@
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -150,7 +119,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
-import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -186,21 +154,16 @@
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
 
-  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
-
   private final BatchUpdate.Factory updateFactory;
+  private final PostReviewOp.Factory postReviewOpFactory;
+  private final PostReviewCopyApprovalsOpFactory postReviewCopyApprovalsOpFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeData.Factory changeDataFactory;
   private final AccountCache accountCache;
   private final ApprovalsUtil approvalsUtil;
-  private final ChangeMessagesUtil cmUtil;
   private final CommentsUtil commentsUtil;
-  private final PublishCommentUtil publishCommentUtil;
-  private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
   private final AccountResolver accountResolver;
-  private final EmailReviewComments.Factory email;
-  private final CommentAdded commentAdded;
   private final ReviewerModifier reviewerModifier;
   private final Metrics metrics;
   private final ModifyReviewersEmail modifyReviewersEmail;
@@ -208,28 +171,23 @@
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
-  private final PluginSetContext<CommentValidator> commentValidators;
-  private final PluginSetContext<OnPostReview> onPostReviews;
+
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final ReviewerAdded reviewerAdded;
   private final boolean strictLabels;
-  private final boolean publishPatchSetLevelComment;
 
   @Inject
   PostReview(
       BatchUpdate.Factory updateFactory,
+      PostReviewOp.Factory postReviewOpFactory,
+      PostReviewCopyApprovalsOpFactory postReviewCopyApprovalsOpFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
       AccountCache accountCache,
       ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil cmUtil,
       CommentsUtil commentsUtil,
-      PublishCommentUtil publishCommentUtil,
-      PatchSetUtil psUtil,
       PatchListCache patchListCache,
       AccountResolver accountResolver,
-      EmailReviewComments.Factory email,
-      CommentAdded commentAdded,
       ReviewerModifier reviewerModifier,
       Metrics metrics,
       ModifyReviewersEmail modifyReviewersEmail,
@@ -238,23 +196,18 @@
       WorkInProgressOp.Factory workInProgressOpFactory,
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
-      PluginSetContext<CommentValidator> commentValidators,
-      PluginSetContext<OnPostReview> onPostReviews,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
       ReviewerAdded reviewerAdded) {
     this.updateFactory = updateFactory;
+    this.postReviewOpFactory = postReviewOpFactory;
+    this.postReviewCopyApprovalsOpFactory = postReviewCopyApprovalsOpFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
     this.commentsUtil = commentsUtil;
-    this.publishCommentUtil = publishCommentUtil;
-    this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
-    this.cmUtil = cmUtil;
     this.accountResolver = accountResolver;
-    this.email = email;
-    this.commentAdded = commentAdded;
     this.reviewerModifier = reviewerModifier;
     this.metrics = metrics;
     this.modifyReviewersEmail = modifyReviewersEmail;
@@ -262,13 +215,9 @@
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
-    this.commentValidators = commentValidators;
-    this.onPostReviews = onPostReviews;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.reviewerAdded = reviewerAdded;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
-    this.publishPatchSetLevelComment =
-        gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
   }
 
   @Override
@@ -354,8 +303,13 @@
     }
     output.labels = input.labels;
 
+    // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
+    NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
+
     try (BatchUpdate bu =
         updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
+      bu.setNotify(notify);
+
       Account account = revision.getUser().asIdentifiedUser().getAccount();
       boolean ccOrReviewer = false;
       if (input.labels != null && !input.labels.isEmpty()) {
@@ -425,32 +379,32 @@
         bu.addOp(revision.getChange().getId(), wipOp);
       }
 
-      // Add the review op.
+      // Add the review ops.
       logger.atFine().log("posting review");
+      PostReviewOp postReviewOp =
+          postReviewOpFactory.create(projectState, revision.getPatchSet().id(), input);
+      bu.addOp(revision.getChange().getId(), postReviewOp);
       bu.addOp(
-          revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
-
-      // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
-      NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
-      bu.setNotify(notify);
+          revision.getChange().getId(),
+          postReviewCopyApprovalsOpFactory.create(revision.getPatchSet().id()));
 
       // Adjust the attention set based on the input
       replyAttentionSetUpdates.updateAttentionSet(
           bu, revision.getNotes(), input, revision.getUser());
       bu.execute();
-
-      // Re-read change to take into account results of the update.
-      ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
-      for (ReviewerModification reviewerResult : reviewerResults) {
-        reviewerResult.gatherResults(cd);
-      }
-
-      // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
-      // email/event here.
-      batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
-      batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
     }
 
+    // Re-read change to take into account results of the update.
+    ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
+    for (ReviewerModification reviewerResult : reviewerResults) {
+      reviewerResult.gatherResults(cd);
+    }
+
+    // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
+    // email/event here.
+    batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
+    batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
+
     return Response.ok(output);
   }
 
@@ -487,7 +441,9 @@
       Change change,
       List<ReviewerModification> reviewerModifications,
       NotifyResolver.Result notify) {
-    try (TraceContext.TraceTimer ignored = newTimer("batchEmailReviewers")) {
+    try (TraceContext.TraceTimer ignored =
+        TraceContext.newTimer(
+            getClass().getSimpleName() + "#batchEmailReviewers", Metadata.empty())) {
       List<Account.Id> to = new ArrayList<>();
       List<Account.Id> cc = new ArrayList<>();
       List<Account.Id> removed = new ArrayList<>();
@@ -557,7 +513,8 @@
 
   private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
       throws BadRequestException, AuthException, UnprocessableEntityException,
-          PermissionBackendException, IOException, ConfigInvalidException {
+          ResourceConflictException, PermissionBackendException, IOException,
+          ConfigInvalidException {
     logger.atFine().log("request is executed on behalf of %s", in.onBehalfOf);
 
     if (in.labels == null || in.labels.isEmpty()) {
@@ -615,7 +572,7 @@
     try {
       permissionBackend.user(reviewer).change(rev.getNotes()).check(ChangePermission.READ);
     } catch (AuthException e) {
-      throw new UnprocessableEntityException(
+      throw new ResourceConflictException(
           String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()), e);
     }
 
@@ -698,10 +655,6 @@
         .collect(toList());
   }
 
-  private TraceContext.TraceTimer newTimer(String method) {
-    return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
-  }
-
   private <T extends com.google.gerrit.extensions.client.Comment> void checkComments(
       RevisionResource revision, Map<String, List<T>> commentsPerPath)
       throws BadRequestException, PatchListNotAvailableException {
@@ -1008,643 +961,4 @@
     @Nullable
     abstract Comment.Range range();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final ProjectState projectState;
-    private final PatchSet.Id psId;
-    private final ReviewInput in;
-
-    private IdentifiedUser user;
-    private ChangeNotes notes;
-    private PatchSet ps;
-    private String mailMessage;
-    private List<Comment> comments = new ArrayList<>();
-    private List<LabelVote> labelDelta = new ArrayList<>();
-    private Map<String, Short> approvals = new HashMap<>();
-    private Map<String, Short> oldApprovals = new HashMap<>();
-
-    private Op(ProjectState projectState, PatchSet.Id psId, ReviewInput in) {
-      this.projectState = projectState;
-      this.psId = psId;
-      this.in = in;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceConflictException, UnprocessableEntityException, IOException,
-            CommentsRejectedException {
-      user = ctx.getIdentifiedUser();
-      notes = ctx.getNotes();
-      ps = psUtil.get(ctx.getNotes(), psId);
-      List<RobotComment> newRobotComments =
-          in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
-      boolean dirty = false;
-      try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
-        dirty |= insertComments(ctx, newRobotComments);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
-        dirty |= insertRobotComments(ctx, newRobotComments);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
-        dirty |= updateLabels(projectState, ctx);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
-        dirty |= insertMessage(ctx);
-      }
-      return dirty;
-    }
-
-    @Override
-    public void postUpdate(PostUpdateContext ctx) {
-      if (mailMessage == null) {
-        return;
-      }
-      NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
-      if (notify.shouldNotify()) {
-        try {
-          email
-              .create(
-                  notify,
-                  notes,
-                  ps,
-                  user,
-                  mailMessage,
-                  ctx.getWhen(),
-                  comments,
-                  in.message,
-                  labelDelta,
-                  ctx.getRepoView())
-              .sendAsync();
-        } catch (IOException ex) {
-          throw new StorageException(
-              String.format("Repository %s not found", ctx.getProject().get()), ex);
-        }
-      }
-      String comment = mailMessage;
-      if (publishPatchSetLevelComment) {
-        // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
-        // added event. For backwards compatibility, patchset level comment has a higher priority
-        // than change message and should be used as comment in comment added event.
-        if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
-          List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
-          if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
-            CommentInput firstComment = patchSetLevelComments.get(0);
-            if (!Strings.isNullOrEmpty(firstComment.message)) {
-              comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
-            }
-          }
-        }
-      }
-      commentAdded.fire(
-          ctx.getChangeData(notes),
-          ps,
-          user.state(),
-          comment,
-          approvals,
-          oldApprovals,
-          ctx.getWhen());
-    }
-
-    /**
-     * Publishes draft and input comments. Input comments are those passed as input in the request
-     * body.
-     *
-     * @param ctx context for performing the change update.
-     * @param newRobotComments robot comments. Used only for validation in this method.
-     * @return true if any input comments where published.
-     */
-    private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
-        throws CommentsRejectedException {
-      Map<String, List<CommentInput>> inputComments = in.comments;
-      if (inputComments == null) {
-        inputComments = Collections.emptyMap();
-      }
-
-      // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
-      Map<String, HumanComment> drafts = new HashMap<>();
-
-      if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
-        drafts =
-            in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
-                ? changeDrafts(ctx)
-                : patchSetDrafts(ctx);
-      }
-
-      // Existing published comments
-      Set<CommentSetEntry> existingComments =
-          in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
-
-      // Input comments should be deduplicated from existing drafts
-      List<HumanComment> inputCommentsToPublish =
-          resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
-
-      switch (in.drafts) {
-        case PUBLISH:
-        case PUBLISH_ALL_REVISIONS:
-          Collection<HumanComment> filteredDrafts =
-              in.draftIdsToPublish == null
-                  ? drafts.values()
-                  : drafts.values().stream()
-                      .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
-                      .collect(Collectors.toList());
-
-          validateComments(
-              ctx,
-              Streams.concat(
-                  drafts.values().stream(),
-                  inputCommentsToPublish.stream(),
-                  newRobotComments.stream()));
-          publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
-          comments.addAll(drafts.values());
-          break;
-        case KEEP:
-          validateComments(
-              ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
-          break;
-      }
-      commentsUtil.putHumanComments(
-          ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
-      comments.addAll(inputCommentsToPublish);
-      return !inputCommentsToPublish.isEmpty();
-    }
-
-    /**
-     * Returns the subset of {@code inputComments} that do not have a matching comment (with same
-     * id) neither in {@code existingComments} nor in {@code drafts}.
-     *
-     * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
-     * removed.
-     *
-     * @param inputComments new comments provided as {@link CommentInput} entries in the API.
-     * @param existingComments existing published comments in the database.
-     * @param drafts existing draft comments in the database. This map can be modified.
-     */
-    private List<HumanComment> resolveInputCommentsAndDrafts(
-        Map<String, List<CommentInput>> inputComments,
-        Set<CommentSetEntry> existingComments,
-        Map<String, HumanComment> drafts,
-        ChangeContext ctx) {
-      List<HumanComment> inputCommentsToPublish = new ArrayList<>();
-      for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
-        String path = entry.getKey();
-        for (CommentInput inputComment : entry.getValue()) {
-          HumanComment comment = drafts.remove(Url.decode(inputComment.id));
-          if (comment == null) {
-            String parent = Url.decode(inputComment.inReplyTo);
-            comment =
-                commentsUtil.newHumanComment(
-                    ctx.getNotes(),
-                    ctx.getUser(),
-                    ctx.getWhen(),
-                    path,
-                    psId,
-                    inputComment.side(),
-                    inputComment.message,
-                    inputComment.unresolved,
-                    parent);
-          } else {
-            // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
-            comment.writtenOn = Timestamp.from(ctx.getWhen());
-            comment.side = inputComment.side();
-            comment.message = inputComment.message;
-          }
-
-          commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
-          comment.setLineNbrAndRange(inputComment.line, inputComment.range);
-          comment.tag = in.tag;
-
-          if (existingComments.contains(CommentSetEntry.create(comment))) {
-            continue;
-          }
-          inputCommentsToPublish.add(comment);
-        }
-      }
-      return inputCommentsToPublish;
-    }
-
-    /**
-     * Validates all comments and the change message in a single call to fulfill the interface
-     * contract of {@link CommentValidator#validateComments(CommentValidationContext,
-     * ImmutableList)}.
-     */
-    private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
-        throws CommentsRejectedException {
-      CommentValidationContext commentValidationCtx =
-          CommentValidationContext.create(
-              ctx.getChange().getChangeId(),
-              ctx.getChange().getProject().get(),
-              ctx.getChange().getDest().branch());
-      String changeMessage = Strings.nullToEmpty(in.message).trim();
-      ImmutableList<CommentForValidation> draftsForValidation =
-          Stream.concat(
-                  comments.map(
-                      comment ->
-                          CommentForValidation.create(
-                              comment instanceof RobotComment
-                                  ? CommentForValidation.CommentSource.ROBOT
-                                  : CommentForValidation.CommentSource.HUMAN,
-                              comment.lineNbr > 0
-                                  ? CommentForValidation.CommentType.INLINE_COMMENT
-                                  : CommentForValidation.CommentType.FILE_COMMENT,
-                              comment.message,
-                              comment.getApproximateSize())),
-                  Stream.of(
-                      CommentForValidation.create(
-                          CommentForValidation.CommentSource.HUMAN,
-                          CommentForValidation.CommentType.CHANGE_MESSAGE,
-                          changeMessage,
-                          changeMessage.length())))
-              .collect(toImmutableList());
-      ImmutableList<CommentValidationFailure> draftValidationFailures =
-          PublishCommentUtil.findInvalidComments(
-              commentValidationCtx, commentValidators, draftsForValidation);
-      if (!draftValidationFailures.isEmpty()) {
-        throw new CommentsRejectedException(draftValidationFailures);
-      }
-    }
-
-    private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
-      if (in.robotComments == null) {
-        return false;
-      }
-      commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
-      comments.addAll(newRobotComments);
-      return !newRobotComments.isEmpty();
-    }
-
-    private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
-      List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
-
-      Set<CommentSetEntry> existingIds =
-          in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
-
-      for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
-        String path = ent.getKey();
-        for (RobotCommentInput c : ent.getValue()) {
-          RobotComment e = createRobotCommentFromInput(ctx, path, c);
-          if (existingIds.contains(CommentSetEntry.create(e))) {
-            continue;
-          }
-          toAdd.add(e);
-        }
-      }
-      return toAdd;
-    }
-
-    private RobotComment createRobotCommentFromInput(
-        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
-      RobotComment robotComment =
-          commentsUtil.newRobotComment(
-              ctx,
-              path,
-              psId,
-              robotCommentInput.side(),
-              robotCommentInput.message,
-              robotCommentInput.robotId,
-              robotCommentInput.robotRunId);
-      robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
-      robotComment.url = robotCommentInput.url;
-      robotComment.properties = robotCommentInput.properties;
-      robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
-      robotComment.tag = in.tag;
-      commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
-      robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
-      return robotComment;
-    }
-
-    private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
-        List<FixSuggestionInfo> fixSuggestionInfos) {
-      if (fixSuggestionInfos == null) {
-        return ImmutableList.of();
-      }
-
-      ImmutableList.Builder<FixSuggestion> fixSuggestions =
-          ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
-      for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
-        fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
-      }
-      return fixSuggestions.build();
-    }
-
-    private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
-      List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
-      String fixId = ChangeUtil.messageUuid();
-      return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
-    }
-
-    private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
-      return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
-    }
-
-    private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
-      Comment.Range range = new Comment.Range(fixReplacementInfo.range);
-      return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
-    }
-
-    private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
-      return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
-          .map(CommentSetEntry::create)
-          .collect(toSet());
-    }
-
-    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
-      return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
-          .map(CommentSetEntry::create)
-          .collect(toSet());
-    }
-
-    private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
-      return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
-          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
-    }
-
-    private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
-      return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
-          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
-    }
-
-    private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
-      Map<String, Short> labels = new HashMap<>();
-      for (PatchSetApproval psa : patchsetApprovals) {
-        labels.put(psa.label(), psa.value());
-      }
-      return labels;
-    }
-
-    private Map<String, Short> getAllApprovals(
-        LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
-      Map<String, Short> allApprovals = new HashMap<>();
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        allApprovals.put(lt.getName(), (short) 0);
-      }
-      // set approvals to existing votes
-      if (current != null) {
-        allApprovals.putAll(current);
-      }
-      // set approvals to new votes
-      if (input != null) {
-        allApprovals.putAll(input);
-      }
-      return allApprovals;
-    }
-
-    private Map<String, Short> getPreviousApprovals(
-        Map<String, Short> allApprovals, Map<String, Short> current) {
-      Map<String, Short> previous = new HashMap<>();
-      for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
-        // assume vote is 0 if there is no vote
-        if (!current.containsKey(approval.getKey())) {
-          previous.put(approval.getKey(), (short) 0);
-        } else {
-          previous.put(approval.getKey(), current.get(approval.getKey()));
-        }
-      }
-      return previous;
-    }
-
-    private boolean isReviewer(ChangeContext ctx) {
-      return approvalsUtil
-          .getReviewers(ctx.getNotes())
-          .byState(REVIEWER)
-          .contains(ctx.getAccountId());
-    }
-
-    private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
-        throws ResourceConflictException {
-      Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
-
-      // If no labels were modified and change is closed, abort early.
-      // This avoids trying to record a modified label caused by a user
-      // losing access to a label after the change was submitted.
-      if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
-        return false;
-      }
-
-      List<PatchSetApproval> del = new ArrayList<>();
-      List<PatchSetApproval> ups = new ArrayList<>();
-      Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-      Map<String, Short> allApprovals =
-          getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
-      Map<String, Short> previous =
-          getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
-
-      ChangeUpdate update = ctx.getUpdate(psId);
-      for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
-        String name = ent.getKey();
-        LabelType lt =
-            labelTypes
-                .byLabel(name)
-                .orElseThrow(() -> new IllegalStateException("no label config for " + name));
-
-        PatchSetApproval c = current.remove(lt.getName());
-        String normName = lt.getName();
-        approvals.put(normName, (short) 0);
-        if (ent.getValue() == null || ent.getValue() == 0) {
-          // User requested delete of this label.
-          oldApprovals.put(normName, null);
-          if (c != null) {
-            if (c.value() != 0) {
-              addLabelDelta(normName, (short) 0);
-              oldApprovals.put(normName, previous.get(normName));
-            }
-            del.add(c);
-            update.putApproval(normName, (short) 0);
-          }
-          // Only allow voting again if the vote is copied over from a past patch-set, or the
-          // values are different.
-        } else if (c != null
-            && (c.value() != ent.getValue() || isApprovalCopiedOver(c, ctx.getNotes()))) {
-          PatchSetApproval.Builder b =
-              c.toBuilder()
-                  .value(ent.getValue())
-                  .granted(ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag));
-          ctx.getUser().updateRealAccountId(b::realAccountId);
-          c = b.build();
-          ups.add(c);
-          addLabelDelta(normName, c.value());
-          oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.value());
-          update.putApproval(normName, ent.getValue());
-        } else if (c != null && c.value() == ent.getValue()) {
-          current.put(normName, c);
-          oldApprovals.put(normName, null);
-          approvals.put(normName, c.value());
-        } else if (c == null) {
-          c =
-              ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag))
-                  .granted(ctx.getWhen())
-                  .build();
-          ups.add(c);
-          addLabelDelta(normName, c.value());
-          oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.value());
-          update.putReviewer(user.getAccountId(), REVIEWER);
-          update.putApproval(normName, ent.getValue());
-        }
-      }
-
-      validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
-
-      // Return early if user is not a reviewer and not posting any labels.
-      // This allows us to preserve their CC status.
-      if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
-        return false;
-      }
-
-      return !del.isEmpty() || !ups.isEmpty();
-    }
-
-    /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
-    private boolean isApprovalCopiedOver(
-        PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
-      return !changeNotes.getApprovals().get(changeNotes.getChange().currentPatchSetId()).stream()
-          .anyMatch(p -> p.equals(patchSetApproval));
-    }
-
-    private void validatePostSubmitLabels(
-        ChangeContext ctx,
-        LabelTypes labelTypes,
-        Map<String, Short> previous,
-        List<PatchSetApproval> ups,
-        List<PatchSetApproval> del)
-        throws ResourceConflictException {
-      if (ctx.getChange().isNew()) {
-        return; // Not closed, nothing to validate.
-      } else if (del.isEmpty() && ups.isEmpty()) {
-        return; // No new votes.
-      } else if (!ctx.getChange().isMerged()) {
-        throw new ResourceConflictException("change is closed");
-      }
-
-      // Disallow reducing votes on any labels post-submit. This assumes the
-      // high values were broadly necessary to submit, so reducing them would
-      // make it possible to take a merged change and make it no longer
-      // submittable.
-      List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
-      List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
-
-      for (PatchSetApproval psa : del) {
-        LabelType lt =
-            labelTypes
-                .byLabel(psa.label())
-                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
-        String normName = lt.getName();
-        if (!lt.isAllowPostSubmit()) {
-          disallowed.add(normName);
-        }
-        Short prev = previous.get(normName);
-        if (prev != null && prev != 0) {
-          reduced.add(psa);
-        }
-      }
-
-      for (PatchSetApproval psa : ups) {
-        LabelType lt =
-            labelTypes
-                .byLabel(psa.label())
-                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
-        String normName = lt.getName();
-        if (!lt.isAllowPostSubmit()) {
-          disallowed.add(normName);
-        }
-        Short prev = previous.get(normName);
-        if (prev == null) {
-          continue;
-        }
-        if (prev > psa.value()) {
-          reduced.add(psa);
-        }
-        // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
-      }
-
-      if (!disallowed.isEmpty()) {
-        throw new ResourceConflictException(
-            "Voting on labels disallowed after submit: "
-                + disallowed.stream().distinct().sorted().collect(joining(", ")));
-      }
-      if (!reduced.isEmpty()) {
-        throw new ResourceConflictException(
-            "Cannot reduce vote on labels for closed change: "
-                + reduced.stream()
-                    .map(PatchSetApproval::label)
-                    .distinct()
-                    .sorted()
-                    .collect(joining(", ")));
-      }
-    }
-
-    private Map<String, PatchSetApproval> scanLabels(
-        ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-      Map<String, PatchSetApproval> current = new HashMap<>();
-
-      for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
-        if (a.isLegacySubmit()) {
-          continue;
-        }
-
-        Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
-        if (lt.isPresent()) {
-          current.put(lt.get().getName(), a);
-        } else {
-          del.add(a);
-        }
-      }
-      return current;
-    }
-
-    private boolean insertMessage(ChangeContext ctx) {
-      String msg = Strings.nullToEmpty(in.message).trim();
-
-      StringBuilder buf = new StringBuilder();
-      for (LabelVote d : labelDelta) {
-        buf.append(" ").append(d.format());
-      }
-      if (comments.size() == 1) {
-        buf.append("\n\n(1 comment)");
-      } else if (comments.size() > 1) {
-        buf.append(String.format("\n\n(%d comments)", comments.size()));
-      }
-      if (!msg.isEmpty()) {
-        // Message was already validated when validating comments, since validators need to see
-        // everything in a single call.
-        buf.append("\n\n").append(msg);
-      } else if (in.ready) {
-        buf.append("\n\n" + START_REVIEW_MESSAGE);
-      }
-
-      List<String> pluginMessages = new ArrayList<>();
-      onPostReviews.runEach(
-          onPostReview ->
-              onPostReview
-                  .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
-                  .ifPresent(
-                      pluginMessage ->
-                          pluginMessages.add(
-                              !pluginMessage.endsWith("\n")
-                                  ? pluginMessage + "\n"
-                                  : pluginMessage)));
-      if (!pluginMessages.isEmpty()) {
-        buf.append("\n\n");
-        buf.append(Joiner.on("\n").join(pluginMessages));
-      }
-
-      if (buf.length() == 0) {
-        return false;
-      }
-
-      mailMessage =
-          cmUtil.setChangeMessage(
-              ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
-      return true;
-    }
-
-    private void addLabelDelta(String name, short value) {
-      labelDelta.add(LabelVote.create(name, value));
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java
new file mode 100644
index 0000000..88d2d7b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java
@@ -0,0 +1,236 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Table.Cell;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.approval.ApprovalCopier;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import java.io.IOException;
+import java.util.Optional;
+
+/**
+ * Batch update operation that copy approvals that have been newly applied on outdated patch sets to
+ * the follow-up patch sets if they are copyable and no non-copied approvals prevent the copying.
+ *
+ * <p>Must be invoked after the batch update operation which applied new approvals on outdated patch
+ * sets (e.g. after {@link PostReviewOp}.
+ */
+@AutoFactory
+public class PostReviewCopyApprovalsOp implements BatchUpdateOp {
+  private final ApprovalCopier approvalCopier;
+  private final PatchSetUtil patchSetUtil;
+  private final PatchSet.Id patchSetId;
+
+  private ChangeContext ctx;
+  private ImmutableList<PatchSet.Id> followUpPatchSets;
+
+  PostReviewCopyApprovalsOp(
+      @Provided ApprovalCopier approvalCopier,
+      @Provided PatchSetUtil patchSetUtil,
+      PatchSet.Id patchSetId) {
+    this.approvalCopier = approvalCopier;
+    this.patchSetUtil = patchSetUtil;
+    this.patchSetId = patchSetId;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws IOException {
+    if (ctx.getNotes().getCurrentPatchSet().id().equals(patchSetId)) {
+      // the updated patch set is the current patch, there a no follow-up patch set to which new
+      // approvals could be copied
+      return false;
+    }
+
+    init(ctx);
+
+    boolean dirty = false;
+    ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> newApprovals =
+        ctx.getUpdate(patchSetId).getApprovals();
+    for (Cell<String, Account.Id, Optional<PatchSetApproval>> cell : newApprovals.cellSet()) {
+      String label = cell.getRowKey();
+      Account.Id approverId = cell.getColumnKey();
+      PatchSetApproval.Key psaKey =
+          PatchSetApproval.key(patchSetId, approverId, LabelId.create(label));
+
+      if (isRemoval(cell)) {
+        if (removeCopies(psaKey)) {
+          dirty = true;
+        }
+        continue;
+      }
+
+      PatchSet patchSet = patchSetUtil.get(ctx.getNotes(), patchSetId);
+      PatchSetApproval psaOrig = cell.getValue().get();
+
+      // Target patch sets to which the approval is copyable.
+      ImmutableList<PatchSet.Id> targetPatchSets =
+          approvalCopier.forApproval(
+              ctx.getNotes(),
+              patchSet,
+              psaKey.accountId(),
+              psaKey.labelId().get(),
+              psaOrig.value());
+
+      // Iterate over all follow-up patch sets, in patch set order.
+      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
+        if (hasOverrideOf(followUpPatchSetId, psaKey)) {
+          // a non-copied approval exists that overrides any copied approval
+          // -> do not copy the approval to this patch set nor to any follow-up patch sets
+          break;
+        }
+
+        if (targetPatchSets.contains(followUpPatchSetId)) {
+          // The approval is copyable to the new patch set.
+
+          if (hasCopyOfWithValue(followUpPatchSetId, psaKey, psaOrig.value())) {
+            // a copy approval with the exact value already exists
+            continue;
+          }
+
+          // add/update the copied approval on the target patch set
+          PatchSetApproval copiedPatchSetApproval = psaOrig.copyWithPatchSet(followUpPatchSetId);
+          ctx.getUpdate(followUpPatchSetId).putCopiedApproval(copiedPatchSetApproval);
+          dirty = true;
+        } else {
+          // The approval is not copyable to the new patch set.
+
+          if (hasCopyOf(followUpPatchSetId, psaKey)) {
+            // a copy approval exists and should be removed
+            removeCopy(followUpPatchSetId, psaKey);
+            dirty = true;
+          }
+        }
+      }
+    }
+
+    return dirty;
+  }
+
+  private void init(ChangeContext ctx) {
+    this.ctx = ctx;
+
+    // compute follow-up patch sets (sorted by patch set ID)
+    this.followUpPatchSets =
+        ctx.getNotes().getPatchSets().keySet().stream()
+            .filter(psId -> psId.get() > patchSetId.get())
+            .collect(toImmutableList());
+  }
+
+  /**
+   * Whether the given cell entry from the approval table represents the removal of an approval.
+   *
+   * @param cell cell entry from the approval table
+   * @return {@code true} if the approval is not set or the approval has {@code 0} as the value,
+   *     otherwise {@code false}
+   */
+  private boolean isRemoval(Cell<String, Account.Id, Optional<PatchSetApproval>> cell) {
+    return cell.getValue().isEmpty() || cell.getValue().get().value() == 0;
+  }
+
+  /**
+   * Removes copies of the given approval from all follow-up patch sets.
+   *
+   * @param psaKey the key of the patch set approval for which copies should be removed from all
+   *     follow-up patch sets
+   * @return whether any copy approval has been removed
+   */
+  private boolean removeCopies(PatchSetApproval.Key psaKey) {
+    boolean dirty = false;
+    for (PatchSet.Id followUpPatchSet : followUpPatchSets) {
+      if (hasCopyOf(followUpPatchSet, psaKey)) {
+        removeCopy(followUpPatchSet, psaKey);
+      } else {
+        // Do not remove copy from this follow-up patch sets and also not from any further follow-up
+        // patch sets (if the further follow-up patch sets have copies they are copies of a
+        // non-copied approval on this follow-up patch set and hence those should not be removed).
+        break;
+      }
+    }
+    return dirty;
+  }
+
+  /**
+   * Removes the copy approval with the given key from the given patch set.
+   *
+   * @param patchSet patch set from which the copy approval with the given key should be removed
+   * @param psaKey the key of the patch set approval for which copies should be removed from the
+   *     given patch set
+   */
+  private void removeCopy(PatchSet.Id patchSet, PatchSetApproval.Key psaKey) {
+    ctx.getUpdate(patchSet)
+        .removeCopiedApprovalFor(
+            ctx.getIdentifiedUser().getRealUser().isIdentifiedUser()
+                ? ctx.getIdentifiedUser().getRealUser().getAccountId()
+                : null,
+            psaKey.accountId(),
+            psaKey.labelId().get());
+  }
+
+  /**
+   * Whether the given patch set has a copy approval with the given key.
+   *
+   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
+   *     approval with the given key
+   * @param psaKey the key of the patch set approval
+   */
+  private boolean hasCopyOf(PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
+    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
+        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
+  }
+
+  /**
+   * Whether the given patch set has a copy approval with the given key and value.
+   *
+   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
+   *     approval with the given key and value
+   * @param psaKey the key of the patch set approval
+   */
+  private boolean hasCopyOfWithValue(
+      PatchSet.Id patchSetId, PatchSetApproval.Key psaKey, short value) {
+    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
+        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey) && psa.value() == value);
+  }
+
+  /**
+   * Whether the given patch set has a normal approval with the given key that overrides copy
+   * approvals with that key.
+   *
+   * @param patchSetId the ID of the patch for which it should be checked whether it has a normal
+   *     approval with the given key that overrides copy approvals with that key
+   * @param psaKey the key of the patch set approval
+   */
+  private boolean hasOverrideOf(PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
+    return ctx.getNotes().getApprovals().onlyNonCopied().get(patchSetId).stream()
+        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
+  }
+
+  private boolean areAccountAndLabelTheSame(
+      PatchSetApproval.Key psaKey1, PatchSetApproval.Key psaKey2) {
+    return psaKey1.accountId().equals(psaKey2.accountId())
+        && psaKey1.labelId().equals(psaKey2.labelId());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
new file mode 100644
index 0000000..5ff0968
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -0,0 +1,758 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.PublishCommentUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.change.PostReview.CommentSetEntry;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.CommentsRejectedException;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+
+public class PostReviewOp implements BatchUpdateOp {
+  interface Factory {
+    PostReviewOp create(ProjectState projectState, PatchSet.Id psId, ReviewInput in);
+  }
+
+  @VisibleForTesting
+  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
+
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final CommentsUtil commentsUtil;
+  private final PublishCommentUtil publishCommentUtil;
+  private final PatchSetUtil psUtil;
+  private final EmailReviewComments.Factory email;
+  private final CommentAdded commentAdded;
+  private final PluginSetContext<CommentValidator> commentValidators;
+  private final PluginSetContext<OnPostReview> onPostReviews;
+
+  private final ProjectState projectState;
+  private final PatchSet.Id psId;
+  private final ReviewInput in;
+  private final boolean publishPatchSetLevelComment;
+
+  private IdentifiedUser user;
+  private ChangeNotes notes;
+  private PatchSet ps;
+  private String mailMessage;
+  private List<Comment> comments = new ArrayList<>();
+  private List<LabelVote> labelDelta = new ArrayList<>();
+  private Map<String, Short> approvals = new HashMap<>();
+  private Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  PostReviewOp(
+      @GerritServerConfig Config gerritConfig,
+      ApprovalsUtil approvalsUtil,
+      ChangeMessagesUtil cmUtil,
+      CommentsUtil commentsUtil,
+      PublishCommentUtil publishCommentUtil,
+      PatchSetUtil psUtil,
+      EmailReviewComments.Factory email,
+      CommentAdded commentAdded,
+      PluginSetContext<CommentValidator> commentValidators,
+      PluginSetContext<OnPostReview> onPostReviews,
+      @Assisted ProjectState projectState,
+      @Assisted PatchSet.Id psId,
+      @Assisted ReviewInput in) {
+    this.approvalsUtil = approvalsUtil;
+    this.publishCommentUtil = publishCommentUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.commentsUtil = commentsUtil;
+    this.email = email;
+    this.commentAdded = commentAdded;
+    this.commentValidators = commentValidators;
+    this.onPostReviews = onPostReviews;
+    this.publishPatchSetLevelComment =
+        gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
+
+    this.projectState = projectState;
+    this.psId = psId;
+    this.in = in;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws ResourceConflictException, UnprocessableEntityException, IOException,
+          CommentsRejectedException {
+    user = ctx.getIdentifiedUser();
+    notes = ctx.getNotes();
+    ps = psUtil.get(ctx.getNotes(), psId);
+    List<RobotComment> newRobotComments =
+        in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
+    boolean dirty = false;
+    try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
+      dirty |= insertComments(ctx, newRobotComments);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
+      dirty |= insertRobotComments(ctx, newRobotComments);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
+      dirty |= updateLabels(projectState, ctx);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
+      dirty |= insertMessage(ctx);
+    }
+    return dirty;
+  }
+
+  @Override
+  public void postUpdate(PostUpdateContext ctx) {
+    if (mailMessage == null) {
+      return;
+    }
+    NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
+    if (notify.shouldNotify()) {
+      email
+          .create(ctx, ps, notes.getMetaId(), mailMessage, comments, in.message, labelDelta)
+          .sendAsync();
+    }
+    String comment = mailMessage;
+    if (publishPatchSetLevelComment) {
+      // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
+      // added event. For backwards compatibility, patchset level comment has a higher priority
+      // than change message and should be used as comment in comment added event.
+      if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
+        List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
+        if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
+          CommentInput firstComment = patchSetLevelComments.get(0);
+          if (!Strings.isNullOrEmpty(firstComment.message)) {
+            comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
+          }
+        }
+      }
+    }
+    commentAdded.fire(
+        ctx.getChangeData(notes),
+        ps,
+        user.state(),
+        comment,
+        approvals,
+        oldApprovals,
+        ctx.getWhen());
+  }
+
+  /**
+   * Publishes draft and input comments. Input comments are those passed as input in the request
+   * body.
+   *
+   * @param ctx context for performing the change update.
+   * @param newRobotComments robot comments. Used only for validation in this method.
+   * @return true if any input comments where published.
+   */
+  private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
+      throws CommentsRejectedException {
+    Map<String, List<CommentInput>> inputComments = in.comments;
+    if (inputComments == null) {
+      inputComments = Collections.emptyMap();
+    }
+
+    // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
+    Map<String, HumanComment> drafts = new HashMap<>();
+
+    if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
+      drafts =
+          in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
+              ? changeDrafts(ctx)
+              : patchSetDrafts(ctx);
+    }
+
+    // Existing published comments
+    Set<CommentSetEntry> existingComments =
+        in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
+
+    // Input comments should be deduplicated from existing drafts
+    List<HumanComment> inputCommentsToPublish =
+        resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
+
+    switch (in.drafts) {
+      case PUBLISH:
+      case PUBLISH_ALL_REVISIONS:
+        Collection<HumanComment> filteredDrafts =
+            in.draftIdsToPublish == null
+                ? drafts.values()
+                : drafts.values().stream()
+                    .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
+                    .collect(Collectors.toList());
+
+        validateComments(
+            ctx,
+            Streams.concat(
+                drafts.values().stream(),
+                inputCommentsToPublish.stream(),
+                newRobotComments.stream()));
+        publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
+        comments.addAll(drafts.values());
+        break;
+      case KEEP:
+        validateComments(
+            ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
+        break;
+    }
+    commentsUtil.putHumanComments(
+        ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
+    comments.addAll(inputCommentsToPublish);
+    return !inputCommentsToPublish.isEmpty();
+  }
+
+  /**
+   * Returns the subset of {@code inputComments} that do not have a matching comment (with same id)
+   * neither in {@code existingComments} nor in {@code drafts}.
+   *
+   * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
+   * removed.
+   *
+   * @param inputComments new comments provided as {@link CommentInput} entries in the API.
+   * @param existingComments existing published comments in the database.
+   * @param drafts existing draft comments in the database. This map can be modified.
+   */
+  private List<HumanComment> resolveInputCommentsAndDrafts(
+      Map<String, List<CommentInput>> inputComments,
+      Set<CommentSetEntry> existingComments,
+      Map<String, HumanComment> drafts,
+      ChangeContext ctx) {
+    List<HumanComment> inputCommentsToPublish = new ArrayList<>();
+    for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
+      String path = entry.getKey();
+      for (CommentInput inputComment : entry.getValue()) {
+        HumanComment comment = drafts.remove(Url.decode(inputComment.id));
+        if (comment == null) {
+          String parent = Url.decode(inputComment.inReplyTo);
+          comment =
+              commentsUtil.newHumanComment(
+                  ctx.getNotes(),
+                  ctx.getUser(),
+                  ctx.getWhen(),
+                  path,
+                  psId,
+                  inputComment.side(),
+                  inputComment.message,
+                  inputComment.unresolved,
+                  parent);
+        } else {
+          // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
+          comment.writtenOn = Timestamp.from(ctx.getWhen());
+          comment.side = inputComment.side();
+          comment.message = inputComment.message;
+        }
+
+        commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
+        comment.setLineNbrAndRange(inputComment.line, inputComment.range);
+        comment.tag = in.tag;
+
+        if (existingComments.contains(CommentSetEntry.create(comment))) {
+          continue;
+        }
+        inputCommentsToPublish.add(comment);
+      }
+    }
+    return inputCommentsToPublish;
+  }
+
+  /**
+   * Validates all comments and the change message in a single call to fulfill the interface
+   * contract of {@link CommentValidator#validateComments(CommentValidationContext, ImmutableList)}.
+   */
+  private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
+      throws CommentsRejectedException {
+    CommentValidationContext commentValidationCtx =
+        CommentValidationContext.create(
+            ctx.getChange().getChangeId(),
+            ctx.getChange().getProject().get(),
+            ctx.getChange().getDest().branch());
+    String changeMessage = Strings.nullToEmpty(in.message).trim();
+    ImmutableList<CommentForValidation> draftsForValidation =
+        Stream.concat(
+                comments.map(
+                    comment ->
+                        CommentForValidation.create(
+                            comment instanceof RobotComment
+                                ? CommentForValidation.CommentSource.ROBOT
+                                : CommentForValidation.CommentSource.HUMAN,
+                            comment.lineNbr > 0
+                                ? CommentForValidation.CommentType.INLINE_COMMENT
+                                : CommentForValidation.CommentType.FILE_COMMENT,
+                            comment.message,
+                            comment.getApproximateSize())),
+                Stream.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentSource.HUMAN,
+                        CommentForValidation.CommentType.CHANGE_MESSAGE,
+                        changeMessage,
+                        changeMessage.length())))
+            .collect(toImmutableList());
+    ImmutableList<CommentValidationFailure> draftValidationFailures =
+        PublishCommentUtil.findInvalidComments(
+            commentValidationCtx, commentValidators, draftsForValidation);
+    if (!draftValidationFailures.isEmpty()) {
+      throw new CommentsRejectedException(draftValidationFailures);
+    }
+  }
+
+  private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
+    if (in.robotComments == null) {
+      return false;
+    }
+    commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
+    comments.addAll(newRobotComments);
+    return !newRobotComments.isEmpty();
+  }
+
+  private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
+    List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
+
+    Set<CommentSetEntry> existingIds =
+        in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
+
+    for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
+      String path = ent.getKey();
+      for (RobotCommentInput c : ent.getValue()) {
+        RobotComment e = createRobotCommentFromInput(ctx, path, c);
+        if (existingIds.contains(CommentSetEntry.create(e))) {
+          continue;
+        }
+        toAdd.add(e);
+      }
+    }
+    return toAdd;
+  }
+
+  private RobotComment createRobotCommentFromInput(
+      ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
+    RobotComment robotComment =
+        commentsUtil.newRobotComment(
+            ctx,
+            path,
+            psId,
+            robotCommentInput.side(),
+            robotCommentInput.message,
+            robotCommentInput.robotId,
+            robotCommentInput.robotRunId);
+    robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
+    robotComment.url = robotCommentInput.url;
+    robotComment.properties = robotCommentInput.properties;
+    robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
+    robotComment.tag = in.tag;
+    commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
+    robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
+    return robotComment;
+  }
+
+  private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
+      List<FixSuggestionInfo> fixSuggestionInfos) {
+    if (fixSuggestionInfos == null) {
+      return ImmutableList.of();
+    }
+
+    ImmutableList.Builder<FixSuggestion> fixSuggestions =
+        ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
+    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
+      fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
+    }
+    return fixSuggestions.build();
+  }
+
+  private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
+    List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
+    String fixId = ChangeUtil.messageUuid();
+    return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
+  }
+
+  private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
+    return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
+  }
+
+  private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
+    Comment.Range range = new Comment.Range(fixReplacementInfo.range);
+    return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
+  }
+
+  private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
+    return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
+        .map(CommentSetEntry::create)
+        .collect(toSet());
+  }
+
+  private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
+    return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
+        .map(CommentSetEntry::create)
+        .collect(toSet());
+  }
+
+  private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
+    return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
+        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+  }
+
+  private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
+    return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
+        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+  }
+
+  private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
+    Map<String, Short> labels = new HashMap<>();
+    for (PatchSetApproval psa : patchsetApprovals) {
+      labels.put(psa.label(), psa.value());
+    }
+    return labels;
+  }
+
+  private Map<String, Short> getAllApprovals(
+      LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
+    Map<String, Short> allApprovals = new HashMap<>();
+    for (LabelType lt : labelTypes.getLabelTypes()) {
+      allApprovals.put(lt.getName(), (short) 0);
+    }
+    // set approvals to existing votes
+    if (current != null) {
+      allApprovals.putAll(current);
+    }
+    // set approvals to new votes
+    if (input != null) {
+      allApprovals.putAll(input);
+    }
+    return allApprovals;
+  }
+
+  private Map<String, Short> getPreviousApprovals(
+      Map<String, Short> allApprovals, Map<String, Short> current) {
+    Map<String, Short> previous = new HashMap<>();
+    for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
+      // assume vote is 0 if there is no vote
+      if (!current.containsKey(approval.getKey())) {
+        previous.put(approval.getKey(), (short) 0);
+      } else {
+        previous.put(approval.getKey(), current.get(approval.getKey()));
+      }
+    }
+    return previous;
+  }
+
+  private boolean isReviewer(ChangeContext ctx) {
+    return approvalsUtil
+        .getReviewers(ctx.getNotes())
+        .byState(REVIEWER)
+        .contains(ctx.getAccountId());
+  }
+
+  private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
+      throws ResourceConflictException {
+    Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
+
+    // If no labels were modified and change is closed, abort early.
+    // This avoids trying to record a modified label caused by a user
+    // losing access to a label after the change was submitted.
+    if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
+      return false;
+    }
+
+    List<PatchSetApproval> del = new ArrayList<>();
+    List<PatchSetApproval> ups = new ArrayList<>();
+    Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
+    LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
+    Map<String, Short> allApprovals =
+        getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
+    Map<String, Short> previous =
+        getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
+
+    ChangeUpdate update = ctx.getUpdate(psId);
+    for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
+      String name = ent.getKey();
+      LabelType lt =
+          labelTypes
+              .byLabel(name)
+              .orElseThrow(() -> new IllegalStateException("no label config for " + name));
+
+      PatchSetApproval c = current.remove(lt.getName());
+      String normName = lt.getName();
+      approvals.put(normName, (short) 0);
+      if (ent.getValue() == null || ent.getValue() == 0) {
+        // User requested delete of this label.
+        oldApprovals.put(normName, null);
+        if (c != null) {
+          if (c.value() != 0) {
+            addLabelDelta(normName, (short) 0);
+            oldApprovals.put(normName, previous.get(normName));
+          }
+          del.add(c);
+          update.putApproval(normName, (short) 0);
+        }
+        // Only allow voting again if the vote is copied over from a past patch-set, or the
+        // values are different.
+      } else if (c != null
+          && (c.value() != ent.getValue()
+              || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
+        PatchSetApproval.Builder b =
+            c.toBuilder()
+                .value(ent.getValue())
+                .granted(ctx.getWhen())
+                .tag(Optional.ofNullable(in.tag));
+        ctx.getUser().updateRealAccountId(b::realAccountId);
+        c = b.build();
+        ups.add(c);
+        addLabelDelta(normName, c.value());
+        oldApprovals.put(normName, previous.get(normName));
+        approvals.put(normName, c.value());
+        update.putApproval(normName, ent.getValue());
+      } else if (c != null && c.value() == ent.getValue()) {
+        current.put(normName, c);
+        oldApprovals.put(normName, null);
+        approvals.put(normName, c.value());
+      } else if (c == null) {
+        c =
+            ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
+                .tag(Optional.ofNullable(in.tag))
+                .granted(ctx.getWhen())
+                .build();
+        ups.add(c);
+        addLabelDelta(normName, c.value());
+        oldApprovals.put(normName, previous.get(normName));
+        approvals.put(normName, c.value());
+        update.putReviewer(user.getAccountId(), REVIEWER);
+        update.putApproval(normName, ent.getValue());
+      }
+    }
+
+    validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
+
+    // Return early if user is not a reviewer and not posting any labels.
+    // This allows us to preserve their CC status.
+    if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
+      return false;
+    }
+
+    return !del.isEmpty() || !ups.isEmpty();
+  }
+
+  /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
+  private boolean isApprovalCopiedOver(PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
+    return !changeNotes.getApprovals().onlyNonCopied()
+        .get(changeNotes.getChange().currentPatchSetId()).stream()
+        .anyMatch(p -> p.equals(patchSetApproval));
+  }
+
+  private void validatePostSubmitLabels(
+      ChangeContext ctx,
+      LabelTypes labelTypes,
+      Map<String, Short> previous,
+      List<PatchSetApproval> ups,
+      List<PatchSetApproval> del)
+      throws ResourceConflictException {
+    if (ctx.getChange().isNew()) {
+      return; // Not closed, nothing to validate.
+    } else if (del.isEmpty() && ups.isEmpty()) {
+      return; // No new votes.
+    } else if (!ctx.getChange().isMerged()) {
+      throw new ResourceConflictException("change is closed");
+    }
+
+    // Disallow reducing votes on any labels post-submit. This assumes the
+    // high values were broadly necessary to submit, so reducing them would
+    // make it possible to take a merged change and make it no longer
+    // submittable.
+    List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
+    List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
+
+    for (PatchSetApproval psa : del) {
+      LabelType lt =
+          labelTypes
+              .byLabel(psa.label())
+              .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
+      String normName = lt.getName();
+      if (!lt.isAllowPostSubmit()) {
+        disallowed.add(normName);
+      }
+      Short prev = previous.get(normName);
+      if (prev != null && prev != 0) {
+        reduced.add(psa);
+      }
+    }
+
+    for (PatchSetApproval psa : ups) {
+      LabelType lt =
+          labelTypes
+              .byLabel(psa.label())
+              .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
+      String normName = lt.getName();
+      if (!lt.isAllowPostSubmit()) {
+        disallowed.add(normName);
+      }
+      Short prev = previous.get(normName);
+      if (prev == null) {
+        continue;
+      }
+      if (prev > psa.value()) {
+        reduced.add(psa);
+      }
+      // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
+    }
+
+    if (!disallowed.isEmpty()) {
+      throw new ResourceConflictException(
+          "Voting on labels disallowed after submit: "
+              + disallowed.stream().distinct().sorted().collect(joining(", ")));
+    }
+    if (!reduced.isEmpty()) {
+      throw new ResourceConflictException(
+          "Cannot reduce vote on labels for closed change: "
+              + reduced.stream()
+                  .map(PatchSetApproval::label)
+                  .distinct()
+                  .sorted()
+                  .collect(joining(", ")));
+    }
+  }
+
+  private Map<String, PatchSetApproval> scanLabels(
+      ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
+    LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
+    Map<String, PatchSetApproval> current = new HashMap<>();
+
+    for (PatchSetApproval a :
+        approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
+      if (a.isLegacySubmit()) {
+        continue;
+      }
+
+      Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
+      if (lt.isPresent()) {
+        current.put(lt.get().getName(), a);
+      } else {
+        del.add(a);
+      }
+    }
+    return current;
+  }
+
+  private boolean insertMessage(ChangeContext ctx) {
+    String msg = Strings.nullToEmpty(in.message).trim();
+
+    StringBuilder buf = new StringBuilder();
+    for (LabelVote d : labelDelta) {
+      buf.append(" ").append(d.format());
+    }
+    if (comments.size() == 1) {
+      buf.append("\n\n(1 comment)");
+    } else if (comments.size() > 1) {
+      buf.append(String.format("\n\n(%d comments)", comments.size()));
+    }
+    if (!msg.isEmpty()) {
+      // Message was already validated when validating comments, since validators need to see
+      // everything in a single call.
+      buf.append("\n\n").append(msg);
+    } else if (in.ready) {
+      buf.append("\n\n" + START_REVIEW_MESSAGE);
+    }
+
+    List<String> pluginMessages = new ArrayList<>();
+    onPostReviews.runEach(
+        onPostReview ->
+            onPostReview
+                .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
+                .ifPresent(
+                    pluginMessage ->
+                        pluginMessages.add(
+                            !pluginMessage.endsWith("\n") ? pluginMessage + "\n" : pluginMessage)));
+    if (!pluginMessages.isEmpty()) {
+      buf.append("\n\n");
+      buf.append(Joiner.on("\n").join(pluginMessages));
+    }
+
+    if (buf.length() == 0) {
+      return false;
+    }
+
+    mailMessage =
+        cmUtil.setChangeMessage(ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
+    return true;
+  }
+
+  private void addLabelDelta(String name, short value) {
+    labelDelta.add(LabelVote.create(name, value));
+  }
+
+  private TraceContext.TraceTimer newTimer(String method) {
+    return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewFix.java b/java/com/google/gerrit/server/restapi/change/PreviewFix.java
new file mode 100644
index 0000000..e771898
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PreviewFix.java
@@ -0,0 +1,201 @@
+// 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.restapi.change;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment.Range;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffWebLinkInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+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.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.FixResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.diff.DiffInfoCreator;
+import com.google.gerrit.server.diff.DiffSide;
+import com.google.gerrit.server.diff.DiffWebLinksProvider;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Repository;
+
+public class PreviewFix {
+  public interface Factory {
+    PreviewFix create(RevisionResource revisionResource);
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory;
+  private final PatchSet patchSet;
+  private final ChangeNotes notes;
+  private final ProjectState state;
+
+  @Inject
+  PreviewFix(
+      GitRepositoryManager repoManager,
+      PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory,
+      ProjectCache projectCache,
+      @Assisted RevisionResource revisionResource) {
+    this.repoManager = repoManager;
+    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
+    patchSet = revisionResource.getPatchSet();
+    notes = revisionResource.getNotes();
+    Change change = notes.getChange();
+    state = projectCache.get(change.getProject()).orElseThrow(illegalState(change.getProject()));
+  }
+
+  @Singleton
+  public static class Stored implements RestReadView<FixResource> {
+    private final PreviewFix.Factory previewFixFactory;
+
+    @Inject
+    Stored(PreviewFix.Factory previewFixFactory) {
+      this.previewFixFactory = previewFixFactory;
+    }
+
+    @Override
+    public Response<Map<String, DiffInfo>> apply(FixResource fixResource)
+        throws PermissionBackendException, ResourceNotFoundException, ResourceConflictException,
+            AuthException, IOException, InvalidChangeOperationException {
+
+      PreviewFix previewFix = previewFixFactory.create(fixResource.getRevisionResource());
+
+      Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
+          fixResource.getFixReplacements().stream()
+              .collect(groupingBy(fixReplacement -> fixReplacement.path));
+
+      return Response.ok(previewFix.previewAllFiles(fixReplacementsPerFilePath));
+    }
+  }
+
+  @Singleton
+  public static class Provided implements RestModifyView<RevisionResource, ApplyProvidedFixInput> {
+    private final PreviewFix.Factory previewFixFactory;
+
+    @Inject
+    Provided(PreviewFix.Factory previewFixFactory) {
+      this.previewFixFactory = previewFixFactory;
+    }
+
+    @Override
+    public Response<Map<String, DiffInfo>> apply(
+        RevisionResource revisionResource, ApplyProvidedFixInput applyProvidedFixInput)
+        throws BadRequestException, PermissionBackendException, ResourceNotFoundException,
+            ResourceConflictException, AuthException, IOException, InvalidChangeOperationException {
+      if (applyProvidedFixInput == null) {
+        throw new BadRequestException("applyProvidedFixInput is required");
+      }
+      if (applyProvidedFixInput.fixReplacementInfos == null) {
+        throw new BadRequestException("applyProvidedFixInput.fixReplacementInfos is required");
+      }
+
+      PreviewFix previewFix = previewFixFactory.create(revisionResource);
+
+      Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
+          applyProvidedFixInput.fixReplacementInfos.stream()
+              .map(fix -> new FixReplacement(fix.path, new Range(fix.range), fix.replacement))
+              .collect(groupingBy(fixReplacement -> fixReplacement.path));
+
+      return Response.ok(previewFix.previewAllFiles(fixReplacementsPerFilePath));
+    }
+  }
+
+  private Map<String, DiffInfo> previewAllFiles(
+      Map<String, List<FixReplacement>> fixReplacementsPerFilePath)
+      throws PermissionBackendException, ResourceNotFoundException, ResourceConflictException,
+          AuthException, IOException, InvalidChangeOperationException {
+    Map<String, DiffInfo> result = new HashMap<>();
+    try (Repository git = repoManager.openRepository(notes.getProjectName())) {
+      for (Map.Entry<String, List<FixReplacement>> entry : fixReplacementsPerFilePath.entrySet()) {
+        String fileName = entry.getKey();
+        DiffInfo diffInfo =
+            previewSingleFile(git, fileName, ImmutableList.copyOf(entry.getValue()));
+        result.put(fileName, diffInfo);
+      }
+    } catch (NoSuchChangeException e) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    } catch (LargeObjectException e) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    }
+    return result;
+  }
+
+  private DiffInfo previewSingleFile(
+      Repository git, String fileName, ImmutableList<FixReplacement> fixReplacements)
+      throws PermissionBackendException, AuthException, LargeObjectException,
+          InvalidChangeOperationException, IOException, ResourceNotFoundException {
+    PatchScriptFactoryForAutoFix psf =
+        patchScriptFactoryFactory.create(
+            git, notes, fileName, patchSet, fixReplacements, DiffPreferencesInfo.defaults());
+    PatchScript ps = psf.call();
+
+    DiffSide sideA =
+        DiffSide.create(
+            ps.getFileInfoA(),
+            MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
+            DiffSide.Type.SIDE_A);
+    DiffSide sideB = DiffSide.create(ps.getFileInfoB(), ps.getNewName(), DiffSide.Type.SIDE_B);
+
+    DiffInfoCreator diffInfoCreator =
+        new DiffInfoCreator(state, new DiffWebLinksProviderImpl(), true);
+    return diffInfoCreator.create(ps, sideA, sideB);
+  }
+
+  private static class DiffWebLinksProviderImpl implements DiffWebLinksProvider {
+
+    @Override
+    public ImmutableList<DiffWebLinkInfo> getDiffLinks() {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public ImmutableList<WebLinkInfo> getEditWebLinks() {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type fileInfoType) {
+      return ImmutableList.of();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index c62200a..f898dca 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -48,7 +48,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -64,7 +64,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repositoryManager;
   private final Provider<CurrentUser> userProvider;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final PatchSetInserter.Factory psInserterFactory;
   private final PermissionBackend permissionBackend;
   private final PatchSetUtil psUtil;
@@ -86,7 +86,7 @@
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
     this.psInserterFactory = psInserterFactory;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
     this.permissionBackend = permissionBackend;
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
@@ -167,7 +167,8 @@
     builder.setTreeId(basePatchSetCommit.getTree());
     builder.setParentIds(basePatchSetCommit.getParents());
     builder.setAuthor(basePatchSetCommit.getAuthorIdent());
-    builder.setCommitter(userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, tz));
+    builder.setCommitter(
+        userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, zoneId));
     builder.setMessage(commitMessage);
     ObjectId newCommitId = objectInserter.insert(builder);
     objectInserter.flush();
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 2c15bc9..6ce4b39 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -159,7 +159,8 @@
     return Response.ok(out.size() == 1 ? out.get(0) : out);
   }
 
-  private List<List<ChangeInfo>> query() throws QueryParseException, PermissionBackendException {
+  private List<List<ChangeInfo>> query()
+      throws BadRequestException, QueryParseException, PermissionBackendException {
     ChangeQueryProcessor queryProcessor = queryProcessorProvider.get();
     if (queryProcessor.isDisabled()) {
       throw new QueryParseException("query disabled");
@@ -169,6 +170,9 @@
       queryProcessor.setUserProvidedLimit(limit);
     }
     if (start != null) {
+      if (start < 0) {
+        throw new BadRequestException("'start' parameter cannot be less than zero");
+      }
       queryProcessor.setStart(start);
     }
     if (noLimit != null && !AnonymousUser.class.isAssignableFrom(userProvider.get().getClass())) {
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 49286fc..3d9d588 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -284,9 +284,8 @@
    */
   private void addToAttentionSet(
       BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
-    AddToAttentionSetOp addOwnerToAttentionSet =
-        addToAttentionSetOpFactory.create(user, reason, notify);
-    bu.addOp(changeNotes.getChangeId(), addOwnerToAttentionSet);
+    AddToAttentionSetOp addToAttentionSet = addToAttentionSetOpFactory.create(user, reason, notify);
+    bu.addOp(changeNotes.getChangeId(), addToAttentionSet);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index e3cf4db..418eb9c 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -28,13 +28,14 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.common.GroupBaseInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -57,6 +58,7 @@
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexRewriter;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectState;
@@ -120,6 +122,7 @@
     }
   }
 
+  private final AccountVisibility accountVisibility;
   private final AccountLoader.Factory accountLoaderFactory;
   private final AccountQueryBuilder accountQueryBuilder;
   private final AccountIndexRewriter accountIndexRewriter;
@@ -135,6 +138,7 @@
 
   @Inject
   ReviewersUtil(
+      AccountVisibility accountVisibility,
       AccountLoader.Factory accountLoaderFactory,
       AccountQueryBuilder accountQueryBuilder,
       AccountIndexRewriter accountIndexRewriter,
@@ -147,6 +151,7 @@
       AccountControl.Factory accountControlFactory,
       Provider<CurrentUser> self,
       ServiceUserClassifier serviceUserClassifier) {
+    this.accountVisibility = accountVisibility;
     this.accountLoaderFactory = accountLoaderFactory;
     this.accountQueryBuilder = accountQueryBuilder;
     this.accountIndexRewriter = accountIndexRewriter;
@@ -192,13 +197,20 @@
       logger.atFine().log("Reviewer suggestion is disabled.");
       return Collections.emptyList();
     }
+    AccountControl accountControl = accountControlFactory.get();
+
+    if (accountVisibility == AccountVisibility.NONE && !accountControl.canViewAll()) {
+      logger.atFine().log(
+          "Not suggesting reviewers: accountVisibility = %s and the user does not have %s capability",
+          AccountVisibility.NONE, GlobalPermission.VIEW_ALL_ACCOUNTS);
+      return Collections.emptyList();
+    }
 
     List<Account.Id> candidateList = new ArrayList<>();
     if (!Strings.isNullOrEmpty(query)) {
       candidateList = suggestAccounts(suggestReviewers);
       logger.atFine().log("Candidate list: %s", candidateList);
     }
-
     List<Account.Id> sortedRecommendations =
         recommendAccounts(
             reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
@@ -216,8 +228,7 @@
           continue;
         }
         // Check if change is visible to reviewer and if the current user can see reviewer
-        if (visibilityControl.isVisibleTo(reviewer)
-            && accountControlFactory.get().canSee(reviewer)) {
+        if (visibilityControl.isVisibleTo(reviewer) && accountControl.canSee(reviewer)) {
           filteredRecommendations.add(reviewer);
         }
       }
@@ -238,9 +249,9 @@
 
   private static Account.Id fromIdField(FieldBundle f, boolean useLegacyNumericFields) {
     if (useLegacyNumericFields) {
-      return Account.id(f.getValue(AccountField.ID).intValue());
+      return Account.id(f.<Integer>getValue(AccountField.ID_FIELD_SPEC).intValue());
     }
-    return Account.id(Integer.valueOf(f.getValue(AccountField.ID_STR)));
+    return Account.id(Integer.valueOf(f.<String>getValue(AccountField.ID_STR_FIELD_SPEC)));
   }
 
   private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers)
@@ -256,9 +267,9 @@
       logger.atFine().log("accounts index query: %s", pred);
       accountIndexRewriter.validateMaxTermsInQuery(pred);
       boolean useLegacyNumericFields =
-          accountIndexes.getSearchIndex().getSchema().useLegacyNumericFields();
-      FieldDef<AccountState, ?> idField =
-          useLegacyNumericFields ? AccountField.ID : AccountField.ID_STR;
+          accountIndexes.getSearchIndex().getSchema().hasField(AccountField.ID_FIELD_SPEC);
+      SchemaField<AccountState, ?> idField =
+          useLegacyNumericFields ? AccountField.ID_FIELD_SPEC : AccountField.ID_STR_FIELD_SPEC;
       ResultSet<FieldBundle> result =
           accountIndexes
               .getSearchIndex()
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index 41fecaf..bdc6816 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -106,18 +106,11 @@
   }
 
   private boolean visible(ChangeResource change) throws PermissionBackendException {
-    try {
-      permissionBackend
-          .user(change.getUser())
-          .change(change.getNotes())
-          .check(ChangePermission.READ);
-      return projectCache
-          .get(change.getProject())
-          .map(ProjectState::statePermitsRead)
-          .orElse(false);
-    } catch (AuthException e) {
-      return false;
-    }
+    return permissionBackend
+            .user(change.getUser())
+            .change(change.getNotes())
+            .test(ChangePermission.READ)
+        && projectCache.get(change.getProject()).map(ProjectState::statePermitsRead).orElse(false);
   }
 
   private ImmutableList<RevisionResource> find(ChangeResource change, String id)
@@ -154,9 +147,6 @@
     return ImmutableList.of();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private ImmutableList<RevisionResource> loadEdit(
       ChangeResource change, @Nullable ObjectId commitId) throws AuthException, IOException {
     Optional<ChangeEdit> edit = editUtil.byChange(change.getNotes(), change.getUser());
@@ -167,7 +157,7 @@
               .id(PatchSet.id(change.getId(), 0))
               .commitId(editCommit)
               .uploader(change.getUser().getAccountId())
-              .createdOn(editCommit.getCommitterIdent().getWhen().toInstant())
+              .createdOn(editCommit.getCommitterIdent().getWhenAsInstant())
               .build();
       if (commitId == null || editCommit.equals(commitId)) {
         return ImmutableList.of(new RevisionResource(change, ps, edit));
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 9597dde..560f4e0 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.BranchNameKey;
@@ -168,9 +169,12 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(RevisionResource rsrc, SubmitInput input)
+  public Response<ChangeInfo> apply(RevisionResource rsrc, @Nullable SubmitInput input)
       throws RestApiException, RepositoryNotFoundException, IOException, PermissionBackendException,
           UpdateException, ConfigInvalidException {
+    if (input == null) {
+      input = new SubmitInput();
+    }
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
     IdentifiedUser submitter;
     if (input.onBehalfOf != null) {
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
index 26c7297..26a0415 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
@@ -93,6 +93,7 @@
       throw new BadRequestException(
           String.format("Unsupported reviewer state: %s", ReviewerState.REMOVED));
     }
+
     return Response.ok(
         reviewersUtil.suggestReviewers(
             reviewerState,
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index 74f5290..0035a03 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -93,11 +93,7 @@
         cfg.getInt("suggest", "maxSuggestedReviewers", DEFAULT_MAX_SUGGESTED);
     this.limit = this.maxSuggestedReviewers;
     String suggest = cfg.getString("suggest", null, "accounts");
-    if ("OFF".equalsIgnoreCase(suggest) || "false".equalsIgnoreCase(suggest)) {
-      this.suggestAccounts = false;
-    } else {
-      this.suggestAccounts = (av != AccountVisibility.NONE);
-    }
+    this.suggestAccounts = !"OFF".equalsIgnoreCase(suggest) && !"false".equalsIgnoreCase(suggest);
 
     this.maxAllowed =
         cfg.getInt("addreviewer", "maxAllowed", ReviewerModifier.DEFAULT_MAX_REVIEWERS);
diff --git a/java/com/google/gerrit/server/restapi/change/Unignore.java b/java/com/google/gerrit/server/restapi/change/Unignore.java
deleted file mode 100644
index 999e736..0000000
--- a/java/com/google/gerrit/server/restapi/change/Unignore.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// 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.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class Unignore implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final StarredChangesUtil stars;
-
-  @Inject
-  Unignore(StarredChangesUtil stars) {
-    this.stars = stars;
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Unignore")
-        .setTitle("Unignore the change")
-        .setVisible(isIgnored(rsrc));
-  }
-
-  @Override
-  public Response<String> apply(ChangeResource rsrc, Input input) throws IllegalLabelException {
-    if (isIgnored(rsrc)) {
-      stars.unignore(rsrc);
-    }
-    return Response.ok();
-  }
-
-  private boolean isIgnored(ChangeResource rsrc) {
-    try {
-      return stars.isIgnored(rsrc);
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("failed to check ignored star");
-    }
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/config/IndexChanges.java b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
index 904c44f..caca5bc 100644
--- a/java/com/google/gerrit/server/restapi/config/IndexChanges.java
+++ b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
@@ -16,6 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -27,6 +28,8 @@
 import com.google.gerrit.server.restapi.config.IndexChanges.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
@@ -36,6 +39,7 @@
 
   public static class Input {
     public Set<String> changes;
+    boolean deleteMissing;
   }
 
   private final ChangeFinder changeFinder;
@@ -57,7 +61,21 @@
     }
 
     for (String id : input.changes) {
-      for (ChangeNotes n : changeFinder.find(id)) {
+      List<ChangeNotes> notes = changeFinder.find(id);
+
+      if (notes.isEmpty()) {
+        logger.atWarning().log("Change %s missing in NoteDb", id);
+        if (input.deleteMissing) {
+          Optional<Change.Id> changeId = Change.Id.tryParse(id);
+          if (changeId.isPresent()) {
+            logger.atWarning().log("Deleting change %s from index", changeId.get());
+            indexer.delete(changeId.get());
+          }
+        }
+        continue;
+      }
+
+      for (ChangeNotes n : notes) {
         indexer.index(changeDataFactory.create(n));
         logger.atFine().log("Indexed change %s", id);
       }
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
index dcc44ae..8ada657 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -72,11 +72,8 @@
     }
 
     List<TaskInfo> allTasks = getTasks();
-    try {
-      permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
+    if (permissionBackend.user(user).test(GlobalPermission.VIEW_QUEUE)) {
       return Response.ok(allTasks);
-    } catch (AuthException e) {
-      // Fall through to filter tasks.
     }
 
     Map<String, Boolean> visibilityCache = new HashMap<>();
@@ -90,10 +87,9 @@
           if (!state.isPresent() || !state.get().statePermitsRead()) {
             visible = false;
           } else {
-            try {
-              permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+            if (permissionBackend.user(user).project(nameKey).test(ProjectPermission.ACCESS)) {
               visible = true;
-            } catch (AuthException e) {
+            } else {
               visible = false;
             }
           }
diff --git a/java/com/google/gerrit/server/restapi/config/TasksCollection.java b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
index 409aa9c..29a0033 100644
--- a/java/com/google/gerrit/server/restapi/config/TasksCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
@@ -94,21 +94,14 @@
       }
 
       state.get().checkStatePermitsRead();
-
-      try {
-        permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+      if (permissionBackend.user(user).project(nameKey).test(ProjectPermission.ACCESS)) {
         return new TaskResource(task);
-      } catch (AuthException e) {
-        // Fall through and try view queue permission.
       }
     }
 
     if (task != null) {
-      try {
-        permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
+      if (permissionBackend.user(user).test(GlobalPermission.VIEW_QUEUE)) {
         return new TaskResource(task);
-      } catch (AuthException e) {
-        // Fall through and return not found.
       }
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index f257f86..e617931 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -61,13 +61,13 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -77,7 +77,7 @@
 public class CreateGroup
     implements RestCollectionCreateView<TopLevelResource, GroupResource, GroupInput> {
   private final Provider<IdentifiedUser> self;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupCache groupCache;
   private final GroupResolver groups;
@@ -102,7 +102,7 @@
       @GerritServerConfig Config cfg,
       Sequences sequences) {
     this.self = self;
-    this.serverTimeZone = serverIdent.get().getTimeZone();
+    this.serverZoneId = serverIdent.get().getZoneId();
     this.groupsUpdateProvider = groupsUpdateProvider;
     this.groupCache = groupCache;
     this.groups = groups;
@@ -212,7 +212,7 @@
             createGroupArgs.uuid,
             GroupUuid.make(
                 createGroupArgs.getGroupName(),
-                self.get().newCommitterIdent(TimeUtil.now(), serverTimeZone)));
+                self.get().newCommitterIdent(TimeUtil.now(), serverZoneId)));
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
             .setGroupUUID(uuid)
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
index befccfe..fed2302 100644
--- a/java/com/google/gerrit/server/restapi/group/QueryGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -107,6 +107,10 @@
       throw new MethodNotAllowedException("query disabled");
     }
 
+    if (start < 0) {
+      throw new BadRequestException("'start' parameter cannot be less than zero");
+    }
+
     if (start != 0) {
       queryProcessor.setStart(start);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
index 2d78bb0..00d8658 100644
--- a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -75,14 +74,16 @@
       // ListBranches checks the target of a symbolic reference to determine access
       // rights on the symbolic reference itself. This check prevents seeing a hidden
       // branch simply because the symbolic reference name was visible.
-      permissionBackend
-          .currentUser()
-          .project(project)
-          .ref(ref.isSymbolic() ? ref.getTarget().getName() : ref.getName())
-          .check(RefPermission.READ);
-      return new BranchResource(parent.getProjectState(), parent.getUser(), ref);
-    } catch (AuthException notAllowed) {
-      throw new ResourceNotFoundException(id, notAllowed);
+      boolean canRead =
+          permissionBackend
+              .currentUser()
+              .project(project)
+              .ref(ref.isSymbolic() ? ref.getTarget().getName() : ref.getName())
+              .test(RefPermission.READ);
+      if (canRead) {
+        return new BranchResource(parent.getProjectState(), parent.getUser(), ref);
+      }
+      throw new ResourceNotFoundException(id);
     } catch (RepositoryNotFoundException noRepo) {
       throw new ResourceNotFoundException(id, noRepo);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 5c2f932..160dbdc 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -98,7 +98,6 @@
                 HttpServletResponse.SC_FORBIDDEN,
                 String.format("user %s cannot see project %s", match, rsrc.getName())));
       }
-
       RefPermission refPerm;
       if (!Strings.isNullOrEmpty(input.permission)) {
         if (Strings.isNullOrEmpty(input.ref)) {
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 8a0cc39..977bfdb 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -113,8 +113,10 @@
         .checkStatePermitsWrite();
 
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
-    ImmutableList<AccessSection> removals = setAccess.getAccessSections(input.remove);
-    ImmutableList<AccessSection> additions = setAccess.getAccessSections(input.add);
+    ImmutableList<AccessSection> removals =
+        setAccess.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
+    ImmutableList<AccessSection> additions =
+        setAccess.getAccessSections(input.add, /* rejectNonResolvableGroups= */ true);
 
     Project.NameKey newParentProjectName =
         input.parent == null ? null : Project.nameKey(input.parent);
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index 018ed86..c39b1f4 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -85,8 +86,9 @@
 
   @Override
   public Response<BranchInfo> apply(ProjectResource rsrc, IdString id, BranchInput input)
-      throws BadRequestException, AuthException, ResourceConflictException, IOException,
-          PermissionBackendException, NoSuchProjectException {
+      throws BadRequestException, AuthException, ResourceConflictException,
+          UnprocessableEntityException, IOException, PermissionBackendException,
+          NoSuchProjectException {
     String ref = id.get();
     if (input == null) {
       input = new BranchInput();
@@ -122,7 +124,7 @@
 
     BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
-      ObjectId revid = RefUtil.parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
+      ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
       RevObject object = rw.parseAny(revid);
 
@@ -211,8 +213,6 @@
                 : null;
       }
       return Response.created(info);
-    } catch (RefUtil.InvalidRevisionException e) {
-      throw new BadRequestException("invalid revision \"" + input.revision + "\"", e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 01686ff..ad32f4f 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -173,10 +173,6 @@
       labelType.setCanOverride(input.canOverride);
     }
 
-    if (input.copyAnyScore != null) {
-      labelType.setCopyAnyScore(input.copyAnyScore);
-    }
-
     if (input.copyCondition != null) {
       try {
         approvalQueryBuilder.parse(input.copyCondition);
@@ -194,40 +190,6 @@
       labelType.setCopyCondition(null);
     }
 
-    if (input.copyMinScore != null) {
-      labelType.setCopyMinScore(input.copyMinScore);
-    }
-
-    if (input.copyMaxScore != null) {
-      labelType.setCopyMaxScore(input.copyMaxScore);
-    }
-
-    if (input.copyAllScoresIfListOfFilesDidNotChange != null) {
-      labelType.setCopyAllScoresIfListOfFilesDidNotChange(
-          input.copyAllScoresIfListOfFilesDidNotChange);
-    }
-
-    if (input.copyAllScoresIfNoChange != null) {
-      labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
-    }
-
-    if (input.copyAllScoresIfNoCodeChange != null) {
-      labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
-    }
-
-    if (input.copyAllScoresOnTrivialRebase != null) {
-      labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
-    }
-
-    if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
-      labelType.setCopyAllScoresOnMergeFirstParentUpdate(
-          input.copyAllScoresOnMergeFirstParentUpdate);
-    }
-
-    if (input.copyValues != null) {
-      labelType.setCopyValues(input.copyValues);
-    }
-
     if (input.allowPostSubmit != null) {
       labelType.setAllowPostSubmit(input.allowPostSubmit);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
new file mode 100644
index 0000000..2aeba89
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.project.SubmitRequirementsUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** A rest create view that creates a "submit requirement" for a project. */
+@Singleton
+public class CreateSubmitRequirement
+    implements RestCollectionCreateView<
+        ProjectResource, SubmitRequirementResource, SubmitRequirementInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+  private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+  @Inject
+  public CreateSubmitRequirement(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache,
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+    this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+  }
+
+  @Override
+  public Response<SubmitRequirementInfo> apply(
+      ProjectResource rsrc, IdString id, SubmitRequirementInput input)
+      throws AuthException, BadRequestException, IOException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new SubmitRequirementInput();
+    }
+
+    if (input.name != null && !input.name.equals(id.get())) {
+      throw new BadRequestException("name in input must match name in URL");
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      SubmitRequirement submitRequirement = createSubmitRequirement(config, id.get(), input);
+
+      md.setMessage(String.format("Create Submit Requirement %s", submitRequirement.name()));
+      config.commit(md);
+
+      projectCache.evict(rsrc.getProjectState().getProject().getNameKey());
+
+      return Response.created(SubmitRequirementJson.format(submitRequirement));
+    } catch (ConfigInvalidException e) {
+      throw new IOException("Failed to read project config", e);
+    } catch (ResourceConflictException e) {
+      throw new BadRequestException("Failed to create submit requirement", e);
+    }
+  }
+
+  public SubmitRequirement createSubmitRequirement(
+      ProjectConfig config, String name, SubmitRequirementInput input)
+      throws BadRequestException, ResourceConflictException {
+    validateSRName(name);
+    ensureSRUnique(name, config);
+    if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
+      throw new BadRequestException("submittability_expression is required");
+    }
+    if (input.allowOverrideInChildProjects == null) {
+      // default is false
+      input.allowOverrideInChildProjects = false;
+    }
+    SubmitRequirement submitRequirement =
+        SubmitRequirement.builder()
+            .setName(name)
+            .setDescription(Optional.ofNullable(input.description))
+            .setApplicabilityExpression(
+                SubmitRequirementExpression.of(input.applicabilityExpression))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(input.submittabilityExpression))
+            .setOverrideExpression(SubmitRequirementExpression.of(input.overrideExpression))
+            .setAllowOverrideInChildProjects(input.allowOverrideInChildProjects)
+            .build();
+
+    List<String> validationMessages =
+        submitRequirementExpressionsValidator.validateExpressions(submitRequirement);
+    if (!validationMessages.isEmpty()) {
+      throw new BadRequestException(
+          String.format("Invalid submit requirement input: %s", validationMessages));
+    }
+
+    config.upsertSubmitRequirement(submitRequirement);
+    return submitRequirement;
+  }
+
+  private void validateSRName(String name) throws BadRequestException {
+    try {
+      SubmitRequirementsUtil.validateName(name);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage(), e);
+    }
+  }
+
+  private void ensureSRUnique(String name, ProjectConfig config) throws ResourceConflictException {
+    for (String srName : config.getSubmitRequirementSections().keySet()) {
+      if (srName.equalsIgnoreCase(name)) {
+        throw new ResourceConflictException(
+            String.format(
+                "submit requirement \"%s\" conflicts with existing submit requirement \"%s\"",
+                name, srName));
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index 6980006..63734bb 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -38,13 +38,12 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.RefUtil;
-import com.google.gerrit.server.project.RefUtil.InvalidRevisionException;
 import com.google.gerrit.server.project.TagResource;
 import com.google.gerrit.server.util.time.TimeUtil;
 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;
@@ -100,7 +99,7 @@
         permissionBackend.currentUser().project(resource.getNameKey()).ref(ref);
 
     try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
-      ObjectId revid = RefUtil.parseBaseRevision(repo, resource.getNameKey(), input.revision);
+      ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
       // Reachability through tags does not influence a commit's visibility, so no need to check for
       // visibility.
@@ -136,7 +135,7 @@
                   resource
                       .getUser()
                       .asIdentifiedUser()
-                      .newCommitterIdent(TimeUtil.now(), TimeZone.getDefault()));
+                      .newCommitterIdent(TimeUtil.now(), ZoneId.systemDefault()));
         }
 
         Ref result = tag.call();
@@ -153,8 +152,6 @@
               ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links));
         }
       }
-    } catch (InvalidRevisionException e) {
-      throw new BadRequestException("Invalid base revision", e);
     } catch (GitAPIException e) {
       logger.atSevere().withCause(e).log("Cannot create tag \"%s\"", ref);
       throw new IOException(e);
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
new file mode 100644
index 0000000..1be4a5f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeleteSubmitRequirement implements RestModifyView<SubmitRequirementResource, Input> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public DeleteSubmitRequirement(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<?> apply(SubmitRequirementResource rsrc, Input input) throws Exception {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getProject().getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      if (!deleteSubmitRequirement(config, rsrc.getSubmitRequirement().name())) {
+        // This code is unreachable because the exception is thrown when rsrc was parsed
+        throw new ResourceNotFoundException(
+            String.format(
+                "Submit requirement '%s' not found",
+                IdString.fromDecoded(rsrc.getSubmitRequirement().name())));
+      }
+
+      md.setMessage("Delete submit requirement");
+      config.commit(md);
+    }
+
+    projectCache.evict(rsrc.getProject().getProjectState().getProject().getNameKey());
+
+    return Response.none();
+  }
+
+  /**
+   * Delete the given submit requirement from the project config.
+   *
+   * @param config the project config from which the submit-requirement should be deleted
+   * @param srName the name of the submit requirement that should be deleted
+   * @return {@code true} if the submit-requirement was deleted, {@code false} if the
+   *     submit-requirement was not found
+   */
+  public boolean deleteSubmitRequirement(ProjectConfig config, String srName) {
+    if (!config.getSubmitRequirementSections().containsKey(srName)) {
+      return false;
+    }
+    config.getSubmitRequirementSections().remove(srName);
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetReflog.java b/java/com/google/gerrit/server/restapi/project/GetReflog.java
index e0131ee..967b3c5 100644
--- a/java/com/google/gerrit/server/restapi/project/GetReflog.java
+++ b/java/com/google/gerrit/server/restapi/project/GetReflog.java
@@ -89,9 +89,6 @@
     this.permissionBackend = permissionBackend;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Override
   public Response<List<ReflogEntryInfo>> apply(BranchResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
@@ -118,7 +115,7 @@
       } else {
         entries = limit > 0 ? new ArrayList<>(limit) : new ArrayList<>();
         for (ReflogEntry e : r.getReverseEntries()) {
-          Instant timestamp = e.getWho().getWhen().toInstant();
+          Instant timestamp = e.getWho().getWhenAsInstant();
           if ((from == null || from.isBefore(timestamp)) && (to == null || to.isAfter(timestamp))) {
             entries.add(e);
           }
diff --git a/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java
new file mode 100644
index 0000000..ce482e3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Singleton;
+
+/** A rest read view that retrieves a "submit requirement" for a project by its name. */
+@Singleton
+public class GetSubmitRequirement implements RestReadView<SubmitRequirementResource> {
+  @Override
+  public Response<SubmitRequirementInfo> apply(SubmitRequirementResource rsrc) {
+    return Response.ok(SubmitRequirementJson.format(rsrc.getSubmitRequirement()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index cd68a2f..c0185a7 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -421,14 +421,8 @@
     if (all && state != null) {
       throw new BadRequestException("'all' and 'state' may not be used together");
     }
-    if (groupUuid != null) {
-      try {
-        if (!groupControlFactory.controlFor(groupUuid).isVisible()) {
-          return Collections.emptySortedMap();
-        }
-      } catch (NoSuchGroupException ex) {
-        return Collections.emptySortedMap();
-      }
+    if (!isGroupVisible()) {
+      return Collections.emptySortedMap();
     }
 
     int foundIndex = 0;
@@ -554,6 +548,14 @@
     }
   }
 
+  private boolean isGroupVisible() {
+    try {
+      return groupUuid == null || groupControlFactory.controlFor(groupUuid).isVisible();
+    } catch (NoSuchGroupException ex) {
+      return false;
+    }
+  }
+
   private void printProjectBranches(PrintWriter stdout, ProjectInfo info) {
     for (String name : showBranch) {
       String ref = info.branches != null ? info.branches.get(name) : null;
diff --git a/java/com/google/gerrit/server/restapi/project/ListSubmitRequirements.java b/java/com/google/gerrit/server/restapi/project/ListSubmitRequirements.java
new file mode 100644
index 0000000..69e2cd3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListSubmitRequirements.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.server.restapi.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+
+/** List submit requirements in a project. */
+public class ListSubmitRequirements implements RestReadView<ProjectResource> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  public ListSubmitRequirements(Provider<CurrentUser> user, PermissionBackend permissionBackend) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Option(name = "--inherited", usage = "to include inherited submit requirements")
+  private boolean inherited;
+
+  public ListSubmitRequirements withInherited(boolean inherited) {
+    this.inherited = inherited;
+    return this;
+  }
+
+  @Override
+  public Response<List<SubmitRequirementInfo>> apply(ProjectResource rsrc)
+      throws AuthException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    if (inherited) {
+      List<SubmitRequirementInfo> allSubmitRequirements = new ArrayList<>();
+      for (ProjectState projectState : rsrc.getProjectState().treeInOrder()) {
+        try {
+          permissionBackend
+              .currentUser()
+              .project(projectState.getNameKey())
+              .check(ProjectPermission.READ_CONFIG);
+        } catch (AuthException e) {
+          throw new AuthException(projectState.getNameKey() + ": " + e.getMessage(), e);
+        }
+        allSubmitRequirements.addAll(listSubmitRequirements(projectState));
+      }
+      return Response.ok(allSubmitRequirements);
+    }
+
+    permissionBackend.currentUser().project(rsrc.getNameKey()).check(ProjectPermission.READ_CONFIG);
+    return Response.ok(listSubmitRequirements(rsrc.getProjectState()));
+  }
+
+  private ImmutableList<SubmitRequirementInfo> listSubmitRequirements(ProjectState projectState) {
+    return projectState.getConfig().getSubmitRequirementSections().values().stream()
+        .map(SubmitRequirementJson::format)
+        .collect(ImmutableList.toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index eccdcfc..ac0dff9 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -172,9 +172,6 @@
     throw new ResourceNotFoundException(id);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public static TagInfo createTagInfo(
       PermissionBackend.ForRef perm, Ref ref, RevWalk rw, ProjectState projectState, WebLinks links)
       throws IOException {
@@ -200,12 +197,12 @@
           tagger != null ? CommonConverters.toGitPerson(tagger) : null,
           canDelete,
           webLinks.isEmpty() ? null : webLinks,
-          tagger != null ? tagger.getWhen().toInstant() : null);
+          tagger != null ? tagger.getWhenAsInstant() : null);
     }
 
     Instant timestamp =
         object instanceof RevCommit
-            ? ((RevCommit) object).getCommitterIdent().getWhen().toInstant()
+            ? ((RevCommit) object).getCommitterIdent().getWhenAsInstant()
             : null;
 
     // Lightweight tag
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index e50a494..d188bc8 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.project.FileResource.FILE_KIND;
 import static com.google.gerrit.server.project.LabelResource.LABEL_KIND;
 import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
+import static com.google.gerrit.server.project.SubmitRequirementResource.SUBMIT_REQUIREMENT_KIND;
 import static com.google.gerrit.server.project.TagResource.TAG_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -46,6 +47,7 @@
     DynamicMap.mapOf(binder(), COMMIT_KIND);
     DynamicMap.mapOf(binder(), TAG_KIND);
     DynamicMap.mapOf(binder(), LABEL_KIND);
+    DynamicMap.mapOf(binder(), SUBMIT_REQUIREMENT_KIND);
 
     DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
     DynamicSet.bind(binder(), ProjectCreationValidationListener.class)
@@ -78,6 +80,12 @@
     delete(LABEL_KIND).to(DeleteLabel.class);
     postOnCollection(LABEL_KIND).to(PostLabels.class);
 
+    child(PROJECT_KIND, "submit_requirements").to(SubmitRequirementsCollection.class);
+    create(SUBMIT_REQUIREMENT_KIND).to(CreateSubmitRequirement.class);
+    put(SUBMIT_REQUIREMENT_KIND).to(UpdateSubmitRequirement.class);
+    get(SUBMIT_REQUIREMENT_KIND).to(GetSubmitRequirement.class);
+    delete(SUBMIT_REQUIREMENT_KIND).to(DeleteSubmitRequirement.class);
+
     get(PROJECT_KIND, "HEAD").to(GetHead.class);
     put(PROJECT_KIND, "HEAD").to(SetHead.class);
 
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 30d667c..d4077c8 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -18,6 +18,9 @@
 import static com.google.gerrit.server.project.ProjectConfig.KEY_ENABLED;
 import static com.google.gerrit.server.project.ProjectConfig.KEY_LINK;
 import static com.google.gerrit.server.project.ProjectConfig.KEY_MATCH;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_PREFIX;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_SUFFIX;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_TEXT;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
@@ -300,6 +303,15 @@
         Config cfg = new Config();
         cfg.setString(COMMENTLINK, name, KEY_MATCH, value.match);
         cfg.setString(COMMENTLINK, name, KEY_LINK, value.link);
+        if (!Strings.isNullOrEmpty(value.prefix)) {
+          cfg.setString(COMMENTLINK, name, KEY_PREFIX, value.prefix);
+        }
+        if (!Strings.isNullOrEmpty(value.suffix)) {
+          cfg.setString(COMMENTLINK, name, KEY_SUFFIX, value.suffix);
+        }
+        if (!Strings.isNullOrEmpty(value.text)) {
+          cfg.setString(COMMENTLINK, name, KEY_TEXT, value.text);
+        }
         cfg.setBoolean(COMMENTLINK, name, KEY_ENABLED, value.enabled == null || value.enabled);
         projectConfig.addCommentLinkSection(ProjectConfig.buildCommentLink(cfg, name, false));
       } else {
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index a9d818d..b219085 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -106,6 +106,10 @@
 
     ProjectQueryProcessor queryProcessor = queryProcessorProvider.get();
 
+    if (start < 0) {
+      throw new BadRequestException("'start' parameter cannot be less than zero");
+    }
+
     if (start != 0) {
       queryProcessor.setStart(start);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 07dbeca..23d60fe 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -82,8 +82,10 @@
 
     ProjectConfig config;
 
-    List<AccessSection> removals = accessUtil.getAccessSections(input.remove);
-    List<AccessSection> additions = accessUtil.getAccessSections(input.add);
+    List<AccessSection> removals =
+        accessUtil.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
+    List<AccessSection> additions =
+        accessUtil.getAccessSections(input.add, /* rejectNonResolvableGroups= */ true);
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
       config = projectConfigFactory.read(md);
 
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 205420c..547a214 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.LabelType;
@@ -65,7 +66,8 @@
     this.pluginPermissionsUtil = pluginPermissionsUtil;
   }
 
-  ImmutableList<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
+  ImmutableList<AccessSection> getAccessSections(
+      Map<String, AccessSectionInfo> sectionInfos, boolean rejectNonResolvableGroups)
       throws UnprocessableEntityException {
     if (sectionInfos == null) {
       return ImmutableList.of();
@@ -93,13 +95,20 @@
         for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
             permissionEntry.getValue().rules.entrySet()) {
           GroupDescription.Basic group = groupResolver.parseId(permissionRuleInfoEntry.getKey());
-          if (group == null) {
-            throw new UnprocessableEntityException(
-                permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+          GroupReference groupReference;
+          if (group != null) {
+            groupReference = GroupReference.forGroup(group);
+          } else {
+            if (rejectNonResolvableGroups) {
+              throw new UnprocessableEntityException(
+                  permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+            }
+            AccountGroup.UUID uuid = AccountGroup.UUID.parse(permissionRuleInfoEntry.getKey());
+            groupReference = GroupReference.create(uuid, uuid.get());
           }
 
           PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
-          PermissionRule.Builder r = PermissionRule.builder(GroupReference.forGroup(group));
+          PermissionRule.Builder r = PermissionRule.builder(groupReference);
           if (pri != null) {
             if (pri.max != null) {
               r.setMax(pri.max);
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index 79bb4ee..10589cc 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -206,53 +206,6 @@
       dirty = true;
     }
 
-    if (input.copyAnyScore != null) {
-      labelTypeBuilder.setCopyAnyScore(input.copyAnyScore);
-      dirty = true;
-    }
-
-    if (input.copyMinScore != null) {
-      labelTypeBuilder.setCopyMinScore(input.copyMinScore);
-      dirty = true;
-    }
-
-    if (input.copyMaxScore != null) {
-      labelTypeBuilder.setCopyMaxScore(input.copyMaxScore);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresIfListOfFilesDidNotChange != null) {
-      labelTypeBuilder.setCopyAllScoresIfListOfFilesDidNotChange(
-          input.copyAllScoresIfListOfFilesDidNotChange);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresIfNoChange != null) {
-      labelTypeBuilder.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresIfNoCodeChange != null) {
-      labelTypeBuilder.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresOnTrivialRebase != null) {
-      labelTypeBuilder.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
-      labelTypeBuilder.setCopyAllScoresOnMergeFirstParentUpdate(
-          input.copyAllScoresOnMergeFirstParentUpdate);
-      dirty = true;
-    }
-
-    if (input.copyValues != null) {
-      labelTypeBuilder.setCopyValues(input.copyValues);
-      dirty = true;
-    }
-
     if (input.allowPostSubmit != null) {
       labelTypeBuilder.setAllowPostSubmit(input.allowPostSubmit);
       dirty = true;
diff --git a/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java b/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java
new file mode 100644
index 0000000..1388033
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.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.server.restapi.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SubmitRequirementsCollection
+    implements ChildCollection<ProjectResource, SubmitRequirementResource> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final DynamicMap<RestView<SubmitRequirementResource>> views;
+  private final Provider<ListSubmitRequirements> list;
+
+  @Inject
+  SubmitRequirementsCollection(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      DynamicMap<RestView<SubmitRequirementResource>> views,
+      Provider<ListSubmitRequirements> list) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.list = list;
+    this.views = views;
+  }
+
+  @Override
+  public RestView<ProjectResource> list() throws RestApiException {
+    return list.get();
+  }
+
+  @Override
+  public SubmitRequirementResource parse(ProjectResource parent, IdString id)
+      throws AuthException, ResourceNotFoundException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(parent.getNameKey())
+        .check(ProjectPermission.READ_CONFIG);
+
+    SubmitRequirement submitRequirement =
+        parent.getProjectState().getConfig().getSubmitRequirementSections().get(id.get());
+
+    if (submitRequirement == null) {
+      throw new ResourceNotFoundException(
+          String.format("Submit requirement '%s' does not exist", id));
+    }
+    return new SubmitRequirementResource(parent, submitRequirement);
+  }
+
+  @Override
+  public DynamicMap<RestView<SubmitRequirementResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
new file mode 100644
index 0000000..bbd617c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -0,0 +1,152 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.project.SubmitRequirementsUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * A rest modify view that updates the definition of an existing submit requirement for a project.
+ */
+@Singleton
+public class UpdateSubmitRequirement
+    implements RestModifyView<SubmitRequirementResource, SubmitRequirementInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+  private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+  @Inject
+  public UpdateSubmitRequirement(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache,
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+    this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+  }
+
+  @Override
+  public Response<SubmitRequirementInfo> apply(
+      SubmitRequirementResource rsrc, SubmitRequirementInput input)
+      throws AuthException, BadRequestException, PermissionBackendException, IOException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getProject().getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new SubmitRequirementInput();
+    }
+
+    if (input.name != null && !input.name.equals(rsrc.getSubmitRequirement().name())) {
+      throw new BadRequestException("name in input must match name in URL");
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      SubmitRequirement submitRequirement =
+          createSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
+
+      md.setMessage(String.format("Update Submit Requirement %s", submitRequirement.name()));
+      config.commit(md);
+
+      projectCache.evict(rsrc.getProject().getNameKey());
+
+      return Response.created(SubmitRequirementJson.format(submitRequirement));
+    } catch (ConfigInvalidException e) {
+      throw new IOException("Failed to read project config", e);
+    }
+  }
+
+  public SubmitRequirement createSubmitRequirement(
+      ProjectConfig config, String name, SubmitRequirementInput input) throws BadRequestException {
+    validateSRName(name);
+    if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
+      throw new BadRequestException("submittability_expression is required");
+    }
+    if (input.allowOverrideInChildProjects == null) {
+      // default is false
+      input.allowOverrideInChildProjects = false;
+    }
+    SubmitRequirement submitRequirement =
+        SubmitRequirement.builder()
+            .setName(name)
+            .setDescription(Optional.ofNullable(input.description))
+            .setApplicabilityExpression(
+                SubmitRequirementExpression.of(input.applicabilityExpression))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(input.submittabilityExpression))
+            .setOverrideExpression(SubmitRequirementExpression.of(input.overrideExpression))
+            .setAllowOverrideInChildProjects(input.allowOverrideInChildProjects)
+            .build();
+
+    List<String> validationMessages =
+        submitRequirementExpressionsValidator.validateExpressions(submitRequirement);
+    if (!validationMessages.isEmpty()) {
+      throw new BadRequestException(
+          String.format("Invalid submit requirement input: %s", validationMessages));
+    }
+
+    config.upsertSubmitRequirement(submitRequirement);
+    return submitRequirement;
+  }
+
+  private void validateSRName(String name) throws BadRequestException {
+    try {
+      SubmitRequirementsUtil.validateName(name);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage(), e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index ddc3fca..cab5b45 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -98,6 +98,7 @@
   private final PrologOptions opts;
   private Term submitRule;
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private PrologRuleEvaluator(
       AccountCache accountCache,
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index c040347..a079050 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.notedb.Sequences;
 import java.util.Optional;
@@ -54,8 +55,10 @@
                 LabelValue.create((short) 0, "No score"),
                 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)
+        .setCopyCondition(
+            String.format(
+                "changekind:%s OR changekind:%s OR is:MIN",
+                ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()))
         .build();
   }
 
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
new file mode 100644
index 0000000..46a6857
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
@@ -0,0 +1,330 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Shorts;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectLevelConfig;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Migrates all label configurations of a project to copy conditions.
+ *
+ * <p>The label configuration in {@code project.config} controls under which conditions approvals
+ * should be copied to new patch sets:
+ *
+ * <ul>
+ *   <li>old way: by setting boolean flags and copy values (fields {@code copyAnyScore}, {@code
+ *       copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange}, {@code
+ *       copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
+ *       copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
+ *       copyValue})
+ *   <li>new way: by setting a query as a copy condition (field {@code copyCondition})
+ * </ul>
+ *
+ * <p>This class updates all label configurations in the {@code project.config} of the given
+ * project:
+ *
+ * <ul>
+ *   <li>it stores the conditions under which approvals should be copied to new patchs as a copy
+ *       condition query (field {@code copyCondition})
+ *   <li>it unsets all deprecated fields to control approval copying (fields {@code copyAnyScore},
+ *       {@code copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange}, {@code
+ *       copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
+ *       copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
+ *       copyValue})
+ * </ul>
+ *
+ * <p>This migration assumes {@code true} as default value for the {@code copyAllScoresIfNoChange}
+ * flag since this default value was used for all labels that were created before this migration has
+ * been run (for labels that are created after this migration has been run the default value for
+ * this flag has been changed to {@code false}).
+ */
+public class MigrateLabelConfigToCopyCondition {
+  public static final String COMMIT_MESSAGE = "Migrate label configs to copy conditions";
+
+  @VisibleForTesting public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
+
+  @VisibleForTesting public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
+
+  @VisibleForTesting public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
+
+  @VisibleForTesting public static final String KEY_COPY_VALUE = "copyValue";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
+      "copyAllScoresOnMergeFirstParentUpdate";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
+      "copyAllScoresIfListOfFilesDidNotChange";
+
+  private final GitRepositoryManager repoManager;
+  private final PersonIdent serverUser;
+
+  @Inject
+  public MigrateLabelConfigToCopyCondition(
+      GitRepositoryManager repoManager, @GerritPersonIdent PersonIdent serverUser) {
+    this.repoManager = repoManager;
+    this.serverUser = serverUser;
+  }
+
+  /**
+   * Executes the migration for the given project.
+   *
+   * @param projectName the name of the project for which the migration should be executed
+   * @throws IOException thrown if an IO error occurs
+   * @throws ConfigInvalidException thrown if the existing project.config is invalid and cannot be
+   *     parsed
+   */
+  public void execute(Project.NameKey projectName) throws IOException, ConfigInvalidException {
+    ProjectLevelConfig.Bare projectConfig =
+        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    try (Repository repo = repoManager.openRepository(projectName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo)) {
+      boolean isAlreadyMigrated = hasMigrationAlreadyRun(repo);
+
+      projectConfig.load(projectName, repo);
+
+      Config cfg = projectConfig.getConfig();
+      String orgConfigAsText = cfg.toText();
+      for (String labelName : cfg.getSubsections(ProjectConfig.LABEL)) {
+        String newCopyCondition = computeCopyCondition(isAlreadyMigrated, cfg, labelName);
+        if (!Strings.isNullOrEmpty(newCopyCondition)) {
+          cfg.setString(
+              ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION, newCopyCondition);
+        }
+
+        unsetDeprecatedFields(cfg, labelName);
+      }
+
+      if (cfg.toText().equals(orgConfigAsText)) {
+        // Config was not changed (ie. none of the label definitions had any deprecated field set).
+        return;
+      }
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MESSAGE + "\n");
+      projectConfig.commit(md);
+    }
+  }
+
+  private static String computeCopyCondition(
+      boolean isAlreadyMigrated, Config cfg, String labelName) {
+    List<String> copyConditions = new ArrayList<>();
+
+    ifTrue(cfg, labelName, KEY_COPY_ANY_SCORE, () -> copyConditions.add("is:ANY"));
+    ifTrue(cfg, labelName, KEY_COPY_MIN_SCORE, () -> copyConditions.add("is:MIN"));
+    ifTrue(cfg, labelName, KEY_COPY_MAX_SCORE, () -> copyConditions.add("is:MAX"));
+    forEachSkipNullValues(
+        cfg,
+        labelName,
+        KEY_COPY_VALUE,
+        value -> copyConditions.add("is:" + quoteIfNegative(parseCopyValue(value))));
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        () -> copyConditions.add("changekind:" + ChangeKind.NO_CHANGE.name()));
+
+    // If the migration has already been run on this project we must no longer assume true as
+    // default value when copyAllScoresIfNoChange is unset. This is to ensure that the migration is
+    // idempotent when copyAllScoresIfNoChange is set to false:
+    //
+    // 1. migration run:
+    // Removes the copyAllScoresIfNoChange flag, but doesn't add anything to the copy condition.
+    //
+    // 2. migration run:
+    // Since the copyAllScoresIfNoChange flag is no longer set, we would wrongly assume true now and
+    // wrongly include "changekind:NO_CHANGE" into the copy condition. To prevent this we assume
+    // false as default for copyAllScoresIfNoChange once the migration has been run, so that the 2.
+    // migration run is a no-op.
+    if (!isAlreadyMigrated) {
+      // The default value for copyAllScoresIfNoChange is true, hence if this parameter is not set
+      // we need to include "changekind:NO_CHANGE" into the copy condition.
+      ifUnset(
+          cfg,
+          labelName,
+          KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+          () -> copyConditions.add("changekind:" + ChangeKind.NO_CHANGE.name()));
+    }
+
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        () -> copyConditions.add("changekind:" + ChangeKind.NO_CODE_CHANGE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        () -> copyConditions.add("changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        () -> copyConditions.add("changekind:" + ChangeKind.TRIVIAL_REBASE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        () -> copyConditions.add("has:unchanged-files"));
+
+    if (copyConditions.isEmpty()) {
+      // No copy conditions need to be added. Simply return the current copy condition as it is.
+      // Returning here prevents that OR conditions are reordered and that parentheses are added
+      // unnecessarily.
+      return cfg.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
+    }
+
+    ifSet(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_CONDITION,
+        copyCondition -> copyConditions.addAll(splitOrConditions(copyCondition)));
+
+    return copyConditions.stream()
+        .map(MigrateLabelConfigToCopyCondition::encloseInParenthesesIfNeeded)
+        .sorted()
+        // Remove duplicated OR conditions
+        .distinct()
+        .collect(joining(" OR "));
+  }
+
+  private static void ifSet(Config cfg, String labelName, String key, Consumer<String> consumer) {
+    Optional.ofNullable(cfg.getString(ProjectConfig.LABEL, labelName, key)).ifPresent(consumer);
+  }
+
+  private static void ifUnset(Config cfg, String labelName, String key, Runnable runnable) {
+    Optional<String> value =
+        Optional.ofNullable(cfg.getString(ProjectConfig.LABEL, labelName, key));
+    if (!value.isPresent()) {
+      runnable.run();
+    }
+  }
+
+  private static void ifTrue(Config cfg, String labelName, String key, Runnable runnable) {
+    if (cfg.getBoolean(ProjectConfig.LABEL, labelName, key, /* defaultValue= */ false)) {
+      runnable.run();
+    }
+  }
+
+  private static void forEachSkipNullValues(
+      Config cfg, String labelName, String key, Consumer<String> consumer) {
+    Arrays.stream(cfg.getStringList(ProjectConfig.LABEL, labelName, key))
+        .filter(Objects::nonNull)
+        .forEach(consumer);
+  }
+
+  private static void unsetDeprecatedFields(Config cfg, String labelName) {
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ANY_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_MIN_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_MAX_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
+  }
+
+  private static ImmutableList<String> splitOrConditions(String copyCondition) {
+    if (copyCondition.contains("(") || copyCondition.contains(")")) {
+      // cannot parse complex predicate tree
+      return ImmutableList.of(copyCondition);
+    }
+
+    // split query on OR, this way we can detect and remove duplicate OR conditions later
+    return ImmutableList.copyOf(Splitter.on(" OR ").splitToList(copyCondition));
+  }
+
+  /**
+   * Add parentheses around the given copyCondition if it consists out of 2 or more predicates and
+   * if it isn't enclosed in parentheses yet.
+   */
+  private static String encloseInParenthesesIfNeeded(String copyCondition) {
+    if (copyCondition.contains(" ")
+        && !(copyCondition.startsWith("(") && copyCondition.endsWith(")"))) {
+      return "(" + copyCondition + ")";
+    }
+    return copyCondition;
+  }
+
+  private static short parseCopyValue(String value) {
+    return Shorts.checkedCast(PermissionRule.parseInt(value));
+  }
+
+  private static String quoteIfNegative(short value) {
+    if (value < 0) {
+      return "\"" + value + "\"";
+    }
+    return Integer.toString(value);
+  }
+
+  public static boolean hasMigrationAlreadyRun(Repository repo) throws IOException {
+    try (RevWalk revWalk = new RevWalk(repo)) {
+      Ref refsMetaConfig = repo.exactRef(RefNames.REFS_CONFIG);
+      if (refsMetaConfig == null) {
+        return false;
+      }
+      revWalk.markStart(revWalk.parseCommit(refsMetaConfig.getObjectId()));
+      RevCommit commit;
+      while ((commit = revWalk.next()) != null) {
+        if (COMMIT_MESSAGE.equals(commit.getShortMessage())) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
index 209ff89..d84ae60 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
@@ -33,7 +33,8 @@
               Schema_181.class,
               Schema_182.class,
               Schema_183.class,
-              Schema_184.class)
+              Schema_184.class,
+              Schema_185.class)
           .collect(toImmutableSortedMap(naturalOrder(), v -> guessVersion(v).get(), v -> v));
 
   public static final int FIRST = ALL.firstKey();
diff --git a/java/com/google/gerrit/server/schema/SchemaModule.java b/java/com/google/gerrit/server/schema/SchemaModule.java
index ff2073d..9593522 100644
--- a/java/com/google/gerrit/server/schema/SchemaModule.java
+++ b/java/com/google/gerrit/server/schema/SchemaModule.java
@@ -16,6 +16,7 @@
 
 import static com.google.inject.Scopes.SINGLETON;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -25,9 +26,12 @@
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.gerrit.server.config.GerritImportedServerIdsProvider;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.GerritServerIdProvider;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.inject.TypeLiteral;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /** Bindings for low-level Gerrit schema data. */
@@ -51,6 +55,11 @@
         .toProvider(GerritServerIdProvider.class)
         .in(SINGLETON);
 
+    bind(new TypeLiteral<ImmutableList<String>>() {})
+        .annotatedWith(GerritImportedServerIds.class)
+        .toProvider(GerritImportedServerIdsProvider.class)
+        .in(SINGLETON);
+
     // It feels wrong to have this binding in a seemingly unrelated module, but it's a dependency of
     // SchemaCreatorImpl, so it's needed.
     // TODO(dborowitz): Is there any way to untangle this?
diff --git a/java/com/google/gerrit/server/schema/Schema_185.java b/java/com/google/gerrit/server/schema/Schema_185.java
new file mode 100644
index 0000000..264914f
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_185.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.ProjectConfig;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.List;
+import java.util.NavigableSet;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/**
+ * Migrates the label configurations of all projects to copy conditions.
+ *
+ * @see MigrateLabelConfigToCopyCondition
+ */
+public class Schema_185 implements NoteDbSchemaVersion {
+  private AtomicInteger i = new AtomicInteger();
+  private Stopwatch sw = Stopwatch.createStarted();
+  private int size;
+
+  @Override
+  public void upgrade(Arguments args, UpdateUI ui) throws Exception {
+    ui.message("Migrating label configurations");
+
+    NavigableSet<Project.NameKey> projects = args.repoManager.list();
+    size = projects.size();
+
+    Set<List<Project.NameKey>> batches = Sets.newHashSet(Iterables.partition(projects, 50));
+    ExecutorService pool = createExecutor(ui);
+    try {
+      batches.stream()
+          .forEach(
+              batch -> {
+                @SuppressWarnings("unused")
+                Future<?> possiblyIgnoredError =
+                    pool.submit(() -> processBatch(args.repoManager, args.serverUser, batch, ui));
+              });
+      pool.shutdown();
+      pool.awaitTermination(Long.MAX_VALUE, TimeUnit.DAYS);
+    } catch (InterruptedException e) {
+      throw new RuntimeException(e);
+    }
+    ui.message(
+        String.format(
+            "... (%.3f s) Migrated label configurations of all %d projects to schema 185",
+            elapsed(), i.get()));
+  }
+
+  private ExecutorService createExecutor(UpdateUI ui) {
+    int threads;
+    try {
+      threads = Integer.parseInt(System.getProperty("threadcount"));
+    } catch (NumberFormatException e) {
+      threads = Runtime.getRuntime().availableProcessors();
+    }
+    ui.message(String.format("... using %d threads ...", threads));
+    return Executors.newFixedThreadPool(threads);
+  }
+
+  private void processBatch(
+      GitRepositoryManager repoManager,
+      PersonIdent serverUser,
+      List<Project.NameKey> batch,
+      UpdateUI ui) {
+    try {
+      for (Project.NameKey project : batch) {
+        try {
+          new MigrateLabelConfigToCopyCondition(repoManager, serverUser).execute(project);
+          int count = i.incrementAndGet();
+          showProgress(ui, count);
+        } catch (ConfigInvalidException e) {
+          ui.message(
+              String.format(
+                  "WARNING: Skipping migration of label configurations for project %s"
+                      + " since its %s file is invalid: %s",
+                  project, ProjectConfig.PROJECT_CONFIG, e.getMessage()));
+        }
+      }
+    } catch (IOException e) {
+      throw new UncheckedIOException(
+          String.format("Failed to migrate batch of projects to schema 185: %s", batch), e);
+    }
+  }
+
+  private double elapsed() {
+    return sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
+  }
+
+  private void showProgress(UpdateUI ui, int count) {
+    if (count % 100 == 0) {
+      ui.message(
+          String.format(
+              "... (%.3f s) migrated label configurations of %d%% (%d/%d) projects",
+              elapsed(), Math.round(100.0 * count / size), count, size));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index e7d8337..7243bdf 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -102,8 +102,7 @@
           "[label \"Code-Review\"]",
           "  function = MaxWithBlock",
           "  defaultValue = 0",
-          "  copyMinScore = true",
-          "  copyAllScoresOnTrivialRebase = true",
+          "  copyCondition = changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
           "  value = -2 This shall not be submitted",
           "  value = -1 I would prefer this is not submitted as is",
           "  value = 0 No score",
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 58db331..27eb0a4 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
@@ -72,8 +71,6 @@
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -247,7 +244,6 @@
   private final RetryHelper retryHelper;
   private final ChangeData.Factory changeDataFactory;
   private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
-  private final ProjectCache projectCache;
 
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
@@ -282,8 +278,7 @@
       TopicMetrics topicMetrics,
       RetryHelper retryHelper,
       ChangeData.Factory changeDataFactory,
-      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory,
-      ProjectCache projectCache) {
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.internalUserFactory = internalUserFactory;
@@ -301,7 +296,6 @@
     this.changeDataFactory = changeDataFactory;
     this.updatedChanges = new HashMap<>();
     this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
-    this.projectCache = projectCache;
   }
 
   @Override
@@ -654,12 +648,12 @@
         Project.NameKey project = entry.getValue().project();
         Change.Id changeId = entry.getKey();
         ChangeData cd = entry.getValue();
-        Collection<SubmitRequirementResult> srResults =
-            cd.submitRequirementsIncludingLegacy().values();
         batchUpdatesByProject
             .get(project)
-            .addOp(changeId, storeSubmitRequirementsOpFactory.create(srResults, cd));
-        crossCheckSubmitRequirementResults(cd, srResults, project);
+            .addOp(
+                changeId,
+                storeSubmitRequirementsOpFactory.create(
+                    cd.submitRequirementsIncludingLegacy().values(), cd));
       }
       try {
         submissionExecutor.setAdditionalBatchUpdateListeners(
@@ -730,7 +724,7 @@
       OpenRepo or = orm.getRepo(branch.project());
       if (toSubmit.containsKey(branch)) {
         BranchBatch submitting = toSubmit.get(branch);
-        logger.atFine().log("adding ops for branch batch %s", submitting);
+        logger.atFine().log("adding ops for branch %s, batch = %s", branch, submitting);
         OpenBranch ob = or.getBranch(branch);
         requireNonNull(
             submitting.submitType(),
@@ -1009,28 +1003,4 @@
         + " projects involved; some projects may have submitted successfully, but others may have"
         + " failed";
   }
-
-  /**
-   * Make sure that for every project config submit requirement there exists a corresponding result
-   * with the same name in {@code srResults}. If no result is found, log a warning message.
-   */
-  private void crossCheckSubmitRequirementResults(
-      ChangeData cd, Collection<SubmitRequirementResult> srResults, Project.NameKey project) {
-    ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
-    Map<String, SubmitRequirement> projectConfigRequirements = state.getSubmitRequirements();
-    for (String srName : projectConfigRequirements.keySet()) {
-      boolean hasResult = false;
-      for (SubmitRequirementResult srResult : srResults) {
-        if (!srResult.isLegacy() && srResult.submitRequirement().name().equals(srName)) {
-          hasResult = true;
-          break;
-        }
-      }
-      if (!hasResult) {
-        logger.atWarning().log(
-            "Change %d: No result found for project config submit requirement '%s'",
-            cd.getId().get(), srName);
-      }
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmitDryRun.java b/java/com/google/gerrit/server/submit/SubmitDryRun.java
index cee0ad9..a3bb58b 100644
--- a/java/com/google/gerrit/server/submit/SubmitDryRun.java
+++ b/java/com/google/gerrit/server/submit/SubmitDryRun.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -96,13 +97,13 @@
   }
 
   private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
   SubmitDryRun(
       ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       Provider<InternalChangeQuery> queryProvider) {
     this.projectCache = projectCache;
     this.mergeUtilFactory = mergeUtilFactory;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 83c6634..f638078 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
@@ -151,7 +152,7 @@
         EmailMerge.Factory mergedSenderFactory,
         GitRepositoryManager repoManager,
         LabelNormalizer labelNormalizer,
-        MergeUtil.Factory mergeUtilFactory,
+        MergeUtilFactory mergeUtilFactory,
         PatchSetInfoFactory patchSetInfoFactory,
         PatchSetUtil psUtil,
         @GerritPersonIdent PersonIdent serverIdent,
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index f26882a..32529f7 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -26,6 +26,7 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -34,10 +35,13 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectChangeKey;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -48,6 +52,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
@@ -69,6 +74,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -76,7 +82,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;
@@ -148,7 +153,9 @@
         }
         for (ChangesHandle h : changesHandles) {
           h.execute();
-          indexFutures.addAll(h.startIndexFutures());
+          if (h.requiresReindex()) {
+            indexFutures.addAll(h.startIndexFutures());
+          }
         }
         notifyAfterUpdateRefs(listeners);
         notifyAfterUpdateChanges(listeners);
@@ -257,8 +264,8 @@
     }
 
     @Override
-    public TimeZone getTimeZone() {
-      return tz;
+    public ZoneId getZoneId() {
+      return zoneId;
     }
 
     @Override
@@ -354,6 +361,12 @@
     }
 
     @Override
+    public ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId) {
+      return changeDatas.computeIfAbsent(
+          changeId, id -> changeDataFactory.create(projectName, changeId));
+    }
+
+    @Override
     public ChangeData getChangeData(Change change) {
       return changeDatas.computeIfAbsent(change.getId(), id -> changeDataFactory.create(change));
     }
@@ -377,7 +390,7 @@
   private final Project.NameKey project;
   private final CurrentUser user;
   private final Instant when;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
 
   private final ListMultimap<Change.Id, BatchUpdateOp> ops =
       MultimapBuilder.linkedHashKeys().arrayListValues().build();
@@ -387,11 +400,15 @@
 
   private RepoView repoView;
   private BatchRefUpdate batchRefUpdate;
+  private ImmutableListMultimap<ProjectChangeKey, AttentionSetUpdate> attentionSetUpdates;
+
   private boolean executed;
   private OnSubmitValidators onSubmitValidators;
   private PushCertificate pushCert;
   private String refLogMessage;
   private NotifyResolver.Result notify = NotifyResolver.Result.all();
+  // Batch operations doesn't need observer
+  private AttentionSetObserver attentionSetObserver;
 
   @Inject
   BatchUpdate(
@@ -403,6 +420,7 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeIndexer indexer,
       GitReferenceUpdated gitRefUpdated,
+      AttentionSetObserver attentionSetObserver,
       @Assisted Project.NameKey project,
       @Assisted CurrentUser user,
       @Assisted Instant when) {
@@ -416,7 +434,8 @@
     this.project = project;
     this.user = user;
     this.when = when;
-    tz = serverIdent.getTimeZone();
+    this.attentionSetObserver = attentionSetObserver;
+    zoneId = serverIdent.getZoneId();
   }
 
   @Override
@@ -589,6 +608,16 @@
     }
   }
 
+  private void fireAttentionSetUpdateEvents(PostUpdateContext ctx) {
+    for (ProjectChangeKey key : attentionSetUpdates.keySet()) {
+      ChangeData change = ctx.getChangeData(key.projectName(), key.changeId());
+      AccountState account = ctx.getAccount();
+      for (AttentionSetUpdate update : attentionSetUpdates.get(key)) {
+        attentionSetObserver.fire(change, account, update, ctx.getWhen());
+      }
+    }
+  }
+
   private class ChangesHandle implements AutoCloseable {
     private final NoteDbUpdateManager manager;
     private final boolean dryrun;
@@ -613,6 +642,17 @@
     void execute() throws IOException {
       BatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
       BatchUpdate.this.executed = manager.isExecuted();
+      BatchUpdate.this.attentionSetUpdates = manager.attentionSetUpdates();
+    }
+
+    boolean requiresReindex() {
+      // We do not need to reindex changes if there are no ref updates, or if updated refs
+      // are all draft comment refs (since draft fields are not stored in the change index).
+      BatchRefUpdate bru = BatchUpdate.this.batchRefUpdate;
+      return !(bru == null
+          || bru.getCommands().isEmpty()
+          || bru.getCommands().stream()
+              .allMatch(cmd -> RefNames.isRefsDraftsComments(cmd.getRefName())));
     }
 
     ImmutableList<ListenableFuture<ChangeData>> startIndexFutures() {
@@ -660,7 +700,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);
@@ -730,6 +770,10 @@
         op.postUpdate(ctx);
       }
     }
+    try (TraceContext.TraceTimer ignored =
+        TraceContext.newTimer("fireAttentionSetUpdates#postUpdate", Metadata.empty())) {
+      fireAttentionSetUpdateEvents(ctx);
+    }
   }
 
   private static void logDebug(String msg) {
diff --git a/java/com/google/gerrit/server/update/Context.java b/java/com/google/gerrit/server/update/Context.java
index 57ebedd..aa41d90 100644
--- a/java/com/google/gerrit/server/update/Context.java
+++ b/java/com/google/gerrit/server/update/Context.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -70,13 +70,13 @@
   Instant getWhen();
 
   /**
-   * Get the time zone in which this update takes place.
+   * Get the time zone ID in which this update takes place.
    *
-   * <p>In the current implementation, this is always the time zone of the server.
+   * <p>In the current implementation, this is always the time zone ID of the server.
    *
-   * @return time zone.
+   * @return zone ID.
    */
-  TimeZone getTimeZone();
+  ZoneId getZoneId();
 
   /**
    * Get the user performing the update.
@@ -162,6 +162,6 @@
    * @return the created committer {@link PersonIdent}
    */
   default PersonIdent newCommitterIdent(IdentifiedUser user) {
-    return user.newCommitterIdent(getWhen(), getTimeZone());
+    return user.newCommitterIdent(getWhen(), getZoneId());
   }
 }
diff --git a/java/com/google/gerrit/server/update/PostUpdateContext.java b/java/com/google/gerrit/server/update/PostUpdateContext.java
index d4d1f62..25af264 100644
--- a/java/com/google/gerrit/server/update/PostUpdateContext.java
+++ b/java/com/google/gerrit/server/update/PostUpdateContext.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.update;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 
@@ -27,9 +28,13 @@
    * an update or because this method has been invoked before, the cached change data instance is
    * returned.
    *
-   * @param change the change for which the change data should be returned
+   * @param changeId the ID of the change for which the change data should be returned
    */
-  ChangeData getChangeData(Change change);
+  ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId);
+
+  default ChangeData getChangeData(Change change) {
+    return getChangeData(change.getProject(), change.getId());
+  }
 
   default ChangeData getChangeData(ChangeNotes changeNotes) {
     return getChangeData(changeNotes.getChange());
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 48ddd31..1b36139 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -18,19 +18,24 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.mail.send.AttentionSetSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.update.Context;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 
-public class AttentionSetEmail implements Runnable, RequestContext {
+public class AttentionSetEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -43,7 +48,6 @@
      * @param ctx context for sending the email.
      * @param change the change that the user was added/removed in.
      * @param reason reason for adding/removing the user.
-     * @param messageId messageId for tracking the email.
      * @param attentionUserId the user added/removed.
      */
     AttentionSetEmail create(
@@ -51,70 +55,117 @@
         Context ctx,
         Change change,
         String reason,
-        MessageIdGenerator.MessageId messageId,
         Account.Id attentionUserId);
   }
 
-  private ExecutorService sendEmailsExecutor;
-  private AccountTemplateUtil accountTemplateUtil;
-  private AttentionSetSender sender;
-  private Context ctx;
-  private Change change;
-  private String reason;
-
-  private MessageIdGenerator.MessageId messageId;
-  private Account.Id attentionUserId;
+  private final ExecutorService sendEmailsExecutor;
+  private final AsyncSender asyncSender;
 
   @Inject
   AttentionSetEmail(
       @SendEmailExecutor ExecutorService executor,
+      ThreadLocalRequestContext requestContext,
+      MessageIdGenerator messageIdGenerator,
       AccountTemplateUtil accountTemplateUtil,
       @Assisted AttentionSetSender sender,
       @Assisted Context ctx,
       @Assisted Change change,
       @Assisted String reason,
-      @Assisted MessageIdGenerator.MessageId messageId,
       @Assisted Account.Id attentionUserId) {
     this.sendEmailsExecutor = executor;
-    this.accountTemplateUtil = accountTemplateUtil;
-    this.sender = sender;
-    this.ctx = ctx;
-    this.change = change;
-    this.reason = reason;
-    this.messageId = messageId;
-    this.attentionUserId = attentionUserId;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              ctx.getRepoView(), change.currentPatchSetId(), "AttentionSetEmail");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    this.asyncSender =
+        new AsyncSender(
+            requestContext,
+            ctx.getIdentifiedUser(),
+            sender,
+            messageId,
+            ctx.getNotify(change.getId()),
+            attentionUserId,
+            accountTemplateUtil.replaceTemplates(reason),
+            change.getId());
   }
 
   public void sendAsync() {
     @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSender);
   }
 
-  @Override
-  public void run() {
-    try {
-      AccountState accountState =
-          ctx.getUser().isIdentifiedUser() ? ctx.getUser().asIdentifiedUser().state() : null;
-      if (accountState != null) {
-        sender.setFrom(accountState.account().id());
-      }
-      sender.setNotify(ctx.getNotify(change.getId()));
-      sender.setAttentionSetUser(attentionUserId);
-      sender.setReason(accountTemplateUtil.replaceTemplates(reason));
-      sender.setMessageId(messageId);
-      sender.send();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final ThreadLocalRequestContext requestContext;
+    private final IdentifiedUser user;
+    private final AttentionSetSender sender;
+    private final MessageIdGenerator.MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Account.Id attentionUserId;
+    private final String reason;
+    private final Change.Id changeId;
+
+    AsyncSender(
+        ThreadLocalRequestContext requestContext,
+        IdentifiedUser user,
+        AttentionSetSender sender,
+        MessageIdGenerator.MessageId messageId,
+        NotifyResolver.Result notify,
+        Account.Id attentionUserId,
+        String reason,
+        Change.Id changeId) {
+      this.requestContext = requestContext;
+      this.user = user;
+      this.sender = sender;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.attentionUserId = attentionUserId;
+      this.reason = reason;
+      this.changeId = changeId;
     }
-  }
 
-  @Override
-  public String toString() {
-    return "send-email comments";
-  }
+    @Override
+    public void run() {
+      RequestContext old = requestContext.setContext(this);
+      try {
+        Optional<Account.Id> accountId =
+            user.isIdentifiedUser()
+                ? Optional.of(user.asIdentifiedUser().getAccountId())
+                : Optional.empty();
+        if (accountId.isPresent()) {
+          sender.setFrom(accountId.get());
+        }
+        sender.setNotify(notify);
+        sender.setAttentionSetUser(attentionUserId);
+        sender.setReason(reason);
+        sender.setMessageId(messageId);
+        sender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot email update for change %s", changeId);
+      } finally {
+        requestContext.setContext(old);
+      }
+    }
 
-  @Override
-  public CurrentUser getUser() {
-    return ctx.getUser();
+    @Override
+    public String toString() {
+      return "send-email attention-set-update";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/CheckProjectAccessCommand.java b/java/com/google/gerrit/sshd/commands/CheckProjectAccessCommand.java
new file mode 100644
index 0000000..39f9ef2
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/CheckProjectAccessCommand.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Option;
+
+@CommandMetaData(
+    name = "check-project-access",
+    description = "Check project readability for specified user(s)",
+    runsAt = MASTER_OR_SLAVE)
+public class CheckProjectAccessCommand extends SshCommand {
+  @Inject private AccountResolver accountResolver;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private Provider<CurrentUser> userProvider;
+
+  @Option(
+      name = "--project",
+      aliases = {"-p"},
+      metaVar = "PROJECT",
+      required = true,
+      usage = "project name to check")
+  private String projectName;
+
+  @Option(
+      name = "--user",
+      aliases = {"-u"},
+      metaVar = "USER",
+      required = true,
+      usage = "Account identifier used to find the user(s) for which to check access.")
+  private String userName;
+
+  @Override
+  protected void run() throws Failure, ConfigInvalidException, IOException {
+    PermissionBackend.WithUser userPermission = permissionBackend.user(userProvider.get());
+
+    boolean isAdmin = userPermission.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
+    boolean canViewAccount = isAdmin || userPermission.testOrFalse(GlobalPermission.VIEW_ACCESS);
+
+    if (!user.hasSameAccountId(userProvider.get()) && !canViewAccount) {
+      throw die("This command requires 'view access' or 'administrate server' capabilities.");
+    }
+
+    try {
+      for (IdentifiedUser user : getUserList(userName)) {
+        stdout.println(
+            String.format(
+                "Username: '%s', Email: '%s', Full Name: '%s', Result: %b\n",
+                user.getLoggableName(),
+                user.getNameEmail()
+                    .substring(
+                        user.getNameEmail().indexOf("<") + 1, user.getNameEmail().indexOf(">")),
+                user.getName(),
+                permissionBackend
+                    .user(user)
+                    .project(Project.nameKey(projectName))
+                    .test(ProjectPermission.READ)));
+      }
+    } catch (ConfigInvalidException
+        | ResourceNotFoundException
+        | IllegalArgumentException
+        | PermissionBackendException e) {
+      throw die(e);
+    }
+  }
+
+  private Set<IdentifiedUser> getUserList(String userName)
+      throws ConfigInvalidException, IOException, ResourceNotFoundException {
+    return getIdList(userName).stream().map(userFactory::create).collect(Collectors.toSet());
+  }
+
+  private Set<Account.Id> getIdList(String userName)
+      throws ConfigInvalidException, IOException, ResourceNotFoundException {
+    Set<Account.Id> idList = accountResolver.resolve(userName).asIdSet();
+    if (idList.isEmpty()) {
+      throw new ResourceNotFoundException(
+          "No accounts found for your query: \""
+              + userName
+              + "\""
+              + " Tip: Try double-escaping spaces, for example: \"--user Last,\\\\ First\"");
+    }
+    return idList;
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index e7fe22f..42e7c0f 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -46,6 +46,7 @@
     command(gerrit).toProvider(new DispatchCommandProvider(gerrit));
     command(gerrit, AproposCommand.class);
     command(gerrit, BanCommitCommand.class);
+    command(gerrit, CheckProjectAccessCommand.class);
     command(gerrit, CloseConnection.class);
     command(gerrit, ConvertRefStorage.class);
     command(gerrit, FlushCaches.class);
diff --git a/java/com/google/gerrit/sshd/commands/ScpCommand.java b/java/com/google/gerrit/sshd/commands/ScpCommand.java
index 6912795..3be98fd 100644
--- a/java/com/google/gerrit/sshd/commands/ScpCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -147,7 +147,7 @@
     for (; ; ) {
       int c = in.read();
       if (c == '\n') {
-        return baos.toString();
+        return baos.toString(UTF_8);
       } else if (c == -1) {
         throw new IOException("End of stream");
       } else {
diff --git a/java/com/google/gerrit/testing/FakeAccountCache.java b/java/com/google/gerrit/testing/FakeAccountCache.java
index 49a8d71..2c01548 100644
--- a/java/com/google/gerrit/testing/FakeAccountCache.java
+++ b/java/com/google/gerrit/testing/FakeAccountCache.java
@@ -24,6 +24,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Fake implementation of {@link AccountCache} for testing. */
 public class FakeAccountCache implements AccountCache {
@@ -60,6 +61,11 @@
     throw new UnsupportedOperationException();
   }
 
+  @Override
+  public AccountState getFromMetaId(Account.Id accountId, ObjectId metaId) {
+    return get(accountId).get();
+  }
+
   public synchronized void put(Account account) {
     AccountState state = newState(account);
     byId.put(account.id(), state);
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b00cadb..1afacab 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -19,6 +19,7 @@
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
@@ -61,6 +62,8 @@
 import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
 import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.gerrit.server.config.GerritImportedServerIdsProvider;
 import com.google.gerrit.server.config.GerritInstanceIdModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
 import com.google.gerrit.server.config.GerritOptions;
@@ -311,6 +314,17 @@
 
   @Provides
   @Singleton
+  @GerritImportedServerIds
+  public ImmutableList<String> createImportedServerIds() {
+    ImmutableList<String> serverIds =
+        ImmutableList.copyOf(
+            cfg.getStringList(
+                GerritServerIdProvider.SECTION, null, GerritImportedServerIdsProvider.KEY));
+    return serverIds;
+  }
+
+  @Provides
+  @Singleton
   @SendEmailExecutor
   public ExecutorService createSendEmailExecutor() {
     return newDirectExecutorService();
diff --git a/java/com/google/gerrit/testing/IndexConfig.java b/java/com/google/gerrit/testing/IndexConfig.java
index 13b346f..45b54ce 100644
--- a/java/com/google/gerrit/testing/IndexConfig.java
+++ b/java/com/google/gerrit/testing/IndexConfig.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.testing;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import org.eclipse.jgit.lib.Config;
 
@@ -31,9 +32,14 @@
     cfg.setString("trackingid", "query-bug", "footer", "Bug:");
     cfg.setString("trackingid", "query-bug", "match", "QUERY\\d{2,8}");
     cfg.setString("trackingid", "query-bug", "system", "querytests");
-    cfg.setString("trackingid", "query-feature", "footer", "Feature");
-    cfg.setString("trackingid", "query-feature", "match", "QUERY\\d{2,8}");
-    cfg.setString("trackingid", "query-feature", "system", "querytests");
+    cfg.setStringList(
+        "trackingid", "query-google", "footer", ImmutableList.of("Issue", "Google-Bug-Id"));
+    cfg.setString(
+        "trackingid",
+        "query-google",
+        "match",
+        "(?:[Bb]ug|[Ii]ssue|b/)[ \\t]*\\r?\\n?[ \\t]*#?(\\d+)");
+    cfg.setString("trackingid", "query-google", "system", "querygo");
     return cfg;
   }
 
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index 8bd02b8..4a97bc5 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Injector;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -109,7 +109,7 @@
     try (Repository repo = repoManager.openRepository(c.getProject());
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
       PersonIdent ident =
-          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), TimeZone.getDefault());
+          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), ZoneId.systemDefault());
       TestRepository<Repository>.CommitBuilder cb =
           tr.commit()
               .author(ident)
diff --git a/java/com/google/gerrit/testing/TestUpdateUI.java b/java/com/google/gerrit/testing/TestUpdateUI.java
index 76671fb..08c9a14 100644
--- a/java/com/google/gerrit/testing/TestUpdateUI.java
+++ b/java/com/google/gerrit/testing/TestUpdateUI.java
@@ -14,12 +14,29 @@
 
 package com.google.gerrit.testing;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.schema.UpdateUI;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Set;
 
 public class TestUpdateUI implements UpdateUI {
+  private final List<String> messages = new ArrayList<>();
+
   @Override
-  public void message(String message) {}
+  public void message(String message) {
+    messages.add(message);
+  }
+
+  public ImmutableList<String> getMessages() {
+    return ImmutableList.copyOf(messages);
+  }
+
+  public String getOutput() {
+    return messages.stream().collect(joining("\n"));
+  }
 
   @Override
   public boolean yesno(boolean defaultValue, String message) {
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index 877ccd5..b6e5b74 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
-import java.util.Date;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
@@ -333,13 +332,10 @@
     assertThat(repo.exactRef(ref.getName())).isNull();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private ObjectId createCommit(Repository repo) throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent ident =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), Date.from(TimeUtil.now()));
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 78a0eeb..c2b779b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.accounts;
 
 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;
@@ -32,7 +33,11 @@
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithSecondUserId;
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
+import static com.google.gerrit.server.account.AccountProperties.ACCOUNT;
+import static com.google.gerrit.server.account.AccountProperties.ACCOUNT_CONFIG;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 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;
@@ -101,6 +106,7 @@
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountsInput;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -158,7 +164,6 @@
 import java.security.KeyPair;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -504,16 +509,16 @@
       assertThat(timestampDiffMs).isAtMost(SECONDS.toMillis(1));
 
       // Check the 'account.config' file.
-      try (TreeWalk tw = TreeWalk.forPath(or, AccountProperties.ACCOUNT_CONFIG, c.getTree())) {
+      try (TreeWalk tw = TreeWalk.forPath(or, ACCOUNT_CONFIG, c.getTree())) {
         if (name != null || status != null) {
           assertThat(tw).isNotNull();
           Config cfg = new Config();
           cfg.fromText(new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8));
           assertThat(cfg)
-              .stringValue(AccountProperties.ACCOUNT, null, AccountProperties.KEY_FULL_NAME)
+              .stringValue(ACCOUNT, null, AccountProperties.KEY_FULL_NAME)
               .isEqualTo(name);
           assertThat(cfg)
-              .stringValue(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS)
+              .stringValue(ACCOUNT, null, AccountProperties.KEY_STATUS)
               .isEqualTo(status);
         } else {
           // No account properties were set, hence an 'account.config' file was not created.
@@ -797,58 +802,6 @@
   }
 
   @Test
-  public void ignoreChange() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestAccount user2 = accountCreator.user2();
-      accountIndexedCounter.clear();
-
-      PushOneCommit.Result r = createChange();
-
-      ReviewerInput in = new ReviewerInput();
-      in.reviewer = user.email();
-      gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-      in = new ReviewerInput();
-      in.reviewer = user2.email();
-      gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-      requestScopeOperations.setApiUser(user.id());
-      gApi.changes().id(r.getChangeId()).ignore(true);
-
-      sender.clear();
-      requestScopeOperations.setApiUser(admin.id());
-      gApi.changes().id(r.getChangeId()).abandon();
-      List<Message> messages = sender.getMessages();
-      assertThat(messages).hasSize(1);
-      assertThat(messages.get(0).rcpt()).containsExactly(user2.getNameEmail());
-      accountIndexedCounter.assertNoReindex();
-    }
-  }
-
-  @Test
-  public void addReviewerToIgnoredChange() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      PushOneCommit.Result r = createChange();
-
-      requestScopeOperations.setApiUser(user.id());
-      gApi.changes().id(r.getChangeId()).ignore(true);
-
-      sender.clear();
-      requestScopeOperations.setApiUser(admin.id());
-
-      ReviewerInput in = new ReviewerInput();
-      in.reviewer = user.email();
-      gApi.changes().id(r.getChangeId()).addReviewer(in);
-      List<Message> messages = sender.getMessages();
-      assertThat(messages).hasSize(0);
-    }
-  }
-
-  @Test
   public void addExistingReviewersUsingPostReview() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -2466,9 +2419,6 @@
   }
 
   @Test
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public void stalenessChecker() throws Exception {
     // Newly created account is not stale.
     AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
@@ -2482,7 +2432,7 @@
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), Date.from(TimeUtil.now()));
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(commit.getTree());
       cb.setCommitter(ident);
@@ -2936,6 +2886,24 @@
     assertThat(gApi.accounts().id("secondary").get()._accountId).isEqualTo(foo.id().get());
   }
 
+  @Test
+  public void getAccountFromMetaId() throws RestApiException {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.accounts().self().setStatus("New status");
+
+    AccountState postUpdateStatus = accountCache.get(admin.id()).get();
+    assertThat(postUpdateStatus).isNotEqualTo(preUpdateState);
+    assertThat(
+            accountCache.getFromMetaId(
+                admin.id(), ObjectId.fromString(preUpdateState.account().metaId())))
+        .isEqualTo(preUpdateState);
+    assertThat(
+            accountCache.getFromMetaId(
+                admin.id(), ObjectId.fromString(postUpdateStatus.account().metaId())))
+        .isEqualTo(postUpdateStatus);
+  }
+
   private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
     DraftInput in = new DraftInput();
     in.path = path;
@@ -2958,6 +2926,206 @@
     }
   }
 
+  @Test
+  public void projectWatchesUpdate_refsUsersUpdated() throws RestApiException {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ProjectWatchInfo projectWatchInfo = new ProjectWatchInfo();
+    projectWatchInfo.project = project.get();
+    projectWatchInfo.notifyAllComments = true;
+    gApi.accounts().self().setWatchedProjects(ImmutableList.of(projectWatchInfo));
+
+    AccountState updatedState1 = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState1.account().metaId());
+
+    gApi.accounts().self().deleteWatchedProjects(ImmutableList.of(projectWatchInfo));
+
+    AccountState updatedState2 = accountCache.get(admin.id()).get();
+    assertThat(updatedState1.account().metaId()).isNotEqualTo(updatedState2.account().metaId());
+  }
+
+  @Test
+  public void updateExternalId_externalIdApiUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    gApi.accounts().self().addEmail(newEmailInput("secondary@google.com"));
+    assertExternalIds(
+        admin.id(),
+        ImmutableSet.of(
+            "mailto:admin@example.com", "username:admin", "mailto:secondary@google.com"));
+
+    AccountState updatedState1 = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState1.account().metaId());
+
+    gApi.accounts().self().deleteExternalIds(ImmutableList.of("mailto:secondary@google.com"));
+
+    AccountState updatedState2 = accountCache.get(admin.id()).get();
+    assertThat(updatedState1.account().metaId()).isNotEqualTo(updatedState2.account().metaId());
+  }
+
+  @Test
+  public void addExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId = externalIdFactory.create("custom", "value", admin.id());
+    accountsUpdateProvider
+        .get()
+        .update("Add External ID", admin.id(), (a, u) -> u.addExternalId(externalId));
+    assertExternalIds(
+        admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:value"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void deleteExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId = externalIdFactory.create("mailto", "admin@example.com", admin.id());
+    accountsUpdateProvider
+        .get()
+        .update("Remove External ID", admin.id(), (a, u) -> u.deleteExternalId(externalId));
+    assertExternalIds(admin.id(), ImmutableSet.of("username:admin"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void updateExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId =
+        externalIdFactory.createWithEmail(
+            SCHEME_USERNAME, "admin", admin.id(), "secondary@example.com");
+    accountsUpdateProvider
+        .get()
+        .update("Remove External ID", admin.id(), (a, u) -> u.updateExternalId(externalId));
+    assertExternalIds(admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void replaceExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId =
+        externalIdFactory.createWithEmail(
+            SCHEME_USERNAME, "admin", admin.id(), "secondary@example.com");
+    accountsUpdateProvider
+        .get()
+        .update(
+            "Remove External ID",
+            admin.id(),
+            (a, u) ->
+                u.replaceExternalId(
+                    externalIds.get(externalIdKeyFactory.create(SCHEME_USERNAME, "admin")).get(),
+                    externalId));
+    assertExternalIds(admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void accountUpdate_updateBatch_allUsersExternalIdsUpdated_refsUsersUpdated()
+      throws Exception {
+    AccountState preUpdateAdminState = accountCache.get(admin.id()).get();
+    AccountState preUpdateUserState = accountCache.get(user.id()).get();
+
+    requestScopeOperations.setApiUser(admin.id());
+    ExternalId extId1 =
+        externalIdFactory.createWithEmail("custom", "admin-id", admin.id(), "admin-id@test.com");
+
+    ExternalId extId2 =
+        externalIdFactory.createWithEmail("custom", "user-id", user.id(), "user-id@test.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+    }
+    accountIndexedCounter.assertReindexOf(admin.id(), 1);
+    accountIndexedCounter.assertReindexOf(user.id(), 1);
+
+    assertExternalIds(
+        admin.id(),
+        ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:admin-id"));
+    assertExternalIds(
+        user.id(), ImmutableSet.of("username:user1", "mailto:user1@example.com", "custom:user-id"));
+    // Assert reindexing has worked on the updated accounts.
+    assertThat(
+            Iterables.getOnlyElement(gApi.accounts().query("admin-id@test.com").get())._accountId)
+        .isEqualTo(admin.id().get());
+    assertThat(Iterables.getOnlyElement(gApi.accounts().query("user-id@test.com").get())._accountId)
+        .isEqualTo(user.id().get());
+    AccountState updatedAdminState = accountCache.get(admin.id()).get();
+    AccountState updatedUserState = accountCache.get(user.id()).get();
+    assertThat(preUpdateAdminState.account().metaId())
+        .isNotEqualTo(updatedAdminState.account().metaId());
+    assertThat(preUpdateUserState.account().metaId())
+        .isNotEqualTo(updatedUserState.account().metaId());
+  }
+
+  @Test
+  public void accountUpdate_updateBatch_someUsersExternalIdsUpdated_refsUsersUpdated()
+      throws Exception {
+    AccountState preUpdateAdminState = accountCache.get(admin.id()).get();
+    AccountState preUpdateUserState = accountCache.get(user.id()).get();
+
+    requestScopeOperations.setApiUser(admin.id());
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Update Display Name", admin.id(), (a, u) -> u.setDisplayName("DN"));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Remove external Id",
+            user.id(),
+            (a, u) ->
+                u.deleteExternalId(
+                    externalIdFactory.createWithEmail(
+                        SCHEME_MAILTO, user.email(), user.id(), user.email())));
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+    }
+    accountIndexedCounter.assertReindexOf(admin.id(), 1);
+    accountIndexedCounter.assertReindexOf(user.id(), 1);
+
+    // Only the version in config of the user with external id update was updated.
+    AccountState updatedAdminState = accountCache.get(admin.id()).get();
+    AccountState updatedUserState = accountCache.get(user.id()).get();
+    assertThat(preUpdateAdminState.account().metaId())
+        .isNotEqualTo(updatedAdminState.account().metaId());
+    assertThat(preUpdateUserState.account().metaId())
+        .isNotEqualTo(updatedUserState.account().metaId());
+  }
+
+  private void assertExternalIds(Account.Id accountId, ImmutableSet<String> extIds)
+      throws Exception {
+    assertThat(
+            gApi.accounts().id(accountId.get()).getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toImmutableSet()))
+        .isEqualTo(extIds);
+  }
+
   private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
     return NullAwareCorrespondence.transforming(groupInfo -> groupInfo.name, "has name");
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index aa8615b..f5b311b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -62,6 +62,7 @@
             new MenuItem("Edits", "#/q/has:edit", null),
             new MenuItem("Watched Changes", "#/q/is:watched+is:open", null),
             new MenuItem("Starred Changes", "#/q/is:starred", null),
+            new MenuItem("All Visible Changes", "#/q/is:visible", null),
             new MenuItem("Groups", "#/settings/#Groups", null));
     assertThat(o.changeTable).isEmpty();
 
@@ -83,6 +84,7 @@
     i.legacycidInChangeTable ^= true;
     i.muteCommonPathPrefixes ^= true;
     i.signedOffBy ^= true;
+    i.allowBrowserNotifications ^= false;
     i.diffView = DiffView.UNIFIED_DIFF;
     i.my = new ArrayList<>();
     i.my.add(new MenuItem("name", "url"));
@@ -94,6 +96,7 @@
     assertThat(o.my).containsExactlyElementsIn(i.my);
     assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
     assertThat(o.theme).isEqualTo(i.theme);
+    assertThat(o.allowBrowserNotifications).isEqualTo(i.allowBrowserNotifications);
     assertThat(o.disableKeyboardShortcuts).isEqualTo(i.disableKeyboardShortcuts);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/BUILD b/javatests/com/google/gerrit/acceptance/api/change/BUILD
index 94fb0dc..306852a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/change/BUILD
@@ -6,5 +6,8 @@
     labels = [
         "api",
     ],
-    deps = ["//java/com/google/gerrit/server/util/time"],
+    deps = [
+        "//java/com/google/gerrit/server/util/time",
+        "//javatests/com/google/gerrit/acceptance/server/change:util",
+    ],
 ) for f in glob(["*IT.java"])]
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index c5f0d23..bd156f4 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -83,7 +83,9 @@
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
+import com.google.gerrit.acceptance.api.change.ChangeIT.TestAttentionSetListenerModule.TestAttentionSetListener;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.server.change.CommentsUtil;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
@@ -95,6 +97,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
@@ -147,7 +150,9 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
+import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -160,7 +165,6 @@
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
@@ -1536,6 +1540,38 @@
   }
 
   @Test
+  public void attentionSetListener_firesOnChange() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    AttentionSetInput addUser = new AttentionSetInput(user.email(), "some reason");
+    TestAttentionSetListener attentionSetListener = new TestAttentionSetListener();
+
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(attentionSetListener)) {
+
+      gApi.changes().id(r1.getChangeId()).addReviewer(user.email());
+      assertThat(attentionSetListener.firedCount).isEqualTo(1);
+      assertThat(attentionSetListener.lastEvent.usersAdded().size()).isEqualTo(1);
+      attentionSetListener
+          .lastEvent
+          .usersAdded()
+          .forEach(u -> assertThat(u).isEqualTo(user.id().get()));
+
+      gApi.changes().id(r1.getChangeId()).addToAttentionSet(addUser);
+      assertThat(attentionSetListener.firedCount).isEqualTo(1);
+
+      gApi.changes().id(r1.getChangeId()).attention(user.username()).remove(addUser);
+
+      assertThat(attentionSetListener.firedCount).isEqualTo(2);
+      assertThat(attentionSetListener.lastEvent.usersAdded()).isEmpty();
+      assertThat(attentionSetListener.lastEvent.usersRemoved().size()).isEqualTo(1);
+      attentionSetListener
+          .lastEvent
+          .usersRemoved()
+          .forEach(u -> assertThat(u).isEqualTo(user.id().get()));
+    }
+  }
+
+  @Test
   public void rebaseChangeBase() throws Exception {
     PushOneCommit.Result r1 = createChange();
     PushOneCommit.Result r2 = createChange();
@@ -2505,6 +2541,36 @@
   }
 
   @Test
+  public void removeChangeOwnerAsReviewerByDelete() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // vote on the change so that the change owner becomes a reviewer
+    approve(changeId);
+    assertThat(getReviewers(gApi.changes().id(changeId).get().reviewers.get(REVIEWER)))
+        .containsExactly(admin.id());
+
+    gApi.changes().id(changeId).reviewer(admin.id().toString()).remove();
+    assertThat(getReviewers(gApi.changes().id(changeId).get().reviewers.get(REVIEWER))).isEmpty();
+  }
+
+  @Test
+  public void removeChangeOwnerAsReviewerByPostReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // vote on the change so that the change owner becomes a reviewer
+    approve(changeId);
+    assertThat(getReviewers(gApi.changes().id(changeId).get().reviewers.get(REVIEWER)))
+        .containsExactly(admin.id());
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewer(admin.id().toString(), ReviewerState.REMOVED, /* confirmed= */ false);
+    gApi.changes().id(changeId).current().review(reviewInput);
+    assertThat(getReviewers(gApi.changes().id(changeId).get().reviewers.get(REVIEWER))).isEmpty();
+  }
+
+  @Test
   public void removeCC() throws Exception {
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
@@ -2731,6 +2797,27 @@
   }
 
   @Test
+  public void deleteVoteWithReason() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+    sender.clear();
+    DeleteVoteInput in = new DeleteVoteInput();
+    in.label = LabelId.CODE_REVIEW;
+    in.reason = "Internal conflict resolved";
+    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote(in);
+    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).messages()).message)
+        .isEqualTo(
+            "Removed Code-Review+1 by User1 <user1@example.com>\n"
+                + "\n"
+                + "Internal conflict resolved\n");
+  }
+
+  @Test
   public void deleteVoteNotifyAccount() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
@@ -4567,118 +4654,41 @@
       ChangeInfo change = info(triplet);
       assertThat(change.starred).isTrue();
       assertThat(change.stars).contains(DEFAULT_LABEL);
-      changeIndexedCounter.assertReindexOf(change);
+      // change was not re-indexed
+      changeIndexedCounter.assertReindexOf(change, 0);
 
       gApi.accounts().self().unstarChange(triplet);
       change = info(triplet);
       assertThat(change.starred).isNull();
       assertThat(change.stars).isNull();
-      changeIndexedCounter.assertReindexOf(change);
+      // change was not re-indexed
+      changeIndexedCounter.assertReindexOf(change, 0);
     }
   }
 
   @Test
-  public void ignore() throws Exception {
-    String email = "user2@example.com";
-    String fullname = "User2";
-    accountOperations
-        .newAccount()
-        .username("user2")
-        .preferredEmail(email)
-        .fullname(fullname)
-        .create();
+  public void createAndDeleteDraftCommentDoesNotTriggerChangeReindex() throws Exception {
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      String triplet = project.get() + "~master~" + r.getChangeId();
+      changeIndexedCounter.clear();
 
-    PushOneCommit.Result r = createChange();
+      // Create the draft. Change is not re-indexed
+      DraftInput draftInput =
+          CommentsUtil.newDraft("file1", Side.REVISION, /* line= */ 1, "comment 1");
+      CommentInfo draftInfo =
+          gApi.changes().id(changeId).revision(revId).createDraft(draftInput).get();
+      ChangeInfo change = info(triplet);
+      changeIndexedCounter.assertReindexOf(change, 0);
 
-    ReviewerInput in = new ReviewerInput();
-    in.reviewer = user.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    in = new ReviewerInput();
-    in.reviewer = email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).ignore(true);
-    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isTrue();
-
-    // New patch set notification is not sent to users ignoring the change
-    sender.clear();
-    requestScopeOperations.setApiUser(admin.id());
-    amendChange(r.getChangeId());
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Address address = Address.create(fullname, email);
-    assertThat(messages.get(0).rcpt()).containsExactly(address);
-
-    // Review notification is not sent to users ignoring the change
-    sender.clear();
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(address);
-
-    // Abandoned notification is not sent to users ignoring the change
-    sender.clear();
-    gApi.changes().id(r.getChangeId()).abandon();
-    messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(address);
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).ignore(false);
-    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isFalse();
-  }
-
-  @Test
-  public void cannotIgnoreOwnChange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    BadRequestException thrown =
-        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).ignore(true));
-    assertThat(thrown).hasMessageThat().contains("cannot ignore own change");
-  }
-
-  @Test
-  public void cannotIgnoreStarredChange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.accounts().self().starChange(changeId);
-    assertThat(gApi.changes().id(changeId).get().starred).isTrue();
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(changeId).ignore(true));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            "The labels "
-                + StarredChangesUtil.DEFAULT_LABEL
-                + " and "
-                + StarredChangesUtil.IGNORE_LABEL
-                + " are mutually exclusive. Only one of them can be set.");
-  }
-
-  @Test
-  public void cannotStarIgnoredChange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).ignore(true);
-    assertThat(gApi.changes().id(changeId).ignored()).isTrue();
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class, () -> gApi.accounts().self().starChange(changeId));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            "The labels "
-                + StarredChangesUtil.DEFAULT_LABEL
-                + " and "
-                + StarredChangesUtil.IGNORE_LABEL
-                + " are mutually exclusive. Only one of them can be set.");
+      // Delete the draft comment. Change is not re-indexed
+      gApi.changes().id(changeId).revision(revId).draft(draftInfo.id).delete();
+      changeIndexedCounter.assertReindexOf(change, 0);
+    }
   }
 
   @Test
@@ -4721,6 +4731,137 @@
     assertThat(gApi.changes().query(changeId).get().get(0).mergeable).isNull();
   }
 
+  @Test
+  public void ccUserThatCannotSeeTheChange() throws Exception {
+    // Create a project that is only visible to admin users.
+    Project.NameKey project = projectOperations.newProject().create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Create a change
+    TestRepository<?> adminTestRepo = cloneProject(project, admin);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), adminTestRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Check that the change is not visible to user.
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(r.getChangeId()).get());
+
+    // Add user as a CC.
+    requestScopeOperations.setApiUser(admin.id());
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = user.id().toString();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
+
+    // Check that user was not added as a CC since they cannot see the change. Note,
+    // ChangeInfo#reviewers is a map that also contains CCs (if any are present).
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
+
+    // Check that the change is still not visible to user.
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(r.getChangeId()).get());
+  }
+
+  @Test
+  public void ccNonExistentAccountByEmailThenRemoveByDelete() throws Exception {
+    // Create a project that allows reviewers by email.
+    Project.NameKey project = projectOperations.newProject().create();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateProject(
+              b ->
+                  b.setBooleanConfig(
+                      BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE));
+      u.save();
+    }
+
+    // Create a change
+    TestRepository<?> testRepo = cloneProject(project, admin);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Add an email as a CC for which no Gerrit account exists.
+    sender.clear();
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = "email-without-account@example.com";
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
+
+    // Check that the email was added as a CC and an email was sent.
+    AccountInfo ccedAccountInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(r.getChangeId()).get().reviewers.get(ReviewerState.CC));
+    assertThat(ccedAccountInfo.email).isEqualTo(reviewerInput.reviewer);
+    assertThat(ccedAccountInfo._accountId).isNull();
+    assertThat(ccedAccountInfo.name).isNull();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(String.format("%s has uploaded this change for review", admin.fullName()));
+
+    // Remove the CC.
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).reviewer(reviewerInput.reviewer).remove();
+
+    // Check that the email was removed as a CC and an email was sent.
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(String.format("%s has removed %s", admin.fullName(), reviewerInput.reviewer));
+  }
+
+  @Test
+  public void ccNonExistentAccountByEmailThenRemoveByPostReview() throws Exception {
+    // Create a project that allows reviewers by email.
+    Project.NameKey project = projectOperations.newProject().create();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateProject(
+              b ->
+                  b.setBooleanConfig(
+                      BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE));
+      u.save();
+    }
+
+    // Create a change
+    TestRepository<?> testRepo = cloneProject(project, admin);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Add an email as a CC for which no Gerrit account exists.
+    sender.clear();
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = "email-without-account@example.com";
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
+
+    // Check that the email was added as a CC and an email was sent.
+    AccountInfo ccedAccountInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(r.getChangeId()).get().reviewers.get(ReviewerState.CC));
+    assertThat(ccedAccountInfo.email).isEqualTo(reviewerInput.reviewer);
+    assertThat(ccedAccountInfo._accountId).isNull();
+    assertThat(ccedAccountInfo.name).isNull();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(String.format("%s has uploaded this change for review", admin.fullName()));
+
+    // Remove the CC.
+    sender.clear();
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewer(reviewerInput.reviewer, ReviewerState.REMOVED, /* confirmed= */ false);
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+
+    // Check that the email was removed as a CC and an email was sent.
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(String.format("%s has removed %s", admin.fullName(), reviewerInput.reviewer));
+  }
+
   private PushOneCommit.Result createWorkInProgressChange() throws Exception {
     return pushTo("refs/for/master%wip");
   }
@@ -4745,13 +4886,34 @@
     Boolean wip;
 
     @Override
-    public void onWorkInProgressStateChanged(Event event) {
+    public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event event) {
       this.invoked = true;
       this.wip =
           event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
     }
   }
 
+  public static class TestAttentionSetListenerModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), AttentionSetListener.class).to(TestAttentionSetListener.class);
+    }
+
+    public static class TestAttentionSetListener implements AttentionSetListener {
+      AttentionSetListener.Event lastEvent;
+      int firedCount;
+
+      @Inject
+      public TestAttentionSetListener() {}
+
+      @Override
+      public void onAttentionSetChanged(AttentionSetListener.Event event) {
+        firedCount++;
+        lastEvent = event;
+      }
+    }
+  }
+
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
new file mode 100644
index 0000000..f8cf5fd
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
@@ -0,0 +1,981 @@
+// 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.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.approval.ApprovalCopier;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
+  @Inject private ApprovalsUtil approvalsUtil;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void cannotFormatWithNullApprovalCopierResult() throws Exception {
+    LabelTypes labelTypes = projectCache.get(project).get().getLabelTypes();
+    NullPointerException exception =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                approvalsUtil.formatApprovalCopierResult(
+                    /* approvalCopierResult= */ null, labelTypes));
+    assertThat(exception).hasMessageThat().isEqualTo("approvalCopierResult");
+  }
+
+  @Test
+  public void cannotFormatWithNullLabelTypes() throws Exception {
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
+    NullPointerException exception =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                approvalsUtil.formatApprovalCopierResult(
+                    approvalCopierResult, /* labelTypes= */ null));
+    assertThat(exception).hasMessageThat().isEqualTo("labelTypes");
+  }
+
+  @Test
+  public void format_noCopiedApprovals_noOutdatedApprovals() throws Exception {
+    LabelTypes labelTypes = projectCache.get(project).get().getLabelTypes();
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .isEmpty();
+  }
+
+  @Test
+  public void formatCopiedApproval_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatOutdatedApproval_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Outdated Votes:\n* Code-Review+1 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatCopiedApproval_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1\n");
+  }
+
+  @Test
+  public void formatOutdatedApproval_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Outdated Votes:\n* Code-Review+1\n");
+  }
+
+  @Test
+  public void formatCopiedApproval_withCopyCondition_noUserInPredicate() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+  }
+
+  @Test
+  public void formatOutdatedApproval_withCopyCondition_noUserInPredicate() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Outdated Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+  }
+
+  @Test
+  public void formatCopiedApproval_withNonParseableCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void formatOutdatedApproval_withNonParseableCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Outdated Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void formatCopiedApproval_withCopyCondition_withUserInPredicate() throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s"
+                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
+  }
+
+  @Test
+  public void formatOutdatedpproval_withCopyCondition_withUserInPredicate() throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Outdated Votes:\n"
+                    + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
+  }
+
+  @Test
+  public void formatCopiedApproval_withCopyCondition_withUserInPredicateThatContainNonVisibleGroup()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+
+    // Set 'user' as the current user in the request scope.
+    // 'user' cannot see the Administrators group that is used in the copy condition.
+    // Parsing the copy condition should still succeed since ApprovalsUtil should use the internal
+    // user that can see everything when parsing the copy condition.
+    requestScopeOperations.setApiUser(user.id());
+
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s"
+                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
+  }
+
+  @Test
+  public void
+      formatOutdatedpproval_withCopyCondition_withUserInPredicateThatContainNonVisibleGroup()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+
+    // Set 'user' as the current user in the request scope.
+    // 'user' cannot see the Administrators group that is used in the copy condition.
+    // Parsing the copy condition should still succeed since ApprovalsUtil should use the internal
+    // user that can see everything when parsing the copy condition.
+    requestScopeOperations.setApiUser(user.id());
+
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Outdated Votes:\n"
+                    + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
+  }
+
+  @Test
+  public void formatCopiedApproval_withNonParseableCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid));
+  }
+
+  @Test
+  public void formatOutdatedApproval_withNonParseableCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Outdated Votes:\n"
+                    + "* Code-Review+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentLabels_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1 (label type is missing)\n"
+                + "* Verified+1 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentValues_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1, Code-Review+2 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentLabel_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null),
+                createLabelType(/* labelName= */ "Verified", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1\n* Verified+1\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentValue_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1, Code-Review+2\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_withCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentLabel_withCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX"),
+                createLabelType(
+                    /* labelName= */ "Verified", /* copyCondition= */ "is:MIN OR is:MAX")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n"
+                + "* Verified+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1, Code-Review+2 (copy condition: \"is:MIN OR is:MAX\")\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_withNonParseableCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentLabel_withNonParseableCopyCondition_noUserInPredicate()
+          throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz"),
+                createLabelType(/* labelName= */ "Verified", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n"
+                + "* Verified+1 (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentValue_withNonParseableCopyCondition_noUserInPredicate()
+          throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1, Code-Review+2"
+                + " (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s, %s"
+                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentLabel_withCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid)),
+                createLabelType(
+                    /* labelName= */ "Verified",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n"
+                    + "* Verified+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                groupUuid,
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s, Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentAndSameValue_withCopyCondition_withUserInPredicate()
+      throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user2, "Code-Review", 1);
+    PatchSetApproval patchSetApproval3 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                patchSetApproval1, patchSetApproval2, patchSetApproval3),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s, %s, Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                AccountTemplateUtil.getAccountTemplate(user2.id()),
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_withNonParseableCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid));
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentLabel_withNonParseableCopyCondition_withUserInPredicate()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)),
+                createLabelType(
+                    /* labelName= */ "Verified",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n"
+                    + "* Verified+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid, groupUuid));
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentValue_withNonParseableCopyCondition_withUserInPredicate()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1, Code-Review+2 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid));
+  }
+
+  @Test
+  public void copiedAndOutdatedApprovalsAreIncludedInChangeMessageOnPatchSetCreationByPush()
+      throws Exception {
+    // Add Verified label without copy condition.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    // Vote Code-Review-2 (sticky because it's a veto vote and the Code-Review label has "is:MIN" as
+    // part of its copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Vote Verified+1 (not sticky because the Verified label has no copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Verified", 1));
+
+    amendChange(r.getChangeId()).assertOkStatus();
+
+    ChangeInfo change = change(r).get();
+    assertThat(Iterables.getLast(change.messages).message)
+        .isEqualTo(
+            "Uploaded patch set 2.\n"
+                + "\n"
+                + "Copied Votes:\n"
+                + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Verified+1\n");
+  }
+
+  @Test
+  public void
+      copiedAndOutdatedApprovalsAreIncludedInChangeMessageOnPatchSetCreationByPush_withReviewMessage()
+          throws Exception {
+    // Add Verified label without copy condition.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    // Vote Code-Review-2 (sticky because it's a veto vote and the Code-Review label has "is:MIN" as
+    // part of its copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Vote Verified+1 (not sticky because the Verified label has no copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Verified", 1));
+
+    String reviewMessage = "Foo-Bar-Baz";
+
+    amendChange(r.getChangeId(), "refs/for/master%m=" + reviewMessage, admin, testRepo)
+        .assertOkStatus();
+
+    ChangeInfo change = change(r).get();
+    assertThat(Iterables.getLast(change.messages).message)
+        .isEqualTo(
+            "Uploaded patch set 2.\n"
+                + "\n"
+                + "Foo-Bar-Baz\n"
+                + "\n"
+                + "Copied Votes:\n"
+                + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Verified+1\n");
+  }
+
+  @Test
+  public void copiedAndOutdatedApprovalsAreIncludedInChangeMessageOnPatchSetCreationByApi()
+      throws Exception {
+    // Add Verified label without copy condition.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    // Vote Code-Review-2 (sticky because it's a veto vote and the Code-Review label has "is:MIN" as
+    // part of its copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Vote Verified+1 (not sticky because the Verified label has no copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Verified", 1));
+
+    gApi.changes().id(r.getChangeId()).edit().modifyFile("a.txt", RawInputUtil.create("content"));
+    gApi.changes().id(r.getChangeId()).edit().publish();
+
+    ChangeInfo change = change(r).get();
+    assertThat(Iterables.getLast(change.messages).message)
+        .isEqualTo(
+            "Patch Set 2: Published edit on patch set 1.\n"
+                + "\n"
+                + "Copied Votes:\n"
+                + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Verified+1\n");
+  }
+
+  private PatchSetApproval createPatchSetApproval(
+      TestAccount testAccount, String label, int value) {
+    return PatchSetApproval.builder()
+        .key(
+            PatchSetApproval.key(
+                PatchSet.id(Change.id(1), 1), testAccount.id(), LabelId.create(label)))
+        .value(value)
+        .granted(TimeUtil.now())
+        .build();
+  }
+
+  private LabelType createLabelType(String labelName, @Nullable String copyCondition) {
+    LabelType.Builder labelTypeBuilder =
+        LabelType.builder(
+            labelName,
+            ImmutableList.of(
+                LabelValue.create((short) -2, "Vetoed"),
+                LabelValue.create((short) -1, "Disliked"),
+                LabelValue.create((short) 0, "No Vote"),
+                LabelValue.create((short) 1, "Liked"),
+                LabelValue.create((short) 2, "Approved")));
+    if (copyCondition != null) {
+      labelTypeBuilder.setCopyCondition(copyCondition);
+    }
+    return labelTypeBuilder.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
new file mode 100644
index 0000000..9d0e10a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
@@ -0,0 +1,1060 @@
+// 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.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.inject.Inject;
+import java.util.Optional;
+import java.util.function.Consumer;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Test to verify that {@link com.google.gerrit.server.restapi.change.PostReviewCopyApprovalsOp}
+ * copies approvals to follow-up patch sets if possible.
+ */
+public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Before
+  public void setup() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      // Overwrite "Code-Review" label that is inherited from All-Projects.
+      // This way changes to the "Code Review" label don't affect other tests.
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are not copied to the follow-up patch set if
+   * the new approvals are not copyable because no matching copy rule is configured.
+   */
+  @Test
+  public void newApprovals_notCopied_copyingNotEnabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    // Verify that no votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertNoApprovals(patchSet2.id(), admin);
+    assertNoApprovals(patchSet2.id(), user);
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are copied to the follow-up patch set if the
+   * follow-up patch set has no regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void newApprovals_copied_noCurrentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    // Verify that the votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are not copied to the follow-up patch set if
+   * the follow-up patch set has regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void newApprovals_notCopied_currentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set.
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    // Verify that the votes have not been copied to the current patch set (since a current vote
+    // already exists).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are not copied to the follow-up patch set if
+   * the follow-up patch set has deletions of regular votes (non-copied deletion votes that override
+   * copied votes).
+   */
+  @Test
+  public void newApprovals_notCopied_currentDeletedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set.
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Delete the votes on the current patch set.
+    deleteCurrentVotes(admin, changeId);
+    deleteCurrentVotes(user, changeId);
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    // Verify that the votes have not been copied to the current patch set (since a deletion vote
+    // already exists on the current patch set).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the updated approvals are not copyable because no matching copy rule is configured.
+   */
+  @Test
+  public void updatedApprovals_notCopied_copyingNotEnabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Update the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    // Verify that no votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -1, 1, /* expectedToBeCopied= */ false);
+    assertNoApprovals(patchSet2.id(), admin);
+    assertNoApprovals(patchSet2.id(), user);
+  }
+
+  /**
+   * Tests that if updated approvals on an outdated patch set are not copied to the follow-up patch
+   * set that existing copies of the approvals on the follow-up patch sets are unset.
+   */
+  @Test
+  public void updatedApprovals_notCopied_copyingNotEnabled_unsetsCopiedApprovals()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:max"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set with votes that are copied.
+    vote(admin, changeId, patchSet1.number(), 1, 1);
+    vote(user, changeId, patchSet1.number(), 2, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 1, 1);
+    assertCurrentVotes(c, user, 2, 1);
+
+    // Update the votes on the first patch set with votes that are not copied
+    vote(admin, changeId, patchSet1.number(), -1, -1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    // Verify that the copied votes on the current patch set have been unset.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, -1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are copied to the follow-up patch set if
+   * the follow-up patch set has no regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void updatedApprovals_copied_noCurrentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:max"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set with votes that are not copied.
+    vote(admin, changeId, patchSet1.number(), -2, -1);
+    vote(user, changeId, patchSet1.number(), -1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have not been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Update the votes on the first patch set with votes that are copied.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), 1, 1);
+
+    // Verify that the votes have been copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, 1, 1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 1, 1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void updatedApprovals_notCopied_currentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set (overrides the copied votes).
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Update the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), -1, 1);
+    vote(user, changeId, patchSet1.number(), 1, -1);
+
+    // Verify that the votes have not been copied to the current patch set (since a current vote
+    // already exists).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, -1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has deletions of regular votes (non-copied deletion votes that
+   * override copied votes).
+   */
+  @Test
+  public void updatedApprovals_notCopied_currentDeletedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set (overrides the copied approvals).
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Delete the votes on the current patch set.
+    deleteCurrentVotes(admin, changeId);
+    deleteCurrentVotes(user, changeId);
+
+    // Update the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), -1, 1);
+    vote(user, changeId, patchSet1.number(), 1, -1);
+
+    // Verify that the votes have not been copied to the current patch set (since a deletion vote
+    // already exists on the current patch set).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, -1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are copied to the follow-up patch set if
+   * the follow-up patch set has copied votes (the copied votes on the follow-up patch set are
+   * updated).
+   */
+  @Test
+  public void updatedApprovals_copied_currentCopiedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), -2, -1);
+    vote(user, changeId, patchSet1.number(), 2, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, -2, -1);
+    assertCurrentVotes(c, user, 2, 1);
+
+    // Update the votes on the first patch set with votes that are copied.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    // Verify that the votes have been copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the deleted approvals are not copyable because no matching copy rule is configured.
+   */
+  @Test
+  public void deletedApprovals_notCopied_copyingNotEnabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set.
+    vote(admin, changeId, patchSet2.number(), -2, -1);
+    vote(user, changeId, patchSet2.number(), 2, 1);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that the vote deletions have not been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, -2, -1);
+    assertCurrentVotes(c, user, 2, 1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, 2, 1, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has no regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void deletedApprovals_notCopied_noCurrentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:0 OR is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:0 OR is:1"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set with votes that are not copied.
+    vote(admin, changeId, patchSet1.number(), -2, -1);
+    vote(user, changeId, patchSet1.number(), -1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have not been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that there are still no votes on the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb (the deletion votes have not been copied).
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertNoApprovals(patchSet2.id(), admin);
+    assertNoApprovals(patchSet2.id(), user);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void deletedApprovals_notCopied_currentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set (overrides the copied votes).
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that the vote deletions have not been copied to the current patch set (since a current
+    // vote already exists).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has deletions of regular votes (non-copied deletion votes that
+   * override copied votes).
+   */
+  @Test
+  public void deletedApprovals_notCopied_currentDeletedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set (overrides the copied approvals).
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Delete the votes on the current patch set.
+    deleteCurrentVotes(admin, changeId);
+    deleteCurrentVotes(user, changeId);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that there are still no votes on the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb (the deletion votes have not been copied).
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are copied to the follow-up patch set if
+   * the follow-up patch set has copied votes (the copied votes on the follow-up patch set are
+   * removed).
+   */
+  @Test
+  public void deletedApprovals_copied_currentCopiedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), -2, -1);
+    vote(user, changeId, patchSet1.number(), 2, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, -2, -1);
+    assertCurrentVotes(c, user, 2, 1);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that the vote deletions have been copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+  }
+
+  /** Tests that new approvals on an outdated patch set are copied to all follow-up patch sets. */
+  @Test
+  public void copyNewApprovalAcrossMultipleFollowUpPatchSets() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    // Verify that votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are copied to all follow-up patch sets, but
+   * not across patch sets have non-copied votes.
+   */
+  @Test
+  public void
+      copyNewApprovalAcrossMultipleFollowUpPatchSets_stopOnFirstFollowUpPatchSetToWhichTheVoteIsNotCopyable()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 or is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    // Vote on the third patch set with non-copyable Code-Review votes and copyable Verified votes.
+    vote(admin, changeId, patchSet3.number(), -2, -1);
+    vote(user, changeId, patchSet3.number(), -1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Verify that the Verified votes from patch set 3 have been copied to the current patch
+    // set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, -1);
+    assertCurrentVotes(c, user, 0, -1);
+
+    // Vote on the first patch set with copyable votes.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), 1, 1);
+
+    // Verify that votes have been not copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, -1);
+    assertCurrentVotes(c, user, 0, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 1, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), admin, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet3.id(), user, -1, -1, /* expectedToBeCopied= */ false);
+    assertNoApproval(patchSet4.id(), admin, LabelId.CODE_REVIEW);
+    assertApproval(patchSet4.id(), admin, LabelId.VERIFIED, -1, /* expectedToBeCopied= */ true);
+    assertNoApproval(patchSet4.id(), user, LabelId.CODE_REVIEW);
+    assertApproval(patchSet4.id(), user, LabelId.VERIFIED, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are copied to all follow-up patch sets.
+   */
+  @Test
+  public void copyApprovalDeletionAcrossMultipleFollowUpPatchSets() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Verify that votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that the votes has been copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are copied to all follow-up patch sets,
+   * but not across patch sets have non-copied votes.
+   */
+  @Test
+  public void
+      copyApprovalDeletionAcrossMultipleFollowUpPatchSets_stopOnFirstFollowUpPatchSetToWhichTheVoteIsNotCopyable()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 or is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set with copyable votes.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), 1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    // Vote on the third patch set with non-copyable Code-Review votes and copyable Verified votes.
+    vote(admin, changeId, patchSet3.number(), -2, -1);
+    vote(user, changeId, patchSet3.number(), -1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Verify that the Verified votes from patch set 3 have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, -1);
+    assertCurrentVotes(c, user, 0, -1);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that the vote deletions have been not copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, -1);
+    assertCurrentVotes(c, user, 0, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), admin, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet3.id(), user, -1, -1, /* expectedToBeCopied= */ false);
+    assertNoApproval(patchSet4.id(), admin, LabelId.CODE_REVIEW);
+    assertApproval(patchSet4.id(), admin, LabelId.VERIFIED, -1, /* expectedToBeCopied= */ true);
+    assertNoApproval(patchSet4.id(), user, LabelId.CODE_REVIEW);
+    assertApproval(patchSet4.id(), user, LabelId.VERIFIED, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /** Tests that new approvals on an outdated patch set are not copied to predecessor patch sets. */
+  @Test
+  public void notCopyToPredecessorPatchSets() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Vote on the third patch set.
+    vote(admin, changeId, patchSet3.number(), 2, 1);
+    vote(user, changeId, patchSet3.number(), -2, -1);
+
+    // Verify that votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertNoApprovals(patchSet1.id(), admin);
+    assertNoApprovals(patchSet1.id(), user);
+    assertNoApprovals(patchSet2.id(), admin);
+    assertNoApprovals(patchSet2.id(), user);
+    assertApprovals(patchSet3.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet3.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet4.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+  }
+
+  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();
+    }
+  }
+
+  private ChangeInfo detailedChange(String changeId) throws Exception {
+    return gApi.changes().id(changeId).get(DETAILED_LABELS, CURRENT_REVISION, CURRENT_COMMIT);
+  }
+
+  private void vote(
+      TestAccount user, String changeId, int psNum, int codeReviewVote, int verifiedVote)
+      throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput in =
+        new ReviewInput()
+            .label(LabelId.CODE_REVIEW, codeReviewVote)
+            .label(LabelId.VERIFIED, verifiedVote);
+    gApi.changes().id(changeId).revision(psNum).review(in);
+  }
+
+  private void deleteCurrentVotes(TestAccount user, String changeId) throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    deleteCurrentVote(user, changeId, LabelId.CODE_REVIEW);
+    deleteCurrentVote(user, changeId, LabelId.VERIFIED);
+  }
+
+  private void deleteCurrentVote(TestAccount user, String changeId, String label) throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).reviewer(user.id().toString()).deleteVote(label);
+  }
+
+  private void assertCurrentVotes(
+      ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote) {
+    assertCurrentVote(c, user, LabelId.CODE_REVIEW, codeReviewVote);
+    assertCurrentVote(c, user, LabelId.VERIFIED, verifiedVote);
+  }
+
+  private void assertCurrentVote(ChangeInfo c, TestAccount user, String label, int expectedVote) {
+    Integer vote = 0;
+    if (c.labels.get(label) != null && c.labels.get(label).all != null) {
+      for (ApprovalInfo approval : c.labels.get(label).all) {
+        if (approval._accountId == user.id().get()) {
+          vote = approval.value;
+          break;
+        }
+      }
+    }
+
+    assertWithMessage("label = " + label).that(vote).isEqualTo(expectedVote);
+  }
+
+  private void assertNoApprovals(PatchSet.Id patchSetId, TestAccount user) {
+    assertNoApproval(patchSetId, user, LabelId.CODE_REVIEW);
+    assertNoApproval(patchSetId, user, LabelId.VERIFIED);
+  }
+
+  private void assertNoApproval(PatchSet.Id patchSetId, TestAccount user, String label) {
+    ChangeNotes notes = notesFactory.create(project, patchSetId.changeId());
+    Optional<PatchSetApproval> patchSetApproval =
+        notes.getApprovals().all().get(patchSetId).stream()
+            .filter(psa -> psa.accountId().equals(user.id()) && psa.label().equals(label))
+            .findAny();
+    assertThat(patchSetApproval).isEmpty();
+  }
+
+  private void assertApprovals(
+      PatchSet.Id patchSetId,
+      TestAccount user,
+      int expectedCodeReviewVote,
+      int expectedVerifiedVote,
+      boolean expectedToBeCopied) {
+    assertApproval(
+        patchSetId, user, LabelId.CODE_REVIEW, expectedCodeReviewVote, expectedToBeCopied);
+    assertApproval(patchSetId, user, LabelId.VERIFIED, expectedVerifiedVote, expectedToBeCopied);
+  }
+
+  private void assertApproval(
+      PatchSet.Id patchSetId,
+      TestAccount user,
+      String label,
+      int expectedVote,
+      boolean expectedToBeCopied) {
+    ChangeNotes notes = notesFactory.create(project, patchSetId.changeId());
+    Optional<PatchSetApproval> patchSetApproval =
+        notes.getApprovals().all().get(patchSetId).stream()
+            .filter(psa -> psa.accountId().equals(user.id()) && psa.label().equals(label))
+            .findAny();
+    assertThat(patchSetApproval).isPresent();
+    assertThat(patchSetApproval.get().value()).isEqualTo((short) expectedVote);
+    assertThat(patchSetApproval.get().copied()).isEqualTo(expectedToBeCopied);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index dcd8f77f..9e7a693 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -67,6 +67,7 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.OnPostReview;
@@ -79,6 +80,7 @@
 import com.google.inject.Module;
 import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -1060,9 +1062,17 @@
 
     @Override
     public Optional<SubmitRecord> evaluate(ChangeData changeData) {
-      count++;
+      if (!isAsyncCallForSendingReviewCommentsEmail()) {
+        count++;
+      }
       return Optional.empty();
     }
+
+    private boolean isAsyncCallForSendingReviewCommentsEmail() {
+      return Arrays.stream(Thread.currentThread().getStackTrace())
+          .map(StackTraceElement::getClassName)
+          .anyMatch(className -> EmailReviewComments.class.getName().equals(className));
+    }
   }
 
   private static class TestReviewerAddedListener implements ReviewerAddedListener {
@@ -1100,6 +1110,6 @@
   private static void assertAttentionSet(
       ImmutableSet<AttentionSetUpdate> attentionSet, Account.Id... accounts) {
     assertThat(attentionSet.stream().map(AttentionSetUpdate::account).collect(Collectors.toList()))
-        .containsExactly(accounts);
+        .containsExactlyElementsIn(accounts);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 407d04e..9de33be 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -20,7 +20,11 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.config.GerritConfig;
@@ -46,6 +50,10 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.permissions.PermissionDeniedException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
@@ -64,6 +72,7 @@
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Test
   public void pureRevertFactBlocksSubmissionOfNonReverts() throws Exception {
@@ -513,6 +522,24 @@
   }
 
   @Test
+  public void revertWithValidationOptions() throws Exception {
+    PushOneCommit.Result result = createChange();
+    approve(result.getChangeId());
+    gApi.changes().id(result.getChangeId()).current().submit();
+
+    RevertInput revertInput = new RevertInput();
+    revertInput.validationOptions = ImmutableMap.of("key", "value");
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(result.getChangeId()).revert(revertInput);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   @GerritConfig(name = "change.submitWholeTopic", value = "true")
   public void cantCreateRevertSubmissionWithoutProjectWritePermission() throws Exception {
     String secondProject = "secondProject";
@@ -1463,4 +1490,15 @@
     input.workInProgress = true;
     return input;
   }
+
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 8dbef88..2668d1f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -33,7 +33,6 @@
 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;
@@ -113,13 +112,11 @@
               value(0, "No score"),
               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());
 
       LabelType.Builder verified =
           labelBuilder(
               LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-      verified.setCopyAllScoresIfNoChange(false);
       u.getConfig().upsertLabelType(verified.build());
 
       u.save();
@@ -147,19 +144,9 @@
   }
 
   @Test
-  public void stickyOnAnyScore_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testStickyOnAnyScore();
-  }
-
-  @Test
-  public void stickyOnAnyScore_withCopyCondition() throws Exception {
+  public void stickyOnAnyScore() 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.
@@ -185,18 +172,9 @@
   }
 
   @Test
-  public void stickyOnMinScore_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyMinScore(true));
-    testStickyOnMinScore();
-  }
-
-  @Test
-  public void stickyOnMinScore_withCopyCondition() throws Exception {
+  public void stickyOnMinScore() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:min"));
-    testStickyOnMinScore();
-  }
 
-  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"));
@@ -213,18 +191,9 @@
   }
 
   @Test
-  public void stickyOnMaxScore_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
-    testStickyOnMaxScore();
-  }
-
-  @Test
-  public void stickyOnMaxScore_withCopyCondition() throws Exception {
+  public void stickyOnMaxScore() 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"));
@@ -241,18 +210,9 @@
   }
 
   @Test
-  public void stickyOnCopyValues_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
-    testStickyOnCopyValues();
-  }
-
-  @Test
-  public void stickyOnCopyValues_withCopyCondition() throws Exception {
+  public void stickyOnCopyValues() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:\"-1\" OR is:1"));
-    testStickyOnCopyValues();
-  }
 
-  private void testStickyOnCopyValues() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
     for (ChangeKind changeKind :
@@ -273,18 +233,9 @@
   }
 
   @Test
-  public void stickyOnTrivialRebase_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresOnTrivialRebase(true));
-    testStickyOnTrivialRebase();
-  }
-
-  @Test
-  public void stickyOnTrivialRebase_withCopyCondition() throws Exception {
+  public void stickyOnTrivialRebase() 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);
@@ -326,18 +277,9 @@
   }
 
   @Test
-  public void stickyOnNoCodeChange_withoutCopyCondition() throws Exception {
-    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
-    testStickyOnNoCodeChange();
-  }
-
-  @Test
-  public void stickyOnNoCodeChange_withCopyCondition() throws Exception {
+  public void stickyOnNoCodeChange() 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);
@@ -356,19 +298,10 @@
   }
 
   @Test
-  public void stickyOnMergeFirstParentUpdate_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
-    testStickyOnMergeFirstParentUpdate();
-  }
-
-  @Test
-  public void stickyOnMergeFirstParentUpdate_withCopyCondition() throws Exception {
+  public void stickyOnMergeFirstParentUpdate() 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);
@@ -387,24 +320,11 @@
   }
 
   @Test
-  public void
-      notStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
-    testNotStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate();
-  }
-
-  @Test
-  public void
-      notStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate_withCopyCondition()
-          throws Exception {
+  public void notStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate()
+      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);
@@ -418,20 +338,9 @@
   }
 
   @Test
-  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated_withoutCopyCondition()
-      throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfNoChange(true));
-    testNotStickyWithCopyOnNoChangeWhenSecondParentIsUpdated();
-  }
-
-  @Test
-  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated_withCopyCondition()
-      throws Exception {
+  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() 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);
@@ -443,22 +352,10 @@
   }
 
   @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
-  }
-
-  @Test
-  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withCopyCondition()
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
       throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
-  }
 
-  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
-      throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -478,23 +375,10 @@
   }
 
   @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
-  }
-
-  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
       throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     // create "existing file" and submit it.
     String existingFile = "existing file";
     Change.Id prep =
@@ -526,23 +410,10 @@
   }
 
   @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
-  }
-
-  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
       throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -557,22 +428,10 @@
   }
 
   @Test
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
-  }
-
-  @Test
-  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withCopyCondition()
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
       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);
@@ -588,23 +447,10 @@
   }
 
   @Test
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase();
-  }
-
-  @Test
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase_withCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase();
-  }
-
-  private void testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase()
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase()
       throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     // Create two changes both with the same parent
     PushOneCommit.Result r = createChange();
     testRepo.reset("HEAD~1");
@@ -626,7 +472,7 @@
     // The code-review approval is copied for the second change between PS1 and PS2 since the only
     // modified file is due to rebase.
     List<PatchSetApproval> patchSetApprovals =
-        r2.getChange().notes().getApprovalsWithCopied().values().stream()
+        r2.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
     PatchSetApproval nonCopied = patchSetApprovals.get(0);
@@ -637,25 +483,10 @@
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
-  }
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withCopyCondition()
-          throws Exception {
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
+      throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
-  }
 
-  private void
-      testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
-          throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -672,23 +503,10 @@
 
   @Test
   public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withCopyCondition()
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
           throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
-  }
 
-  private void
-      testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
-          throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -709,23 +527,10 @@
   }
 
   @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
-  }
-
-  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
       throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -740,18 +545,9 @@
   }
 
   @Test
-  public void copyWithListOfFilesUnchanged_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testCopyWithListOfFilesUnchanged();
-  }
-
-  @Test
-  public void copyWithListOfFilesUnchanged_withCopyCondition() throws Exception {
+  public void copyWithListOfFilesUnchanged() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testCopyWithListOfFilesUnchanged();
-  }
 
-  private void testCopyWithListOfFilesUnchanged() throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -969,20 +765,10 @@
   }
 
   @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 {
+  public void removedVotesNotSticky() 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"));
@@ -1006,20 +792,10 @@
   }
 
   @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 {
+  public void stickyAcrossMultiplePatchSets() 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);
 
@@ -1035,27 +811,15 @@
   }
 
   @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 {
+  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.
 
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+
     String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
     changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
@@ -1081,18 +845,9 @@
   }
 
   @Test
-  public void copyMinMaxAcrossMultiplePatchSets_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyMaxScore(true).setCopyMinScore(true));
-    testCopyMinMaxAcrossMultiplePatchSets();
-  }
-
-  @Test
-  public void copyMinMaxAcrossMultiplePatchSets_withCopyCondition() throws Exception {
+  public void copyMinMaxAcrossMultiplePatchSets() 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);
@@ -1126,18 +881,9 @@
   }
 
   @Test
-  public void deleteStickyVote_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
-    testDeleteStickyVote();
-  }
-
-  @Test
-  public void deleteStickyVote_withCopyCondition() throws Exception {
+  public void deleteStickyVote() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
-    testDeleteStickyVote();
-  }
 
-  private void testDeleteStickyVote() throws Exception {
     String label = LabelId.CODE_REVIEW;
 
     // Vote max score on PS1
@@ -1153,18 +899,9 @@
   }
 
   @Test
-  public void canVoteMultipleTimesOnNewPatchsets_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testCanVoteMultipleTimesOnNewPatchsets();
-  }
-
-  @Test
-  public void canVoteMultipleTimesOnNewPatchsets_withCopyCondition() throws Exception {
+  public void canVoteMultipleTimesOnNewPatchsets() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
-    testCanVoteMultipleTimesOnNewPatchsets();
-  }
 
-  private void testCanVoteMultipleTimesOnNewPatchsets() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Add a new vote.
@@ -1172,30 +909,110 @@
     gApi.changes().id(r.getChangeId()).current().review(input);
 
     // Make a new patchset, keeping the Code-Review +2 vote.
-    amendChange(r.getChangeId());
+    r = amendChange(r.getChangeId());
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
 
     // Post without changing the vote.
     input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
     gApi.changes().id(r.getChangeId()).current().review(input);
 
-    // There is a vote both on patchset 1 and on patchset 2, although both votes are Code-Review +2.
+    // There is a vote both on patch set 1 and on patch set 2, although both votes are Code-Review
+    // +2. The approval on patch set 2 is no longer copied since it was reapplied.
     assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 1))).hasSize(1);
-    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 2))).hasSize(1);
+    approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isFalse();
   }
 
   @Test
-  public void stickyVoteStoredOnUpload_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testStickyVoteStoredOnUpload();
-  }
-
-  @Test
-  public void stickyVoteStoredOnUpload_withCopyCondition() throws Exception {
+  public void copiedVoteIsNotReapplied_onVoteOnOtherLabel() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
-    testStickyVoteStoredOnUpload();
+
+    PushOneCommit.Result r = createChange();
+
+    // Add vote that will be copied.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Create a new patchset, the Code-Review +2 vote is copied.
+    r = amendChange(r.getChangeId());
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+
+    // Vote on another label. This shouldn't touch the copied approval.
+    input = new ReviewInput().label(LabelId.VERIFIED, 1);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Patch set 2 has 2 approvals now, one copied approval for the Code-Review label and one
+    // non-copied
+    // approval for the Verified label.
+    approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(2);
+    assertThat(
+            Iterables.getOnlyElement(
+                    approvalsPs2.stream()
+                        .filter(psa -> LabelId.CODE_REVIEW.equals(psa.label()))
+                        .collect(toImmutableList()))
+                .copied())
+        .isTrue();
+    assertThat(
+            Iterables.getOnlyElement(
+                    approvalsPs2.stream()
+                        .filter(psa -> LabelId.VERIFIED.equals(psa.label()))
+                        .collect(toImmutableList()))
+                .copied())
+        .isFalse();
   }
 
-  private void testStickyVoteStoredOnUpload() throws Exception {
+  @Test
+  public void copiedVoteIsNotReapplied_onRebase() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+
+    // Create a sibling change
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Add vote that will be copied.
+    approve(r2.getChangeId());
+
+    // Verify that that the approval exists and is not copied.
+    List<PatchSetApproval> approvalsPs2 = r2.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isFalse();
+
+    // Approve, verify and submit the first change.
+    approve(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Rebase the second change, the approval should be sticky.
+    gApi.changes().id(r2.getChangeId()).rebase();
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    approvalsPs2 = changeDataFactory.create(project, r2.getChange().getId()).currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+  }
+
+  @Test
+  public void stickyVoteStoredOnUpload() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
     PushOneCommit.Result r = createChange();
     // Add a new vote.
     ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
@@ -1208,7 +1025,7 @@
     }
 
     List<PatchSetApproval> patchSetApprovals =
-        r.getChange().notes().getApprovalsWithCopied().values().stream()
+        r.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
 
@@ -1229,18 +1046,9 @@
   }
 
   @Test
-  public void stickyVoteStoredOnRebase_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testStickyVoteStoredOnRebase();
-  }
-
-  @Test
-  public void stickyVoteStoredOnRebase_withCopyCondition() throws Exception {
+  public void stickyVoteStoredOnRebase() 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");
@@ -1258,7 +1066,7 @@
     gApi.changes().id(r2.getChangeId()).rebase();
 
     List<PatchSetApproval> patchSetApprovals =
-        r2.getChange().notes().getApprovalsWithCopied().values().stream()
+        r2.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
     PatchSetApproval nonCopied = patchSetApprovals.get(0);
@@ -1268,18 +1076,9 @@
   }
 
   @Test
-  public void stickyVoteStoredOnUploadWithRealAccount_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testStickyVoteStoredOnUploadWithRealAccount();
-  }
-
-  @Test
-  public void stickyVoteStoredOnUploadWithRealAccount_withCopyCondition() throws Exception {
+  public void stickyVoteStoredOnUploadWithRealAccount() 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)
@@ -1304,7 +1103,7 @@
     amendChange(r.getChangeId());
 
     List<PatchSetApproval> patchSetApprovals =
-        r.getChange().notes().getApprovalsWithCopied().values().stream()
+        r.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
 
@@ -1326,19 +1125,9 @@
   }
 
   @Test
-  public void stickyVoteStoredOnUploadWithRealAccountAndTag_withoutCopyCondition()
-      throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testStickyVoteStoredOnUploadWithRealAccountAndTag();
-  }
-
-  @Test
-  public void stickyVoteStoredOnUploadWithRealAccountAndTag_withCopyCondition() throws Exception {
+  public void stickyVoteStoredOnUploadWithRealAccountAndTag() 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)
@@ -1364,7 +1153,7 @@
     amendChange(r.getChangeId());
 
     List<PatchSetApproval> patchSetApprovals =
-        r.getChange().notes().getApprovalsWithCopied().values().stream()
+        r.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
 
@@ -1388,18 +1177,9 @@
   }
 
   @Test
-  public void stickyVoteStoredCanBeRemoved_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testStickyVoteStoredCanBeRemoved();
-  }
-
-  @Test
-  public void stickyVoteStoredCanBeRemoved_withCopyCondition() throws Exception {
+  public void stickyVoteStoredCanBeRemoved() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
-    testStickyVoteStoredCanBeRemoved();
-  }
 
-  private void testStickyVoteStoredCanBeRemoved() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Add a new vote
@@ -1416,7 +1196,8 @@
         Iterables.getOnlyElement(
             r.getChange()
                 .notes()
-                .getApprovalsWithCopied()
+                .getApprovals()
+                .all()
                 .get(r.getChange().change().currentPatchSetId()));
 
     assertThat(nonCopiedSecondPatchsetRemovedVote.patchSetId().get()).isEqualTo(2);
@@ -1428,18 +1209,9 @@
   }
 
   @Test
-  public void reviewerStickyVotingCanBeRemoved_withoutCopyConfition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testReviewerStickyVotingCanBeRemoved();
-  }
-
-  @Test
-  public void reviewerStickyVotingCanBeRemoved_withCopyCondition() throws Exception {
+  public void reviewerStickyVotingCanBeRemoved() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
-    testReviewerStickyVotingCanBeRemoved();
-  }
 
-  private void testReviewerStickyVotingCanBeRemoved() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Add a new vote by user
@@ -1453,7 +1225,7 @@
 
     gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
 
-    assertThat(r.getChange().notes().getApprovalsWithCopied()).isEmpty();
+    assertThat(r.getChange().notes().getApprovals().all()).isEmpty();
 
     // Changes message has info about vote removed.
     assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).messages()).message)
@@ -1461,25 +1233,6 @@
   }
 
   @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));
 
@@ -1536,8 +1289,7 @@
       vote(admin, changeId, 2, 1);
 
       List<PatchSetApproval> patchSetApprovals =
-          notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
-              .stream()
+          notesFactory.create(project, r.getChange().getId()).getApprovals().all().values().stream()
               .sorted(comparing(a -> a.patchSetId().get()))
               .collect(toImmutableList());
 
@@ -1559,8 +1311,7 @@
       gApi.changes().id(changeId).current().submit();
 
       patchSetApprovals =
-          notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
-              .stream()
+          notesFactory.create(project, r.getChange().getId()).getApprovals().all().values().stream()
               .sorted(comparing(a -> a.patchSetId().get()))
               .collect(toImmutableList());
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 865dd6c..242c278 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -21,6 +21,7 @@
 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 static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -66,6 +67,7 @@
 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.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -82,10 +84,12 @@
 import java.util.stream.IntStream;
 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.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.junit.Test;
 
@@ -144,6 +148,198 @@
   }
 
   @Test
+  public void checkSubmitRequirement_fromRefsConfigChange_satisfied() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    fetchRefsMetaConfig();
+    PushOneCommit.Result configResult =
+        createConfigChangeWithSubmitRequirement("Foo", "label:Code-Review=+2");
+
+    // Upload a normal change. Check the SR against it.
+    testRepo.reset(oldHead);
+    PushOneCommit.Result r2 = createChange();
+    String changeId = r2.getChangeId();
+    SubmitRequirementResultInfo info =
+        gApi.changes()
+            .id(changeId)
+            .checkSubmitRequirementRequest()
+            .srName("Foo")
+            .refsConfigChangeId(configResult.getChange().getId().toString())
+            .get();
+    assertThat(info.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+    voteLabel(changeId, "Code-Review", 2);
+    info =
+        gApi.changes()
+            .id(changeId)
+            .checkSubmitRequirementRequest()
+            .srName("Foo")
+            .refsConfigChangeId(configResult.getChange().getId().toString())
+            .get();
+    assertThat(info.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
+  public void checkSubmitRequirement_fromRefsConfigChangeOfAnotherProject_satisfied()
+      throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    fetchRefsMetaConfig();
+    PushOneCommit.Result configResult =
+        createConfigChangeWithSubmitRequirement("Foo", "label:Code-Review=+2");
+
+    // Upload a normal change in another project. Check the SR against it.
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    TestRepository<InMemoryRepository> otherRepo = cloneProject(otherProject, admin);
+    PushOneCommit.Result r2 = createChange(otherRepo);
+    String changeId = r2.getChangeId();
+    SubmitRequirementResultInfo info =
+        gApi.changes()
+            .id(changeId)
+            .checkSubmitRequirementRequest()
+            .srName("Foo")
+            .refsConfigChangeId(configResult.getChange().getId().toString())
+            .get();
+    assertThat(info.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+    voteLabel(changeId, "Code-Review", 2);
+    info =
+        gApi.changes()
+            .id(changeId)
+            .checkSubmitRequirementRequest()
+            .srName("Foo")
+            .refsConfigChangeId(configResult.getChange().getId().toString())
+            .get();
+    assertThat(info.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
+  public void checkSubmitRequirement_fromRefsConfigChange_failsForNonExistingSR() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    fetchRefsMetaConfig();
+    PushOneCommit.Result configResult =
+        createConfigChangeWithSubmitRequirement("Foo", "label:Code-Review=+2");
+
+    // Upload a normal change. Check the SR against it.
+    testRepo.reset(oldHead);
+    PushOneCommit.Result r2 = createChange();
+    String changeId = r2.getChangeId();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .checkSubmitRequirementRequest()
+                    .srName("Bar")
+                    .refsConfigChangeId(configResult.getChange().getId().toString())
+                    .get());
+    assertThat(thrown).hasMessageThat().isEqualTo("No submit requirement matching name 'Bar'");
+  }
+
+  @Test
+  public void checkSubmitRequirement_notAllowedFromNonRefsConfigChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .checkSubmitRequirementRequest()
+                    .srName("Foo")
+                    .refsConfigChangeId(r.getChange().getId().toString())
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Change '%s' is not in refs/meta/config branch.", r.getChange().getId().get()));
+  }
+
+  @Test
+  public void checkSubmitRequirement_notAllowedFromNonExistingChange() throws Exception {
+    String invalidChangeNumber = "2134789";
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .checkSubmitRequirementRequest()
+                    .srName("Foo")
+                    .refsConfigChangeId(invalidChangeNumber)
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(String.format("Change '%s' does not exist", invalidChangeNumber));
+  }
+
+  @Test
+  public void checkSubmitRequirement_fromRefsConfigChange_failsIfBothParametersAreNotSet()
+      throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    fetchRefsMetaConfig();
+    PushOneCommit.Result configResult =
+        createConfigChangeWithSubmitRequirement("Foo", "label:Code-Review=+2");
+
+    // Upload a normal change. Check the SR against it.
+    testRepo.reset(oldHead);
+    PushOneCommit.Result r2 = createChange();
+    String changeId = r2.getChangeId();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).checkSubmitRequirementRequest().srName("Bar").get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Both 'sr-name' and 'refs-config-change-id' parameters must be set");
+
+    thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .checkSubmitRequirementRequest()
+                    .refsConfigChangeId(configResult.getChangeId())
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Both 'sr-name' and 'refs-config-change-id' parameters must be set");
+  }
+
+  @Test
   public void checkSubmitRequirement_satisfied() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -1894,6 +2090,7 @@
         /* expression= */ null,
         /* passingAtoms= */ null,
         /* failingAtoms= */ null,
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
         /* fulfilled= */ true);
     assertThat(requirement.submittabilityExpressionResult).isNotNull();
   }
@@ -1927,9 +2124,16 @@
         /* expression= */ null,
         /* passingAtoms= */ null,
         /* failingAtoms= */ null,
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
         /* fulfilled= */ false);
-    assertThat(requirement.submittabilityExpressionResult).isNull();
-    assertThat(requirement.overrideExpressionResult).isNull();
+    assertThat(requirement.submittabilityExpressionResult.status)
+        .isEqualTo(SubmitRequirementExpressionInfo.Status.NOT_EVALUATED);
+    assertThat(requirement.submittabilityExpressionResult.expression)
+        .isEqualTo(SubmitRequirementExpression.maxCodeReview().expressionString());
+    assertThat(requirement.overrideExpressionResult.status)
+        .isEqualTo(SubmitRequirementExpressionInfo.Status.NOT_EVALUATED);
+    assertThat(requirement.overrideExpressionResult.expression)
+        .isEqualTo("project:" + project.get());
   }
 
   @Test
@@ -1962,12 +2166,14 @@
         /* passingAtoms= */ ImmutableList.of(
             SubmitRequirementExpression.maxCodeReview().expressionString()),
         /* failingAtoms= */ ImmutableList.of(),
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
         /* fulfilled= */ true);
     assertSubmitRequirementExpression(
         requirement.overrideExpressionResult,
         /* expression= */ "project:non-existent",
         /* passingAtoms= */ ImmutableList.of(),
         /* failingAtoms= */ ImmutableList.of("project:non-existent"),
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
         /* fulfilled= */ false);
   }
 
@@ -2002,12 +2208,14 @@
         /* passingAtoms= */ ImmutableList.of(),
         /* failingAtoms= */ ImmutableList.of(
             SubmitRequirementExpression.maxCodeReview().expressionString()),
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
         /* fulfilled= */ false);
     assertSubmitRequirementExpression(
         requirement.overrideExpressionResult,
         /* expression= */ "project:" + project.get(),
         /* passingAtoms= */ ImmutableList.of("project:" + project.get()),
         /* failingAtoms= */ ImmutableList.of(),
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
         /* fulfilled= */ true);
   }
 
@@ -2809,6 +3017,7 @@
       @Nullable String expression,
       @Nullable List<String> passingAtoms,
       @Nullable List<String> failingAtoms,
+      SubmitRequirementExpressionInfo.Status status,
       boolean fulfilled) {
     assertThat(result.expression).isEqualTo(expression);
     if (passingAtoms == null) {
@@ -2821,6 +3030,7 @@
     } else {
       assertThat(result.failingAtoms).containsExactlyElementsIn(failingAtoms);
     }
+    assertThat(result.status).isEqualTo(status);
     assertThat(result.fulfilled).isEqualTo(fulfilled);
   }
 
@@ -2900,4 +3110,30 @@
     in.comments = ImmutableMap.of("foo", ImmutableList.of(ci));
     gApi.changes().id(changeId).current().review(in);
   }
+
+  private void fetchRefsMetaConfig() throws Exception {
+    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
+    testRepo.reset(RefNames.REFS_CONFIG);
+  }
+
+  private PushOneCommit.Result createConfigChangeWithSubmitRequirement(
+      String srName, String submitExpression) throws Exception {
+    Config cfg = projectOperations.project(project).getConfig();
+    cfg.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        srName,
+        ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        submitExpression);
+    return createConfigChange(cfg);
+  }
+
+  private PushOneCommit.Result createConfigChange(Config cfg) throws Exception {
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                admin.newIdent(), testRepo, "Update project config", "project.config", cfg.toText())
+            .to("refs/for/refs/meta/config");
+    r.assertOkStatus();
+    return r;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
index 77582c6..651130e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -63,7 +63,7 @@
               value(0, "No score"),
               value(-1, "I would prefer this is not submitted as is"),
               value(-2, "This shall not be submitted"));
-      codeReview.setCopyAnyScore(true);
+      codeReview.setCopyCondition("is:ANY");
       u.getConfig().upsertLabelType(codeReview.build());
       u.save();
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 63b67f8..d630296 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -112,7 +112,6 @@
 import java.time.Instant;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Date;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Stream;
@@ -1608,9 +1607,6 @@
     return createCommit(repo, commitMessage, null);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private ObjectId createCommit(Repository repo, String commitMessage, @Nullable ObjectId treeId)
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
@@ -1618,7 +1614,7 @@
         treeId = oi.insert(Constants.OBJ_TREE, new byte[] {});
       }
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), Date.from(TimeUtil.now()));
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
index df5a094..7c33ec2 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
@@ -62,6 +63,7 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.schema.GrantRevertPermission;
 import com.google.inject.Inject;
@@ -467,6 +469,40 @@
   }
 
   @Test
+  public void removePermissionRuleForNonExistingeExternalGroup() throws Exception {
+    // Register a group backend with an external group
+    TestGroupBackend testGroupBackend = new TestGroupBackend();
+    GroupDescription.Basic externalGroup = testGroupBackend.create("External Group");
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(testGroupBackend)) {
+      // Add a permission for the external group.
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+      PermissionInfo push = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      push.rules.put(externalGroup.getGroupUUID().get(), pri);
+      accessSectionInfo.permissions.put(Permission.PUSH, push);
+      accessInput.add.put(REFS_HEADS, accessSectionInfo);
+      pApi().access(accessInput);
+      assertThat(pApi().access().local).isNotEmpty();
+
+      // Remove the external group.
+      testGroupBackend.remove(externalGroup.getGroupUUID());
+
+      // Remove the permission rule for the external group that no longer exists.
+      AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+      push = newPermissionInfo();
+      pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      push.rules.put(externalGroup.getGroupUUID().get(), pri);
+      accessSectionToRemove.permissions.put(Permission.PUSH, push);
+      ProjectAccessInput removal = newProjectAccessInput();
+      removal.remove.put(REFS_HEADS, accessSectionToRemove);
+      pApi().access(removal);
+      assertThat(pApi().access().local).isEmpty();
+    }
+  }
+
+  @Test
   public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
     // Add initial permission set
     ProjectAccessInput accessInput = newProjectAccessInput();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
new file mode 100644
index 0000000..168819c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
@@ -0,0 +1,841 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.project.LabelConfigValidator;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class ProjectConfigIT extends AbstractDaemonTest {
+  private static final String INVALID_PRROJECT_CONFIG =
+      "[label \"Foo\"]\n"
+          // copyAllScoresOnTrivialRebase is deprecated and no longer allowed to be set
+          + "  copyAllScoresOnTrivialRebase = true";
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void noLabelValidationForNonRefsMetaConfigChange() throws Exception {
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            "refs/heads/master",
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            INVALID_PRROJECT_CONFIG,
+            /* topic= */ null);
+    r.assertOkStatus();
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void noLabelValidationForNoneProjectConfigChange() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Test Change",
+            "foo.config",
+            INVALID_PRROJECT_CONFIG,
+            /* topic= */ null);
+    r.assertOkStatus();
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void validateNoIssues_push() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[label \"Foo\"]\n  description = Foo Label");
+    PushOneCommit.Result r = push.to("refs/for/" + RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void validateNoIssues_createChangeApi() throws Exception {
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = RefNames.REFS_CONFIG;
+    changeInput.subject = "A change";
+    changeInput.status = ChangeStatus.NEW;
+    ChangeInfo changeInfo = gApi.changes().create(changeInput).get();
+
+    gApi.changes().id(changeInfo.id).edit().create();
+    gApi.changes()
+        .id(changeInfo.id)
+        .edit()
+        .modifyFile(
+            ProjectConfig.PROJECT_CONFIG,
+            RawInputUtil.create("[label \"Foo\"]\n  description = Foo Label"));
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    gApi.changes().id(changeInfo.id).edit().publish(publishInput);
+
+    approve(changeInfo.id);
+    gApi.changes().id(changeInfo.id).current().submit();
+    assertThat(gApi.changes().id(changeInfo.id).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void rejectSettingCopyAnyScore() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ true, "is:ANY");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ false, "is:ANY");
+  }
+
+  @Test
+  public void rejectSettingCopyMinScore() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ false, "is:MIN");
+  }
+
+  @Test
+  public void rejectSettingCopyMaxScore() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ true, "is:MAX");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ false, "is:MAX");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfNoChange() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CHANGE");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfNoCodeChange() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CODE_CHANGE");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CODE_CHANGE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ true,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ false,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresOnTrivialRebase() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ true,
+        "changekind:TRIVIAL_REBASE");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ false,
+        "changekind:TRIVIAL_REBASE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true,
+        "has:unchanged-files");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ false,
+        "has:unchanged-files");
+  }
+
+  private void testRejectSettingLabelFlag(
+      String key, boolean value, String expectedPredicateSuggestion) throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  %s = %s", key, value));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use '%s' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), key, expectedPredicateSuggestion));
+  }
+
+  @Test
+  public void rejectSettingCopyValues() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:<copy-value>' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), LabelConfigValidator.KEY_COPY_VALUE));
+  }
+
+  @Test
+  public void rejectChangingCopyAnyScore() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ true, "is:ANY");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ false, "is:ANY");
+  }
+
+  @Test
+  public void rejectChangingCopyMinScore() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ false, "is:MIN");
+  }
+
+  @Test
+  public void rejectChangingCopyMaxScore() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ true, "is:MAX");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ false, "is:MAX");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfNoChange() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CHANGE");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfNoCodeChange() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CODE_CHANGE");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CODE_CHANGE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ true,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ false,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresOnTrivialRebase() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ true,
+        "changekind:TRIVIAL_REBASE");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ false,
+        "changekind:TRIVIAL_REBASE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true,
+        "has:unchanged-files");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ false,
+        "has:unchanged-files");
+  }
+
+  private void testRejectChangingLabelFlag(
+      String key, boolean value, String expectedPredicateSuggestion) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format("[label \"Foo\"]\n  %s = %s", key, !value))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    testRejectSettingLabelFlag(key, value, expectedPredicateSuggestion);
+  }
+
+  @Test
+  public void rejectChangingCopyValues() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = -1\n  %s = -2",
+                LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:<copy-value>' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), LabelConfigValidator.KEY_COPY_VALUE));
+  }
+
+  @Test
+  public void testRejectChangingLabelFunction_toMaxWithBlock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.NO_BLOCK,
+        /* newLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* errorMessage= */ String.format(
+            "Value '%s' of 'label.foo.function' is not allowed and cannot be set."
+                + " Label functions can only be set to {no_block, no_op, patch_set_lock}."
+                + " Use submit requirements instead of label functions.",
+            LabelFunction.MAX_WITH_BLOCK.getFunctionName()));
+  }
+
+  @Test
+  public void testRejectChangingLabelFunction_toMaxNoBlock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.NO_BLOCK,
+        /* newLabelFunction= */ LabelFunction.MAX_NO_BLOCK,
+        /* errorMessage= */ String.format(
+            "Value '%s' of 'label.foo.function' is not allowed and cannot be set."
+                + " Label functions can only be set to {no_block, no_op, patch_set_lock}."
+                + " Use submit requirements instead of label functions.",
+            LabelFunction.MAX_NO_BLOCK.getFunctionName()));
+  }
+
+  @Test
+  public void testRejectChangingLabelFunction_toAnyWithBlock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.NO_BLOCK,
+        /* newLabelFunction= */ LabelFunction.ANY_WITH_BLOCK,
+        /* errorMessage= */ String.format(
+            "Value '%s' of 'label.foo.function' is not allowed and cannot be set."
+                + " Label functions can only be set to {no_block, no_op, patch_set_lock}."
+                + " Use submit requirements instead of label functions.",
+            LabelFunction.ANY_WITH_BLOCK.getFunctionName()));
+  }
+
+  @Test
+  public void testChangingLabelFunction_toNoBlock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* newLabelFunction= */ LabelFunction.NO_BLOCK,
+        /* errorMessage= */ null);
+  }
+
+  @Test
+  public void testChangingLabelFunction_toNoOp() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* newLabelFunction= */ LabelFunction.NO_OP,
+        /* errorMessage= */ null);
+  }
+
+  @Test
+  public void testChangingLabelFunction_toPatchSetLock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* newLabelFunction= */ LabelFunction.PATCH_SET_LOCK,
+        /* errorMessage= */ null);
+  }
+
+  @Test
+  public void testRejectRemovingLabelFunction() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* newLabelFunction= */ null,
+        /* errorMessage= */ String.format(
+            "Cannot delete '%s.%s.%s'."
+                + " Label functions can only be set to {%s, %s, %s}."
+                + " Use submit requirements instead of label functions.",
+            ProjectConfig.LABEL,
+            "Foo",
+            ProjectConfig.KEY_FUNCTION,
+            LabelFunction.NO_BLOCK,
+            LabelFunction.NO_OP,
+            LabelFunction.PATCH_SET_LOCK));
+  }
+
+  private void testChangingLabelFunction(
+      LabelFunction initialLabelFunction,
+      @Nullable LabelFunction newLabelFunction,
+      @Nullable String errorMessage)
+      throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = %s\n",
+                  ProjectConfig.KEY_FUNCTION, initialLabelFunction.getFunctionName()))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            newLabelFunction == null
+                ? "[label \"Foo\"]\n"
+                : String.format(
+                    "[label \"Foo\"]\n  %s = %s\n",
+                    ProjectConfig.KEY_FUNCTION, newLabelFunction.getFunctionName()));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    if (errorMessage == null) {
+      r.assertOkStatus();
+      return;
+    }
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(errorMessage);
+  }
+
+  @Test
+  public void unsetCopyAnyScore() throws Exception {
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_ANY_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_ANY_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyMinScore() throws Exception {
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_MIN_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_MIN_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyMaxScore() throws Exception {
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_MAX_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_MAX_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoChange() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoCodeChange() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnTrivialRebase() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* previousValue= */ false);
+  }
+
+  private void testUnsetLabelFlag(String key, boolean previousValue) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format("[label \"Foo\"]\n  %s = %s", key, previousValue))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  otherKey = value"));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void unsetCopyValues() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  otherKey = value"));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void keepCopyAnyScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyMinScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyMaxScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfNoChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfNoCodeChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresOnMergeFirstParentUpdateUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresOnTrivialRebaseUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfListOfFilesDidNotChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ false);
+  }
+
+  private void testKeepLabelFlagUnchanged(String key, boolean value) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG, String.format("[label \"Foo\"]\n  %s = %s", key, value))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  %s = %s\n  otherKey = value", key, value));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void keepCopyValuesUnchanged() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = 1\n  %s = 2\n  otherKey = value",
+                LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void keepCopyValuesUnchanged_differentOrder() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = 2\n  %s = 1",
+                LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void rejectMultipleLabelFlags() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = true\n  %s = true",
+                LabelConfigValidator.KEY_COPY_MIN_SCORE, LabelConfigValidator.KEY_COPY_MAX_SCORE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:MIN' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), LabelConfigValidator.KEY_COPY_MIN_SCORE));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:MAX' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), LabelConfigValidator.KEY_COPY_MAX_SCORE));
+  }
+
+  @Test
+  public void setCopyCondition() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  %s = is:ANY", ProjectConfig.KEY_COPY_CONDITION));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void validateLabelConfigInInitialCommit() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo.delete(RefNames.REFS_CONFIG);
+    }
+
+    PushOneCommit push =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "Test Change",
+                ProjectConfig.PROJECT_CONFIG,
+                INVALID_PRROJECT_CONFIG)
+            .setParents(ImmutableList.of());
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+  }
+
+  private void fetchRefsMetaConfig() throws Exception {
+    fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+    testRepo.reset(RefNames.REFS_CONFIG);
+  }
+
+  private String abbreviateName(AnyObjectId id) throws Exception {
+    return ObjectIds.abbreviateName(id, testRepo.getRevWalk().getObjectReader());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 58d1628..f997c77 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -788,6 +788,39 @@
   }
 
   @Test
+  public void projectConfigUsesLocallySetCommentlinksWithOptionalFields() throws Exception {
+    ConfigInput input = new ConfigInput();
+    CommentLinkInput bugzillaInput = new CommentLinkInput();
+    String match = "(^|\\\\s)(bug\\\\s+#?)(\\\\d+)($|\\\\s)";
+    String link = "http://bugzilla.example.com/?id=$3";
+    String prefix = "$1";
+    String suffix = "$4";
+    String text = "$2$3";
+    bugzillaInput.match = match;
+    bugzillaInput.link = link;
+    bugzillaInput.prefix = prefix;
+    bugzillaInput.suffix = suffix;
+    bugzillaInput.text = text;
+    addCommentLink(input, BUGZILLA, bugzillaInput);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    CommentLinkInfo bugzillaInfo = new CommentLinkInfo();
+    bugzillaInfo.name = BUGZILLA;
+    bugzillaInfo.match = match;
+    bugzillaInfo.link = link;
+    bugzillaInfo.prefix = prefix;
+    bugzillaInfo.suffix = suffix;
+    bugzillaInfo.text = text;
+    expected.put(BUGZILLA, bugzillaInfo);
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+    assertCommentLinks(getConfig(), expected);
+  }
+
+  @Test
   @GerritConfig(name = "commentlink.bugzilla.match", value = BUGZILLA_MATCH)
   @GerritConfig(name = "commentlink.bugzilla.link", value = BUGZILLA_LINK)
   public void projectConfigUsesCommentLinksFromGlobalAndLocal() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
new file mode 100644
index 0000000..97a2d2b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
@@ -0,0 +1,651 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+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 com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmitRequirementsAPIIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void cannotGetANonExistingSR() throws Exception {
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("code-review").get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Submit requirement 'code-review' does not exist");
+  }
+
+  @Test
+  public void getExistingSR() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").get();
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.applicabilityExpression).isEqualTo("topic:foo");
+    assertThat(info.submittabilityExpression).isEqualTo("label:code-review=+2");
+    assertThat(info.allowOverrideInChildProjects).isEqualTo(false);
+  }
+
+  @Test
+  public void updateSubmitRequirement() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.submittabilityExpression = "label:code-review=+1";
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.submittabilityExpression).isEqualTo("label:code-review=+1");
+  }
+
+  @Test
+  public void updateSRWithEmptyApplicabilityExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.applicabilityExpression = null;
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.applicabilityExpression).isNull();
+  }
+
+  @Test
+  public void updateSRWithEmptyOverrideExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.overrideExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.overrideExpression = null;
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.overrideExpression).isNull();
+  }
+
+  @Test
+  public void allowOverrideInChildProjectsDefaultsToFalse_updateSR() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.overrideExpression = "topic:foo";
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.allowOverrideInChildProjects).isFalse();
+  }
+
+  @Test
+  public void cannotUpdateSRAsAnonymousUser() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.submittabilityExpression = "label:code-review=+1";
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .update(new SubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotUpdateSRtIfSRDoesNotExist() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Submit requirement 'code-review' does not exist");
+  }
+
+  @Test
+  public void cannotUpdateSRWithEmptySubmittableIf() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.submittabilityExpression = null;
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+
+    assertThat(thrown).hasMessageThat().isEqualTo("submittability_expression is required");
+  }
+
+  @Test
+  public void cannotUpdateSRWithInvalidSubmittableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.submittabilityExpression = "invalid_field:invalid_value";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.submittableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotUpdateSRWithInvalidOverrideIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.overrideExpression = "invalid_field:invalid_value";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.overrideIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotUpdateSRWithInvalidApplicableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.applicabilityExpression = "invalid_field:invalid_value";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.applicableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void createSubmitRequirement() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+    input.allowOverrideInChildProjects = true;
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.description).isEqualTo(input.description);
+    assertThat(info.applicabilityExpression).isEqualTo(input.applicabilityExpression);
+    assertThat(info.applicabilityExpression).isEqualTo(input.applicabilityExpression);
+    assertThat(info.submittabilityExpression).isEqualTo(input.submittabilityExpression);
+    assertThat(info.overrideExpression).isEqualTo(input.overrideExpression);
+    assertThat(info.allowOverrideInChildProjects).isEqualTo(true);
+  }
+
+  @Test
+  public void createSRWithEmptyApplicabilityExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.applicabilityExpression).isNull();
+  }
+
+  @Test
+  public void createSRWithEmptyOverrideExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.overrideExpression).isNull();
+  }
+
+  @Test
+  public void allowOverrideInChildProjectsDefaultsToFalse_createSR() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.allowOverrideInChildProjects).isEqualTo(false);
+  }
+
+  @Test
+  public void cannotCreateSRAsAnonymousUser() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(new SubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotCreateSRtIfNameInInputDoesNotMatchResource() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("other-requirement")
+                    .create(input)
+                    .get());
+    assertThat(thrown).hasMessageThat().isEqualTo("name in input must match name in URL");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidName() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "wrong$%";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("wrong$%")
+                    .create(input)
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Illegal submit requirement name \"wrong$%\". "
+                + "Name can only consist of alphanumeric characters and '-'."
+                + " Name cannot start with '-' or number.");
+  }
+
+  @Test
+  public void cannotCreateSRWithEmptySubmittableIf() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+
+    assertThat(thrown).hasMessageThat().isEqualTo("submittability_expression is required");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidSubmittableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "invalid_field:invalid_value";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.submittableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidOverrideIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:Code-Review=+2";
+    input.overrideExpression = "invalid_field:invalid_value";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.overrideIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidApplicableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "invalid_field:invalid_value";
+    input.submittabilityExpression = "label:Code-Review=+2";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.applicableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotListSRsAsAnonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements().get());
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotListSRs_withMissingReadPermissionsToRefsConfig() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements().get());
+    assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void cannotListSRs_withMissingReadPermissionsInParent_withInheritance() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirements().withInherited(true).get());
+    assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void canListSRs_withReadPermissionsInAllParentProjects_withInheritance() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.projects().name(project.get()).submitRequirements().get();
+  }
+
+  @Test
+  public void canListSRs_withMissingReadPermissionsInParent_withoutInheritance() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.projects().name(project.get()).submitRequirements().withInherited(false).get();
+  }
+
+  @Test
+  public void listSRs() throws Exception {
+    createSubmitRequirement("sr-1");
+    createSubmitRequirement("sr-2");
+
+    List<SubmitRequirementInfo> infos =
+        gApi.projects().name(project.get()).submitRequirements().get();
+
+    assertThat(names(infos)).containsExactly("sr-1", "sr-2");
+  }
+
+  @Test
+  public void listSRsWithInheritance() throws Exception {
+    createSubmitRequirement(allProjects.get(), "base-sr");
+    createSubmitRequirement(project.get(), "sr-1");
+    createSubmitRequirement(project.get(), "sr-2");
+
+    List<SubmitRequirementInfo> infos =
+        gApi.projects().name(project.get()).submitRequirements().withInherited(false).get();
+
+    assertThat(names(infos)).containsExactly("sr-1", "sr-2");
+
+    infos = gApi.projects().name(project.get()).submitRequirements().withInherited(true).get();
+
+    assertThat(names(infos)).containsExactly("base-sr", "sr-1", "sr-2");
+  }
+
+  @Test
+  public void cannotDeleteSRAsAnonymousUser() throws Exception {
+    createSubmitRequirement("code-review");
+
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("code-review").delete());
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotDeleteSRWithMissingWritePermissionsToRefsConfig() throws Exception {
+    createSubmitRequirement("sr-1");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(block("write").ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("sr-1").delete());
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void cannotDeleteNonExistingSR() throws Exception {
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("non-existing").delete());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Submit requirement 'non-existing' does not exist");
+  }
+
+  @Test
+  public void deleteSubmitRequirement() throws Exception {
+    createSubmitRequirement("code-review");
+    createSubmitRequirement("verified");
+
+    List<SubmitRequirementInfo> infos =
+        gApi.projects().name(project.get()).submitRequirements().get();
+    assertThat(names(infos)).containsExactly("code-review", "verified");
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").delete();
+    infos = gApi.projects().name(project.get()).submitRequirements().get();
+    assertThat(names(infos)).containsExactly("verified");
+  }
+
+  private SubmitRequirementInfo createSubmitRequirement(String srName) throws RestApiException {
+    return createSubmitRequirement(project.get(), srName);
+  }
+
+  private SubmitRequirementInfo createSubmitRequirement(String project, String srName)
+      throws RestApiException {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = srName;
+    input.submittabilityExpression = "label:dummy=+2";
+
+    return gApi.projects().name(project).submitRequirement(srName).create(input).get();
+  }
+
+  private List<String> names(List<SubmitRequirementInfo> infos) {
+    return infos.stream().map(sr -> sr.name).collect(Collectors.toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
new file mode 100644
index 0000000..7c0b713
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
@@ -0,0 +1,581 @@
+// 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.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
+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.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.testing.BinaryResultSubject;
+import com.google.gson.stream.JsonReader;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ApplyProvidedFixIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  private static final String FILE_NAME = "file_to_fix.txt";
+  private static final String FILE_NAME2 = "another_file_to_fix.txt";
+  private static final String FILE_NAME3 = "file_without_newline_at_end.txt";
+  private static final String FILE_CONTENT =
+      "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
+          + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+  private static final String FILE_CONTENT3 = "1st line\n2nd line";
+
+  private String changeId;
+  private String commitId;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Provide files which can be used for fixes",
+            ImmutableMap.of(
+                FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2, FILE_NAME3, FILE_CONTENT3));
+    PushOneCommit.Result changeResult = push.to("refs/for/master");
+    changeId = changeResult.getChangeId();
+    commitId = changeResult.getCommit().getName();
+  }
+
+  @Test
+  public void applyProvidedFixWithinALineCanBeApplied() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixRestAPItestForASimpleFix() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    RestResponse resp =
+        adminRestSession.post(
+            "/changes/" + changeId + "/revisions/" + commitId + "/fix:apply",
+            applyProvidedFixInput);
+    readContentFromJson(resp, 200, ReviewResult.class);
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixSpanningMultipleLinesCanBeApplied() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content\n5", 3, 2, 5, 3);
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nThModified content\n5th line\nSixth line\nSeventh line\n"
+                + "Eighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nSome other modified content\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoApplyProvidedFixesOnSameFileCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoConflictingApplyProvidedFixesOnSameFileCannotBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown).hasMessageThat().contains("Cannot calculate fix replacement");
+  }
+
+  @Test
+  public void applyProvidedFixInvolvingTwoFilesCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "Different file modification\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nEighth line\nNinth line\nTenth line\n");
+    Optional<BinaryResult> file2 = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file2)
+        .value()
+        .asString()
+        .isEqualTo("Different file modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void applyProvidedFixReferringToNonExistentFileCannotBeApplied() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput("a_non_existent_file.txt", "Modified content\n", 1, 0, 2, 0);
+
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+  }
+
+  @Test
+  public void applyProvidedFixRestAPIcallWithoutAddPatchSetPermissionCannotBeApplied()
+      throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+
+    String allRefs = RefNames.REFS + "*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref(allRefs).group(ANONYMOUS_USERS))
+        .add(block(Permission.ADD_PATCH_SET).ref(allRefs).group(REGISTERED_USERS))
+        .update();
+
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + changeId + "/revisions/" + commitId + "/fix:apply",
+            applyProvidedFixInput);
+    resp.assertStatus(403);
+  }
+
+  @Test
+  public void applyProvidedFixOnCurrentPatchSetWithExistingChangeEditCanBeApplied()
+      throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList = Arrays.asList(fixReplacementInfo1);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixOnPreviousPatchSetCannotBeApplied() throws Exception {
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .revision(previousRevision)
+                    .applyFix(applyProvidedFixInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("A change edit may only be created for the current patch set");
+  }
+
+  @Test
+  public void applyProvidedFixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
+      throws Exception {
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+    // Add another patch set.
+    amendChange(changeId);
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("on which the existing change edit is based may be modified");
+  }
+
+  @Test
+  public void applyProvidedFixOnCommitMessageCanBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(Patch.COMMIT_MSG, "Modified line\n", 7, 0, 8, 0);
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo("Modified line\nLine 2 of commit message\n" + footer);
+  }
+
+  @Test
+  public void applyProvidedFixOnHeaderPartOfCommitMessageCannotBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "Change-Id: " + changeId;
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\n" + "\n" + footer + "\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(Patch.COMMIT_MSG, "Modified line\n", 1, 0, 2, 0);
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(exception).hasMessageThat().contains("header");
+  }
+
+  @Test
+  public void applyProvidedFixContainingSeveralModificationsOfCommitMessageCanBeApplied()
+      throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = Patch.COMMIT_MSG;
+    fixReplacementInfo2.range = createRange(9, 0, 10, 0);
+    fixReplacementInfo2.replacement = "Modified line 3\n";
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage)
+        .isEqualTo("Modified line 1\nLine 2 of commit message\nModified line 3\n" + footer);
+  }
+
+  @Test
+  public void applyProvidedFixModifyingTheCommitMessageAndAFileCanBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "File modification\n";
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo("Modified line 1\nLine 2 of commit message\n" + footer);
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo("File modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void twoApplyProvidedFixesNonOverlappingOnCommitMessageCanBeAppliedSubsequently()
+      throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = Patch.COMMIT_MSG;
+    fixReplacementInfo2.range = createRange(9, 0, 10, 0);
+    fixReplacementInfo2.replacement = "Modified line 3\n";
+    List<FixReplacementInfo> fixReplacementInfoList1 = Arrays.asList(fixReplacementInfo1);
+    ApplyProvidedFixInput applyProvidedFixInput1 = new ApplyProvidedFixInput();
+    applyProvidedFixInput1.fixReplacementInfos = fixReplacementInfoList1;
+    List<FixReplacementInfo> fixReplacementInfoList2 = Arrays.asList(fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput2 = new ApplyProvidedFixInput();
+    applyProvidedFixInput2.fixReplacementInfos = fixReplacementInfoList2;
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput1);
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput2);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage)
+        .isEqualTo("Modified line 1\nLine 2 of commit message\nModified line 3\n" + footer);
+  }
+
+  @Test
+  public void applyingStoredFixTwiceIsIdempotent() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String expectedEditCommit =
+        gApi.changes().id(changeId).edit().get().map(edit -> edit.commit.commit).orElse("");
+
+    // Apply the fix again.
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    Optional<EditInfo> editInfo = gApi.changes().id(changeId).edit().get();
+    assertThat(editInfo).value().commit().commit().isEqualTo(expectedEditCommit);
+  }
+
+  @Test
+  public void applyProvidedFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void createdApplyProvidedFixChangeEditIsBasedOnCurrentPatchSet() throws Exception {
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    assertThat(editInfo).baseRevision().isEqualTo(currentRevision);
+  }
+
+  @Test
+  public void applyProvidedFixRestCallWithDifferentUserTheUserBecomesUploader() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    RevisionInfo rev = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(rev.uploader.username).isEqualTo(admin.username());
+
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + changeId + "/revisions/" + commitId + "/fix:apply",
+            applyProvidedFixInput);
+    resp.assertStatus(200);
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    RestResponse resp2 =
+        userRestSession.post("/changes/" + changeId + "/edit:publish", publishInput);
+    resp2.assertStatus(204);
+
+    changeInfo = gApi.changes().id(changeId).get();
+    RevisionInfo rev2 = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(rev2.uploader.username).isEqualTo(user.username());
+  }
+
+  @Test
+  public void applyProvidedFixInputNullReturnsBadRequestException() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput = null;
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown).hasMessageThat().contains("applyProvidedFixInput is required");
+  }
+
+  @Test
+  public void applyProvidedFixInputFixReplacementInfosNullReturnsBadRequestException()
+      throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("applyProvidedFixInput.fixReplacementInfos is required");
+  }
+
+  private ApplyProvidedFixInput createApplyProvidedFixInput(
+      String file_name,
+      String replacement,
+      int startLine,
+      int startCharacter,
+      int endLine,
+      int endCharacter) {
+    FixReplacementInfo fixReplacementInfo = new FixReplacementInfo();
+    fixReplacementInfo.path = file_name;
+    fixReplacementInfo.replacement = replacement;
+    fixReplacementInfo.range = createRange(startLine, startCharacter, endLine, endCharacter);
+
+    List<FixReplacementInfo> fixReplacementInfoList = Arrays.asList(fixReplacementInfo);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    return applyProvidedFixInput;
+  }
+
+  private static Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+
+  private static <T> T readContentFromJson(RestResponse r, int expectedStatus, Class<T> clazz)
+      throws Exception {
+    r.assertStatus(expectedStatus);
+    try (JsonReader jsonReader = new JsonReader(r.getReader())) {
+      jsonReader.setLenient(true);
+      return newGson().fromJson(jsonReader, clazz);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
new file mode 100644
index 0000000..c257e703
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
@@ -0,0 +1,278 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffInfo.IntraLineStatus;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PreviewProvidedFixIT extends AbstractDaemonTest {
+  private static final String PLAIN_TEXT_CONTENT_TYPE = "text/plain";
+  private static final String GERRIT_COMMIT_MESSAGE_TYPE = "text/x-gerrit-commit-message";
+
+  private static final String FILE_NAME = "file_to_fix.txt";
+  private static final String FILE_NAME2 = "another_file_to_fix.txt";
+  private static final String FILE_NAME3 = "file_without_newline_at_end.txt";
+  private static final String FILE_CONTENT =
+      "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
+          + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+  private static final String FILE_CONTENT3 = "1st line\n2nd line";
+
+  private String changeId;
+  private String commitId;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Provide files which can be used for fixes",
+            ImmutableMap.of(
+                FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2, FILE_NAME3, FILE_CONTENT3));
+    PushOneCommit.Result changeResult = push.to("refs/for/master");
+    changeId = changeResult.getChangeId();
+    commitId = changeResult.getCommit().getName();
+  }
+
+  @Test
+  public void previewFixDetailedCheck() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.replacement = "some replacement code";
+    fixReplacementInfo1.range = createRange(3, 9, 8, 4);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.replacement = "New line\n";
+    fixReplacementInfo2.range = createRange(2, 0, 2, 0);
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    Map<String, DiffInfo> fixPreview =
+        gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput);
+
+    DiffInfo diff = fixPreview.get(FILE_NAME);
+    assertThat(diff).intralineStatus().isEqualTo(IntraLineStatus.OK);
+    assertThat(diff).webLinks().isNull();
+    assertThat(diff).binary().isNull();
+    assertThat(diff).diffHeader().isNull();
+    assertThat(diff).changeType().isEqualTo(ChangeType.MODIFIED);
+    assertThat(diff).metaA().totalLineCount().isEqualTo(11);
+    assertThat(diff).metaA().name().isEqualTo(FILE_NAME);
+    assertThat(diff).metaA().commitId().isEqualTo(commitId);
+    assertThat(diff).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff).metaA().webLinks().isNull();
+    assertThat(diff).metaB().totalLineCount().isEqualTo(6);
+    assertThat(diff).metaB().name().isEqualTo(FILE_NAME);
+    assertThat(diff).metaB().commitId().isNull();
+    assertThat(diff).metaB().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff).metaB().webLinks().isNull();
+
+    assertThat(diff).content().hasSize(3);
+    assertThat(diff)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsExactly("First line", "Second line");
+    assertThat(diff).content().element(0).linesOfA().isNull();
+    assertThat(diff).content().element(0).linesOfB().isNull();
+
+    assertThat(diff).content().element(1).commonLines().isNull();
+    assertThat(diff)
+        .content()
+        .element(1)
+        .linesOfA()
+        .containsExactly(
+            "Third line", "Fourth line", "Fifth line", "Sixth line", "Seventh line", "Eighth line");
+    assertThat(diff)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Third linsome replacement codeth line");
+
+    assertThat(diff)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Ninth line", "Tenth line", "");
+    assertThat(diff).content().element(2).linesOfA().isNull();
+    assertThat(diff).content().element(2).linesOfB().isNull();
+
+    DiffInfo diff2 = fixPreview.get(FILE_NAME2);
+    assertThat(diff2).intralineStatus().isEqualTo(IntraLineStatus.OK);
+    assertThat(diff2).webLinks().isNull();
+    assertThat(diff2).binary().isNull();
+    assertThat(diff2).diffHeader().isNull();
+    assertThat(diff2).changeType().isEqualTo(ChangeType.MODIFIED);
+    assertThat(diff2).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diff2).metaA().name().isEqualTo(FILE_NAME2);
+    assertThat(diff2).metaA().commitId().isEqualTo(commitId);
+    assertThat(diff2).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff2).metaA().webLinks().isNull();
+    assertThat(diff2).metaB().totalLineCount().isEqualTo(5);
+    assertThat(diff2).metaB().name().isEqualTo(FILE_NAME2);
+    assertThat(diff2).metaB().commitId().isNull();
+    assertThat(diff2).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff2).metaB().webLinks().isNull();
+
+    assertThat(diff2).content().hasSize(3);
+    assertThat(diff2).content().element(0).commonLines().containsExactly("1st line");
+    assertThat(diff2).content().element(0).linesOfA().isNull();
+    assertThat(diff2).content().element(0).linesOfB().isNull();
+
+    assertThat(diff2).content().element(1).commonLines().isNull();
+    assertThat(diff2).content().element(1).linesOfA().isNull();
+    assertThat(diff2).content().element(1).linesOfB().containsExactly("New line");
+
+    assertThat(diff2)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("2nd line", "3rd line", "");
+    assertThat(diff2).content().element(2).linesOfA().isNull();
+    assertThat(diff2).content().element(2).linesOfB().isNull();
+  }
+
+  @Test
+  public void previewFixForCommitMsg() throws Exception {
+    String footer = "Change-Id: " + changeId;
+    updateCommitMessage(
+        changeId,
+        "Commit title\n\nCommit message line 1\nLine 2\nLine 3\nLast line\n\n" + footer + "\n");
+    // The test assumes that the first 5 lines is a header.
+    // Line 10 has content "Line 2"
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(Patch.COMMIT_MSG, "New content\n", 10, 0, 11, 0);
+    Map<String, DiffInfo> fixPreview =
+        gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput);
+    assertThat(fixPreview).hasSize(1);
+    assertThat(fixPreview).containsKey(Patch.COMMIT_MSG);
+
+    DiffInfo diff = fixPreview.get(Patch.COMMIT_MSG);
+    assertThat(diff).metaA().name().isEqualTo(Patch.COMMIT_MSG);
+    assertThat(diff).metaA().contentType().isEqualTo(GERRIT_COMMIT_MESSAGE_TYPE);
+    assertThat(diff).metaB().name().isEqualTo(Patch.COMMIT_MSG);
+    assertThat(diff).metaB().contentType().isEqualTo(GERRIT_COMMIT_MESSAGE_TYPE);
+
+    assertThat(diff).content().element(0).commonLines().hasSize(9);
+    // Header has a dynamic content, do not check it
+    assertThat(diff).content().element(0).commonLines().element(6).isEqualTo("Commit title");
+    assertThat(diff).content().element(0).commonLines().element(7).isEqualTo("");
+    assertThat(diff)
+        .content()
+        .element(0)
+        .commonLines()
+        .element(8)
+        .isEqualTo("Commit message line 1");
+    assertThat(diff).content().element(1).linesOfA().containsExactly("Line 2");
+    assertThat(diff).content().element(1).linesOfB().containsExactly("New content");
+    assertThat(diff)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Line 3", "Last line", "", footer, "");
+  }
+
+  @Test
+  public void previewFixForNonExistingFile() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput("a_non_existent_file.txt", "Modified content\n", 1, 0, 2, 0);
+
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput));
+  }
+
+  @Test
+  public void previewFixAddNewLineAtEnd() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME3, "\n", 2, 8, 2, 8);
+    Map<String, DiffInfo> fixPreview =
+        gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput);
+    assertThat(fixPreview).hasSize(1);
+    assertThat(fixPreview).containsKey(FILE_NAME3);
+
+    DiffInfo diff = fixPreview.get(FILE_NAME3);
+    assertThat(diff).metaA().totalLineCount().isEqualTo(2);
+    // Original file doesn't have EOL marker at the end of file.
+    // Due to the additional EOL mark diff has one additional line
+    // This behavior is in line with ordinary get diff API.
+    assertThat(diff).metaB().totalLineCount().isEqualTo(3);
+
+    assertThat(diff).content().hasSize(2);
+    assertThat(diff).content().element(0).commonLines().containsExactly("1st line");
+    assertThat(diff).content().element(1).linesOfA().containsExactly("2nd line");
+    assertThat(diff).content().element(1).linesOfB().containsExactly("2nd line", "");
+  }
+
+  private ApplyProvidedFixInput createApplyProvidedFixInput(
+      String file_name,
+      String replacement,
+      int startLine,
+      int startCharacter,
+      int endLine,
+      int endCharacter) {
+    FixReplacementInfo fixReplacementInfo = new FixReplacementInfo();
+    fixReplacementInfo.path = file_name;
+    fixReplacementInfo.replacement = replacement;
+    fixReplacementInfo.range = createRange(startLine, startCharacter, endLine, endCharacter);
+
+    List<FixReplacementInfo> fixReplacementInfoList = Arrays.asList(fixReplacementInfo);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    return applyProvidedFixInput;
+  }
+
+  private void updateCommitMessage(String changeId, String newCommitMessage) throws Exception {
+    gApi.changes().id(changeId).edit().create();
+    gApi.changes().id(changeId).edit().modifyCommitMessage(newCommitMessage);
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    gApi.changes().id(changeId).edit().publish(publishInput);
+  }
+
+  private static Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index efd3cea..1919810 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -3005,9 +3005,6 @@
     return "An unchanged patchset\n\nChange-Id: " + changeId;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private void assertDiffForNewFile(
       PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
     DiffInfo diff =
@@ -3030,14 +3027,14 @@
       DateTimeFormatter fmt =
           DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z")
               .withLocale(Locale.US)
-              .withZone(author.getTimeZone().toZoneId());
+              .withZone(author.getZoneId());
       headers.add("Author:     " + author.getName() + " <" + author.getEmailAddress() + ">");
-      headers.add("AuthorDate: " + fmt.format(author.getWhen().toInstant()));
+      headers.add("AuthorDate: " + fmt.format(author.getWhenAsInstant()));
 
       PersonIdent committer = c.getCommitterIdent();
-      fmt = fmt.withZone(committer.getTimeZone().toZoneId());
+      fmt = fmt.withZone(committer.getZoneId());
       headers.add("Commit:     " + committer.getName() + " <" + committer.getEmailAddress() + ">");
-      headers.add("CommitDate: " + fmt.format(committer.getWhen().toInstant()));
+      headers.add("CommitDate: " + fmt.format(committer.getWhenAsInstant()));
       headers.add("");
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 4a7849f..804516a 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -506,7 +506,7 @@
     PushOneCommit.Result r1 = createChange();
 
     // Push another new change (change 2)
-    String subject = "Test change\n\nChange-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String subject = "Test change";
     PushOneCommit push =
         pushFactory.create(
             admin.newIdent(), testRepo, subject, "another_file.txt", "another content");
@@ -520,7 +520,7 @@
     ChangeApi orig = gApi.changes().id(triplet);
     CherryPickInput in = new CherryPickInput();
     in.destination = "master";
-    in.message = subject;
+    in.message = subject + "\n\nChange-Id: " + r2.getChangeId();
     ChangeApi cherry = orig.revision(r2.getCommit().name()).cherryPick(in);
     ChangeInfo cherryInfo = cherry.get();
     assertThat(cherryInfo.messages).hasSize(2);
@@ -1706,15 +1706,13 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
   // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
   // Instant
   @SuppressWarnings("JdkObsolete")
   private void assertPersonIdent(GitPerson gitPerson, PersonIdent expectedIdent) {
     assertThat(gitPerson.name).isEqualTo(expectedIdent.getName());
     assertThat(gitPerson.email).isEqualTo(expectedIdent.getEmailAddress());
-    assertThat(gitPerson.date.getTime()).isEqualTo(expectedIdent.getWhen().getTime());
+    assertThat(gitPerson.date.getTime()).isEqualTo(expectedIdent.getWhenAsInstant().toEpochMilli());
     assertThat(gitPerson.tz).isEqualTo(expectedIdent.getTimeZoneOffset());
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 875ce97..a16cdb6 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -716,7 +716,7 @@
   }
 
   @Test
-  public void fixWithinALineCanBeApplied() throws Exception {
+  public void storedFixWithinALineCanBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -739,7 +739,7 @@
   }
 
   @Test
-  public void fixSpanningMultipleLinesCanBeApplied() throws Exception {
+  public void storedFixSpanningMultipleLinesCanBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content\n5";
     fixReplacementInfo.range = createRange(3, 2, 5, 3);
@@ -761,7 +761,7 @@
   }
 
   @Test
-  public void fixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
+  public void storedFixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -793,7 +793,7 @@
   }
 
   @Test
-  public void twoFixesOnSameFileCanBeApplied() throws Exception {
+  public void twoStoredFixesOnSameFileCanBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -828,7 +828,7 @@
   }
 
   @Test
-  public void twoConflictingFixesOnSameFileCannotBeApplied() throws Exception {
+  public void twoConflictingStoredFixesOnSameFileCannotBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 1);
@@ -859,7 +859,7 @@
   }
 
   @Test
-  public void twoFixesOfSameRobotCommentCanBeApplied() throws Exception {
+  public void twoStoredFixesOfSameRobotCommentCanBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -892,7 +892,7 @@
   }
 
   @Test
-  public void fixReferringToDifferentFileThanRobotCommentCanBeApplied() throws Exception {
+  public void storedFixReferringToDifferentFileThanRobotCommentCanBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME2;
     fixReplacementInfo.range = createRange(2, 0, 3, 0);
     fixReplacementInfo.replacement = "Modified content\n";
@@ -912,7 +912,7 @@
   }
 
   @Test
-  public void fixInvolvingTwoFilesCanBeApplied() throws Exception {
+  public void storedFixInvolvingTwoFilesCanBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -949,7 +949,7 @@
   }
 
   @Test
-  public void fixReferringToNonExistentFileCannotBeApplied() throws Exception {
+  public void storedFixReferringToNonExistentFileCannotBeApplied() throws Exception {
     fixReplacementInfo.path = "a_non_existent_file.txt";
     fixReplacementInfo.range = createRange(1, 0, 2, 0);
     fixReplacementInfo.replacement = "Modified content\n";
@@ -965,7 +965,7 @@
   }
 
   @Test
-  public void fixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
+  public void storedFixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -988,7 +988,7 @@
   }
 
   @Test
-  public void fixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
+  public void storedFixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
     // Create an empty change edit.
     gApi.changes().id(changeId).edit().create();
 
@@ -1019,7 +1019,7 @@
   }
 
   @Test
-  public void fixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
+  public void storedFixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
       throws Exception {
     // Create an empty change edit.
     gApi.changes().id(changeId).edit().create();
@@ -1045,7 +1045,7 @@
   }
 
   @Test
-  public void fixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
+  public void storedFixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
     String changeEditCommitMessage =
         "This is the commit message of the change edit.\n\nChange-Id: " + changeId + "\n";
     gApi.changes().id(changeId).edit().modifyCommitMessage(changeEditCommitMessage);
@@ -1064,7 +1064,7 @@
   }
 
   @Test
-  public void fixOnCommitMessageCanBeApplied() throws Exception {
+  public void storedFixOnCommitMessageCanBeApplied() throws Exception {
     // Set a dedicated commit message.
     String footer = "\nChange-Id: " + changeId + "\n";
     String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
@@ -1086,7 +1086,7 @@
   }
 
   @Test
-  public void fixOnHeaderPartOfCommitMessageCannotBeApplied() throws Exception {
+  public void storedFixOnHeaderPartOfCommitMessageCannotBeApplied() throws Exception {
     // Set a dedicated commit message.
     String footer = "Change-Id: " + changeId;
     String originalCommitMessage =
@@ -1110,7 +1110,8 @@
   }
 
   @Test
-  public void fixContainingSeveralModificationsOfCommitMessageCanBeApplied() throws Exception {
+  public void storedFixContainingSeveralModificationsOfCommitMessageCanBeApplied()
+      throws Exception {
     // Set a dedicated commit message.
     String footer = "\nChange-Id: " + changeId + "\n";
     String originalCommitMessage =
@@ -1144,7 +1145,7 @@
   }
 
   @Test
-  public void fixModifyingTheCommitMessageAndAFileCanBeApplied() throws Exception {
+  public void storedFixModifyingTheCommitMessageAndAFileCanBeApplied() throws Exception {
     // Set a dedicated commit message.
     String footer = "\nChange-Id: " + changeId + "\n";
     String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
@@ -1180,7 +1181,7 @@
   }
 
   @Test
-  public void twoFixesOnCommitMessageCanBeAppliedOneAfterTheOther() throws Exception {
+  public void twoStoredFixesOnCommitMessageCanBeAppliedOneAfterTheOther() throws Exception {
     // Set a dedicated commit message.
     String footer = "\nChange-Id: " + changeId + "\n";
     String originalCommitMessage =
@@ -1217,7 +1218,8 @@
   }
 
   @Test
-  public void twoConflictingFixesOnCommitMessageCanNotBeAppliedOneAfterTheOther() throws Exception {
+  public void twoConflictingStoredFixesOnCommitMessageCanNotBeAppliedOneAfterTheOther()
+      throws Exception {
     // Set a dedicated commit message.
     String footer = "Change-Id: " + changeId;
     String originalCommitMessage =
@@ -1254,7 +1256,7 @@
   }
 
   @Test
-  public void applyingFixTwiceIsIdempotent() throws Exception {
+  public void applyingStoredFixTwiceIsIdempotent() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -1277,7 +1279,7 @@
   }
 
   @Test
-  public void nonExistentFixCannotBeApplied() throws Exception {
+  public void nonExistentStoredFixCannotBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -1295,7 +1297,7 @@
   }
 
   @Test
-  public void applyingFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
+  public void applyingStoredFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -1316,7 +1318,8 @@
   }
 
   @Test
-  public void applyingFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit() throws Exception {
+  public void applyingStoredFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit()
+      throws Exception {
     gApi.changes().id(changeId).edit().create();
 
     fixReplacementInfo.path = FILE_NAME;
@@ -1379,7 +1382,7 @@
   }
 
   @Test
-  public void getFixPreviewWithNonexistingFixId() throws Exception {
+  public void previewStoredFixWithNonexistentFixId() throws Exception {
     testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     assertThrows(
@@ -1388,7 +1391,7 @@
   }
 
   @Test
-  public void getFixPreviewForCommitMsg() throws Exception {
+  public void previewStoredFixForCommitMsg() throws Exception {
     String footer = "Change-Id: " + changeId;
     updateCommitMessage(
         changeId,
@@ -1447,7 +1450,7 @@
   }
 
   @Test
-  public void getFixPreviewForNonExistingFile() throws Exception {
+  public void PreviewStoredFixForNonExistingFile() throws Exception {
     FixReplacementInfo replacement = new FixReplacementInfo();
     replacement.path = "a_non_existent_file.txt";
     replacement.range = createRange(1, 0, 2, 0);
@@ -1468,7 +1471,7 @@
   }
 
   @Test
-  public void getFixPreview() throws Exception {
+  public void PreviewStoredFix() throws Exception {
     FixReplacementInfo fixReplacementInfoFile1 = new FixReplacementInfo();
     fixReplacementInfoFile1.path = FILE_NAME;
     fixReplacementInfoFile1.replacement = "some replacement code";
@@ -1578,7 +1581,7 @@
   }
 
   @Test
-  public void getFixPreviewAddNewLineAtEnd() throws Exception {
+  public void PreviewStoredFixAddNewLineAtEnd() throws Exception {
     FixReplacementInfo replacement = new FixReplacementInfo();
     replacement.path = FILE_NAME3;
     replacement.range = createRange(2, 8, 2, 8);
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 0e0168e..9fae6c0 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ChangeEditDetailOption;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -810,7 +811,9 @@
       LabelType codeReview = TestLabels.codeReview();
       u.getConfig().upsertLabelType(codeReview);
       u.getConfig()
-          .updateLabelType(codeReview.getName(), lt -> lt.setCopyAllScoresIfNoCodeChange(true));
+          .updateLabelType(
+              codeReview.getName(),
+              lt -> lt.setCopyCondition("changekind:" + ChangeKind.NO_CODE_CHANGE.name()));
       u.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index ba1e1a7..cd1d911 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -46,6 +46,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
@@ -1062,6 +1063,9 @@
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 1: Code-Review+1.");
 
+    // Check that the user who pushed the change was added as a reviewer since they added a vote.
+    assertThatUserIsOnlyReviewer(ci, admin);
+
     PushOneCommit push =
         pushFactory.create(
             admin.newIdent(),
@@ -1071,12 +1075,18 @@
             "anotherContent",
             r.getChangeId());
     r = push.to("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
 
     ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     cr = ci.labels.get(LabelId.CODE_REVIEW);
     assertThat(Iterables.getLast(ci.messages).message)
-        .isEqualTo("Uploaded patch set 2: Code-Review+2.");
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
+        .isEqualTo(
+            "Uploaded patch set 2: Code-Review+2.\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Code-Review+1 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n");
+    // Check that the user who pushed the change was added as a reviewer since they added a vote.
     assertThatUserIsOnlyReviewer(ci, admin);
 
     assertThat(cr.all).hasSize(1);
@@ -1092,8 +1102,15 @@
             "moreContent",
             r.getChangeId());
     r = push.to("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
     ci = get(r.getChangeId(), MESSAGES);
-    assertThat(Iterables.getLast(ci.messages).message).isEqualTo("Uploaded patch set 3.");
+    assertThat(Iterables.getLast(ci.messages).message)
+        .isEqualTo(
+            "Uploaded patch set 3.\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Code-Review+2 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n");
   }
 
   @Test
@@ -1110,6 +1127,7 @@
             "anotherContent",
             r.getChangeId());
     r = push.to("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
 
     ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     LabelInfo cr = ci.labels.get(LabelId.CODE_REVIEW);
@@ -1117,7 +1135,7 @@
         .isEqualTo("Uploaded patch set 2: Code-Review+2.");
 
     // Check that the user who pushed the new patch set was added as a reviewer since they added
-    // a vote
+    // a vote.
     assertThatUserIsOnlyReviewer(ci, admin);
 
     assertThat(cr.all).hasSize(1);
@@ -1244,7 +1262,7 @@
     assertThat(cr.all).hasSize(1);
     cr = ci.labels.get("Custom-Label");
     assertThat(cr.all).hasSize(1);
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
+    // Check that the user who pushed the change was added as a reviewer since they added a vote.
     assertThatUserIsOnlyReviewer(ci, admin);
   }
 
@@ -1936,7 +1954,7 @@
   @Test
   public void pushNewPatchsetOverridingStickyLabel() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReview = TestLabels.codeReview().toBuilder().setCopyMaxScore(true).build();
+      LabelType codeReview = TestLabels.codeReview().toBuilder().setCopyCondition("is:MAX").build();
       u.getConfig().upsertLabelType(codeReview);
       u.save();
     }
@@ -2423,8 +2441,8 @@
     }
 
     @Nullable
-    public CommitReceivedEvent getReceivedEvent() {
-      return receivedEvent;
+    public ImmutableListMultimap<String, String> pushOptions() {
+      return receivedEvent != null ? receivedEvent.pushOptions : null;
     }
   }
 
@@ -2520,7 +2538,7 @@
       push.setPushOptions(ImmutableList.of("trace=123", "gerrit~foo", "gerrit~bar=456"));
       PushOneCommit.Result r = push.to("refs/for/master");
       r.assertOkStatus();
-      assertThat(validator.getReceivedEvent().pushOptions)
+      assertThat(validator.pushOptions())
           .containsExactly("trace", "123", "gerrit~foo", "", "gerrit~bar", "456");
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index cac376f..7386a03 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -32,10 +32,12 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.server.index.GerritIndexStatus;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -48,6 +50,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
+import org.junit.Assume;
 import org.junit.Test;
 
 @NoHttpd
@@ -175,7 +178,9 @@
 
   @Test
   public void onlineUpgradeChanges() throws Exception {
-    int prevVersion = ChangeSchemaDefinitions.INSTANCE.getPrevious().getVersion();
+    Schema<ChangeData> previous = ChangeSchemaDefinitions.INSTANCE.getPrevious();
+    Assume.assumeNotNull(previous);
+    int prevVersion = previous.getVersion();
     int currVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
 
     // Before storing any changes, switch back to the previous version.
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java
new file mode 100644
index 0000000..eec2811
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java
@@ -0,0 +1,840 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.schema.MigrateLabelConfigToCopyCondition;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MigrateLabelConfigToCopyConditionIT extends AbstractDaemonTest {
+  private static final ImmutableSet<String> DEPRECATED_FIELDS =
+      ImmutableSet.<String>builder()
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_VALUE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
+          .add(
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
+          .build();
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Before
+  public void setup() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      // Overwrite "Code-Review" label that is inherited from All-Projects.
+      // This way changes to the "Code Review" label don't affect other tests.
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      u.save();
+    }
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // overwrite the default value for copyAllScoresIfNoChange which is true for the migration
+    updateProjectConfig(
+        cfg -> {
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.VERIFIED,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+        });
+  }
+
+  @Test
+  public void nothingToMigrate_noLabels() throws Exception {
+    Project.NameKey projectWithoutLabelDefinitions = projectOperations.newProject().create();
+    RevCommit refsMetaConfigHead =
+        projectOperations.project(projectWithoutLabelDefinitions).getHead(RefNames.REFS_CONFIG);
+
+    runMigration(projectWithoutLabelDefinitions);
+
+    // verify that refs/meta/config was not touched
+    assertThat(
+            projectOperations.project(projectWithoutLabelDefinitions).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void noFieldsToMigrate() throws Exception {
+    assertThat(projectOperations.project(project).getConfig().getSubsections(ProjectConfig.LABEL))
+        .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+
+    // copyAllScoresIfNoChange=false is set in the test setup to override the default value
+    assertDeprecatedFieldsUnset(
+        LabelId.CODE_REVIEW, MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    assertDeprecatedFieldsUnset(
+        LabelId.VERIFIED, MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED);
+  }
+
+  @Test
+  public void noFieldsToMigrate_copyConditionExists() throws Exception {
+    String copyCondition = "is:MIN";
+    setCopyConditionOnCodeReviewLabel(copyCondition);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // verify that the copy condition was not changed
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo(copyCondition);
+  }
+
+  @Test
+  public void noFieldsToMigrate_complexCopyConditionExists() throws Exception {
+    String copyCondition = "is:MIN has:unchanged-files";
+    setCopyConditionOnCodeReviewLabel(copyCondition);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // verify that the copy condition was not changed (e.g. no parentheses have been added around
+    // the
+    // copy condition)
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo(copyCondition);
+  }
+
+  @Test
+  public void noFieldsToMigrate_nonOrderedCopyConditionExists() throws Exception {
+    String copyCondition = "is:MIN OR has:unchanged-files";
+    setCopyConditionOnCodeReviewLabel(copyCondition);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // verify that the copy condition was not changed (e.g. the order of OR conditions has not be
+    // changed and no parentheses have been added around the copy condition)
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo(copyCondition);
+  }
+
+  @Test
+  public void migrateCopyAnyScore() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:ANY"));
+  }
+
+  @Test
+  public void migrateCopyMinScore() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:MIN"));
+  }
+
+  @Test
+  public void migrateCopyMaxScore() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:MAX"));
+  }
+
+  @Test
+  public void migrateCopyValues_singleValue() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(1), copyCondition -> assertThat(copyCondition).isEqualTo("is:1"));
+  }
+
+  @Test
+  public void migrateCopyValues_negativeValue() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(-1), copyCondition -> assertThat(copyCondition).isEqualTo("is:\"-1\""));
+  }
+
+  @Test
+  public void migrateCopyValues_multipleValues() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(-1, 1),
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:\"-1\" OR is:1"));
+  }
+
+  @Test
+  public void migrateCopyValues_manyValues() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(-2, -1, 1, 2),
+        copyCondition ->
+            assertThat(copyCondition).isEqualTo("is:\"-1\" OR is:\"-2\" OR is:1 OR is:2"));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfNoCange() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        copyCondition ->
+            assertThat(copyCondition).isEqualTo("changekind:" + ChangeKind.NO_CHANGE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfNoCodeCange() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.NO_CODE_CHANGE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresOnTrivialRebase() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.TRIVIAL_REBASE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("has:unchanged-files"));
+  }
+
+  @Test
+  public void migrateDefaultValues() throws Exception {
+    // remove copyAllScoresIfNoChange=false that was set in the test setup to override to default
+    // value
+    unset(LabelId.CODE_REVIEW, MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // expect that the copy condition was set to "changekind:NO_CHANGE" since
+    // copyAllScoresIfNoChange was not set and has true as default value
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void migrateDefaultValues_copyConditionExists() throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:MIN");
+
+    // remove copyAllScoresIfNoChange=false that was set in the test setup to override to default
+    // value
+    unset(LabelId.CODE_REVIEW, MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // expect that the copy condition includes "changekind:NO_CHANGE" since
+    // copyAllScoresIfNoChange was not set and has true as default value
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE OR is:MIN");
+  }
+
+  @Test
+  public void migrateAll() throws Exception {
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE);
+    setCopyValuesOnCodeReviewLabel(-2, -1, 1, 2);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo(
+            "changekind:MERGE_FIRST_PARENT_UPDATE"
+                + " OR changekind:NO_CHANGE"
+                + " OR changekind:NO_CODE_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE"
+                + " OR has:unchanged-files"
+                + " OR is:\"-1\""
+                + " OR is:\"-2\""
+                + " OR is:1"
+                + " OR is:2"
+                + " OR is:ANY"
+                + " OR is:MAX"
+                + " OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_mutualllyExclusive() throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("is:ANY OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_noDuplicatePredicate()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("is:ANY OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_noDuplicatePredicates()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY OR is:MIN");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("is:ANY OR is:MAX OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_complexCopyCondition_v1()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY changekind:TRIVIAL_REBASE");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("(is:ANY changekind:TRIVIAL_REBASE) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_complexCopyCondition_v2()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel(
+        "is:ANY AND (changekind:TRIVIAL_REBASE OR changekind:NO_CODE_CHANGE)");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo(
+            "(is:ANY AND (changekind:TRIVIAL_REBASE OR changekind:NO_CODE_CHANGE)) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_noUnnecessaryParenthesesAdded()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("(is:ANY changekind:TRIVIAL_REBASE)");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("(is:ANY changekind:TRIVIAL_REBASE) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_existingCopyConditionIsNotParseable()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("NOT-PARSEABLE");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("NOT-PARSEABLE OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void
+      migrationMergesFlagsIntoExistingCopyCondition_existingComplexCopyConditionIsNotParseable()
+          throws Exception {
+    setCopyConditionOnCodeReviewLabel("NOT PARSEABLE");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("(NOT PARSEABLE) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrateMultipleLabels() throws Exception {
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    setFlagOnVerifiedLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    setFlagOnVerifiedLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE OR is:MIN");
+
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED);
+    assertThat(getCopyConditionOfVerifiedLabel()).isEqualTo("changekind:TRIVIAL_REBASE OR is:MAX");
+  }
+
+  @Test
+  public void deprecatedFlagsThatAreSetToFalseAreUnset() throws Exception {
+    // set all flags to false
+    updateProjectConfig(
+        cfg -> {
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+              /* value= */ false);
+        });
+  }
+
+  @Test
+  public void emptyCopyValueParameterIsUnset() throws Exception {
+    updateProjectConfig(
+        cfg ->
+            cfg.setString(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_VALUE,
+                /* value= */ ""));
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+  }
+
+  @Test
+  public void migrationCreatesASingleCommit() throws Exception {
+    // Set flags on 2 labels (the migrations for both labels are expected to be done in a single
+    // commit)
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+    setFlagOnVerifiedLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE);
+
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    runMigration();
+
+    // verify that the new commit in refs/meta/config is a successor of the old head
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getParent(0))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void commitMessageIsDistinct() throws Exception {
+    // Set a flag so that the migration has to do something.
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    // Verify that the commit message is distinct (e.g. this is important in case there is an issue
+    // with the migration, having a distinct commit message allows to identify the commit that was
+    // done for the migration and would allow to revert it)
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(refsMetaConfigHead.getShortMessage())
+        .isEqualTo(MigrateLabelConfigToCopyCondition.COMMIT_MESSAGE);
+  }
+
+  @Test
+  public void gerritIsAuthorAndCommitterOfTheMigrationCommit() throws Exception {
+    // Set a flag so that the migration has to do something.
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(refsMetaConfigHead.getAuthorIdent().getEmailAddress())
+        .isEqualTo(serverIdent.get().getEmailAddress());
+    assertThat(refsMetaConfigHead.getAuthorIdent().getName())
+        .isEqualTo(serverIdent.get().getName());
+    assertThat(refsMetaConfigHead.getCommitterIdent())
+        .isEqualTo(refsMetaConfigHead.getAuthorIdent());
+  }
+
+  @Test
+  public void migrationFailsIfProjectConfigIsNotParseable() throws Exception {
+    projectOperations.project(project).forInvalidation().makeProjectConfigInvalid().invalidate();
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    ConfigInvalidException exception =
+        assertThrows(ConfigInvalidException.class, () -> runMigration());
+    assertThat(exception)
+        .hasMessageThat()
+        .contains(String.format("Invalid config file project.config in project %s", project));
+
+    // verify that refs/meta/config was not touched
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void migrateWhenProjectConfigIsMissing() throws Exception {
+    deleteProjectConfig();
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    runMigration();
+
+    // verify that refs/meta/config was not touched (e.g. project.config was not created)
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void migrateWhenRefsMetaConfigIsMissing() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo.delete(RefNames.REFS_CONFIG);
+    }
+
+    runMigration();
+
+    // verify that refs/meta/config was not created
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      assertThat(testRepo.getRepository().exactRef(RefNames.REFS_CONFIG)).isNull();
+    }
+  }
+
+  @Test
+  public void migrationIsIdempotent_copyAllScoresIfNoChangeIsUnset() throws Exception {
+    testMigrationIsIdempotent(/* copyAllScoresIfNoChangeValue= */ null);
+  }
+
+  @Test
+  public void migrationIsIdempotent_copyAllScoresIfNoChangeIsFalse() throws Exception {
+    testMigrationIsIdempotent(/* copyAllScoresIfNoChangeValue= */ false);
+  }
+
+  @Test
+  public void migrationIsIdempotent_copyAllScoresIfNoChangeIsTrue() throws Exception {
+    testMigrationIsIdempotent(/* copyAllScoresIfNoChangeValue= */ true);
+  }
+
+  private void testMigrationIsIdempotent(@Nullable Boolean copyAllScoresIfNoChangeValue)
+      throws Exception {
+    updateProjectConfig(
+        cfg -> {
+          if (copyAllScoresIfNoChangeValue != null) {
+            cfg.setBoolean(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+                copyAllScoresIfNoChangeValue);
+            cfg.setBoolean(
+                ProjectConfig.LABEL,
+                LabelId.VERIFIED,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+                copyAllScoresIfNoChangeValue);
+          } else {
+            cfg.unset(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+            cfg.unset(
+                ProjectConfig.LABEL,
+                LabelId.VERIFIED,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+          }
+        });
+
+    // Run the migration to update the label configuration.
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED);
+
+    // default value for copyAllScoresIfNoChangeValue is true
+    if (copyAllScoresIfNoChangeValue == null || copyAllScoresIfNoChangeValue) {
+      assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE");
+    } else {
+      assertThat(getCopyConditionOfCodeReviewLabel()).isNull();
+    }
+
+    // Running the migration again doesn't change anything.
+    RevCommit head = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    runMigration();
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG)).isEqualTo(head);
+  }
+
+  private void testFlagMigration(String key, Consumer<String> copyConditionValidator)
+      throws Exception {
+    setFlagOnCodeReviewLabel(key);
+    runMigration();
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    copyConditionValidator.accept(getCopyConditionOfCodeReviewLabel());
+  }
+
+  private void testCopyValueMigration(List<Integer> values, Consumer<String> copyConditionValidator)
+      throws Exception {
+    setCopyValuesOnCodeReviewLabel(values.toArray(new Integer[0]));
+    runMigration();
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    copyConditionValidator.accept(getCopyConditionOfCodeReviewLabel());
+  }
+
+  private void runMigration() throws Exception {
+    runMigration(project);
+  }
+
+  private void runMigration(Project.NameKey project) throws Exception {
+    new MigrateLabelConfigToCopyCondition(repoManager, serverIdent.get()).execute(project);
+  }
+
+  private void setFlagOnCodeReviewLabel(String key) throws Exception {
+    setFlag(LabelId.CODE_REVIEW, key);
+  }
+
+  private void setFlagOnVerifiedLabel(String key) throws Exception {
+    setFlag(LabelId.VERIFIED, key);
+  }
+
+  private void setFlag(String labelName, String key) throws Exception {
+    updateProjectConfig(
+        cfg -> cfg.setBoolean(ProjectConfig.LABEL, labelName, key, /* value= */ true));
+  }
+
+  private void unset(String labelName, String key) throws Exception {
+    updateProjectConfig(cfg -> cfg.unset(ProjectConfig.LABEL, labelName, key));
+  }
+
+  private void setCopyValuesOnCodeReviewLabel(Integer... values) throws Exception {
+    setCopyValues(LabelId.CODE_REVIEW, values);
+  }
+
+  private void setCopyValues(String labelName, Integer... values) throws Exception {
+    updateProjectConfig(
+        cfg ->
+            cfg.setStringList(
+                ProjectConfig.LABEL,
+                labelName,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_VALUE,
+                Arrays.stream(values).map(Object::toString).collect(toImmutableList())));
+  }
+
+  private void setCopyConditionOnCodeReviewLabel(String copyCondition) throws Exception {
+    setCopyCondition(LabelId.CODE_REVIEW, copyCondition);
+  }
+
+  private void setCopyCondition(String labelName, String copyCondition) throws Exception {
+    updateProjectConfig(
+        cfg ->
+            cfg.setString(
+                ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION, copyCondition));
+  }
+
+  private void updateProjectConfig(Consumer<Config> configUpdater) throws Exception {
+    Config projectConfig = projectOperations.project(project).getConfig();
+    configUpdater.accept(projectConfig);
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+      testRepo.update(
+          RefNames.REFS_CONFIG,
+          testRepo
+              .commit()
+              .parent(head)
+              .message("Update label config")
+              .add(ProjectConfig.PROJECT_CONFIG, projectConfig.toText()));
+    }
+    projectCache.evictAndReindex(project);
+  }
+
+  private void deleteProjectConfig() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+      testRepo.update(
+          RefNames.REFS_CONFIG,
+          testRepo
+              .commit()
+              .parent(head)
+              .message("Update label config")
+              .rm(ProjectConfig.PROJECT_CONFIG));
+    }
+    projectCache.evictAndReindex(project);
+  }
+
+  private void assertDeprecatedFieldsUnset(String labelName, String... excludedFields) {
+    for (String field :
+        Sets.difference(DEPRECATED_FIELDS, Sets.newHashSet(Arrays.asList(excludedFields)))) {
+      assertUnset(labelName, field);
+    }
+  }
+
+  private void assertUnset(String labelName, String key) {
+    assertThat(
+            projectOperations.project(project).getConfig().getNames(ProjectConfig.LABEL, labelName))
+        .doesNotContain(key);
+  }
+
+  private String getCopyConditionOfCodeReviewLabel() {
+    return getCopyCondition(LabelId.CODE_REVIEW);
+  }
+
+  private String getCopyConditionOfVerifiedLabel() {
+    return getCopyCondition(LabelId.VERIFIED);
+  }
+
+  private String getCopyCondition(String labelName) {
+    return projectOperations
+        .project(project)
+        .getConfig()
+        .getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
index 093711f..fd9054c 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_185IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_185IT.java
new file mode 100644
index 0000000..afaf530
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_185IT.java
@@ -0,0 +1,261 @@
+// 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 static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.schema.MigrateLabelConfigToCopyCondition;
+import com.google.gerrit.server.schema.NoteDbSchemaVersion;
+import com.google.gerrit.server.schema.Schema_185;
+import com.google.gerrit.testing.TestUpdateUI;
+import com.google.inject.Inject;
+import java.util.function.Consumer;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+@Sandboxed
+public class Schema_185IT extends AbstractDaemonTest {
+  private static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+  private static final String KEY_COPY_MIN_SCORE = "copyMinScore";
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private NoteDbSchemaVersion.Arguments args;
+
+  private final TestUpdateUI testUpdateUI = new TestUpdateUI();
+
+  @Test
+  public void nothingToMigrate() throws Exception {
+    RevCommit oldHeadAllProjects = getHead(allProjects);
+    RevCommit oldHeadAllUsers = getHead(allUsers);
+    RevCommit oldHeadProject = getHead(project);
+
+    runMigration();
+
+    // All-Projects and All-Users both contain a label definition for Code-Review but without
+    // boolean flags, hence those don't need to be migrated (the migration assumes true for
+    // copyAllScoresIfNoChange if unset, but the copyCondition already contains
+    // 'changekind:NO_CHANGE' so copyCondition doesn't need to be changed).
+    assertThatMigrationHasNotRun(allProjects, oldHeadAllProjects);
+    assertThatMigrationHasNotRun(allUsers, oldHeadAllUsers);
+
+    // Check that the migration was not executed for the projects that do not contain label
+    // definitions.
+    assertThatMigrationHasNotRun(project, oldHeadProject);
+  }
+
+  @Test
+  public void labelConfigsAreMigrated() throws Exception {
+    addLabelThatNeedsToBeMigrated(project);
+
+    RevCommit projectOldHead = getHead(project);
+    runMigration();
+    assertThatMigrationHasRun(project, projectOldHead);
+  }
+
+  @Test
+  public void upgradeIsIdempotent() throws Exception {
+    addLabelThatNeedsToBeMigrated(project);
+
+    // Run the migration to update the label configuration.
+    runMigration();
+
+    // Running the migration again, doesn't change anything.
+    RevCommit projectOldHead = getHead(project);
+    runMigration();
+    assertThatMigrationHasNotRun(project, projectOldHead);
+  }
+
+  @Test
+  public void upgradeIsIdempotent_onlyDefaultFlagIsMigrated() throws Exception {
+    addLabelThatNeedsToBeMigratedDueToDefaultFlag(project);
+
+    // Run the migration to update the label configuration.
+    runMigration();
+
+    // Running the migration again, doesn't change anything.
+    RevCommit projectOldHead = getHead(project);
+    runMigration();
+    assertThatMigrationHasNotRun(project, projectOldHead);
+  }
+
+  @Test
+  public void migrateMultipleProjects() throws Exception {
+    Project.NameKey project1 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+    Project.NameKey project2 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+    Project.NameKey project3 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+
+    RevCommit oldHeadProject1 = getHead(project1);
+    RevCommit oldHeadProject2 = getHead(project2);
+    RevCommit oldHeadProject3 = getHead(project3);
+
+    runMigration();
+
+    assertThatMigrationHasRun(project1, oldHeadProject1);
+    assertThatMigrationHasRun(project2, oldHeadProject2);
+    assertThatMigrationHasRun(project3, oldHeadProject3);
+  }
+
+  @Test
+  public void migrationPrintsOutProgress() throws Exception {
+    // Create 197 projects so that in total we have 200 projects (197 + All-Projects + All-Users +
+    // test project).
+    for (int i = 0; i < 197; i++) {
+      createProjectWithLabelConfigThatNeedsToBeMigrated();
+    }
+
+    runMigration();
+    String output = testUpdateUI.getOutput();
+    assertThat(output).contains("Migrating label configurations");
+    assertThat(output).contains("migrated label configurations of 50% (100/200) projects");
+    assertThat(output).contains("migrated label configurations of 100% (200/200) projects");
+    assertThat(output).contains("Migrated label configurations of all 200 projects to schema 185");
+  }
+
+  @Test
+  public void projectsWithInvalidConfigurationAreSkipped() throws Exception {
+    Project.NameKey projectWithInvalidConfig = createProjectWithLabelConfigThatNeedsToBeMigrated();
+    projectOperations
+        .project(projectWithInvalidConfig)
+        .forInvalidation()
+        .makeProjectConfigInvalid()
+        .invalidate();
+
+    Project.NameKey otherProject1 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+    Project.NameKey otherProject2 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+
+    RevCommit oldHeadProjectWithInvalidConfig = getHead(projectWithInvalidConfig);
+    RevCommit oldHeadOtherProject1 = getHead(otherProject1);
+    RevCommit oldHeadOtherProject2 = getHead(otherProject2);
+
+    runMigration();
+
+    assertThatMigrationHasNotRun(projectWithInvalidConfig, oldHeadProjectWithInvalidConfig);
+    assertThatMigrationHasRun(otherProject1, oldHeadOtherProject1);
+    assertThatMigrationHasRun(otherProject2, oldHeadOtherProject2);
+
+    String output = testUpdateUI.getOutput();
+    assertThat(output)
+        .contains(
+            String.format(
+                "WARNING: Skipping migration of label configurations for project %s"
+                    + " since its %s file is invalid:",
+                projectWithInvalidConfig, ProjectConfig.PROJECT_CONFIG));
+  }
+
+  private void runMigration() throws Exception {
+    Schema_185 upgrade = new Schema_185();
+    upgrade.upgrade(args, testUpdateUI);
+  }
+
+  private RevCommit getHead(Project.NameKey project) {
+    return projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+  }
+
+  private void assertThatMigrationHasRun(Project.NameKey project, RevCommit oldHead) {
+    RevCommit newHead = getHead(project);
+    assertThat(getHead(project)).isNotEqualTo(oldHead);
+    assertThat(newHead.getShortMessage())
+        .isEqualTo(MigrateLabelConfigToCopyCondition.COMMIT_MESSAGE);
+  }
+
+  private void assertThatMigrationHasNotRun(Project.NameKey project, RevCommit oldHead) {
+    assertThat(getHead(project)).isEqualTo(oldHead);
+  }
+
+  private Project.NameKey createProjectWithLabelConfigThatNeedsToBeMigrated() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    addLabelThatNeedsToBeMigrated(project);
+    return project;
+  }
+
+  private void addLabelThatNeedsToBeMigrated(Project.NameKey project) throws Exception {
+    // create a label which needs to be migrated because flags have been set
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    updateProjectConfig(
+        cfg -> {
+          // override the default value
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          // set random flag
+          cfg.setBoolean(
+              ProjectConfig.LABEL, LabelId.CODE_REVIEW, KEY_COPY_MIN_SCORE, /* value= */ true);
+        });
+  }
+
+  private void addLabelThatNeedsToBeMigratedDueToDefaultFlag(Project.NameKey project)
+      throws Exception {
+    // create a label which needs to be migrated (copyAllScoresIfNoChange is unset, the migration
+    // assumes true as default and hence sets copyCondition to "changekind:NO_CHANGE").
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+  }
+
+  private void updateProjectConfig(Consumer<Config> configUpdater) throws Exception {
+    Config projectConfig = projectOperations.project(project).getConfig();
+    configUpdater.accept(projectConfig);
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+      testRepo.update(
+          RefNames.REFS_CONFIG,
+          testRepo
+              .commit()
+              .parent(head)
+              .message("Update label config")
+              .add(ProjectConfig.PROJECT_CONFIG, projectConfig.toText()));
+    }
+    projectCache.evictAndReindex(project);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/DeleteSshKeyIT.java b/javatests/com/google/gerrit/acceptance/rest/account/DeleteSshKeyIT.java
new file mode 100644
index 0000000..4e9b2af
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/DeleteSshKeyIT.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.restapi.account.DeleteSshKey;
+import com.google.inject.Inject;
+import java.security.KeyPair;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class DeleteSshKeyIT extends AbstractDaemonTest {
+
+  private static final String KEY1 =
+      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS"
+          + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28"
+          + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T"
+          + "w== john.doe@example.com";
+
+  @Inject VersionedAuthorizedKeys.Accessor authorizedKeys;
+  @Inject DeleteSshKey deleteSshKey;
+
+  private AccountSshKey userSshKey;
+  private AccountSshKey adminSshKey;
+
+  @Before
+  public void setup() throws Exception {
+    addUserSshKeys();
+    addAdminSshKeys();
+  }
+
+  @Test
+  @UseSsh
+  public void assertUsersHaveSshKeysPreconditions() throws Exception {
+    List<AccountSshKey> userSshKeys = authorizedKeys.getKeys(user.id());
+    assertThat(userSshKeys).containsExactly(userSshKey, AccountSshKey.create(user.id(), 2, KEY1));
+    List<AccountSshKey> adminSshKeys = authorizedKeys.getKeys(admin.id());
+    assertThat(adminSshKeys).containsExactly(adminSshKey);
+  }
+
+  @Test
+  @UseSsh
+  public void deleteSshKeyRestApi() throws Exception {
+    gApi.accounts().id(user.id().get()).deleteSshKey(userSshKey.seq());
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(user.id());
+    assertThat(sshKeysAfterDel).containsExactly(AccountSshKey.create(user.id(), 2, KEY1));
+  }
+
+  @Test
+  @UseSsh
+  public void adminCanDeleteUserSshKey() throws Exception {
+    adminRestSession
+        .delete(String.format("/accounts/%s/sshkeys/%d", user.id(), userSshKey.seq()))
+        .assertNoContent();
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(user.id());
+    assertThat(sshKeysAfterDel).containsExactly(AccountSshKey.create(user.id(), 2, KEY1));
+  }
+
+  @Test
+  @UseSsh
+  public void deleteSshKeyOnBehalf() throws Exception {
+    assertThat(deleteSshKey.apply(identifiedUserFactory.create(user.id()), userSshKey))
+        .isEqualTo(Response.none());
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(user.id());
+    assertThat(sshKeysAfterDel).containsExactly(AccountSshKey.create(user.id(), 2, KEY1));
+  }
+
+  @Test
+  @UseSsh
+  public void userCanDeleteOwnSshKey() throws Exception {
+    userRestSession
+        .delete(String.format("/accounts/self/sshkeys/%d", userSshKey.seq()))
+        .assertNoContent();
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(user.id());
+    assertThat(sshKeysAfterDel).containsExactly(AccountSshKey.create(user.id(), 2, KEY1));
+  }
+
+  @Test
+  @UseSsh
+  public void userCannotDeleteOtherUsersSshKey() throws Exception {
+    userRestSession
+        .delete(String.format("/accounts/%s/sshkeys/%d", admin.id(), adminSshKey.seq()))
+        .assertNotFound();
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(admin.id());
+    assertThat(sshKeysAfterDel).containsExactly(adminSshKey);
+  }
+
+  private void addUserSshKeys() throws Exception {
+    KeyPair keyPair = sshKeys.getKeyPair(user);
+    userSshKey =
+        authorizedKeys.addKey(
+            user(user).getAccountId(), TestSshKeys.publicKey(keyPair, user.email()));
+    gApi.accounts().id(user.id().get()).addSshKey(KEY1);
+  }
+
+  private void addAdminSshKeys() throws Exception {
+    KeyPair keyPair = sshKeys.getKeyPair(admin);
+    adminSshKey =
+        authorizedKeys.addKey(
+            user(admin).getAccountId(), TestSshKeys.publicKey(keyPair, admin.email()));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 4da4da4..eb827c0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -62,6 +62,7 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 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.UnprocessableEntityException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -313,8 +314,8 @@
     in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
 
-    UnprocessableEntityException thrown =
-        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revision.review(in));
     assertThat(thrown)
         .hasMessageThat()
         .contains("on_behalf_of account " + user.id() + " cannot see change");
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index f3b13d2..2d663df 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -74,8 +74,6 @@
           RestCall.delete("/changes/%s/private"),
           RestCall.post("/changes/%s/wip"),
           RestCall.post("/changes/%s/ready"),
-          RestCall.put("/changes/%s/ignore"),
-          RestCall.put("/changes/%s/unignore"),
           RestCall.get("/changes/%s/messages"),
           RestCall.put("/changes/%s/message"),
           RestCall.post("/changes/%s/merge"),
@@ -150,6 +148,8 @@
           RestCall.post("/changes/%s/revisions/%s/test.submit_rule"),
           RestCall.post("/changes/%s/revisions/%s/test.submit_type"),
           RestCall.post("/changes/%s/revisions/%s/rebase"),
+          RestCall.post("/changes/%s/revisions/%s/fix:apply"),
+          RestCall.post("/changes/%s/revisions/%s/fix:preview"),
           RestCall.get("/changes/%s/revisions/%s/description"),
           RestCall.put("/changes/%s/revisions/%s/description"),
           RestCall.get("/changes/%s/revisions/%s/patch"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index f1c0110..cffcc2f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -32,6 +32,8 @@
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
@@ -85,7 +87,9 @@
               .build(),
           RestCall.get("/projects/%s/dashboards"),
           RestCall.put("/projects/%s/labels/new-label"),
-          RestCall.post("/projects/%s/labels/"));
+          RestCall.post("/projects/%s/labels/"),
+          RestCall.put("/projects/%s/submit_requirements/new-sr"),
+          RestCall.get("/projects/%s/submit_requirements"));
 
   /**
    * Child project REST endpoints to be tested, each URL contains placeholders for the parent
@@ -175,6 +179,18 @@
           // Label deletion must be tested last
           RestCall.delete("/projects/%s/labels/%s"));
 
+  /**
+   * Submit requirement REST endpoints to be tested, each URL contains placeholders for the project
+   * identifier and the submit requirement name.
+   */
+  private static final ImmutableList<RestCall> SUBMIT_REQUIREMENT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/submit_requirements/%s"),
+          RestCall.put("/projects/%s/submit_requirements/%s"),
+
+          // Submit requirement deletion must be tested last
+          RestCall.delete("/projects/%s/submit_requirements/%s"));
+
   private static final String FILENAME = "test.txt";
   @Inject private ProjectOperations projectOperations;
 
@@ -236,6 +252,20 @@
     RestApiCallHelper.execute(adminRestSession, LABEL_ENDPOINTS, project.get(), label);
   }
 
+  @Test
+  public void submitRequirementsEndpoints() throws Exception {
+    // Create the SR, so that the GET endpoint succeeds
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    RestApiCallHelper.execute(
+        adminRestSession, SUBMIT_REQUIREMENT_ENDPOINTS, project.get(), "code-review");
+  }
+
   private String createAndSubmitChange(String filename) throws Exception {
     RevCommit c =
         testRepo
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index de14d00..0e4f212 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -28,6 +28,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
@@ -1360,14 +1361,11 @@
     assertThat(actual.getTimeZone()).isEqualTo(expected.getTimeZone());
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   protected void assertAuthorAndCommitDateEquals(RevCommit commit) {
-    assertThat(commit.getAuthorIdent().getWhen().getTime())
-        .isEqualTo(commit.getCommitterIdent().getWhen().getTime());
-    assertThat(commit.getAuthorIdent().getTimeZone())
-        .isEqualTo(commit.getCommitterIdent().getTimeZone());
+    assertThat(commit.getAuthorIdent().getWhenAsInstant())
+        .isEqualTo(commit.getCommitterIdent().getWhenAsInstant());
+    assertThat(commit.getAuthorIdent().getZoneId())
+        .isEqualTo(commit.getCommitterIdent().getZoneId());
   }
 
   protected void assertSubmitter(String changeId, int psId) throws Throwable {
@@ -1446,7 +1444,7 @@
       fmt.setRepository(repo);
       fmt.format(oldTreeId, newTreeId);
       fmt.flush();
-      return out.toString();
+      return out.toString(UTF_8);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index e013267..ea52690 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.restapi.testing.AttentionSetUpdateSubject.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
@@ -42,8 +46,11 @@
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
@@ -60,11 +67,13 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.GetAttentionSet;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
@@ -2029,6 +2038,715 @@
     sender.clear();
   }
 
+  @Test
+  public void approverOfOutdatedApprovalAddedToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add an approval that gets outdated when a new patch set is created (i.e. an approval that is
+    // not copied).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this removes the vote from user, as it is not copied to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approval has been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // User got added to the attention set because users approval got outdated and was removed and
+    // user now needs to re-review the change and renew the approval.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Vote got outdated and was removed: Code-Review+1"));
+
+    // Expect that the email notification contains the outdated vote.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s.\n"
+                    + "\n"
+                    + "Hello %s, \n"
+                    + "\n"
+                    + "I'd like you to reexamine a change."
+                    + " Please visit",
+                user.fullName(), user.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s\n",
+                user.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p> Attention is currently required from: %s. </p>\n"
+                    + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                user.fullName(), admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s</p>",
+                user.fullName()));
+  }
+
+  @Test
+  public void approverOfMultipleOutdatedApprovalsAddedToAttentionSet() throws Exception {
+    // Create a Verify and a Foo-Var label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      LabelType.Builder fooBar =
+          labelBuilder("Foo-Bar", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(fooBar.build());
+
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(
+            allowLabel("Foo-Bar")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add multiple approvals from one user that gets outdated when a new patch set is created (i.e.
+    // approvals that are not copied).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Foo-Bar", -1));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this removes the vote from user, as it is not copied to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approvals have been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // User got added to the attention set because users approvals got outdated and were removed and
+    // user now needs to re-review the change and renew the approvals.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Votes got outdated and were removed: Code-Review+1, Foo-Bar-1, Verified+1"));
+
+    // Expect that the email notification contains the outdated votes.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s.\n"
+                    + "\n"
+                    + "Hello %s, \n"
+                    + "\n"
+                    + "I'd like you to reexamine a change."
+                    + " Please visit",
+                user.fullName(), user.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Foo-Bar-1 by %s, Verified+1 by %s\n",
+                user.fullName(), user.fullName(), user.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p> Attention is currently required from: %s. </p>\n"
+                    + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                user.fullName(), admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Foo-Bar-1 by %s, Verified+1 by %s</p>",
+                user.fullName(), user.fullName(), user.fullName()));
+  }
+
+  @Test
+  public void multipleApproverOfOutdatedApprovalsAddedToAttentionSet() throws Exception {
+    // Create Verify label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add approvals from multiple users that gets outdated when a new patch set is created (i.e.
+    // approvals that are not copied).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this removes the vote from user, as it is not copied to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approvals have been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // User got added to the attention set because users approvals got outdated and were removed and
+    // user now needs to re-review the change and renew the approvals.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Vote got outdated and was removed: Code-Review+1"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user2.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Vote got outdated and was removed: Verified+1"));
+
+    // Expect that the email notification contains the outdated votes.
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "Hello %s, %s, \n"
+                    + "\n"
+                    + "I'd like you to reexamine a change."
+                    + " Please visit",
+                user.fullName(), user2.fullName(), user.fullName(), user2.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Verified+1 by %s\n",
+                user.fullName(), user2.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p> Attention is currently required from: %s, %s. </p>\n"
+                    + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                user.fullName(), user2.fullName(), admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Verified+1 by %s</p>",
+                user.fullName(), user2.fullName()));
+  }
+
+  @Test
+  public void robotApproverOfOutdatedApprovalIsNotAddedToAttentionSet() throws Exception {
+    // Create robot account
+    TestAccount robot =
+        accountCreator.create(
+            "robot-X",
+            "robot-x@example.com",
+            "Ro Bot X",
+            "RoX",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add an approval by a robot that gets outdated when a new patch set is created (i.e. an
+    // approval that is not copied).
+    requestScopeOperations.setApiUser(robot.id());
+    recommend(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // A robot vote doesn't add the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet()).isEmpty();
+
+    // Amend the change, this removes the vote from the robot, as it is not copied to the new patch
+    // set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approval has been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // The robot was not added to the attention set because users service users are never added to
+    // the attention set.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Verify the email for the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    String emailBody = message.body();
+    assertThat(emailBody)
+        .doesNotContain(
+            String.format("Attention is currently required from: %s", robot.fullName()));
+    assertThat(emailBody)
+        .contains(
+            String.format(
+                "Hello %s, \n\nI'd like you to reexamine a change. Please visit",
+                robot.fullName()));
+    assertThat(emailBody)
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\nCode-Review+1 by %s",
+                robot.fullName()));
+    assertThat(message.htmlBody())
+        .doesNotContain(
+            String.format("Attention is currently required from: %s", robot.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s</p>",
+                robot.fullName()));
+  }
+
+  @Test
+  public void approverOfCopiedApprovelNotAddedToAttentionSet() throws Exception {
+    // Allow user to make veto votes.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add a veto vote that will be copied over to a new patch set.
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, -2));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this copies the vote from user to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approval has been copied.
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+
+    // Attention set wasn't changed.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Verify the email for the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .doesNotContain(String.format("Attention is currently required from: %s", user.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Hello %s, \n\nI'd like you to reexamine a change. Please visit", user.fullName()));
+    assertThat(message.body())
+        .doesNotContain("The following approvals got outdated and were removed:");
+    assertThat(message.htmlBody())
+        .doesNotContain(String.format("Attention is currently required from: %s", user.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                admin.fullName()));
+    assertThat(message.htmlBody())
+        .doesNotContain("The following approvals got outdated and were removed:");
+  }
+
+  @Test
+  public void ownerAndUploaderAreAddedToAttentionSetWhenChangeBecomesUnsubmittable_approvalRemoved()
+      throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Attention set is empty.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove the owner (admin) and the uploader (user) from the attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Revoke the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    // Removing the approval added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Verify the email notification that has been sent for removing the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("\nPatch Set 2: Code-Review+2\n");
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+  }
+
+  @Test
+  public void
+      ownerAndUploaderAreAddedToAttentionSetWhenChangeBecomesUnsubmittable_approvalDowngraded()
+          throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Attention set is empty.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove the owner (admin) and the uploader (user) from the attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Downgrade the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Changing the approval added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Verify the email notification that has been sent for downgrading the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("\nPatch Set 2: Code-Review+2\n");
+    assertThat(message.body()).contains("\nPatch Set 2: Code-Review+1\n");
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+  }
+
+  @Test
+  public void ownerAndUploaderAreAddedToAttentionSetWhenChangeBecomesUnsubmittable_vetoApplied()
+      throws Exception {
+    // Allow all users to approve and veto.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Attention set is empty.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove the owner (admin) and the uploader (user) from the attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Apply veto by another user.
+    TestAccount approver2 = accountCreator.user2();
+    sender.clear();
+    requestScopeOperations.setApiUser(approver2.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Adding the veto approval added the owner (admin) and the uploader (user) to the attention
+    // set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Verify the email notification that has been sent for adding the veto.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).contains("\nPatch Set 2: Code-Review-2\n");
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+  }
+
   private void setEmailStrategyForUser(EmailStrategy es) throws Exception {
     requestScopeOperations.setApiUser(user.id());
     GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
new file mode 100644
index 0000000..1094a42
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
@@ -0,0 +1,910 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+/**
+ * Integration test to verify that change-no-longer-submittable emails are sent when a change
+ * becomes not submittable, and that they are sent only in this case (and not when the change
+ * becomes submittable or stays submittable/unsubmittable).
+ */
+public class ChangeNoLongerSubmittableIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_approvalRemoved() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Revoke the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    // Verify the email notifications that have been sent for removing the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_approvalDowngraded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Downgrade the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Verify the email notification that has been sent for downgrading the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_vetoApplied() throws Exception {
+    // Allow all users to approve and veto.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Apply veto by another user.
+    TestAccount approver2 = accountCreator.user2();
+    sender.clear();
+    requestScopeOperations.setApiUser(approver2.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Verify the email notification that has been sent for adding the veto.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_multipleSubmitRequirementsNoLongerSatisfied()
+      throws Exception {
+    // Create a Verify, a Foo-Bar and a Bar-Baz label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      LabelType.Builder fooBar =
+          labelBuilder("Foo-Bar", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(fooBar.build());
+
+      LabelType.Builder barBaz =
+          labelBuilder("Bar-Baz", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(barBaz.build());
+
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(
+            allowLabel("Foo-Bar")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(
+            allowLabel("Bar-Baz")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve all labels.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Foo-Bar", 1));
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Bar-Baz", 1));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Revoke several approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label("Code-Review", 0).label("Foo-Bar", 0).label("Verified", 0));
+
+    // Verify the email notification that have been sent for removing the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains(
+            "The change is no longer submittable:"
+                + " Code-Review, Foo-Bar and Verified are unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains(
+            "<p>The change is no longer submittable:"
+                + " Code-Review, Foo-Bar and Verified are unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeStaysSubmittable_approvalRemoved() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change by 2 users.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    TestAccount approver2 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    requestScopeOperations.setApiUser(approver2.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Revoke one approval.
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void postReview_changeStaysSubmittable_approvalDowngraded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change by 2 users.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    TestAccount approver2 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    requestScopeOperations.setApiUser(approver2.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Downgrade one approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void postReview_changeStaysUnsubmittable_approvalAdded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Add a vote that doesn't make the change submittable.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void postReview_changeBecomesSubmittable_approvalAdded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Add a vote that makes the change submittable.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    approve(r.getChangeId());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalNotCopied() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that removes the approval.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    r = amendChangeWithUploader(r, project, uploaderPs3);
+    r.assertOkStatus();
+
+    r.assertMessage(
+        "The following approvals got outdated and were removed:\n* Code-Review+2 by user2\n");
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(),
+                user.fullName(),
+                approver.fullName(),
+                uploaderPs3.fullName(),
+                admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalCopiedAndRevoked()
+      throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to veto and approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that copies the approval, but revoke it on push.
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, approver);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=-Code-Review",
+            approver,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalCopiedAndDowngraded()
+      throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to veto and approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that copies the approval, but downgrade it on push.
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, approver);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review+1",
+            approver,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalCopiedVetoApplied()
+      throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to veto and approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that copies the approval, but apply a new veto on push.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, uploaderPs3);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review-2",
+            uploaderPs3,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(),
+                user.fullName(),
+                uploaderPs3.fullName(),
+                uploaderPs3.fullName(),
+                admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeStaysSubmittable_approvalCopied() throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set, the approval is copied.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    r = amendChangeWithUploader(r, project, uploaderPs3);
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(), user.fullName(), uploaderPs3.fullName(), admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeStaysSubmittable_approvalReapplied() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that removes the approval, but re-apply a new approval on push.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, uploaderPs3);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review+2",
+            uploaderPs3,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    // uploaderPs3 gets added to the attention set because this user is a new reviewer on the change
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s, %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(),
+                user.fullName(),
+                approver.fullName(),
+                uploaderPs3.fullName(),
+                uploaderPs3.fullName(),
+                admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeStaysUnsubmittable() throws Exception {
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Upload a new patch set.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    r = amendChangeWithUploader(r, project, uploaderPs3);
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                uploaderPs3.fullName(), admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesSubmittable() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Upload a new patch set and approve it.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, uploaderPs3);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review+2",
+            uploaderPs3,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    // uploaderPs3 gets added to the attention set because this user is a new reviewer on the change
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                uploaderPs3.fullName(), uploaderPs3.fullName(), admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
index 15e6360..e2f4b5b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -135,7 +136,7 @@
               bufferedOut.write(data, 0, count);
             }
             bufferedOut.flush();
-            archiveEntries.put(entry.getName(), out.toString());
+            archiveEntries.put(entry.getName(), out.toString(UTF_8));
           }
         }
       }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index b3592e3..c712b14 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -414,8 +414,7 @@
   public void stickyVoteStoredOnSubmitOnNewPatchset_withoutCopyCondition() throws Exception {
     try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
       u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
       u.save();
     }
     stickyVoteStoredOnSubmitOnNewPatchset();
@@ -446,7 +445,7 @@
     // during submit.
     PatchSetApproval patchSetApprovals =
         Iterables.getLast(
-            r.getChange().notes().getApprovalsWithCopied().values().stream()
+            r.getChange().notes().getApprovals().all().values().stream()
                 .filter(a -> a.labelId().equals(LabelId.create(LabelId.CODE_REVIEW)))
                 .sorted(comparing(a -> a.patchSetId().get()))
                 .collect(toImmutableList()));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index 2eade27..d58ad11 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -170,8 +170,7 @@
   public void stickyVoteStoredOnSubmitOnNewPatchset_withoutCopyCondition() throws Exception {
     try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
       u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
       u.save();
     }
     stickyVoteStoredOnSubmitOnNewPatchset();
@@ -202,7 +201,7 @@
     // submit.
     PatchSetApproval patchSetApprovals =
         Iterables.getLast(
-            r.getChange().notes().getApprovalsWithCopied().values().stream()
+            r.getChange().notes().getApprovals().all().values().stream()
                 .filter(a -> a.labelId().equals(LabelId.create(LabelId.CODE_REVIEW)))
                 .sorted(comparing(a -> a.patchSetId().get()))
                 .collect(toImmutableList()));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 3850e13..80bedcd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.entities.Permission.READ;
 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.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
@@ -81,23 +82,58 @@
   }
 
   @Test
-  @GerritConfig(name = "accounts.visibility", value = "NONE")
-  public void suggestReviewersNoResult1() throws Exception {
+  @GerritConfig(name = "suggest.accounts", value = "false")
+  public void suggestReviewers_withSuggestDisabled() throws Exception {
     String changeId = createChange().getChangeId();
+
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+
+    assertThat(reviewers).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void suggestReviewers_accountVisibilityNone_noAccountsSuggested() throws Exception {
+    // Change is created by admin
+    String changeId = createChange().getChangeId();
+
+    requestScopeOperations.setApiUser(user2.id());
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+
     assertThat(reviewers).isEmpty();
   }
 
   @Test
   @GerritConfig(name = "suggest.from", value = "1")
   @GerritConfig(name = "accounts.visibility", value = "NONE")
-  public void suggestReviewersNoResult2() throws Exception {
+  public void suggestReviewers_accountVisibilityNone_withSuggestFrom_noAccountsSuggested()
+      throws Exception {
+    // Change is created by admin
     String changeId = createChange().getChangeId();
+
+    requestScopeOperations.setApiUser(user2.id());
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+
     assertThat(reviewers).isEmpty();
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void suggestReviewers_accountVisibilityNone_withGlobalCapability_allAccountsSuggested()
+      throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.VIEW_ALL_ACCOUNTS).group(REGISTERED_USERS))
+        .update();
+    String changeId = createChange().getChangeId();
+
+    requestScopeOperations.setApiUser(user2.id());
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+
+    assertReviewers(reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group2));
+  }
+
+  @Test
   public void suggestReviewersChange() throws Exception {
     String changeId = createChange().getChangeId();
     testSuggestReviewersChange(changeId);
@@ -137,7 +173,7 @@
   }
 
   @Test
-  @GerritConfig(name = "index.maxTerms", value = "10")
+  @GerritConfig(name = "index.maxTerms", value = "20")
   public void suggestReviewersTooManyQueryTerms() throws Exception {
     String changeId = createChange().getChangeId();
 
@@ -147,14 +183,19 @@
     for (int i = 1; i <= 9; i++) {
       query.append(name("u")).append(" ");
     }
+    // The query expands to (2 * predicates + 1) terms = 2 * 9 + 1 = 19:
+    // (2 * predicates) since the default predicate expands to two "name" OR "username" predicates.
+    // + 1 since the query processor appends a predicate to search for active accounts only.
     assertThat(suggestReviewers(changeId, query.toString())).isNotEmpty();
 
-    // Do a query which exceed index.maxTerms succeeds (10 terms plus 'inactive:1' term which is
+    // Do a query which exceed index.maxTerms succeeds (10 * 2 terms plus 'inactive:1' term which is
     // implicitly added).
     query.append(name("u"));
     BadRequestException exception =
         assertThrows(BadRequestException.class, () -> suggestReviewers(changeId, query.toString()));
-    assertThat(exception).hasMessageThat().isEqualTo("too many terms in query");
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("too many terms in query: 21 terms (max = 20)");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index df899ce..d45c90b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.events.RefReceivedEvent;
 import com.google.gerrit.server.git.validators.RefOperationValidationListener;
 import com.google.gerrit.server.git.validators.ValidationMessage;
@@ -334,8 +335,8 @@
     assertCreateFails(
         testBranch,
         "refs/heads/non-existing",
-        BadRequestException.class,
-        "invalid revision \"refs/heads/non-existing\"");
+        UnprocessableEntityException.class,
+        "base revision \"refs/heads/non-existing\" not found");
   }
 
   @Test
@@ -343,8 +344,8 @@
     assertCreateFails(
         testBranch,
         "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
-        BadRequestException.class,
-        "invalid revision \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\"");
+        UnprocessableEntityException.class,
+        "base revision \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\" not found");
   }
 
   @Test
@@ -352,8 +353,23 @@
     assertCreateFails(
         testBranch,
         "invalid\trevision",
+        UnprocessableEntityException.class,
+        "base revision \"invalid\trevision\" is invalid");
+  }
+
+  @Test
+  public void cannotCreateWithNonCommitAsRevision() throws Exception {
+    String treeId =
+        projectOperations
+            .project(testBranch.project())
+            .getHead("refs/heads/master")
+            .getTree()
+            .name();
+    assertCreateFails(
+        testBranch,
+        treeId,
         BadRequestException.class,
-        "invalid revision \"invalid\trevision\"");
+        "base revision \"" + treeId + "\" is not a commit");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index 755c2e1..462c76f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -251,15 +251,6 @@
     assertThat(createdLabel.defaultValue).isEqualTo(0);
     assertThat(createdLabel.branches).isNull();
     assertThat(createdLabel.canOverride).isTrue();
-    assertThat(createdLabel.copyAnyScore).isNull();
-    assertThat(createdLabel.copyMinScore).isNull();
-    assertThat(createdLabel.copyMaxScore).isNull();
-    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-    assertThat(createdLabel.copyAllScoresIfNoChange).isTrue();
-    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
-    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
-    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-    assertThat(createdLabel.copyValues).isNull();
     assertThat(createdLabel.allowPostSubmit).isTrue();
     assertThat(createdLabel.ignoreSelfApproval).isNull();
   }
@@ -399,50 +390,6 @@
   }
 
   @Test
-  public void createWithCopyAnyScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAnyScore = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAnyScore).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAnyScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAnyScore = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAnyScore).isNull();
-  }
-
-  @Test
-  public void createWithCopyMinScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyMinScore = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyMinScore).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyMinScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyMinScore = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyMinScore).isNull();
-  }
-
-  @Test
   public void createWithCopyCondition() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
@@ -494,149 +441,6 @@
   }
 
   @Test
-  public void createWithCopyMaxScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyMaxScore = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyMaxScore).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyMaxScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyMaxScore = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyMaxScore).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfListOfFilesDidNotChange = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfListOfFilesDidNotChange = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresIfNoChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfNoChange = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfNoChange).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresIfNoChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfNoChange = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfNoChange).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresIfNoCodeChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfNoCodeChange = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresIfNoCodeChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfNoCodeChange = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresOnTrivialRebase() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresOnTrivialRebase = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresOnTrivialRebase() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresOnTrivialRebase = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresOnMergeFirstParentUpdate = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresOnMergeFirstParentUpdate = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-  }
-
-  @Test
-  public void createWithCopyValues() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyValues = ImmutableList.of((short) -1, (short) 1);
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
-  }
-
-  @Test
   public void createWithAllowPostSubmit() 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/GetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
index 302d827..c29762e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -103,7 +103,6 @@
               "foo",
               labelType -> {
                 labelType.setCanOverride(false);
-                labelType.setCopyAllScoresIfNoChange(false);
                 labelType.setAllowPostSubmit(false);
               });
       u.save();
@@ -111,15 +110,6 @@
 
     LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
     assertThat(fooLabel.canOverride).isNull();
-    assertThat(fooLabel.copyAnyScore).isNull();
-    assertThat(fooLabel.copyMinScore).isNull();
-    assertThat(fooLabel.copyMaxScore).isNull();
-    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-    assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
-    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
-    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
-    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-    assertThat(fooLabel.copyValues).isNull();
     assertThat(fooLabel.allowPostSubmit).isNull();
     assertThat(fooLabel.ignoreSelfApproval).isNull();
   }
@@ -134,14 +124,7 @@
           .updateLabelType(
               "foo",
               labelType -> {
-                labelType.setCopyAnyScore(true);
-                labelType.setCopyMinScore(true);
-                labelType.setCopyMaxScore(true);
-                labelType.setCopyAllScoresIfListOfFilesDidNotChange(true);
-                labelType.setCopyAllScoresIfNoCodeChange(true);
-                labelType.setCopyAllScoresOnTrivialRebase(true);
-                labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
-                labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
+                labelType.setCopyCondition("is:MIN OR is:MAX");
                 labelType.setIgnoreSelfApproval(true);
               });
       u.save();
@@ -149,16 +132,8 @@
 
     LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
     assertThat(fooLabel.canOverride).isTrue();
-    assertThat(fooLabel.copyAnyScore).isTrue();
-    assertThat(fooLabel.copyMinScore).isTrue();
-    assertThat(fooLabel.copyMaxScore).isTrue();
-    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
-    assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
-    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
-    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
-    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
-    assertThat(fooLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
     assertThat(fooLabel.allowPostSubmit).isTrue();
+    assertThat(fooLabel.copyCondition).isEqualTo("is:MIN OR is:MAX");
     assertThat(fooLabel.ignoreSelfApproval).isTrue();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 40e5d50..7f2a924 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 
@@ -41,15 +42,11 @@
     assertThat(codeReviewLabel.defaultValue).isEqualTo(0);
     assertThat(codeReviewLabel.branches).isNull();
     assertThat(codeReviewLabel.canOverride).isTrue();
-    assertThat(codeReviewLabel.copyAnyScore).isNull();
-    assertThat(codeReviewLabel.copyMinScore).isTrue();
-    assertThat(codeReviewLabel.copyMaxScore).isNull();
-    assertThat(codeReviewLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-    assertThat(codeReviewLabel.copyAllScoresIfNoChange).isTrue();
-    assertThat(codeReviewLabel.copyAllScoresIfNoCodeChange).isNull();
-    assertThat(codeReviewLabel.copyAllScoresOnTrivialRebase).isTrue();
-    assertThat(codeReviewLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-    assertThat(codeReviewLabel.copyValues).isNull();
+    assertThat(codeReviewLabel.copyCondition)
+        .isEqualTo(
+            String.format(
+                "changekind:%s OR changekind:%s OR is:MIN",
+                ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()));
     assertThat(codeReviewLabel.allowPostSubmit).isTrue();
     assertThat(codeReviewLabel.ignoreSelfApproval).isNull();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index fbec664..7a717d1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -141,7 +141,6 @@
               "foo",
               labelType -> {
                 labelType.setCanOverride(false);
-                labelType.setCopyAllScoresIfNoChange(false);
                 labelType.setAllowPostSubmit(false);
               });
       u.save();
@@ -152,15 +151,6 @@
 
     LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
     assertThat(fooLabel.canOverride).isNull();
-    assertThat(fooLabel.copyAnyScore).isNull();
-    assertThat(fooLabel.copyMinScore).isNull();
-    assertThat(fooLabel.copyMaxScore).isNull();
-    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-    assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
-    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
-    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
-    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-    assertThat(fooLabel.copyValues).isNull();
     assertThat(fooLabel.allowPostSubmit).isNull();
     assertThat(fooLabel.ignoreSelfApproval).isNull();
   }
@@ -175,14 +165,7 @@
           .updateLabelType(
               "foo",
               labelType -> {
-                labelType.setCopyAnyScore(true);
-                labelType.setCopyMinScore(true);
-                labelType.setCopyMaxScore(true);
-                labelType.setCopyAllScoresIfListOfFilesDidNotChange(true);
-                labelType.setCopyAllScoresIfNoCodeChange(true);
-                labelType.setCopyAllScoresOnTrivialRebase(true);
-                labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
-                labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
+                labelType.setCopyCondition("is:MIN OR is:MAX");
                 labelType.setIgnoreSelfApproval(true);
               });
       u.save();
@@ -193,15 +176,7 @@
 
     LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
     assertThat(fooLabel.canOverride).isTrue();
-    assertThat(fooLabel.copyAnyScore).isTrue();
-    assertThat(fooLabel.copyMinScore).isTrue();
-    assertThat(fooLabel.copyMaxScore).isTrue();
-    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
-    assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
-    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
-    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
-    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
-    assertThat(fooLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
+    assertThat(fooLabel.copyCondition).isEqualTo("is:MIN OR is:MAX");
     assertThat(fooLabel.allowPostSubmit).isTrue();
     assertThat(fooLabel.ignoreSelfApproval).isTrue();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index cfca936..b4731db 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -496,40 +496,6 @@
   }
 
   @Test
-  public void setCopyAnyScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAnyScore = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAnyScore).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isTrue();
-  }
-
-  @Test
-  public void unsetCopyAnyScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAnyScore(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAnyScore = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAnyScore).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isNull();
-  }
-
-  @Test
   public void setCopyCondition() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
@@ -607,361 +573,6 @@
   }
 
   @Test
-  public void setCopyMinScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyMinScore = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyMinScore).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isTrue();
-  }
-
-  @Test
-  public void unsetCopyMinScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyMinScore(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyMinScore = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyMinScore).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
-  }
-
-  @Test
-  public void setCopyMaxScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyMaxScore = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyMaxScore).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isTrue();
-  }
-
-  @Test
-  public void unsetCopyMaxScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyMaxScore(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyMaxScore = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyMaxScore).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresIfListOfFilesDidNotChange)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfListOfFilesDidNotChange = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
-
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresIfListOfFilesDidNotChange)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType("foo", lt -> lt.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresIfListOfFilesDidNotChange)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfListOfFilesDidNotChange = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresIfListOfFilesDidNotChange)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresIfNoChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresIfNoChange(false));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfNoChange = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfNoChange).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresIfNoChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfNoChange = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfNoChange).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresIfNoCodeChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfNoCodeChange = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfNoCodeChange).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresIfNoCodeChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfNoCodeChange = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfNoCodeChange).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresOnTrivialRebase() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresOnTrivialRebase = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresOnTrivialRebase).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresOnTrivialRebase() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresOnTrivialRebase(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresOnTrivialRebase = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresOnTrivialRebase).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresOnMergeFirstParentUpdate)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresOnMergeFirstParentUpdate = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
-
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresOnMergeFirstParentUpdate)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresOnMergeFirstParentUpdate(true));
-      u.save();
-    }
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresOnMergeFirstParentUpdate)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresOnMergeFirstParentUpdate = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresOnMergeFirstParentUpdate)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyValues() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyValues = ImmutableList.of((short) -1, (short) 1);
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues)
-        .containsExactly((short) -1, (short) 1)
-        .inOrder();
-  }
-
-  @Test
-  public void unsetCopyValues() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType("foo", lt -> lt.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNotEmpty();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyValues = ImmutableList.of();
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyValues).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNull();
-  }
-
-  @Test
-  public void setAllowPostSubmit() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setAllowPostSubmit(false));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.allowPostSubmit = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.allowPostSubmit).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isTrue();
-  }
-
-  @Test
   public void unsetAllowPostSubmit() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 044da19..795e22c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import java.time.Instant;
@@ -366,16 +367,49 @@
   }
 
   @Test
+  public void nonExistingBaseRevision() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.revision = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("base revision \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\" not found");
+  }
+
+  @Test
   public void invalidBaseRevision() throws Exception {
     grantTagPermissions();
 
     TagInput input = new TagInput();
     input.ref = "test";
-    input.revision = "abcdefg";
+    input.revision = "invalid\trevision";
+
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("base revision \"" + input.revision + "\" is invalid");
+  }
+
+  @Test
+  public void nonCommitRevision() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.revision =
+        projectOperations.project(project).getHead("refs/heads/master").getTree().name();
 
     BadRequestException thrown =
         assertThrows(BadRequestException.class, () -> tag(input.ref).create(input));
-    assertThat(thrown).hasMessageThat().contains("Invalid base revision");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("base revision \"" + input.revision + "\" is not a commit");
   }
 
   @Test
@@ -457,11 +491,8 @@
     return gApi.projects().name(project.get()).tag(tagname);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private Instant instant(PushOneCommit.Result r) {
-    return r.getCommit().getCommitterIdent().getWhen().toInstant();
+    return r.getCommit().getCommitterIdent().getWhenAsInstant();
   }
 
   private void assertBadRequest(ListRefsRequest<TagInfo> req) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
new file mode 100644
index 0000000..0d06946
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
@@ -0,0 +1,432 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.PatchSetApprovalSubject.assertThatList;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.PatchSetApprovalSubject.hasTestId;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Correspondence;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.approval.ApprovalCopier;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.NullAwareCorrespondence;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Set;
+import java.util.function.Predicate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests of the {@link ApprovalCopier} API.
+ *
+ * <p>This class doesn't verify the copy condition predicates, as they are already covered by {@code
+ * StickyApprovalsIT}.
+ */
+@NoHttpd
+public class ApprovalCopierIT extends AbstractDaemonTest {
+  @Inject private ApprovalCopier approvalCopier;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Before
+  public void setup() throws Exception {
+    // Add Verified label.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+                  LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"))
+              .setCopyCondition("is:MIN");
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+
+    // Grant permissions to vote on the verified label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+  }
+
+  @Test
+  public void forInitialPatchSet_noApprovals() throws Exception {
+    ChangeData changeData = createChange().getChange();
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThat(approvalCopierResult.copiedApprovals()).isEmpty();
+      assertThat(approvalCopierResult.outdatedApprovals()).isEmpty();
+    }
+  }
+
+  @Test
+  public void forInitialPatchSet_currentApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add some current approvals.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, -1);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 1);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_noApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_outdatedApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id patchSet1Id = r.getPatchSetId();
+
+    // Add some approvals that are not copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, 1);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThat(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThat(approvalCopierResult.outdatedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, 2),
+            PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.VERIFIED, 1));
+  }
+
+  @Test
+  public void forPatchSet_copiedApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add some approvals that are copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+    PatchSet.Id patchSet2Id = r.getPatchSetId();
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -2),
+            PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_currentApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    // Add some current approvals.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, -1);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_allKindOfApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id patchSet1Id = r.getPatchSetId();
+
+    // Add some approvals that are copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    // Add some approvals that are not copied.
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, 1);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+    PatchSet.Id patchSet2Id = r.getPatchSetId();
+
+    // Add some current approvals.
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, -1);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, -1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -2),
+            PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
+    assertThatList(approvalCopierResult.outdatedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.CODE_REVIEW, 1),
+            PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.VERIFIED, 1));
+  }
+
+  @Test
+  public void forPatchSet_copiedApprovalOverriddenByCurrentApproval() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add approval that is copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    // Override the copied approval.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_approvalForNonExistingLabel() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id patchSet1Id = r.getPatchSetId();
+
+    // Add approval that could be copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+
+    // Delete the Code-Review label (override it with an empty label definition).
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(labelBuilder(LabelId.CODE_REVIEW).build());
+      u.save();
+    }
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, -2));
+  }
+
+  @Test
+  public void forPatchSet_copyableZeroApproval() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Override the inherited Code-Review label to make all votes copyable, including zero votes.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+                  LabelId.CODE_REVIEW,
+                  value(2, "Looks good to me, approved"),
+                  value(1, "Looks good to me, but someone else must approve"),
+                  value(0, "No score"),
+                  value(-1, "I would prefer this is not submitted as is"),
+                  value(-2, "This shall not be submitted"))
+              .setCopyCondition("is:ANY");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Create a zero approval that is copyable, by adding an approval and removing it again.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 0);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_nonCopyableZeroApproval() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Create a zero approval that is non-copyable, by adding an approval and removing it again.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 0);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void copiedFlagSetOnCopiedApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add approvals that are copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+    PatchSet.Id patchSet2Id = r.getPatchSetId();
+
+    // Override copied approval.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 1);
+
+    // Add new current approval.
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    ImmutableSet<PatchSetApproval> copiedApprovals = approvalCopierResult.copiedApprovals();
+    assertThatList(filter(copiedApprovals, PatchSetApproval::copied))
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
+    assertThatList(filter(copiedApprovals, psa -> !psa.copied())).isEmpty();
+  }
+
+  private void vote(String changeId, TestAccount testAccount, String label, int value)
+      throws RestApiException {
+    requestScopeOperations.setApiUser(testAccount.id());
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(label, value));
+    requestScopeOperations.setApiUser(admin.id());
+  }
+
+  private ImmutableSet<PatchSetApproval> filter(
+      Set<PatchSetApproval> approvals, Predicate<PatchSetApproval> filter) {
+    return approvals.stream().filter(filter).collect(toImmutableSet());
+  }
+
+  private ApprovalCopier.Result invokeApprovalCopierForCurrentPatchSet(
+      Change.Id changeId, int expectedCurrentPatchSetNum) throws IOException {
+    ChangeData changeData = changeDataFactory.create(project, changeId);
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(expectedCurrentPatchSetNum);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      return approvalCopier.forPatchSet(
+          changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+    }
+  }
+
+  public static class PatchSetApprovalSubject extends Subject {
+    public static Correspondence<PatchSetApproval, PatchSetApprovalTestId> hasTestId() {
+      return NullAwareCorrespondence.transforming(PatchSetApprovalTestId::create, "has test ID");
+    }
+
+    public static PatchSetApprovalSubject assertThat(PatchSetApproval patchSetApproval) {
+      return assertAbout(patchSetApprovals()).that(patchSetApproval);
+    }
+
+    public static ListSubject<PatchSetApprovalSubject, PatchSetApproval> assertThatList(
+        ImmutableSet<PatchSetApproval> patchSetApprovals) {
+      return ListSubject.assertThat(patchSetApprovals.asList(), patchSetApprovals());
+    }
+
+    private static Factory<PatchSetApprovalSubject, PatchSetApproval> patchSetApprovals() {
+      return PatchSetApprovalSubject::new;
+    }
+
+    private PatchSetApprovalSubject(FailureMetadata metadata, PatchSetApproval patchSetApproval) {
+      super(metadata, patchSetApproval);
+    }
+  }
+
+  /**
+   * AutoValue class that contains all properties of a PatchSetApproval that are relevant to do
+   * assertions in tests (patch set ID, account ID, label name, voting value).
+   */
+  @AutoValue
+  public abstract static class PatchSetApprovalTestId {
+    public abstract PatchSet.Id patchSetId();
+
+    public abstract Account.Id accountId();
+
+    public abstract LabelId labelId();
+
+    public abstract short value();
+
+    public static PatchSetApprovalTestId create(PatchSetApproval patchSetApproval) {
+      return new AutoValue_ApprovalCopierIT_PatchSetApprovalTestId(
+          patchSetApproval.patchSetId(),
+          patchSetApproval.accountId(),
+          patchSetApproval.labelId(),
+          patchSetApproval.value());
+    }
+
+    public static PatchSetApprovalTestId create(
+        PatchSet.Id patchSetId, Account.Id accountId, String labelId, int value) {
+      return new AutoValue_ApprovalCopierIT_PatchSetApprovalTestId(
+          patchSetId, accountId, LabelId.create(labelId), (short) value);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/BUILD b/javatests/com/google/gerrit/acceptance/server/change/BUILD
index 19ca946..4514ea3 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/change/BUILD
@@ -14,6 +14,7 @@
 java_library(
     name = "util",
     srcs = ["CommentsUtil.java"],
+    visibility = ["//javatests/com/google/gerrit/acceptance/api/change:__subpackages__"],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/entities",
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
index c4927f0..f32cf32 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
@@ -34,7 +34,7 @@
  * A utility class for creating {@link CommentInput} objects, publishing comments and creating draft
  * comments. Used by tests that require dealing with comments.
  */
-class CommentsUtil {
+public class CommentsUtil {
   static CommentInput addComment(GerritApi gApi, String changeId) throws Exception {
     ReviewInput input = new ReviewInput();
     CommentInput comment = CommentsUtil.newComment(FILE_NAME, Side.REVISION, 0, "a message", false);
@@ -88,7 +88,7 @@
     return populate(c, path, Side.PARENT, parent, line, message);
   }
 
-  static DraftInput newDraft(String path, Side side, int line, String message) {
+  public static DraftInput newDraft(String path, Side side, int line, String message) {
     DraftInput d = new DraftInput();
     d.unresolved = false;
     return populate(d, path, side, null, line, message);
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 5b6da36..bcde618 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -825,14 +825,11 @@
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
     PersonIdent committer = serverIdent.get();
     PersonIdent author =
         noteUtil.newAccountIdIdent(
-            getAccount(admin.id()).id(), committer.getWhen().toInstant(), committer);
+            getAccount(admin.id()).id(), committer.getWhenAsInstant(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
new file mode 100644
index 0000000..1eef944
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
@@ -0,0 +1,246 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.server.notedb.ChangeNoteJson;
+import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gson.JsonParser;
+import com.google.inject.Inject;
+import java.util.List;
+import org.apache.commons.lang3.reflect.TypeLiteral;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test for {@link com.google.gerrit.server.notedb.DeleteZombieCommentsRefs}. */
+public class DeleteZombieDraftIT extends AbstractDaemonTest {
+  private static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+
+  @Inject private DeleteZombieCommentsRefs.Factory deleteZombieDraftsFactory;
+  @Inject private ChangeNoteJson changeNoteJson;
+  private boolean dryRun;
+
+  @ConfigSuite.Default
+  public static Config dryRunMode() {
+    Config config = new Config();
+    config.setBoolean(TEST_PARAMETER_MARKER, null, "dryRun", true);
+    return config;
+  }
+
+  @ConfigSuite.Config
+  public static Config deleteMode() {
+    Config config = new Config();
+    config.setBoolean(TEST_PARAMETER_MARKER, null, "dryRun", false);
+    return config;
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    dryRun = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "dryRun", true);
+  }
+
+  @Test
+  public void draftRefWithOneZombie() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+
+    // Create a draft. A draft ref is created for this draft comment.
+    addDraft(changeId, revId, "comment 1");
+    Ref draftRef = getOnlyDraftRef();
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).hasSize(1);
+    // Publish the draft. The draft ref is deleted.
+    publishAllDrafts(r);
+    assertNumDrafts(changeId, 0);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).isEmpty();
+    assertNumPublishedComments(changeId, 1);
+
+    // Restore the draft ref. Now the same comment exists as draft and published -> zombie.
+    restoreRef(draftRef.getName(), draftRef.getObjectId());
+    draftRef = getOnlyDraftRef();
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).hasSize(1);
+
+    // Run the cleanup logic. The zombie draft is cleared. The published comment is untouched.
+    DeleteZombieCommentsRefs worker =
+        deleteZombieDraftsFactory.create(/* cleanupPercentage= */ 100, dryRun);
+    assertThat(worker.deleteDraftCommentsThatAreAlsoPublished()).isEqualTo(1);
+    if (dryRun) {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).hasSize(1);
+    } else {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).isEmpty();
+    }
+    assertNumPublishedComments(changeId, 1);
+  }
+
+  @Test
+  public void draftRefWithOneDraftAndOneZombie() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    String changeId = r1.getChangeId();
+    PushOneCommit.Result r2 = amendChange(changeId);
+
+    // Add two draft comments: one on PS1, the other on PS2
+    addDraft(changeId, r1.getCommit().getName(), "comment 1");
+    CommentInfo c2 = addDraft(changeId, r2.getCommit().getName(), "comment 2");
+    Ref draftRef = getOnlyDraftRef();
+
+    // Publish the draft on PS2. Now PS1 still has one draft, PS2 has no drafts
+    publishDraft(r2, c2.id);
+    assertNumDrafts(changeId, 1);
+    assertNumPublishedComments(changeId, 1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).isEmpty();
+
+    // Restore the draft ref for PS2 draft. Now draft on PS2 is zombie because it is also published.
+    restoreRef(draftRef.getName(), draftRef.getObjectId());
+    draftRef = getOnlyDraftRef();
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).hasSize(1);
+
+    // Run the zombie cleanup logic. Zombie draft ref for PS2 will be removed.
+    DeleteZombieCommentsRefs worker =
+        deleteZombieDraftsFactory.create(/* cleanupPercentage= */ 100, dryRun);
+    assertThat(worker.deleteDraftCommentsThatAreAlsoPublished()).isEqualTo(1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
+    if (dryRun) {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).hasSize(1);
+    } else {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).isEmpty();
+    }
+    assertNumPublishedComments(changeId, 1);
+
+    // Re-run the worker: nothing happens.
+    assertThat(worker.deleteDraftCommentsThatAreAlsoPublished()).isEqualTo(dryRun ? 1 : 0);
+    assertNumDrafts(changeId, 1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
+    if (dryRun) {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).hasSize(1);
+    } else {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).isEmpty();
+    }
+    assertNumPublishedComments(changeId, 1);
+  }
+
+  private Ref getOnlyDraftRef() throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      return Iterables.getOnlyElement(
+          allUsersRepo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS));
+    }
+  }
+
+  private void publishAllDrafts(PushOneCommit.Result r) throws Exception {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    reviewInput.message = "foo";
+    revision(r).review(reviewInput);
+  }
+
+  private void publishDraft(PushOneCommit.Result r, String draftId) throws Exception {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    reviewInput.message = "foo";
+    reviewInput.draftIdsToPublish = ImmutableList.of(draftId);
+    revision(r).review(reviewInput);
+  }
+
+  private List<CommentInfo> getDraftComments(String changeId) throws Exception {
+    return gApi.changes().id(changeId).draftsRequest().getAsList();
+  }
+
+  private List<CommentInfo> getPublishedComments(String changeId) throws Exception {
+    return gApi.changes().id(changeId).commentsRequest().getAsList();
+  }
+
+  private CommentInfo addDraft(String changeId, String revId, String commentText) throws Exception {
+    DraftInput comment = CommentsUtil.newDraft("f1.txt", Side.REVISION, /* line= */ 1, commentText);
+    return gApi.changes().id(changeId).revision(revId).createDraft(comment).get();
+  }
+
+  private void restoreRef(String refName, ObjectId id) throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      RefUpdate u = allUsersRepo.updateRef(refName);
+      u.setNewObjectId(id);
+      u.forceUpdate();
+    }
+  }
+
+  /**
+   * Returns all draft comments that are stored in {@code draftRefStr} for a specific revision
+   * (patchset) identified by its {@code blobFile} SHA-1.
+   *
+   * <p>Background: This ref points to a tree containing one or more blob files, each named after
+   * the patchset revision SHA-1, that is drafts for each patchset are stored in a separate blob
+   * file.
+   */
+  private List<HumanComment> getDraftsByParsingDraftRef(String draftRefStr, String blobFile)
+      throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(allUsersRepo)) {
+      Ref draftRef = allUsersRepo.exactRef(draftRefStr);
+      if (draftRef == null) {
+        // draft ref does not exist, i.e. no draft comments stored for this ref.
+        return ImmutableList.of();
+      }
+      RevTree revTree = rw.parseTree(draftRef.getObjectId());
+      TreeWalk tw = TreeWalk.forPath(allUsersRepo, blobFile, revTree);
+      if (tw == null) {
+        // blobFile does not exist, i.e. no draft comments for this revision.
+        return ImmutableList.of();
+      }
+      ObjectLoader open = allUsersRepo.open(tw.getObjectId(0));
+      String content = new String(open.getBytes(), UTF_8);
+      List<HumanComment> drafts =
+          changeNoteJson
+              .getGson()
+              .fromJson(
+                  JsonParser.parseString(content)
+                      .getAsJsonObject()
+                      .getAsJsonArray("comments")
+                      .toString(),
+                  new TypeLiteral<ImmutableList<HumanComment>>() {}.getType());
+      return drafts;
+    }
+  }
+
+  private void assertNumDrafts(String changeId, int num) throws Exception {
+    assertThat(getDraftComments(changeId)).hasSize(num);
+  }
+
+  private void assertNumPublishedComments(String changeId, int num) throws Exception {
+    assertThat(getPublishedComments(changeId)).hasSize(num);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 8bf7443..70b5701 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.api.changes.GetRelatedOption;
 import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -58,8 +59,10 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Optional;
+import javax.annotation.Nullable;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -482,6 +485,7 @@
     gApi.changes().id(changeId2).edit().modifyFile("a.txt", RawInputUtil.create(new byte[] {'a'}));
     Optional<EditInfo> edit = getEdit(changeId2);
     assertThat(edit).isPresent();
+    @SuppressWarnings("OptionalGetWithoutIsPresent")
     ObjectId editRev = ObjectId.fromString(edit.get().commit.commit);
 
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
@@ -632,6 +636,34 @@
         .containsExactly("NEW", "ABANDONED", "MERGED");
   }
 
+  @Test
+  public void submittable() throws Exception {
+    RevCommit c1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    RevCommit c3 = commitBuilder().add("c.txt", "3").message("subject: 3").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1 = getPatchSetId(c1);
+    PatchSet.Id ps2 = getPatchSetId(c2);
+    PatchSet.Id ps3 = getPatchSetId(c3);
+
+    for (RevCommit c : ImmutableList.of(c1, c3)) {
+      gApi.changes()
+          .id(getChange(c).change().getChangeId())
+          .current()
+          .review(ReviewInput.approve());
+    }
+
+    for (PatchSet.Id ps : ImmutableList.of(ps3, ps2, ps1)) {
+      assertRelated(
+          ps,
+          Arrays.asList(
+              changeAndCommit(ps3, c3, 1, true),
+              changeAndCommit(ps2, c2, 1, false),
+              changeAndCommit(ps1, c1, 1, true)),
+          GetRelatedOption.SUBMITTABLE);
+    }
+  }
+
   private static Correspondence<RelatedChangeAndCommitInfo, String>
       getRelatedChangeToStatusCorrespondence() {
     return Correspondence.transforming(
@@ -643,16 +675,21 @@
     return c;
   }
 
-  private PatchSet.Id getPatchSetId(ObjectId c) throws Exception {
+  private PatchSet.Id getPatchSetId(ObjectId c) {
     return getChange(c).change().currentPatchSetId();
   }
 
-  private ChangeData getChange(ObjectId c) throws Exception {
+  private ChangeData getChange(ObjectId c) {
     return Iterables.getOnlyElement(queryProvider.get().byCommit(c));
   }
 
   private RelatedChangeAndCommitInfo changeAndCommit(
       PatchSet.Id psId, ObjectId commitId, int currentRevisionNum) {
+    return changeAndCommit(psId, commitId, currentRevisionNum, null);
+  }
+
+  private RelatedChangeAndCommitInfo changeAndCommit(
+      PatchSet.Id psId, ObjectId commitId, int currentRevisionNum, @Nullable Boolean submittable) {
     RelatedChangeAndCommitInfo result = new RelatedChangeAndCommitInfo();
     result.project = project.get();
     result._changeNumber = psId.changeId().get();
@@ -661,6 +698,7 @@
     result._revisionNumber = psId.get();
     result._currentRevisionNumber = currentRevisionNum;
     result.status = "NEW";
+    result.submittable = submittable;
     return result;
   }
 
@@ -684,10 +722,18 @@
     assertRelated(psId, Arrays.asList(expected));
   }
 
-  private void assertRelated(PatchSet.Id psId, List<RelatedChangeAndCommitInfo> expected)
+  private void assertRelated(
+      PatchSet.Id psId, List<RelatedChangeAndCommitInfo> expected, GetRelatedOption... options)
       throws Exception {
     List<RelatedChangeAndCommitInfo> actual =
-        gApi.changes().id(psId.changeId().get()).revision(psId.get()).related().changes;
+        gApi.changes()
+            .id(psId.changeId().get())
+            .revision(psId.get())
+            .related(
+                options.length > 0
+                    ? EnumSet.copyOf(Arrays.asList(options))
+                    : EnumSet.noneOf(GetRelatedOption.class))
+            .changes;
     assertWithMessage("related to " + psId).that(actual).hasSize(expected.size());
     for (int i = 0; i < actual.size(); i++) {
       String name = "index " + i + " related to " + psId;
@@ -702,6 +748,7 @@
           .that(a._currentRevisionNumber)
           .isEqualTo(e._currentRevisionNumber);
       assertThat(a.status).isEqualTo(e.status);
+      assertThat(a.submittable).isEqualTo(e.submittable);
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java
new file mode 100644
index 0000000..b2836fd
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java
@@ -0,0 +1,58 @@
+// 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.receive;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Tests for checking the validation of Change-Id during receive-commits. */
+public class ReceiveCommitsChangeIdValidationIT extends AbstractDaemonTest {
+
+  @Test
+  public void disallowTruncatingChangeIdAcrossPatchSets() throws Exception {
+    // Create the parent.
+    RevCommit parent =
+        commitBuilder().add("foo.txt", "foo content").message("base commit").create();
+    testRepo.reset(parent);
+
+    String changeId = "I0000000000000000000000000000000000000012";
+    String truncatedChangeId = "I000000000000000000000000000000000000001";
+
+    // The initial Change PS1 is accepted
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "blah",
+            ImmutableMap.of("foo.txt", "first patch-set"),
+            changeId)
+        .setParent(parent)
+        .to("refs/for/master")
+        .assertOkStatus();
+
+    // The Change PS2 is rejected because the Change-Id is truncated
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "blah\n\nChange-Id: " + truncatedChangeId,
+            ImmutableMap.of("foo.txt", "second patch-set"))
+        .setParent(parent)
+        .to("refs/for/master")
+        .assertErrorStatus("invalid Change-Id");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 6013862..7603aec 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -33,6 +33,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.truth.Truth;
 import com.google.gerrit.acceptance.AbstractNotificationTest;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -56,8 +57,9 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.restapi.change.PostReviewOp;
 import com.google.inject.Inject;
+import java.util.UUID;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
@@ -302,32 +304,6 @@
     addReviewerToReviewableChange(batch());
   }
 
-  private void addReviewerToIgnoredChange(Adder adder) throws Exception {
-    StagedChange sc = stageReviewableChange();
-    requestScopeOperations.setApiUser(sc.reviewer.id());
-    gApi.changes().id(sc.changeId).ignore(true);
-    TestAccount addedReviewer = accountCreator.create("added", "added@example.com", "added", null);
-    addReviewer(adder, sc.changeId, sc.owner, addedReviewer.email(), CC_ON_OWN_COMMENTS, null);
-
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(addedReviewer)
-        .cc(sc.owner)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
-    assertThat(sender).didNotSend();
-  }
-
-  @Test
-  public void addReviewerToIgnoredChangeSingly() throws Exception {
-    addReviewerToIgnoredChange(singly());
-  }
-
-  @Test
-  public void addReviewerToIgnoredChangeBatch() throws Exception {
-    addReviewerToIgnoredChange(batch());
-  }
-
   private void addReviewerToReviewableChangeByOwnerCcingSelf(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
@@ -952,13 +928,13 @@
     // TODO(logan): Remove this test check once PolyGerrit workaround is rolled back.
     StagedChange sc = stageWipChange();
     ReviewInput in =
-        ReviewInput.noScore().message(PostReview.START_REVIEW_MESSAGE).setWorkInProgress(false);
+        ReviewInput.noScore().message(PostReviewOp.START_REVIEW_MESSAGE).setWorkInProgress(false);
     gApi.changes().id(sc.changeId).current().review(in);
     Truth.assertThat(sender.getMessages()).isNotEmpty();
     String body = sender.getMessages().get(0).body();
-    int idx = body.indexOf(PostReview.START_REVIEW_MESSAGE);
+    int idx = body.indexOf(PostReviewOp.START_REVIEW_MESSAGE);
     Truth.assertThat(idx).isAtLeast(0);
-    Truth.assertThat(body.indexOf(PostReview.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
+    Truth.assertThat(body.indexOf(PostReviewOp.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
   }
 
   private void review(TestAccount account, String changeId, EmailStrategy strategy)
@@ -2004,7 +1980,19 @@
   private void pushTo(StagedChange sc, String ref, TestAccount by, EmailStrategy emailStrategy)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    pushFactory.create(by.newIdent(), sc.repo, sc.changeId).to(ref).assertOkStatus();
+
+    // Use random file content to avoid that change kind is NO_CHANGE.
+    String randomContent = UUID.randomUUID().toString();
+    pushFactory
+        .create(
+            by.newIdent(),
+            sc.repo,
+            "New Patch Set",
+            PushOneCommit.FILE_NAME,
+            randomContent,
+            sc.changeId)
+        .to(ref)
+        .assertOkStatus();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
index 4e490a7..1ad27eb 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -54,7 +54,7 @@
     assertThat(emails).hasSize(1);
     FakeEmailSender.Message message = emails.get(0);
 
-    String changeURL = "<" + getChangeUrl(newChange.getChange()) + ">";
+    String changeURL = "<" + getChangeUrl(newChange.getChange()) + "?usp=email>";
 
     Map<String, Object> expectedHeaders = new HashMap<>();
     expectedHeaders.put("Gerrit-PatchSet", "1");
@@ -91,7 +91,7 @@
     assertThat(emails).hasSize(1);
     FakeEmailSender.Message message = emails.get(0);
 
-    String changeURL = "<" + getChangeUrl(newChange.getChange()) + ">";
+    String changeURL = "<" + getChangeUrl(newChange.getChange()) + "?usp=email>";
     Map<String, Object> expectedHeaders = new HashMap<>();
     expectedHeaders.put("Gerrit-PatchSet", "1");
     expectedHeaders.put(
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index d911512..1900158 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -461,39 +461,6 @@
   }
 
   @Test
-  public void watchProjectNoNotificationForIgnoredChange() throws Exception {
-    // watch project
-    String watchedProject = projectOperations.newProject().create().get();
-    requestScopeOperations.setApiUser(user.id());
-    watch(watchedProject);
-
-    // push a change to watched project
-    requestScopeOperations.setApiUser(admin.id());
-    TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(Project.nameKey(watchedProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(admin.newIdent(), watchedRepo, "ignored change", "a", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // ignore the change
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).ignore(true);
-
-    sender.clear();
-
-    // post a comment -> should not trigger email notification since user ignored the change
-    requestScopeOperations.setApiUser(admin.id());
-    ReviewInput in = new ReviewInput();
-    in.message = "comment";
-    gApi.changes().id(r.getChangeId()).current().review(in);
-
-    // assert email notification
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
   public void watchProjectNoNotificationForPrivateChange() throws Exception {
     // watch project
     String watchedProject = projectOperations.newProject().create().get();
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 731e0df..d3c4949 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -168,7 +168,7 @@
               false);
       configSubmitRequirement(project, projectSubmitRequirement);
       Map<SubmitRequirement, SubmitRequirementResult> results =
-          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+          evaluator.evaluateAllRequirements(changeData);
       assertThat(results).hasSize(2);
       assertThat(results.get(globalSubmitRequirement).status())
           .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
@@ -199,7 +199,7 @@
               false);
       configSubmitRequirement(project, projectSubmitRequirement);
       Map<SubmitRequirement, SubmitRequirementResult> results =
-          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+          evaluator.evaluateAllRequirements(changeData);
       assertThat(results).hasSize(1);
       assertThat(results.get(projectSubmitRequirement).status())
           .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
@@ -228,7 +228,7 @@
               false);
       configSubmitRequirement(project, projectSubmitRequirement);
       Map<SubmitRequirement, SubmitRequirementResult> results =
-          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+          evaluator.evaluateAllRequirements(changeData);
       assertThat(results).hasSize(1);
       assertThat(results.get(globalSubmitRequirement).status())
           .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
@@ -283,7 +283,10 @@
     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.submittabilityExpressionResult().get().status())
+        .isEqualTo(Status.NOT_EVALUATED);
+    assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+        .isEqualTo("message:\"Fix bug\"");
     assertThat(result.overrideExpressionResult().isPresent()).isFalse();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
index d8aa789..a643d56 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.project.ProjectConfig;
 import java.util.Locale;
@@ -377,6 +378,115 @@
             invalidValue));
   }
 
+  @Test
+  public void validSubmitRequirementCanBePushedForReview_optionalParametersNotSet()
+      throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+    Config projectConfig = readProjectConfig(head);
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        /* value= */ "label:\"Code-Review=+2\"");
+
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Add submit requirement",
+            ProjectConfig.PROJECT_CONFIG,
+            projectConfig.toText(),
+            /* topic= */ null);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void validSubmitRequirementCanBePushedForReview_allParametersSet() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+    Config projectConfig = readProjectConfig(head);
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+        /* value= */ "foo bar description");
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+        /* value= */ "branch:refs/heads/master");
+    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= */ "label:\"override=+1\"");
+    projectConfig.setBoolean(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+        /* value= */ false);
+
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Add submit requirement",
+            ProjectConfig.PROJECT_CONFIG,
+            projectConfig.toText(),
+            /* topic= */ null);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void invalidSubmitRequirementIsRejectedWhenPushingForReview() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidExpression = "invalid_field:invalid_value";
+
+    RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+    Config projectConfig = readProjectConfig(head);
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        /* value= */ invalidExpression);
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Add submit requirement",
+            ProjectConfig.PROJECT_CONFIG,
+            projectConfig.toText(),
+            /* topic= */ null);
+    r.assertErrorStatus(
+        String.format(
+            "invalid submit requirement expressions in project.config (revision = %s)",
+            r.getCommit().name()));
+    assertThat(r.getMessage()).contains("Invalid project configuration");
+    assertThat(r.getMessage())
+        .contains(
+            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));
+  }
+
   private void fetchRefsMetaConfig() throws Exception {
     fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
     testRepo.reset(RefNames.REFS_CONFIG);
diff --git a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
index b019354..4ce62d2 100644
--- a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
@@ -25,9 +25,7 @@
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 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.extensions.client.ChangeKind;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.change.ChangeKindCache;
@@ -35,7 +33,6 @@
 import com.google.gerrit.server.query.approval.ApprovalContext;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
-import java.time.Instant;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
@@ -300,18 +297,14 @@
     ChangeKind changeKind =
         changeKindCache.getChangeKind(
             changeNotes.getChange(), changeNotes.getPatchSets().get(newPsId));
-    PatchSetApproval approval =
-        PatchSetApproval.builder()
-            .postSubmit(false)
-            .granted(Instant.now())
-            .key(PatchSetApproval.key(psId, approver, LabelId.create("Code-Review")))
-            .value(value)
-            .build();
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo.newObjectReader())) {
       return ApprovalContext.create(
           changeNotes,
-          approval,
+          psId,
+          approver,
+          projectCache.get(project).get().getLabelTypes().byLabel("Code-Review").get(),
+          (short) value,
           changeNotes.getPatchSets().get(newPsId),
           changeKind,
           /* isMerge= */ false,
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index ab84e70..dd300058 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -44,6 +44,7 @@
   private static final ImmutableList<String> COMMON_ROOT_COMMANDS =
       ImmutableList.of(
           "apropos",
+          "check-project-access",
           "close-connection",
           "convert-ref-storage",
           "flush-caches",
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 0bd6554..6c629c9 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -610,6 +611,25 @@
   }
 
   @Test
+  public void createdChangeHasSpecifiedTopic() throws Exception {
+    Change.Id changeId = changeOperations.newChange().topic("test-topic").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.topic).isEqualTo("test-topic");
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedApprovals() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().approvals(ImmutableMap.of("Code-Review", (short) 1)).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.labels).hasSize(1);
+    assertThat(change.labels.get("Code-Review").recommended._accountId)
+        .isEqualTo(change.owner._accountId);
+  }
+
+  @Test
   public void createdChangeHasSpecifiedCommitMessage() throws Exception {
     Change.Id changeId =
         changeOperations
diff --git a/javatests/com/google/gerrit/entities/LabelTypeTest.java b/javatests/com/google/gerrit/entities/LabelTypeTest.java
index f31f2c9..fcbe386 100644
--- a/javatests/com/google/gerrit/entities/LabelTypeTest.java
+++ b/javatests/com/google/gerrit/entities/LabelTypeTest.java
@@ -30,18 +30,6 @@
   }
 
   @Test
-  public void sortCopyValues() {
-    LabelValue v0 = LabelValue.create((short) 0, "Zero");
-    LabelValue v1 = LabelValue.create((short) 1, "One");
-    LabelValue v2 = LabelValue.create((short) 2, "Two");
-    LabelType types =
-        LabelType.builder("Label", ImmutableList.of(v2, v0, v1))
-            .setCopyValues(ImmutableList.of((short) 2, (short) 0, (short) 1))
-            .build();
-    assertThat(types.getCopyValues()).containsExactly((short) 0, (short) 1, (short) 2).inOrder();
-  }
-
-  @Test
   public void insertMissingLabelValues() {
     LabelValue v0 = LabelValue.create((short) 0, "Zero");
     LabelValue v2 = LabelValue.create((short) 2, "Two");
diff --git a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
index dd594d6..36641fe 100644
--- a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -72,16 +72,6 @@
       this.fs = fs;
     }
 
-    private Servlet(
-        FileSystem fs,
-        Cache<Path, Resource> cache,
-        boolean refresh,
-        boolean cacheOnClient,
-        int cacheFileSizeLimitBytes) {
-      super(cache, refresh, cacheOnClient, cacheFileSizeLimitBytes);
-      this.fs = fs;
-    }
-
     @Override
     protected Path getResourcePath(String pathInfo) {
       return fs.getPath("/" + CharMatcher.is('/').trimLeadingFrom(pathInfo));
diff --git a/javatests/com/google/gerrit/index/BUILD b/javatests/com/google/gerrit/index/BUILD
index 861e768..7cb86e7 100644
--- a/javatests/com/google/gerrit/index/BUILD
+++ b/javatests/com/google/gerrit/index/BUILD
@@ -9,6 +9,7 @@
         "//antlr3:query_parser",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/index/query/testing",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeTest.java b/javatests/com/google/gerrit/index/IndexUpgradeTest.java
new file mode 100644
index 0000000..724964b
--- /dev/null
+++ b/javatests/com/google/gerrit/index/IndexUpgradeTest.java
@@ -0,0 +1,67 @@
+// 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.index;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import java.util.Collection;
+import java.util.Map.Entry;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+/** Validates index upgrades; see {@link IndexUpgradeValidator} for details. */
+@RunWith(Parameterized.class)
+public class IndexUpgradeTest {
+  /** This is the first version to which {@link IndexUpgradeValidator} is applied. */
+  private static final ImmutableMap<Class<? extends SchemaDefinitions<?>>, Integer>
+      ENFORCE_UPDATE_RESTRICTIONS_FROM_VERSION =
+          ImmutableMap.of(
+              AccountSchemaDefinitions.class, 12,
+              ChangeSchemaDefinitions.class, 78,
+              GroupSchemaDefinitions.class, 8,
+              ProjectSchemaDefinitions.class, 4);
+
+  @Parameter public SchemaDefinitions<?> schemaDefinitions;
+
+  @Parameters(name = "schema: {0}")
+  public static Collection<SchemaDefinitions<?>> indexes() {
+    return ImmutableList.of(
+        AccountSchemaDefinitions.INSTANCE,
+        ChangeSchemaDefinitions.INSTANCE,
+        GroupSchemaDefinitions.INSTANCE,
+        ProjectSchemaDefinitions.INSTANCE);
+  }
+
+  @Test
+  public void upgradesValid() {
+    Schema<?> previousSchema = null;
+    for (Entry<Integer, ? extends Schema<?>> entry : schemaDefinitions.getSchemas().entrySet()) {
+      Schema<?> schema = entry.getValue();
+      if (previousSchema != null
+          && schema.getVersion()
+              >= ENFORCE_UPDATE_RESTRICTIONS_FROM_VERSION.get(schemaDefinitions.getClass())) {
+        IndexUpgradeValidator.assertValid(previousSchema, schema);
+      }
+      previousSchema = schema;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeValidator.java b/javatests/com/google/gerrit/index/IndexUpgradeValidator.java
new file mode 100644
index 0000000..5bcc6ff
--- /dev/null
+++ b/javatests/com/google/gerrit/index/IndexUpgradeValidator.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.index;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Sets.SetView;
+import org.junit.Ignore;
+
+/**
+ * Validates index upgrades to enforce the following constraints: Upgrades may only add or remove
+ * fields. They may not do both, and may not change field types.
+ */
+@Ignore
+public class IndexUpgradeValidator {
+
+  public static void assertValid(Schema<?> previousSchema, Schema<?> schema) {
+    assertValid(previousSchema.getSchemaFields(), schema.getSchemaFields(), schema.getVersion());
+    assertValid(previousSchema.getIndexFields(), schema.getIndexFields(), schema.getVersion());
+  }
+
+  private static void assertValid(
+      ImmutableMap<String, ?> previousSchemaFields,
+      ImmutableMap<String, ?> schemaFields,
+      int schemaVersion) {
+    SetView<String> addedFields =
+        Sets.difference(schemaFields.keySet(), previousSchemaFields.keySet());
+    SetView<String> removedFields =
+        Sets.difference(previousSchemaFields.keySet(), schemaFields.keySet());
+    SetView<String> keptFields =
+        Sets.intersection(previousSchemaFields.keySet(), schemaFields.keySet());
+    assertWithMessage(
+            "Schema upgrade to version "
+                + schemaVersion
+                + " may either add or remove fields, but not both")
+        .that(addedFields.isEmpty() || removedFields.isEmpty())
+        .isTrue();
+    ImmutableList<String> modifiedFields =
+        keptFields.stream()
+            .filter(fieldName -> previousSchemaFields.get(fieldName) != schemaFields.get(fieldName))
+            .collect(toImmutableList());
+    assertWithMessage("Fields may not be modified (create a new field instead)")
+        .that(modifiedFields)
+        .isEmpty();
+  }
+
+  private IndexUpgradeValidator() {}
+}
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java b/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
new file mode 100644
index 0000000..c2caff8
--- /dev/null
+++ b/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
@@ -0,0 +1,89 @@
+// 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.index;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.index.SchemaUtil.schema;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests {@link IndexUpgradeValidator}. */
+@RunWith(JUnit4.class)
+public class IndexUpgradeValidatorTest {
+
+  // TODO(mariasavtchouk): adopt this test to verity IndexedFields follow the same constraints as
+  // SchemaFields.
+  @Test
+  public void valid() {
+    IndexUpgradeValidator.assertValid(schema(1, ChangeField.ID), schema(2, ChangeField.ID));
+    IndexUpgradeValidator.assertValid(
+        schema(1, ChangeField.ID), schema(2, ChangeField.ID, ChangeField.OWNER));
+    IndexUpgradeValidator.assertValid(
+        schema(1, ChangeField.ID),
+        schema(2, ChangeField.ID, ChangeField.OWNER, ChangeField.COMMITTER));
+  }
+
+  @Test
+  public void invalid_addAndRemove() {
+    AssertionError e =
+        assertThrows(
+            AssertionError.class,
+            () ->
+                IndexUpgradeValidator.assertValid(
+                    schema(1, ChangeField.ID), schema(2, ChangeField.OWNER)));
+    assertThat(e)
+        .hasMessageThat()
+        .contains("Schema upgrade to version 2 may either add or remove fields, but not both");
+  }
+
+  @Test
+  public void invalid_modify() {
+    // Change value type from String to Integer.
+    FieldDef<ChangeData, Integer> ID_MODIFIED =
+        new FieldDef.Builder<>(FieldType.INTEGER, ChangeQueryBuilder.FIELD_CHANGE_ID)
+            .build(cd -> 42);
+    AssertionError e =
+        assertThrows(
+            AssertionError.class,
+            () ->
+                IndexUpgradeValidator.assertValid(
+                    schema(1, ChangeField.ID), schema(2, ID_MODIFIED)));
+    assertThat(e).hasMessageThat().contains("Fields may not be modified");
+    assertThat(e).hasMessageThat().contains(ChangeQueryBuilder.FIELD_CHANGE_ID);
+  }
+
+  @Test
+  public void invalid_modify_referenceEquality() {
+    // Comparison uses Object.equals(), i.e. reference equality.
+    Getter<ChangeData, String> getter = cd -> cd.change().getKey().get();
+    FieldDef<ChangeData, String> ID_1 =
+        new FieldDef.Builder<>(FieldType.PREFIX, ChangeQueryBuilder.FIELD_CHANGE_ID).build(getter);
+    FieldDef<ChangeData, String> ID_2 =
+        new FieldDef.Builder<>(FieldType.PREFIX, ChangeQueryBuilder.FIELD_CHANGE_ID).build(getter);
+    AssertionError e =
+        assertThrows(
+            AssertionError.class,
+            () -> IndexUpgradeValidator.assertValid(schema(1, ID_1), schema(2, ID_2)));
+    assertThat(e).hasMessageThat().contains("Fields may not be modified");
+    assertThat(e).hasMessageThat().contains(ChangeQueryBuilder.FIELD_CHANGE_ID);
+  }
+}
diff --git a/javatests/com/google/gerrit/index/SchemaUtilTest.java b/javatests/com/google/gerrit/index/SchemaUtilTest.java
index 698e00a..a92ee0c 100644
--- a/javatests/com/google/gerrit/index/SchemaUtilTest.java
+++ b/javatests/com/google/gerrit/index/SchemaUtilTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.index;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.SchemaUtil.getNameParts;
 import static com.google.gerrit.index.SchemaUtil.getPersonParts;
 import static com.google.gerrit.index.SchemaUtil.schema;
@@ -23,17 +24,37 @@
 import java.util.Map;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
 
+@RunWith(JUnit4.class)
 public class SchemaUtilTest {
+
+  private static final FieldDef<String, String> TEST_DEF =
+      exact("test_id").stored().build(id -> id);
+
+  private static final FieldDef<String, String> OTHER_TEST_DEF =
+      exact("other_test_id").stored().build(id -> id);
+
+  private static final IndexedField<String, String> TEST_FIELD =
+      IndexedField.<String>stringBuilder("TestId").build(a -> a);
+
+  private static final IndexedField<String, String> TEST_FIELD_DUPLICATE_NAME =
+      IndexedField.<String>stringBuilder(TEST_DEF.getName()).build(a -> a);
+
+  private static final IndexedField<String, String>.SearchSpec TEST_FIELD_SPEC =
+      TEST_FIELD.exact(TEST_DEF.getName());
+
   static class TestSchemas {
-    static final Schema<String> V1 = schema();
-    static final Schema<String> V2 = schema();
-    static Schema<String> V3 = schema(); // Not final, ignored.
-    private static final Schema<String> V4 = schema();
+
+    static final Schema<String> V1 = schema(/* version= */ 1);
+    static final Schema<String> V2 = schema(/* version= */ 2);
+    static Schema<String> V3 = schema(V2); // Not final, ignored.
+    private static final Schema<String> V4 = schema(V3);
 
     // Ignored.
-    static Schema<String> V10 = schema();
-    final Schema<String> V11 = schema();
+    static Schema<String> V10 = schema(/* version= */ 10);
+    final Schema<String> V11 = schema(V10);
   }
 
   @Test
@@ -49,6 +70,14 @@
   }
 
   @Test
+  public void schemaVersion_incrementedOnVersionUpgrades() {
+    Schema<String> initialSchemaVersion = schema(/* version= */ 1);
+    Schema<String> schemaVersionUpgrade = schema(initialSchemaVersion);
+    assertThat(initialSchemaVersion.getVersion()).isEqualTo(1);
+    assertThat(schemaVersionUpgrade.getVersion()).isEqualTo(2);
+  }
+
+  @Test
   public void getPersonPartsExtractsParts() {
     // PersonIdent allows empty email, which should be extracted as the empty
     // string. However, it converts empty names to null internally.
@@ -77,4 +106,169 @@
     assertThat(getNameParts("foO-bAr_Baz a.b@c/d"))
         .containsExactly("foo", "bar", "baz", "a", "b", "c", "d");
   }
+
+  @Test
+  public void canAddFieldSpecAndFieldDef() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .add(OTHER_TEST_DEF)
+            .build();
+
+    assertThat(schema0.hasField(TEST_FIELD_SPEC)).isTrue();
+    assertThat(schema0.hasField(OTHER_TEST_DEF)).isTrue();
+    assertThat(schema0.getIndexFields().values()).contains(TEST_FIELD);
+  }
+
+  @Test
+  public void canRemoveIndexedField() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .build();
+
+    Schema<String> schema1 =
+        new Schema.Builder<String>()
+            .add(schema0)
+            .remove(TEST_FIELD_SPEC)
+            .remove(TEST_FIELD)
+            .build();
+    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isFalse();
+    assertThat(schema1.getIndexFields().values()).doesNotContain(TEST_FIELD);
+  }
+
+  @Test
+  public void canRemoveSearchSpec() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .build();
+
+    Schema<String> schema1 =
+        new Schema.Builder<String>().add(schema0).remove(TEST_FIELD_SPEC).build();
+    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isFalse();
+    assertThat(schema1.getIndexFields().values()).contains(TEST_FIELD);
+  }
+
+  @Test
+  public void canRemoveFieldDef() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .add(OTHER_TEST_DEF)
+            .build();
+
+    Schema<String> schema1 =
+        new Schema.Builder<String>().add(schema0).remove(OTHER_TEST_DEF).build();
+    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isTrue();
+    assertThat(schema1.hasField(OTHER_TEST_DEF)).isFalse();
+    assertThat(schema1.getIndexFields().values()).contains(TEST_FIELD);
+  }
+
+  @Test
+  public void addSearchWithoutStoredField_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> new Schema.Builder<String>().version(0).addSearchSpecs(TEST_FIELD_SPEC).build());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("test_id spec can only be added to the schema that contains TestId field");
+  }
+
+  @Test
+  public void addDuplicateIndexField_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD)
+                    .addIndexedFields(TEST_FIELD)
+                    .build());
+    assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: TestId");
+  }
+
+  @Test
+  public void addDuplicateSearchSpec_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD)
+                    .addSearchSpecs(TEST_FIELD_SPEC)
+                    .addSearchSpecs(TEST_FIELD_SPEC)
+                    .build());
+    assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: test_id");
+  }
+
+  @Test
+  public void addFieldDefWithDuplicateSearchName_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD)
+                    .addSearchSpecs(TEST_FIELD_SPEC)
+                    .add(TEST_DEF)
+                    .build());
+    assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: test_id");
+  }
+
+  @Test
+  public void addFieldDefWithDuplicateFieldName_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD_DUPLICATE_NAME)
+                    .add(TEST_DEF)
+                    .build());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("DuplicateKeys found [test_id], indexFields:[test_id], schemaFields: [test_id]");
+  }
+
+  @Test
+  public void removeFieldWithExistingSearchSpec_disallowed() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .build();
+
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> new Schema.Builder<String>().add(schema0).remove(TEST_FIELD).build());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Field TestId can be only removed from schema after all of its searches are removed.");
+
+    Schema<String> schema1 =
+        new Schema.Builder<String>()
+            .add(schema0)
+            .remove(TEST_FIELD_SPEC)
+            .remove(TEST_FIELD)
+            .build();
+    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isFalse();
+    assertThat(schema1.getIndexFields().values()).doesNotContain(TEST_FIELD);
+  }
 }
diff --git a/javatests/com/google/gerrit/index/query/QueryParserTest.java b/javatests/com/google/gerrit/index/query/QueryParserTest.java
index 2ff56a8..268c388 100644
--- a/javatests/com/google/gerrit/index/query/QueryParserTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryParserTest.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.index.query.QueryParser.EXACT_PHRASE;
 import static com.google.gerrit.index.query.QueryParser.FIELD_NAME;
 import static com.google.gerrit.index.query.QueryParser.NOT;
+import static com.google.gerrit.index.query.QueryParser.OR;
 import static com.google.gerrit.index.query.QueryParser.SINGLE_WORD;
 import static com.google.gerrit.index.query.QueryParser.parse;
 import static com.google.gerrit.index.query.testing.TreeSubject.assertThat;
@@ -244,6 +245,36 @@
   }
 
   @Test
+  public void upperCaseParsedAsOperators() throws Exception {
+    Tree r = parse("project:foo:bar AND file:baz");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("project:foo:bar OR file:baz");
+    assertThat(r).hasType(OR);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("NOT project:foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+  }
+
+  @Test
+  public void lowerCaseParsedAsOperators() throws Exception {
+    Tree r = parse("project:foo:bar and file:baz");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("project:foo:bar or file:baz");
+    assertThat(r).hasType(OR);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("not project:foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+  }
+
+  @Test
   public void fieldNameWithNot() throws Exception {
     Tree r = parse("-foo:bar");
     assertThat(r).hasType(NOT);
diff --git a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 1e3063e..5f062be 100644
--- a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
@@ -56,7 +57,8 @@
     backends.add("gerrit", new SystemGroupBackend(new Config()));
     backend =
         new UniversalGroupBackend(
-            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE),
+            new DisabledMetricMaker());
   }
 
   @Test
@@ -124,7 +126,8 @@
     backends.add("gerrit", backend);
     backend =
         new UniversalGroupBackend(
-            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE),
+            new DisabledMetricMaker());
 
     GroupMembership checker = backend.membershipsOf(member);
     assertFalse(checker.contains(REGISTERED_USERS));
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
index 1bb9784..5d420d3 100644
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.cache;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import java.util.function.Supplier;
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 b759fec..872ced9 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -38,17 +38,6 @@
           .setRefPatterns(ImmutableList.of("refs/heads/*", "refs/tags/*"))
           .setDefaultValue((short) 1)
           .setCopyCondition("is:ANY")
-          .setCopyAnyScore(!LabelType.DEF_COPY_ANY_SCORE)
-          .setCopyMaxScore(!LabelType.DEF_COPY_MAX_SCORE)
-          .setCopyMinScore(!LabelType.DEF_COPY_MIN_SCORE)
-          .setCopyAllScoresIfListOfFilesDidNotChange(
-              !LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
-          .setCopyAllScoresOnMergeFirstParentUpdate(
-              !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
-          .setCopyAllScoresOnTrivialRebase(!LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
-          .setCopyAllScoresIfNoCodeChange(!LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
-          .setCopyAllScoresIfNoChange(!LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
-          .setCopyValues(ImmutableList.of((short) 0, (short) 1))
           .setMaxNegative((short) -1)
           .setMaxPositive((short) 1)
           .build();
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
index 4705c55..93f18d6 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -50,4 +51,12 @@
   public void roundTrip_withErrorMessage() throws Exception {
     assertThat(deserialize(serialize(r2))).isEqualTo(r2);
   }
+
+  @Test
+  public void deserializeUnknownStatus() throws Exception {
+    SubmitRequirementExpressionResultProto proto =
+        serialize(r1).toBuilder().setStatus("unknown").build();
+    assertThat(deserialize(proto).status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
index 7b8db25..7e71a3e 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
@@ -191,6 +191,18 @@
   }
 
   @Test
+  public void submitRequirementExpressionResult_deserializeUnrecognizedStatus() throws Exception {
+    // If the status field has an unrecognized value while deserialization, we set the status field
+    // to ERROR.
+    String serial = srExpResultSerial.replace("FAIL", "UNKNOWN");
+    SubmitRequirementExpressionResult entity =
+        srExpResult.toBuilder().status(SubmitRequirementExpressionResult.Status.ERROR).build();
+    TypeAdapter<SubmitRequirementExpressionResult> adapter =
+        SubmitRequirementExpressionResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(serial)).isEqualTo(entity);
+  }
+
+  @Test
   public void submitRequirementResult_serialize() throws Exception {
     assertThat(SubmitRequirementResult.typeAdapter(gson).toJson(srReqResult))
         .isEqualTo(srReqResultSerial);
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index b048163..6cbbd26 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -21,7 +21,7 @@
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.junit.Assert.assertEquals;
 
-import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
@@ -50,7 +50,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.List;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
@@ -149,7 +148,7 @@
 
     PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 2);
     PatchSetApproval v = psa(userId, LabelId.VERIFIED, 1);
-    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
+    assertEquals(Result.create(set(cr, v), set(), set()), norm.normalize(notes, set(cr, v)));
   }
 
   @Test
@@ -167,15 +166,15 @@
     PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 5);
     PatchSetApproval v = psa(userId, LabelId.VERIFIED, 5);
     assertEquals(
-        Result.create(list(), list(copy(cr, 2), copy(v, 1)), list()),
-        norm.normalize(notes, list(cr, v)));
+        Result.create(set(), set(copy(cr, 2), copy(v, 1)), set()),
+        norm.normalize(notes, set(cr, v)));
   }
 
   @Test
   public void emptyPermissionRangeKeepsResult() throws Exception {
     PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 1);
     PatchSetApproval v = psa(userId, LabelId.VERIFIED, 1);
-    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
+    assertEquals(Result.create(set(cr, v), set(), set()), norm.normalize(notes, set(cr, v)));
   }
 
   @Test
@@ -191,7 +190,7 @@
 
     PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 0);
     PatchSetApproval v = psa(userId, LabelId.VERIFIED, 0);
-    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
+    assertEquals(Result.create(set(cr, v), set(), set()), norm.normalize(notes, set(cr, v)));
   }
 
   private ProjectConfig loadAllProjects() throws Exception {
@@ -221,7 +220,7 @@
     return src.toBuilder().value(newValue).build();
   }
 
-  private static List<PatchSetApproval> list(PatchSetApproval... psas) {
-    return ImmutableList.copyOf(psas);
+  private static ImmutableSet<PatchSetApproval> set(PatchSetApproval... psas) {
+    return ImmutableSet.copyOf(psas);
   }
 }
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index 3c9a355..c2b67c3 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -593,6 +593,24 @@
                 .build());
   }
 
+  @Test
+  public void projectHeadUpdatedEvent() {
+    ProjectHeadUpdatedEvent event = new ProjectHeadUpdatedEvent();
+    event.projectName = PROJECT;
+    event.oldHead = "refs/heads/master";
+    event.newHead = REF;
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put("projectName", PROJECT)
+                .put("oldHead", "refs/heads/master")
+                .put("newHead", REF)
+                .put("type", "project-head-updated")
+                .put("eventCreatedOn", TS1)
+                .build());
+  }
+
   private Supplier<AccountAttribute> newAccount(String name) {
     AccountAttribute account = new AccountAttribute();
     account.name = name;
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
index fa5c47f..42a80c3 100644
--- a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
@@ -248,4 +248,30 @@
     assertThat(edit).internalEdits().element(0).isReplace(6, 12, 6, 9);
     assertThat(edit).internalEdits().element(1).isReplace(18, 10, 15, 7);
   }
+
+  @Test
+  public void overlappingChangesInMiddleOfLineRaisesException() throws Exception {
+    FixReplacement firstReplace =
+        new FixReplacement("path", new Range(2, 0, 3, 5), "First modification\n");
+    FixReplacement secondReplace =
+        new FixReplacement("path", new Range(3, 4, 4, 0), "Some other modified content\n");
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            FixCalculator.calculateFix(
+                multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+  }
+
+  @Test
+  public void overlappingChangesInBeginningOfLineRaisesException() throws Exception {
+    FixReplacement firstReplace =
+        new FixReplacement("path", new Range(2, 0, 3, 1), "First modification\n");
+    FixReplacement secondReplace =
+        new FixReplacement("path", new Range(3, 0, 4, 0), "Some other modified content\n");
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            FixCalculator.calculateFix(
+                multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+  }
 }
diff --git a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
index 29dbe58..6bdf80f 100644
--- a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
+++ b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.List;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -60,7 +59,8 @@
       Ref ref2 = createRefWithEmptyTreeCommit(usersRepo, 1, 1000002);
 
       DeleteZombieCommentsRefs clean =
-          new DeleteZombieCommentsRefs(new AllUsersName("All-Users"), repoManager, null);
+          new DeleteZombieCommentsRefs(
+              new AllUsersName("All-Users"), repoManager, null, (msg) -> {});
       clean.execute();
 
       /* Check that ref1 still exists, and ref2 is deleted */
@@ -81,7 +81,7 @@
       int cleanupPercentage = 50;
       DeleteZombieCommentsRefs clean =
           new DeleteZombieCommentsRefs(
-              new AllUsersName("All-Users"), repoManager, cleanupPercentage);
+              new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {});
       clean.execute();
 
       /* ref1 not deleted, ref2 deleted, ref3 not deleted because of the clean percentage */
@@ -101,7 +101,7 @@
       cleanupPercentage = 70;
       clean =
           new DeleteZombieCommentsRefs(
-              new AllUsersName("All-Users"), repoManager, cleanupPercentage);
+              new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {});
 
       clean.execute();
 
@@ -137,7 +137,8 @@
           .isEqualTo(goodRefs.size() + badRefs.size());
 
       DeleteZombieCommentsRefs clean =
-          new DeleteZombieCommentsRefs(new AllUsersName("All-Users"), repoManager, null);
+          new DeleteZombieCommentsRefs(
+              new AllUsersName("All-Users"), repoManager, null, (msg) -> {});
       clean.execute();
 
       assertThat(
@@ -204,14 +205,11 @@
     return repo.exactRef(refName);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private static ObjectId createCommit(Repository repo, ObjectId treeId, ObjectId parentCommit)
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), Date.from(TimeUtil.now()));
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(committer);
diff --git a/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java b/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java
index 2bc6b92..c09d8d5 100644
--- a/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java
+++ b/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java
@@ -60,6 +60,7 @@
   private static class TestRepositoryWithRefCounting extends Repository {
     private int refCounter;
 
+    @SuppressWarnings("resource")
     static TestRepositoryWithRefCounting createWithBranch(String branchName) throws Exception {
       InMemoryRepository.Builder builder =
           new InMemoryRepository.Builder()
@@ -196,6 +197,7 @@
         }
 
         @Override
+        @Deprecated
         public Map<String, Ref> getRefs(String prefix) throws IOException {
           checkIsOpen();
           return refDatabase.getRefs(prefix);
diff --git a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
index 6792703..91d5596 100644
--- a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
@@ -29,10 +29,9 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -56,7 +55,7 @@
   // instead coming up with a replacement interface for
   // VersionedMetaData/BatchMetaDataUpdate/MetaDataUpdate that is easier to use correctly.
 
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
   private static final String DEFAULT_REF = "refs/meta/config";
 
   private Project.NameKey project;
@@ -221,13 +220,10 @@
     return u;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private CommitBuilder newCommitBuilder() {
     CommitBuilder cb = new CommitBuilder();
     PersonIdent author =
-        new PersonIdent("J. Author", "author@example.com", Date.from(TimeUtil.now()), TZ);
+        new PersonIdent("J. Author", "author@example.com", TimeUtil.now(), ZONE_ID);
     cb.setAuthor(author);
     cb.setCommitter(
         new PersonIdent(
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index 54407ca..11f3528 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -31,9 +31,8 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.Date;
+import java.time.ZoneId;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -45,7 +44,7 @@
 
 @Ignore
 public class AbstractGroupTest {
-  protected static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  protected static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
   protected static final String SERVER_ID = "server-id";
   protected static final String SERVER_NAME = "Gerrit Server";
   protected static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
@@ -60,16 +59,13 @@
   protected Account.Id userId;
   protected PersonIdent userIdent;
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Before
   public void abstractGroupTestSetUp() throws Exception {
     allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
     repoManager = new InMemoryRepositoryManager();
     allUsersRepo = repoManager.createRepository(allUsersName);
     serverAccountId = Account.id(SERVER_ACCOUNT_NUMBER);
-    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, Date.from(TimeUtil.now()), TZ);
+    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
     userId = Account.id(USER_ACCOUNT_NUMBER);
     userIdent = newPersonIdent(userId, serverIdent);
   }
@@ -79,15 +75,12 @@
     allUsersRepo.close();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   protected Instant getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
     try (RevWalk rw = new RevWalk(allUsersRepo)) {
       Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(uuid));
       return ref == null
           ? null
-          : rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhen().toInstant();
+          : rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhenAsInstant();
     }
   }
 
@@ -116,11 +109,8 @@
     return md;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   protected static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, Date.from(TimeUtil.now()), TZ);
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
   }
 
   protected static PersonIdent newPersonIdent(Account.Id id, PersonIdent ident) {
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index a8f9ff5..8c19732 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -40,9 +40,7 @@
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneId;
-import java.util.Date;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -64,7 +62,7 @@
   private final AccountGroup.Id groupId = AccountGroup.id(123);
   private final AuditLogFormatter auditLogFormatter =
       AuditLogFormatter.createBackedBy(ImmutableSet.of(), ImmutableSet.of(), "server-id");
-  private final TimeZone timeZone = TimeZone.getTimeZone("America/Los_Angeles");
+  private final ZoneId zoneId = ZoneId.of("America/Los_Angeles");
 
   @Before
   public void setUp() throws Exception {
@@ -1044,9 +1042,6 @@
     assertThat(revCommit.getCommitTime()).isEqualTo(createdOnAsSecondsSinceEpoch);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void timestampOfCommitterMatchesSpecifiedCreatedOnOfNewGroup() throws Exception {
     Instant committerTimestamp =
@@ -1068,23 +1063,18 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent(
-            "Jane", "Jane@gerritcodereview.com", Date.from(committerTimestamp), timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getCommitterIdent().getWhen().getTime())
-        .isEqualTo(createdOn.toEpochMilli());
-    assertThat(revCommit.getCommitterIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getCommitterIdent().getWhenAsInstant()).isEqualTo(createdOn);
+    assertThat(revCommit.getCommitterIdent().getZoneId().getRules().getOffset(createdOn))
+        .isEqualTo(zoneId.getRules().getOffset(createdOn));
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void timestampOfAuthorMatchesSpecifiedCreatedOnOfNewGroup() throws Exception {
     Instant authorTimestamp = toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
@@ -1105,16 +1095,16 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", Date.from(authorTimestamp), timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getAuthorIdent().getWhen().getTime()).isEqualTo(createdOn.toEpochMilli());
-    assertThat(revCommit.getAuthorIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getAuthorIdent().getWhenAsInstant()).isEqualTo(createdOn);
+    assertThat(revCommit.getAuthorIdent().getZoneId().getRules().getOffset(createdOn))
+        .isEqualTo(zoneId.getRules().getOffset(createdOn));
   }
 
   @Test
@@ -1149,9 +1139,6 @@
   }
 
   @Test
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public void timestampOfCommitterMatchesSpecifiedUpdatedOnOfUpdatedGroup() throws Exception {
     Instant committerTimestamp =
         toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
@@ -1167,24 +1154,19 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent(
-            "Jane", "Jane@gerritcodereview.com", Date.from(committerTimestamp), timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getCommitterIdent().getWhen().getTime())
-        .isEqualTo(updatedOn.toEpochMilli());
-    assertThat(revCommit.getCommitterIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getCommitterIdent().getWhenAsInstant()).isEqualTo(updatedOn);
+    assertThat(revCommit.getCommitterIdent().getZoneId().getRules().getOffset(updatedOn))
+        .isEqualTo(zoneId.getRules().getOffset(updatedOn));
   }
 
   @Test
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public void timestampOfAuthorMatchesSpecifiedUpdatedOnOfUpdatedGroup() throws Exception {
     Instant authorTimestamp = toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
     Instant updatedOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
@@ -1199,16 +1181,16 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", Date.from(authorTimestamp), timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getAuthorIdent().getWhen().getTime()).isEqualTo(updatedOn.toEpochMilli());
-    assertThat(revCommit.getAuthorIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getAuthorIdent().getWhenAsInstant()).isEqualTo(updatedOn);
+    assertThat(revCommit.getAuthorIdent().getZoneId().getRules().getOffset(updatedOn))
+        .isEqualTo(zoneId.getRules().getOffset(updatedOn));
   }
 
   @Test
@@ -1557,13 +1539,9 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private MetaDataUpdate createMetaDataUpdate() {
     PersonIdent serverIdent =
-        new PersonIdent(
-            "Gerrit Server", "noreply@gerritcodereview.com", Date.from(TimeUtil.now()), timeZone);
+        new PersonIdent("Gerrit Server", "noreply@gerritcodereview.com", TimeUtil.now(), zoneId);
 
     MetaDataUpdate metaDataUpdate =
         new MetaDataUpdate(
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index afc56ff..9d8f260 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -44,11 +44,10 @@
 import com.google.gerrit.truth.ListSubject;
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.List;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -72,7 +71,7 @@
 public class GroupNameNotesTest {
   private static final String SERVER_NAME = "Gerrit Server";
   private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
 
   private final AccountGroup.UUID groupUuid = AccountGroup.uuid("users-XYZ");
   private final AccountGroup.NameKey groupName = AccountGroup.nameKey("users");
@@ -558,11 +557,8 @@
     return GroupReference.create(AccountGroup.uuid(name + "-" + id), name);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, Date.from(TimeUtil.now()), TZ);
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
   }
 
   private static ObjectId getNoteKey(GroupReference g) {
diff --git a/javatests/com/google/gerrit/server/index/IndexedFieldTest.java b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
new file mode 100644
index 0000000..6a62ed1
--- /dev/null
+++ b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
@@ -0,0 +1,176 @@
+// 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.index;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_INTEGER_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_INTEGER_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_LONG_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_LONG_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_PROTO_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STORED_BYTE_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STORED_BYTE_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STORED_PROTO_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STRING_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STRING_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.LONG_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.LONG_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.STORED_BYTE_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.STORED_BYTE_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.STORED_PROTO_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.STORED_PROTO_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.STRING_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.TIMESTAMP_FIELD_SPEC;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.index.IndexedField;
+import com.google.gerrit.index.StoredValue;
+import com.google.gerrit.index.testing.FakeStoredValue;
+import com.google.gerrit.index.testing.TestIndexedFields;
+import com.google.gerrit.index.testing.TestIndexedFields.TestIndexedData;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Protos;
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.sql.Timestamp;
+import java.util.Map.Entry;
+import org.junit.Test;
+import org.junit.experimental.theories.DataPoints;
+import org.junit.experimental.theories.FromDataPoints;
+import org.junit.experimental.theories.Theories;
+import org.junit.experimental.theories.Theory;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link com.google.gerrit.index.IndexedField} */
+@SuppressWarnings("serial")
+@RunWith(Theories.class)
+public class IndexedFieldTest {
+
+  @DataPoints("nonProtoTypes")
+  public static final ImmutableList<
+          Entry<IndexedField<TestIndexedData, ?>.SearchSpec, Serializable>>
+      fieldToStoredValue =
+          new ImmutableMap.Builder<IndexedField<TestIndexedData, ?>.SearchSpec, Serializable>()
+              .put(INTEGER_FIELD_SPEC, 123456)
+              .put(INTEGER_RANGE_FIELD_SPEC, 123456)
+              .put(ITERABLE_INTEGER_FIELD_SPEC, ImmutableList.of(123456, 654321))
+              .put(ITERABLE_INTEGER_RANGE_FIELD_SPEC, ImmutableList.of(123456, 654321))
+              .put(LONG_FIELD_SPEC, 123456L)
+              .put(LONG_RANGE_FIELD_SPEC, 123456L)
+              .put(ITERABLE_LONG_FIELD_SPEC, ImmutableList.of(123456L, 654321L))
+              .put(ITERABLE_LONG_RANGE_FIELD_SPEC, ImmutableList.of(123456L, 654321L))
+              .put(TIMESTAMP_FIELD_SPEC, new Timestamp(1234567L))
+              .put(STRING_FIELD_SPEC, "123456")
+              .put(ITERABLE_STRING_FIELD_SPEC, ImmutableList.of("123456"))
+              .put(
+                  ITERABLE_STORED_BYTE_SPEC,
+                  ImmutableList.of("123456".getBytes(StandardCharsets.UTF_8)))
+              .put(STORED_BYTE_SPEC, "123456".getBytes(StandardCharsets.UTF_8))
+              .build()
+              .entrySet()
+              .asList();
+
+  @DataPoints("protoTypes")
+  public static final ImmutableList<
+          Entry<IndexedField<TestIndexedData, ?>.SearchSpec, Serializable>>
+      protoFieldToStoredValue =
+          ImmutableMap.<IndexedField<TestIndexedData, ?>.SearchSpec, Serializable>of(
+                  STORED_PROTO_FIELD_SPEC,
+                  TestIndexedFields.createChangeProto(12345),
+                  ITERABLE_PROTO_FIELD_SPEC,
+                  ImmutableList.of(
+                      TestIndexedFields.createChangeProto(12345),
+                      TestIndexedFields.createChangeProto(54321)))
+              .entrySet()
+              .asList();
+
+  @Theory
+  public void testSetIfPossible(
+      @FromDataPoints("nonProtoTypes")
+          Entry<IndexedField<TestIndexedData, StoredValue>.SearchSpec, StoredValue>
+              fieldToStoredValue) {
+    Object docValue = fieldToStoredValue.getValue();
+    IndexedField<TestIndexedData, StoredValue>.SearchSpec searchSpec = fieldToStoredValue.getKey();
+    StoredValue storedValue = new FakeStoredValue(fieldToStoredValue.getValue());
+    TestIndexedData testIndexedData = new TestIndexedData();
+    searchSpec.setIfPossible(testIndexedData, storedValue);
+    assertThat(testIndexedData.getTestField()).isEqualTo(docValue);
+  }
+
+  @Test
+  public void testSetIfPossible_protoFromBytes() {
+    Entities.Change changeProto = TestIndexedFields.createChangeProto(12345);
+    StoredValue storedValue = new FakeStoredValue(Protos.toByteArray(changeProto));
+    TestIndexedData testIndexedData = new TestIndexedData();
+    STORED_PROTO_FIELD_SPEC.setIfPossible(testIndexedData, storedValue);
+    assertThat(testIndexedData.getTestField()).isEqualTo(changeProto);
+  }
+
+  @Test
+  public void testSetIfPossible_iterableProtoFromIterableBytes() {
+    ImmutableList<Entities.Change> changeProtos =
+        ImmutableList.of(
+            TestIndexedFields.createChangeProto(12345), TestIndexedFields.createChangeProto(54321));
+    StoredValue storedValue =
+        new FakeStoredValue(
+            changeProtos.stream()
+                .map(proto -> Protos.toByteArray(proto))
+                .collect(toImmutableList()));
+    TestIndexedData testIndexedData = new TestIndexedData();
+    ITERABLE_STORED_PROTO_FIELD.setIfPossible(testIndexedData, storedValue);
+    assertThat(testIndexedData.getTestField()).isEqualTo(changeProtos);
+  }
+
+  @Theory
+  public void testSetIfPossible_fromProto(
+      @FromDataPoints("protoTypes")
+          Entry<IndexedField<TestIndexedData, StoredValue>.SearchSpec, StoredValue>
+              fieldToStoredValue) {
+    Object docValue = fieldToStoredValue.getValue();
+    IndexedField<TestIndexedData, StoredValue>.SearchSpec searchSpec = fieldToStoredValue.getKey();
+    StoredValue storedValue = new FakeStoredValue(fieldToStoredValue.getValue(), /*isProto=*/ true);
+    TestIndexedData testIndexedData = new TestIndexedData();
+    searchSpec.setIfPossible(testIndexedData, storedValue);
+    assertThat(testIndexedData.getTestField()).isEqualTo(docValue);
+  }
+
+  @Test
+  public void test_isProtoType() {
+    assertThat(STORED_PROTO_FIELD.isProtoType()).isTrue();
+
+    assertThat(ITERABLE_STORED_PROTO_FIELD.isProtoType()).isFalse();
+    assertThat(INTEGER_FIELD.isProtoType()).isFalse();
+    assertThat(ITERABLE_STRING_FIELD.isProtoType()).isFalse();
+    assertThat(STORED_BYTE_FIELD.isProtoType()).isFalse();
+    assertThat(ITERABLE_STORED_BYTE_FIELD.isProtoType()).isFalse();
+  }
+
+  @Test
+  public void test_isProtoIterableType() {
+
+    assertThat(ITERABLE_STORED_PROTO_FIELD.isProtoIterableType()).isTrue();
+
+    assertThat(STORED_PROTO_FIELD.isProtoIterableType()).isFalse();
+    assertThat(INTEGER_FIELD.isProtoIterableType()).isFalse();
+    assertThat(ITERABLE_STRING_FIELD.isProtoIterableType()).isFalse();
+    assertThat(STORED_BYTE_FIELD.isProtoIterableType()).isFalse();
+    assertThat(ITERABLE_STORED_BYTE_FIELD.isProtoType()).isFalse();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index 4d9cb76..65eb3b8 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -39,9 +39,10 @@
     Account.Builder account = Account.builder(Account.id(1), TimeUtil.now());
     String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
     account.setMetaId(metaId);
-    List<String> values =
-        toStrings(AccountField.REF_STATE.get(AccountState.forAccount(account.build())));
-    assertThat(values).hasSize(1);
+    Iterable<byte[]> refStates =
+        (Iterable<byte[]>)
+            AccountField.REF_STATE_SPEC.get(AccountState.forAccount(account.build()));
+    List<String> values = toStrings(refStates);
     String expectedValue =
         allUsersName.get() + ":" + RefNames.refsUsers(account.id()) + ":" + metaId;
     assertThat(Iterables.getOnlyElement(values)).isEqualTo(expectedValue);
@@ -77,7 +78,7 @@
             ObjectId.fromString("483ea804e84282e15ddcdd1d15a797eb4796a760"));
     List<String> values =
         toStrings(
-            AccountField.EXTERNAL_ID_STATE.get(
+            AccountField.EXTERNAL_ID_STATE_FIELD.get(
                 AccountState.forAccount(account, ImmutableSet.of(extId1, extId2, extId3))));
     String expectedValue1 = extId1.key().sha1().name() + ":" + extId1.blobId().name();
     String expectedValue2 = extId2.key().sha1().name() + ":" + extId2.blobId().name();
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 0bdf5cd..e35941c 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -157,14 +157,16 @@
   @Test
   public void tolerateNullValuesForInsertion() {
     Project.NameKey project = Project.nameKey("project");
-    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    ChangeData cd =
+        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
     assertThat(ChangeField.ADDED.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
   }
 
   @Test
   public void tolerateNullValuesForDeletion() {
     Project.NameKey project = Project.nameKey("project");
-    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    ChangeData cd =
+        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
     assertThat(ChangeField.DELETED.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
   }
 
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index fc56a3c..f70c97a 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.index.change;
 
-import com.google.common.collect.ImmutableList;
+import static com.google.gerrit.index.SchemaUtil.schema;
+
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
@@ -28,11 +29,10 @@
 
 @Ignore
 public class FakeChangeIndex implements ChangeIndex {
-  static final Schema<ChangeData> V1 = new Schema<>(1, false, ImmutableList.of(ChangeField.STATUS));
+  static final Schema<ChangeData> V1 = schema(1, ChangeField.STATUS);
 
   static final Schema<ChangeData> V2 =
-      new Schema<>(
-          2, false, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
+      schema(2, ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED);
 
   private static class Source implements ChangeDataSource {
     private final Predicate<ChangeData> p;
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
index 78cefdf..d7a6282 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
@@ -22,7 +22,7 @@
 public class CommentSenderTest {
   private static class TestSender extends CommentSender {
     TestSender() {
-      super(null, null, null, null, null);
+      super(null, null, null, null, null, null, null);
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 222be83..e8cc6b4 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.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.approval.testing.TestPatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.config.AllUsersName;
@@ -48,11 +50,13 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.DefaultUrlFormatter.DefaultUrlFormatterModule;
 import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
+import com.google.gerrit.server.config.GerritImportedServerIds;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.NullProjectCache;
 import com.google.gerrit.server.project.ProjectCache;
@@ -67,10 +71,12 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
 import java.time.Instant;
-import java.util.Date;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import java.util.concurrent.ExecutorService;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -85,10 +91,13 @@
 @Ignore
 @RunWith(ConfigSuite.class)
 public abstract class AbstractChangeNotesTest {
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  protected static final String LOCAL_SERVER_ID = "gerrit";
+
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
 
   @ConfigSuite.Parameter public Config testConfig;
 
+  protected Account.Id changeOwnerId;
   protected Account.Id otherUserId;
   protected FakeAccountCache accountCache;
   protected IdentifiedUser changeOwner;
@@ -112,18 +121,24 @@
 
   @Inject @GerritServerId protected String serverId;
 
+  @Inject protected ExternalIdCache externalIdCache;
+
   protected Injector injector;
   private String systemTimeZone;
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Before
   public void setUpTestEnvironment() throws Exception {
+    setupTestPrerequisites();
+
+    injector = createTestInjector(LOCAL_SERVER_ID);
+    createAllUsers(injector);
+    injector.injectMembers(this);
+  }
+
+  protected void setupTestPrerequisites() throws Exception {
     setTimeForTesting();
 
-    serverIdent =
-        new PersonIdent("Gerrit Server", "noreply@gerrit.com", Date.from(TimeUtil.now()), TZ);
+    serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.now(), ZONE_ID);
     project = Project.nameKey("test-project");
     repoManager = new InMemoryRepositoryManager();
     repo = repoManager.createRepository(project);
@@ -139,60 +154,77 @@
     ou.setPreferredEmail("other@account.com");
     accountCache.put(ou.build());
     assertableFanOutExecutor = new AssertableExecutorService();
-
-    injector =
-        Guice.createInjector(
-            new FactoryModule() {
-              @Override
-              public void configure() {
-                install(new GitModule());
-
-                install(new DefaultUrlFormatterModule());
-                install(NoteDbModule.forTest());
-                bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
-                bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
-                bind(GitRepositoryManager.class).toInstance(repoManager);
-                bind(ProjectCache.class).to(NullProjectCache.class);
-                bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
-                bind(String.class)
-                    .annotatedWith(AnonymousCowardName.class)
-                    .toProvider(AnonymousCowardNameProvider.class);
-                bind(String.class)
-                    .annotatedWith(CanonicalWebUrl.class)
-                    .toInstance("http://localhost:8080/");
-                bind(Boolean.class)
-                    .annotatedWith(EnablePeerIPInReflogRecord.class)
-                    .toInstance(Boolean.FALSE);
-                bind(Realm.class).to(FakeRealm.class);
-                bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
-                bind(AccountCache.class).toInstance(accountCache);
-                bind(PersonIdent.class)
-                    .annotatedWith(GerritPersonIdent.class)
-                    .toInstance(serverIdent);
-                bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
-                bind(MetricMaker.class).to(DisabledMetricMaker.class);
-                bind(ExecutorService.class)
-                    .annotatedWith(FanOutExecutor.class)
-                    .toInstance(assertableFanOutExecutor);
-                bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
-                bind(InternalChangeQuery.class)
-                    .toProvider(
-                        () -> {
-                          throw new UnsupportedOperationException();
-                        });
-                bind(PatchSetApprovalUuidGenerator.class)
-                    .to(TestPatchSetApprovalUuidGenerator.class);
-              }
-            });
-
-    injector.injectMembers(this);
-    repoManager.createRepository(allUsers);
-    changeOwner = userFactory.create(co.id());
-    otherUser = userFactory.create(ou.id());
-    otherUserId = otherUser.getAccountId();
+    changeOwnerId = co.id();
+    otherUserId = ou.id();
     internalUser = new InternalUser();
   }
 
+  protected Injector createTestInjector(String serverId, String... importedServerIds)
+      throws Exception {
+    return createTestInjector(DisabledExternalIdCache.module(), serverId, importedServerIds);
+  }
+
+  protected Injector createTestInjector(
+      Module extraGuiceModule, String serverId, String... importedServerIds) throws Exception {
+
+    return Guice.createInjector(
+        new FactoryModule() {
+          @Override
+          public void configure() {
+            install(extraGuiceModule);
+            install(new GitModule());
+
+            install(new DefaultUrlFormatterModule());
+            install(NoteDbModule.forTest());
+            bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
+            bind(String.class).annotatedWith(GerritServerId.class).toInstance(serverId);
+            bind(new TypeLiteral<ImmutableList<String>>() {})
+                .annotatedWith(GerritImportedServerIds.class)
+                .toInstance(new ImmutableList.Builder<String>().add(importedServerIds).build());
+            bind(GitRepositoryManager.class).toInstance(repoManager);
+            bind(ProjectCache.class).to(NullProjectCache.class);
+            bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
+            bind(String.class)
+                .annotatedWith(AnonymousCowardName.class)
+                .toProvider(AnonymousCowardNameProvider.class);
+            bind(String.class)
+                .annotatedWith(CanonicalWebUrl.class)
+                .toInstance("http://localhost:8080/");
+            bind(Boolean.class)
+                .annotatedWith(EnablePeerIPInReflogRecord.class)
+                .toInstance(Boolean.FALSE);
+            bind(Realm.class).to(FakeRealm.class);
+            bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
+            bind(AccountCache.class).toInstance(accountCache);
+            bind(PersonIdent.class).annotatedWith(GerritPersonIdent.class).toInstance(serverIdent);
+            bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+            bind(MetricMaker.class).to(DisabledMetricMaker.class);
+            bind(ExecutorService.class)
+                .annotatedWith(FanOutExecutor.class)
+                .toInstance(assertableFanOutExecutor);
+            bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
+            bind(InternalChangeQuery.class)
+                .toProvider(
+                    () -> {
+                      throw new UnsupportedOperationException();
+                    });
+            bind(PatchSetApprovalUuidGenerator.class).to(TestPatchSetApprovalUuidGenerator.class);
+          }
+        });
+  }
+
+  protected void createAllUsers(Injector injector)
+      throws RepositoryCaseMismatchException, RepositoryNotFoundException {
+    AllUsersName allUsersName = injector.getInstance(AllUsersName.class);
+
+    repoManager.createRepository(allUsersName);
+
+    IdentifiedUser.GenericFactory identifiedUserFactory =
+        injector.getInstance(IdentifiedUser.GenericFactory.class);
+    changeOwner = identifiedUserFactory.create(changeOwnerId);
+    otherUser = identifiedUserFactory.create(otherUserId);
+  }
+
   private void setTimeForTesting() {
     systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
     TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -205,8 +237,12 @@
   }
 
   protected Change newChange(boolean workInProgress) throws Exception {
+    return newChange(injector, workInProgress);
+  }
+
+  protected Change newChange(Injector injector, boolean workInProgress) throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId());
-    ChangeUpdate u = newUpdateForNewChange(c, changeOwner);
+    ChangeUpdate u = newUpdate(injector, c, changeOwner, false);
     u.setChangeId(c.getKey().get());
     u.setBranch(c.getDest().branch());
     u.setWorkInProgress(workInProgress);
@@ -223,15 +259,20 @@
   }
 
   protected ChangeUpdate newUpdateForNewChange(Change c, CurrentUser user) throws Exception {
-    return newUpdate(c, user, false);
+    return newUpdate(injector, c, user, false);
   }
 
   protected ChangeUpdate newUpdate(Change c, CurrentUser user) throws Exception {
-    return newUpdate(c, user, true);
+    return newUpdate(injector, c, user, true);
   }
 
   protected ChangeUpdate newUpdate(Change c, CurrentUser user, boolean shouldExist)
       throws Exception {
+    return newUpdate(injector, c, user, shouldExist);
+  }
+
+  protected ChangeUpdate newUpdate(
+      Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
     ChangeUpdate update = TestChanges.newUpdate(injector, c, user, shouldExist);
     update.setPatchSetId(c.currentPatchSetId());
     update.setAllowWriteToNewRef(true);
@@ -242,6 +283,10 @@
     return new ChangeNotes(args, c, true, null).load();
   }
 
+  protected ChangeNotes newNotes(AbstractChangeNotes.Args cArgs, Change c) {
+    return new ChangeNotes(cArgs, c, true, null).load();
+  }
+
   protected static SubmitRecord submitRecord(
       String status, String errorMessage, SubmitRecord.Label... labels) {
     SubmitRecord rec = new SubmitRecord();
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
index 666b8fc..9445f4a 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
@@ -98,7 +98,8 @@
   private ChangeNotesParser newParser(ObjectId tip) throws Exception {
     walk.reset();
     ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
-    return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
+    return new ChangeNotesParser(
+        newChange().getId(), tip, walk, changeNoteJson, args.metrics, serverId, externalIdCache);
   }
 
   private RevCommit writeCommit(String body) throws Exception {
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index c33a87f..4543b50 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -201,10 +201,12 @@
             + "Branch: refs/heads/master\n"
             + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
             + "Patch-set: 1\n"
-            + "Copied-Label: Label1=+1 Account <1@gerrit>,Other Account <2@Gerrit>\n"
+            + "Copied-Label: Label1=+1 Account <1@gerrit>,Other Account <2@gerrit>\n"
             + "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: 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 Account <1@gerrit>,Other Account <2@gerrit>\\n"
+            + "Copied-Label: -Label1 Account <1@gerrit>\n"
             + "Subject: This is a test change\n");
 
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=X\n");
@@ -220,6 +222,7 @@
         "Update change\n\nPatch-set: 1\nCopied-Label: Label1 Other Account <2@gerrit>,Other "
             + "Account <2@gerrit>,Other Account <2@gerrit> \n");
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1 non-user\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: -Label1\n");
   }
 
   @Test
@@ -264,6 +267,14 @@
         "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");
+
+    // UUID for removals is not supported.
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: -Label1,"
+            + " 577fb248e474018276351785930358ec0450e9f7\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: -Label1,"
+            + " 577fb248e474018276351785930358ec0450e9f7 Other Account <2@gerrit>\n");
   }
 
   @Test
@@ -771,6 +782,7 @@
   private ChangeNotesParser newParser(ObjectId tip) throws Exception {
     walk.reset();
     ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
-    return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
+    return new ChangeNotesParser(
+        newChange().getId(), tip, walk, changeNoteJson, args.metrics, serverId, externalIdCache);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 3295828..976ffc8 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -744,6 +744,7 @@
                 ImmutableList.of(
                     SubmitRequirementResult.builder()
                         .legacy(Optional.of(true))
+                        .hidden(Optional.of(true))
                         .patchSetCommitId(
                             ObjectId.fromString("26e50c7d315a33a13e5cc00902781fa876bc36cd"))
                         .submitRequirement(
@@ -774,6 +775,7 @@
             .addSubmitRequirementResult(
                 SubmitRequirementResultProto.newBuilder()
                     .setLegacy(true)
+                    .setHidden(true)
                     .setCommit(
                         ObjectIdConverter.create()
                             .toByteString(
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 09c8059..4edfa8b4 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -163,7 +163,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals().all();
     assertThat(approvals).hasSize(1);
     assertThat(approvals.entries().asList().get(0).getValue().tag()).hasValue(tag2);
   }
@@ -209,7 +209,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals().all();
     assertThat(approvals).hasSize(1);
     PatchSetApproval approval = approvals.entries().asList().get(0).getValue();
     assertThat(approval.tag()).hasValue(integrationTag);
@@ -235,8 +235,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
@@ -269,7 +269,7 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals();
+    ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals().all();
     assertThat(psas).hasSize(2);
 
     PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
@@ -298,7 +298,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
     assertParsedUuid(psa);
@@ -308,7 +308,7 @@
     update.commit();
 
     notes = newNotes(c);
-    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    psa = Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) 1);
     assertParsedUuid(psa);
@@ -326,8 +326,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
@@ -354,7 +354,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId().get()).isEqualTo(1);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 1);
@@ -365,7 +365,7 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals())
+    assertThat(notes.getApprovals().all())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 psa.patchSetId(),
@@ -386,7 +386,7 @@
 
       ChangeNotes notes = newNotes(c);
       PatchSetApproval psa =
-          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+          Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
       assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
       assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
       assertThat(psa.value()).isEqualTo((short) value);
@@ -403,7 +403,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
@@ -415,7 +415,7 @@
 
     notes = newNotes(c);
     PatchSetApproval emptyPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(psa.patchSetId()));
     assertThat(emptyPsa.key()).isEqualTo(psa.key());
     assertThat(emptyPsa.value()).isEqualTo((short) 0);
     assertThat(emptyPsa.label()).isEqualTo(psa.label());
@@ -431,7 +431,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
@@ -443,7 +443,7 @@
 
     notes = newNotes(c);
     PatchSetApproval removedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(psa.patchSetId()));
     assertThat(removedPsa.key()).isEqualTo(psa.key());
     assertThat(removedPsa.value()).isEqualTo((short) 0);
     assertThat(removedPsa.label()).isEqualTo(psa.label());
@@ -458,9 +458,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
     PatchSetApproval originalPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
 
     assertThat(originalPsa.patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
@@ -474,9 +474,9 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).hasSize(1);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(1);
     PatchSetApproval removedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(removedPsa.key()).isEqualTo(originalPsa.key());
     assertThat(removedPsa.value()).isEqualTo(0);
     // Add approval with the same author, label, value to the current patch set
@@ -485,9 +485,9 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).hasSize(1);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(1);
     PatchSetApproval reAddedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
 
     assertThat(reAddedPsa.key()).isEqualTo(originalPsa.key());
     assertThat(reAddedPsa.value()).isEqualTo(originalPsa.value());
@@ -504,9 +504,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
     PatchSetApproval originalPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
 
     assertThat(originalPsa.patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(originalPsa.accountId().get()).isEqualTo(1);
@@ -521,11 +521,11 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).hasSize(2);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(2);
     PatchSetApproval postUpdateOriginalPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(originalPsa.patchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(originalPsa.patchSetId()));
     PatchSetApproval reAddedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
 
     // Same patch set approval for the original patch set is returned after the vote was re-issued
     // on the next patch set
@@ -549,8 +549,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> patchSetApprovals = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> patchSetApprovals =
+        notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(patchSetApprovals).hasSize(2);
     assertThat(
             patchSetApprovals.stream()
@@ -573,8 +574,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> patchSetApprovals = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> patchSetApprovals =
+        notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(patchSetApprovals).hasSize(2);
     assertThat(
             patchSetApprovals.stream()
@@ -606,10 +608,10 @@
 
     ChangeNotes notes1 = newNotes(c1);
     PatchSetApproval psa1 =
-        Iterables.getOnlyElement(notes1.getApprovals().get(c1.currentPatchSetId()));
+        Iterables.getOnlyElement(notes1.getApprovals().all().get(c1.currentPatchSetId()));
     ChangeNotes notes2 = newNotes(c2);
     PatchSetApproval psa2 =
-        Iterables.getOnlyElement(notes2.getApprovals().get(c2.currentPatchSetId()));
+        Iterables.getOnlyElement(notes2.getApprovals().all().get(c2.currentPatchSetId()));
     assertThat(psa1.label()).isEqualTo(psa2.label());
     assertThat(psa1.accountId()).isEqualTo(psa2.accountId());
     assertThat(psa1.value()).isEqualTo(psa2.value());
@@ -627,7 +629,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(otherUserId);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 1);
@@ -639,7 +641,7 @@
 
     notes = newNotes(c);
     PatchSetApproval removedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(psa.patchSetId()));
     assertThat(removedPsa.key()).isEqualTo(psa.key());
     assertThat(removedPsa.value()).isEqualTo((short) 0);
     assertThat(removedPsa.label()).isEqualTo(psa.label());
@@ -651,7 +653,7 @@
     update.commit();
 
     notes = newNotes(c);
-    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    psa = Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(otherUserId);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 2);
@@ -668,7 +670,7 @@
 
     ChangeNotes notes = newNotes(c);
     ImmutableList<PatchSetApproval> approvals =
-        notes.getApprovals().get(c.currentPatchSetId()).stream()
+        notes.getApprovals().all().get(c.currentPatchSetId()).stream()
             .sorted(comparing(a -> a.accountId().get()))
             .collect(toImmutableList());
     assertThat(approvals).hasSize(2);
@@ -710,7 +712,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().all().values());
     assertThat(approvals).hasSize(2);
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
@@ -753,7 +755,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().all().values());
     assertThat(approvals).hasSize(3);
     assertThat(approvals.get(0).accountId()).isEqualTo(ownerId);
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
@@ -783,7 +785,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval originalPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(originalPsa.value()).isEqualTo(2);
@@ -797,15 +799,15 @@
     addCopiedApproval(c, changeOwner, originalPsa);
 
     notes = newNotes(c);
-    assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(2);
     PatchSetApproval copiedApproval =
         Iterables.getOnlyElement(
-            notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+            notes.getApprovals().all().get(c.currentPatchSetId()).stream()
                 .filter(a -> a.copied())
                 .collect(toImmutableList()));
     PatchSetApproval nonCopiedApproval =
         Iterables.getOnlyElement(
-            notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+            notes.getApprovals().all().get(originalPsa.patchSetId()).stream()
                 .filter(a -> !a.copied())
                 .collect(toImmutableList()));
 
@@ -829,7 +831,7 @@
       update.commit();
       ChangeNotes notes = newNotes(c);
       PatchSetApproval originalPsa =
-          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+          Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
       assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
       assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
       assertThat(originalPsa.value()).isEqualTo(2);
@@ -843,15 +845,15 @@
       addCopiedApproval(c, changeOwner, originalPsa);
 
       notes = newNotes(c);
-      assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+      assertThat(notes.getApprovals().all().keySet()).hasSize(2);
       PatchSetApproval copiedApproval =
           Iterables.getOnlyElement(
-              notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+              notes.getApprovals().all().get(c.currentPatchSetId()).stream()
                   .filter(a -> a.copied())
                   .collect(toImmutableList()));
       PatchSetApproval nonCopiedApproval =
           Iterables.getOnlyElement(
-              notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+              notes.getApprovals().all().get(originalPsa.patchSetId()).stream()
                   .filter(a -> !a.copied())
                   .collect(toImmutableList()));
 
@@ -876,7 +878,7 @@
 
       ChangeNotes notes = newNotes(c);
       PatchSetApproval originalPsa =
-          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+          Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
       assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
       assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
       assertThat(originalPsa.value()).isEqualTo(2);
@@ -889,15 +891,15 @@
       addCopiedApproval(c, changeOwner, originalPsa);
 
       notes = newNotes(c);
-      assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+      assertThat(notes.getApprovals().all().keySet()).hasSize(2);
       PatchSetApproval copiedApproval =
           Iterables.getOnlyElement(
-              notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+              notes.getApprovals().all().get(c.currentPatchSetId()).stream()
                   .filter(a -> a.copied())
                   .collect(toImmutableList()));
       PatchSetApproval nonCopiedApproval =
           Iterables.getOnlyElement(
-              notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+              notes.getApprovals().all().get(originalPsa.patchSetId()).stream()
                   .filter(a -> !a.copied())
                   .collect(toImmutableList()));
 
@@ -929,18 +931,16 @@
     update.putApprovalFor(otherUserId, LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
-    // Only the non copied approval is reachable by getApprovals.
     ChangeNotes notes = newNotes(c);
     PatchSetApproval approval =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().onlyNonCopied().get(c.currentPatchSetId()));
     assertThat(approval.accountId()).isEqualTo(otherUser.getAccountId());
     assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approval.value()).isEqualTo((short) -1);
     assertThat(approval.copied()).isFalse();
 
-    // Get approvals with copied gets all of the approvals (including copied).
     ImmutableList<PatchSetApproval> approvals =
-        notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+        notes.getApprovals().all().get(c.currentPatchSetId()).stream()
             .sorted(comparing(a -> a.accountId().get()))
             .collect(toImmutableList());
     assertThat(approvals).hasSize(2);
@@ -983,7 +983,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval approval =
-        Iterables.getOnlyElement(notes.getApprovalsWithCopied().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(approval.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
     // The vote got removed since the latest patch-set only has one vote and it's "0". The copied
@@ -1014,7 +1014,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval approval =
-        Iterables.getOnlyElement(notes.getApprovalsWithCopied().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(approval.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approval.value()).isEqualTo((short) 1);
@@ -1077,7 +1077,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovalsWithCopied().values());
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().all().values());
     assertThat(approvals).hasSize(2);
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
@@ -1163,7 +1163,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
     assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().id());
@@ -1173,7 +1173,7 @@
     update.commit();
 
     notes = newNotes(c);
-    psas = notes.getApprovals().get(c.currentPatchSetId());
+    psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(1);
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
   }
@@ -1895,7 +1895,7 @@
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1, psId2);
-    assertThat(notes.getApprovals()).isNotEmpty();
+    assertThat(notes.getApprovals().all()).isNotEmpty();
     assertThat(notes.getChangeMessages()).isNotEmpty();
     assertThat(notes.getHumanComments()).isNotEmpty();
 
@@ -1911,7 +1911,7 @@
 
     notes = newNotes(c);
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1);
-    assertThat(notes.getApprovals()).isEmpty();
+    assertThat(notes.getApprovals().all()).isEmpty();
     assertThat(notes.getChangeMessages()).isEmpty();
     assertThat(notes.getHumanComments()).isEmpty();
   }
@@ -2024,7 +2024,7 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
@@ -2084,7 +2084,13 @@
     try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
       ChangeNotesParser notesWithComments =
           new ChangeNotesParser(
-              c.getId(), commitWithComments.copy(), rw, changeNoteJson, args.metrics);
+              c.getId(),
+              commitWithComments.copy(),
+              rw,
+              changeNoteJson,
+              args.metrics,
+              serverId,
+              externalIdCache);
       ChangeNotesState state = notesWithComments.parseAll();
       assertThat(state.approvals()).isEmpty();
       assertThat(state.publishedComments()).hasSize(1);
@@ -2093,7 +2099,13 @@
     try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
       ChangeNotesParser notesWithApprovals =
           new ChangeNotesParser(
-              c.getId(), commitWithApprovals.copy(), rw, changeNoteJson, args.metrics);
+              c.getId(),
+              commitWithApprovals.copy(),
+              rw,
+              changeNoteJson,
+              args.metrics,
+              serverId,
+              externalIdCache);
 
       ChangeNotesState state = notesWithApprovals.parseAll();
       assertThat(state.approvals()).hasSize(1);
@@ -2130,11 +2142,11 @@
     assertThat(ref2.getObjectId()).isNotEqualTo(initial2.getObjectId());
 
     PatchSetApproval approval1 =
-        newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
+        newNotes(c1).getApprovals().all().get(c1.currentPatchSetId()).iterator().next();
     assertThat(approval1.label()).isEqualTo(LabelId.VERIFIED);
 
     PatchSetApproval approval2 =
-        newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
+        newNotes(c2).getApprovals().all().get(c2.currentPatchSetId()).iterator().next();
     assertThat(approval2.label()).isEqualTo(LabelId.CODE_REVIEW);
   }
 
@@ -3528,7 +3540,7 @@
     ChangeNotes notes = newNotes(c);
     int numMessages = notes.getChangeMessages().size();
     int numPatchSets = notes.getPatchSets().size();
-    int numApprovals = notes.getApprovals().size();
+    int numApprovals = notes.getApprovals().all().size();
     int numComments = notes.getHumanComments().size();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -3556,7 +3568,7 @@
     notes = newNotes(c);
     assertThat(notes.getChangeMessages()).hasSize(numMessages);
     assertThat(notes.getPatchSets()).hasSize(numPatchSets);
-    assertThat(notes.getApprovals()).hasSize(numApprovals);
+    assertThat(notes.getApprovals().all()).hasSize(numApprovals);
     assertThat(notes.getHumanComments()).hasSize(numComments);
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
index cf1b5ae..0bb0578 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
@@ -17,11 +17,13 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.CommentRange;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.util.time.TimeUtil;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
@@ -183,10 +185,89 @@
     assertThat(updateWithVote.bypassMaxUpdates()).isFalse();
   }
 
-  private void addToAttentionSet(ChangeUpdate update) {
+  @Test
+  public void commitChangeUpdateWithoutTouchingAttentionSet() throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+
+    assertThat(update.getAttentionSetUpdates()).isEmpty();
+  }
+
+  @Test
+  public void nonCommittedChangeUpdateReturnsEmptyAttentionSetUpdates() throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    addToAttentionSet(update, otherUser);
+
+    assertThat(update.getAttentionSetUpdates()).isEmpty();
+  }
+
+  @Test
+  public void committedChangeUpdateReturnsAttentionSetUpdates() throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate = addToAttentionSet(update, otherUser);
+    update.commit();
+
+    assertThat(update.getAttentionSetUpdates()).containsExactly(attentionSetUpdate);
+  }
+
+  @Test
+  public void committedChangeUpdateReturnsMultipleAttentionSetUpdates() throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate1 = addToAttentionSet(update, otherUser);
+    AttentionSetUpdate attentionSetUpdate2 = addToAttentionSet(update, changeOwner);
+    update.commit();
+
+    assertThat(update.getAttentionSetUpdates())
+        .containsExactly(attentionSetUpdate1, attentionSetUpdate2);
+  }
+
+  @Test
+  public void changeUpdateDoesntReturnAttentionSetUpdateForUserAlreadyAddedInAttentionSet()
+      throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+    ChangeUpdate update1 = newUpdate(c, changeOwner);
+    addToAttentionSet(update1, otherUser);
+    update1.commit();
+
+    ChangeUpdate update2 = newUpdate(c, changeOwner);
+    addToAttentionSet(update2, otherUser);
+    update2.commit();
+
+    assertThat(update2.getAttentionSetUpdates()).isEmpty();
+  }
+
+  /**
+   * Creates a change with an empty attention set
+   *
+   * <p>Method ensures that changeOwner and otherUser can be added to the attention set later. (only
+   * users active on the change can be added to the attention set - see {@link
+   * ChangeUpdate#isActiveOnChange})
+   */
+  private Change newChangeWithEmptyAttentionSet() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(otherUser.getAccountId(), ReviewerStateInternal.CC);
+    update.commit();
+    return c;
+  }
+
+  @CanIgnoreReturnValue
+  private AttentionSetUpdate addToAttentionSet(ChangeUpdate update) {
+    return addToAttentionSet(update, otherUser);
+  }
+
+  @CanIgnoreReturnValue
+  private AttentionSetUpdate addToAttentionSet(ChangeUpdate update, IdentifiedUser user) {
     AttentionSetUpdate attentionSetUpdate =
         AttentionSetUpdate.createForWrite(
-            otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+            user.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
     update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    return attentionSetUpdate;
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 5e2e1f2..b53de89 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -35,15 +35,12 @@
 import org.junit.Test;
 
 public class CommitMessageOutputTest extends AbstractChangeNotesTest {
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void approvalsCommitFormatSimple() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
     ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
-    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
@@ -70,14 +67,15 @@
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen().getTime()).isEqualTo(c.getCreatedOn().toEpochMilli() + 1000);
-    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+    assertThat(author.getWhenAsInstant().toEpochMilli())
+        .isEqualTo(c.getCreatedOn().toEpochMilli() + 1000);
+    assertThat(author.getZoneId()).isEqualTo(ZoneId.of("GMT-7"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
     assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen().getTime()).isEqualTo(author.getWhen().getTime());
-    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
+    assertThat(committer.getWhenAsInstant()).isEqualTo(author.getWhenAsInstant());
+    assertThat(committer.getZoneId()).isEqualTo(author.getZoneId());
   }
 
   @Test
@@ -145,9 +143,6 @@
   }
 
   @Test
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public void submitCommitFormat() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -189,14 +184,15 @@
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen().getTime()).isEqualTo(c.getCreatedOn().toEpochMilli() + 2000);
-    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+    assertThat(author.getWhenAsInstant().toEpochMilli())
+        .isEqualTo(c.getCreatedOn().toEpochMilli() + 2000);
+    assertThat(author.getZoneId()).isEqualTo(ZoneId.of("GMT-7"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
     assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen().getTime()).isEqualTo(author.getWhen().getTime());
-    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
+    assertThat(committer.getWhenAsInstant()).isEqualTo(author.getWhenAsInstant());
+    assertThat(committer.getZoneId()).isEqualTo(author.getZoneId());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 3b18183..5e6803e 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -49,7 +49,6 @@
 import com.google.inject.Inject;
 import java.time.Instant;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -321,9 +320,6 @@
     assertThat(secondRunResult.fixedRefDiff.keySet().size()).isEqualTo(expectedSecondRunResult);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixAuthorIdent() throws Exception {
     Change c = newChange();
@@ -332,8 +328,8 @@
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            Date.from(when),
-            serverIdent.getTimeZone());
+            when,
+            serverIdent.getZoneId());
     RevCommit invalidUpdateCommit =
         writeUpdate(
             RefNames.changeMetaRef(c.getId()),
@@ -374,8 +370,8 @@
     assertThat(fixedUpdateCommit.getAuthorIdent().getName())
         .isEqualTo("Gerrit User " + changeOwner.getAccountId());
     assertThat(originalAuthorIdent.getEmailAddress()).isEqualTo(fixedAuthorIdent.getEmailAddress());
-    assertThat(originalAuthorIdent.getWhen().getTime())
-        .isEqualTo(fixedAuthorIdent.getWhen().getTime());
+    assertThat(originalAuthorIdent.getWhenAsInstant())
+        .isEqualTo(fixedAuthorIdent.getWhenAsInstant());
     assertThat(originalAuthorIdent.getTimeZone()).isEqualTo(fixedAuthorIdent.getTimeZone());
     assertThat(invalidUpdateCommit.getFullMessage()).isEqualTo(fixedUpdateCommit.getFullMessage());
     assertThat(invalidUpdateCommit.getCommitterIdent())
@@ -453,9 +449,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixReviewerFooterIdent() throws Exception {
     Change c = newChange();
@@ -502,7 +495,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
             ReviewerStatusUpdate.create(
@@ -539,9 +532,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixReviewerMessage() throws Exception {
     Change c = newChange();
@@ -589,7 +579,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
             ReviewerStatusUpdate.create(
@@ -669,9 +659,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixLabelFooterIdent() throws Exception {
     Change c = newChange();
@@ -722,7 +709,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<PatchSetApproval> expectedApprovals =
         ImmutableList.of(
             PatchSetApproval.builder()
@@ -773,9 +760,9 @@
                 .build());
     ChangeNotes notesAfterRewrite = newNotes(c);
 
-    assertThat(notesBeforeRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesBeforeRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
-    assertThat(notesAfterRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesAfterRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
 
     Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
@@ -806,9 +793,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixRemoveVoteChangeMessage() throws Exception {
     Change c = newChange();
@@ -862,7 +846,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<PatchSetApproval> expectedApprovals =
         ImmutableList.of(
             PatchSetApproval.builder()
@@ -895,14 +879,14 @@
             "Removed Custom-Label-1 by Other Account <other@account.com>",
             "Removed Verified+2 by Change Owner <change@owner.com>");
 
-    assertThat(notesBeforeRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesBeforeRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
     assertThat(changeMessages(notesAfterRewrite))
         .containsExactly(
             "Removed Code-Review+2 by <GERRIT_ACCOUNT_2>",
             "Removed Custom-Label-1 by <GERRIT_ACCOUNT_2>",
             "Removed Verified+2 by <GERRIT_ACCOUNT_1>");
-    assertThat(notesAfterRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesAfterRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
 
     Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
@@ -932,18 +916,12 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixRemoveVoteChangeMessageWithUnparsableAuthorIdent() throws Exception {
     Change c = newChange();
     PersonIdent invalidAuthorIdent =
         new PersonIdent(
-            changeOwner.getName(),
-            "server@" + serverId,
-            Date.from(TimeUtil.now()),
-            serverIdent.getTimeZone());
+            changeOwner.getName(), "server@" + serverId, TimeUtil.now(), serverIdent.getZoneId());
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
@@ -1188,9 +1166,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixAttentionFooter() throws Exception {
     Change c = newChange();
@@ -1271,7 +1246,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
     notesBeforeRewrite.getAttentionSetUpdates();
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<AttentionSetUpdate> attentionSetUpdatesBeforeRewrite =
         ImmutableList.of(
             AttentionSetUpdate.createFromRead(
@@ -1569,9 +1544,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixSubmitChangeMessageAndFooters() throws Exception {
     Change c = newChange();
@@ -1579,8 +1551,8 @@
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            Date.from(TimeUtil.now()),
-            serverIdent.getTimeZone());
+            TimeUtil.now(),
+            serverIdent.getZoneId());
     String changeOwnerIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
@@ -2281,9 +2253,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void singleRunFixesAll() throws Exception {
     Change c = newChange();
@@ -2293,8 +2262,8 @@
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            Date.from(when),
-            serverIdent.getTimeZone());
+            when,
+            serverIdent.getZoneId());
 
     RevCommit invalidUpdateCommit =
         writeUpdate(
diff --git a/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java
new file mode 100644
index 0000000..bb49a6d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.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.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.inject.AbstractModule;
+import java.util.Optional;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ImportedChangeNotesTest extends AbstractChangeNotesTest {
+
+  private static final String FOREIGN_SERVER_ID = "foreign-server-id";
+  private static final String IMPORTED_SERVER_ID = "gerrit-imported-1";
+
+  private ExternalIdCache externalIdCacheMock;
+
+  @Before
+  @Override
+  public void setUpTestEnvironment() throws Exception {
+    setupTestPrerequisites();
+  }
+
+  private void initServerIds(String serverId, String... importedServerIds)
+      throws Exception, RepositoryCaseMismatchException, RepositoryNotFoundException {
+    externalIdCacheMock = mock(ExternalIdCache.class);
+    injector =
+        createTestInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                bind(ExternalIdCache.class).toInstance(externalIdCacheMock);
+              }
+            },
+            serverId,
+            importedServerIds);
+    injector.injectMembers(this);
+    createAllUsers(injector);
+  }
+
+  @Test
+  public void allowChangeFromImportedServerId() throws Exception {
+    initServerIds(LOCAL_SERVER_ID, IMPORTED_SERVER_ID);
+
+    ExternalId.Key importedAccountIdKey =
+        ExternalId.Key.create(
+            ExternalId.SCHEME_IMPORTED,
+            changeOwner.getAccountId() + "@" + IMPORTED_SERVER_ID,
+            false);
+    ExternalId importedAccountId =
+        ExternalId.create(importedAccountIdKey, changeOwner.getAccountId(), null, null, null);
+
+    when(externalIdCacheMock.byKey(eq(importedAccountIdKey)))
+        .thenReturn(Optional.of(importedAccountId));
+
+    Change importedChange = newChange(createTestInjector(IMPORTED_SERVER_ID), false);
+    Change localChange = newChange();
+
+    assertThat(newNotes(importedChange).getServerId()).isEqualTo(IMPORTED_SERVER_ID);
+    assertThat(newNotes(localChange).getServerId()).isEqualTo(LOCAL_SERVER_ID);
+  }
+
+  @Test
+  public void rejectChangeWithForeignServerId() throws Exception {
+    initServerIds(LOCAL_SERVER_ID);
+    when(externalIdCacheMock.byKey(any())).thenReturn(Optional.empty());
+
+    Change foreignChange = newChange(createTestInjector(FOREIGN_SERVER_ID), false);
+
+    InvalidServerIdException invalidServerIdEx =
+        assertThrows(InvalidServerIdException.class, () -> newNotes(foreignChange));
+
+    String invalidServerIdMessage = invalidServerIdEx.getMessage();
+    assertThat(invalidServerIdMessage).contains("expected " + LOCAL_SERVER_ID);
+    assertThat(invalidServerIdMessage).contains("actual: " + FOREIGN_SERVER_ID);
+  }
+
+  @Test
+  public void changeFromImportedServerIdWithUnknownAccountId() throws Exception {
+    initServerIds(LOCAL_SERVER_ID, IMPORTED_SERVER_ID);
+
+    when(externalIdCacheMock.byKey(any())).thenReturn(Optional.empty());
+
+    Change importedChange = newChange(createTestInjector(IMPORTED_SERVER_ID), false);
+    assertThat(newNotes(importedChange).getServerId()).isEqualTo(IMPORTED_SERVER_ID);
+
+    assertThat(newNotes(importedChange).getChange().getOwner())
+        .isEqualTo(Account.UNKNOWN_ACCOUNT_ID);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index fa04cf8..1c28690 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -32,7 +32,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.io.IOException;
-import java.util.Date;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -93,7 +92,7 @@
   }
 
   @Test
-  public void diffAgainstAutoMergePersistsAutoMergeInRepo() throws Exception {
+  public void diffAgainstAutoMergeDoesNotPersistAutoMergeInRepo() throws Exception {
     ObjectId parent1 =
         createCommit(repo, null, ImmutableList.of(new FileEntity("file_1.txt", "file 1 content")));
     ObjectId parent2 =
@@ -117,8 +116,7 @@
             testProjectName, merge, /* parentNum=*/ 0, DiffOptions.DEFAULTS);
     assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", "/MERGE_LIST", "file_3.txt");
 
-    // Requesting diff against auto-merge had the side effect of updating the auto-merge ref
-    assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNotNull();
+    assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNull();
   }
 
   @Test
@@ -257,14 +255,11 @@
         : createCommitInRepo(repo, treeId, parentCommit);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private static ObjectId createCommitInRepo(Repository repo, ObjectId treeId, ObjectId... parents)
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), Date.from(TimeUtil.now()));
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(committer);
diff --git a/javatests/com/google/gerrit/server/patch/MagicFileTest.java b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
index 21ea641..b0050b0 100644
--- a/javatests/com/google/gerrit/server/patch/MagicFileTest.java
+++ b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
@@ -22,9 +22,8 @@
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.Month;
+import java.time.ZoneId;
 import java.time.ZoneOffset;
-import java.util.Date;
-import java.util.TimeZone;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -95,9 +94,6 @@
     assertThat(magicFile.getStartLineOfModifiableContent()).isEqualTo(1);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void commitMessageFileOfRootCommitContainsCorrectContent() throws Exception {
     try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
@@ -107,20 +103,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       ObjectId commit =
           testRepo
@@ -149,9 +137,6 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void commitMessageFileOfNonMergeCommitContainsCorrectContent() throws Exception {
     try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
@@ -161,20 +146,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       RevCommit parent =
           testRepo.commit().message("Parent subject\n\nParent further details.").create();
@@ -208,9 +185,6 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void commitMessageFileOfMergeCommitContainsCorrectContent() throws Exception {
     try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
@@ -220,20 +194,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
       RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 26f7d60..ef92139 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.entities.BooleanProjectConfig.REQUIRE_CHANGE_ID;
 
 import com.google.common.collect.ImmutableList;
@@ -75,31 +76,8 @@
 
 @RunWith(JUnit4.class)
 public class ProjectConfigTest {
-  private static final String LABEL_SCORES_CONFIG =
-      "  copyAnyScore = "
-          + !LabelType.DEF_COPY_ANY_SCORE
-          + "\n"
-          + "  copyMinScore = "
-          + !LabelType.DEF_COPY_MIN_SCORE
-          + "\n"
-          + "  copyMaxScore = "
-          + !LabelType.DEF_COPY_MAX_SCORE
-          + "\n"
-          + "  copyAllScoresIfListOfFilesDidNotChange = "
-          + !LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE
-          + "\n"
-          + "  copyAllScoresOnMergeFirstParentUpdate = "
-          + !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE
-          + "\n"
-          + "  copyAllScoresOnTrivialRebase = "
-          + !LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE
-          + "\n"
-          + "  copyAllScoresIfNoCodeChange = "
-          + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE
-          + "\n"
-          + "  copyAllScoresIfNoChange = "
-          + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE
-          + "\n";
+  private static final String COPY_CONDITION = "is:MIN OR is:MAX";
+  private static final String LABEL_SCORES_CONFIG = "  copyCondition = " + COPY_CONDITION + "\n";
 
   private static final AllProjectsName ALL_PROJECTS = new AllProjectsName("All-The-Projects");
 
@@ -422,7 +400,7 @@
   }
 
   @Test
-  public void readConfigLabelScores() throws Exception {
+  public void readConfigCondition() throws Exception {
     RevCommit rev =
         tr.commit()
             .add("groups", group(developers))
@@ -432,19 +410,7 @@
     ProjectConfig cfg = read(rev);
     Map<String, LabelType> labels = cfg.getLabelSections();
     LabelType type = labels.entrySet().iterator().next().getValue();
-    assertThat(type.isCopyAnyScore()).isNotEqualTo(LabelType.DEF_COPY_ANY_SCORE);
-    assertThat(type.isCopyMinScore()).isNotEqualTo(LabelType.DEF_COPY_MIN_SCORE);
-    assertThat(type.isCopyMaxScore()).isNotEqualTo(LabelType.DEF_COPY_MAX_SCORE);
-    assertThat(type.isCopyAllScoresIfListOfFilesDidNotChange())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
-    assertThat(type.isCopyAllScoresOnMergeFirstParentUpdate())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
-    assertThat(type.isCopyAllScoresOnTrivialRebase())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-    assertThat(type.isCopyAllScoresIfNoCodeChange())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
-    assertThat(type.isCopyAllScoresIfNoChange())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
+    assertThat(type.getCopyCondition()).hasValue(COPY_CONDITION);
   }
 
   @Test
@@ -736,15 +702,21 @@
             .add(
                 "project.config",
                 "[commentlink \"bugzilla\"]\n"
-                    + "\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n"
-                    + "\tlink = http://bugs.example.com/show_bug.cgi?id=$2")
+                    + "\tmatch = \"(^|\\\\s)(bug\\\\s+#?)(\\\\d+)($|\\\\s)\"\n"
+                    + "\tlink = http://bugs.example.com/show_bug.cgi?id=$3\n"
+                    + "\tprefix = $1\n"
+                    + "\ttext = $2$3\n"
+                    + "\tsuffix = $4\n")
             .create();
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
         .containsExactly(
             StoredCommentLinkInfo.builder("bugzilla")
-                .setMatch("(bug\\s+#?)(\\d+)")
-                .setLink("http://bugs.example.com/show_bug.cgi?id=$2")
+                .setMatch("(^|\\s)(bug\\s+#?)(\\d+)($|\\s)")
+                .setLink("http://bugs.example.com/show_bug.cgi?id=$3")
+                .setPrefix("$1")
+                .setSuffix("$4")
+                .setText("$2$3")
                 .setOverrideOnly(false)
                 .build());
   }
@@ -1059,24 +1031,6 @@
             });
   }
 
-  @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/SubmitRequirementNameValidatorTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java
new file mode 100644
index 0000000..98ee71d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Test for {@link SubmitRequirementsUtil#validateName(String)}. */
+@RunWith(JUnit4.class)
+public class SubmitRequirementNameValidatorTest {
+  @Test
+  public void canStartWithSmallLetter() throws Exception {
+    SubmitRequirementsUtil.validateName("abc");
+  }
+
+  @Test
+  public void canStartWithCapitalLetter() throws Exception {
+    SubmitRequirementsUtil.validateName("Abc");
+  }
+
+  @Test
+  public void canBeEqualToOneLetter() throws Exception {
+    SubmitRequirementsUtil.validateName("a");
+  }
+
+  @Test
+  public void cannotStartWithNumber() throws Exception {
+    assertThrows(
+        IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("98abc"));
+  }
+
+  @Test
+  public void cannotStartWithHyphen() throws Exception {
+    assertThrows(IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("-abc"));
+  }
+
+  @Test
+  public void cannotContainNonAlphanumericOrHyphen() throws Exception {
+    assertThrows(
+        IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("a&^bc"));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index e0a69a0..b0e705b 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
@@ -269,19 +268,7 @@
     AccountInfo user2 = newAccount("user");
     requestContext.setContext(newRequestContext(Account.id(user2._accountId)));
 
-    if (getSchemaVersion() < 5) {
-      assertMissingField(AccountField.PREFERRED_EMAIL);
-      assertFailingQuery("email:foo", "'email' operator is not supported by account index version");
-      return;
-    }
-
-    // This at least needs the PREFERRED_EMAIL field which is available from schema version 5.
-    if (getSchemaVersion() >= 5) {
-      assertQuery(preferredEmail, user1);
-    } else {
-      assertQuery(preferredEmail);
-    }
-
+    assertQuery(preferredEmail, user1);
     assertQuery(secondaryEmail);
 
     assertQuery("email:" + preferredEmail, user1);
@@ -369,14 +356,6 @@
     assertQuery("self", user3);
     assertQuery("me", user3);
 
-    if (getSchemaVersion() < 8) {
-      assertMissingField(AccountField.NAME_PART_NO_SECONDARY_EMAIL);
-
-      // prefix queries only work if the NAME_PART_NO_SECONDARY_EMAIL field is available
-      assertQuery("john");
-      return;
-    }
-
     assertQuery("John", user1);
     assertQuery("john", user1);
     assertQuery("Doe", user1);
@@ -468,6 +447,12 @@
   }
 
   @Test
+  public void withStartCannotBeLessThanZero() throws Exception {
+    assertFailingQuery(
+        newQuery("self").withStart(-1), "'start' parameter cannot be less than zero");
+  }
+
+  @Test
   public void sortedByFullname() throws Exception {
     String appendix = name("name");
 
@@ -646,22 +631,15 @@
             .getRaw(
                 Account.id(userInfo._accountId),
                 QueryOptions.create(
-                    IndexConfig.fromConfig(config).build(),
-                    0,
-                    1,
-                    schema.getStoredFields().keySet()));
+                    IndexConfig.fromConfig(config).build(), 0, 1, schema.getStoredFields()));
 
     assertThat(rawFields).isPresent();
-    if (schema.useLegacyNumericFields()) {
-      assertThat(rawFields.get().getValue(AccountField.ID)).isEqualTo(userInfo._accountId);
-    } else {
-      assertThat(Integer.valueOf(rawFields.get().getValue(AccountField.ID_STR)))
+    if (schema.hasField(AccountField.ID_FIELD_SPEC)) {
+      assertThat(rawFields.get().getValue(AccountField.ID_FIELD_SPEC))
           .isEqualTo(userInfo._accountId);
-    }
-
-    // The field EXTERNAL_ID_STATE is only supported from schema version 6.
-    if (getSchemaVersion() < 6) {
-      return;
+    } else {
+      assertThat(Integer.valueOf(rawFields.get().<String>getValue(AccountField.ID_STR_FIELD_SPEC)))
+          .isEqualTo(userInfo._accountId);
     }
 
     List<AccountExternalIdInfo> externalIdInfos = gApi.accounts().self().getExternalIds();
@@ -671,11 +649,10 @@
       assertThat(extId).isPresent();
       blobs.add(new ByteArrayWrapper(extId.get().toByteArray()));
     }
-    assertThat(rawFields.get().getValue(AccountField.EXTERNAL_ID_STATE)).hasSize(blobs.size());
-    assertThat(
-            Streams.stream(rawFields.get().getValue(AccountField.EXTERNAL_ID_STATE))
-                .map(ByteArrayWrapper::new)
-                .collect(toList()))
+    Iterable<byte[]> externalIdStates =
+        rawFields.get().<Iterable<byte[]>>getValue(AccountField.EXTERNAL_ID_STATE_SPEC);
+    assertThat(externalIdStates).hasSize(blobs.size());
+    assertThat(Streams.stream(externalIdStates).map(b -> new ByteArrayWrapper(b)).collect(toList()))
         .containsExactlyElementsIn(blobs);
   }
 
@@ -879,13 +856,7 @@
     return accounts.stream().map(a -> a._accountId).collect(toList());
   }
 
-  protected void assertMissingField(FieldDef<AccountState, ?> field) {
-    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
-        .that(getSchema().hasField(field))
-        .isFalse();
-  }
-
-  protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
+  protected void assertFailingQuery(QueryRequest query, String expectedMessage) throws Exception {
     try {
       assertQuery(query);
       fail("expected BadRequestException for query '" + query + "'");
@@ -894,14 +865,6 @@
     }
   }
 
-  protected int getSchemaVersion() {
-    return getSchema().getVersion();
-  }
-
-  protected Schema<AccountState> getSchema() {
-    return indexes.getSearchIndex().getSchema();
-  }
-
   /** Boiler plate code to check two byte arrays for equality */
   private static class ByteArrayWrapper {
     private byte[] arr;
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 28d9ac7..9be7772 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -91,7 +91,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.IndexPredicate;
@@ -103,7 +102,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.Accounts;
@@ -117,7 +115,6 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 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;
@@ -178,7 +175,7 @@
   @Inject protected AllUsersName allUsersName;
   @Inject protected BatchUpdate.Factory updateFactory;
   @Inject protected ChangeInserter.Factory changeFactory;
-  @Inject protected Provider<ChangeQueryBuilder> queryBuilderProvider;
+  @Inject protected ChangeQueryBuilder queryBuilder;
   @Inject protected GerritApi gApi;
   @Inject protected IdentifiedUser.GenericFactory userFactory;
   @Inject protected ChangeIndexCollection indexes;
@@ -200,7 +197,6 @@
   @Inject protected TestGroupBackend testGroupBackend;
   @Inject protected ProjectCache projectCache;
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
-  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
   @Inject protected AuthRequest.Factory authRequestFactory;
   @Inject protected ExternalIdFactory externalIdFactory;
   @Inject protected ProjectOperations projectOperations;
@@ -357,6 +353,18 @@
   }
 
   @Test
+  public void byStatusOr() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert(repo, ins1);
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
+    Change change2 = insert(repo, ins2);
+
+    assertQuery("status:new OR status:merged", change2, change1);
+    assertQuery("status:new or status:merged", change2, change1);
+  }
+
+  @Test
   public void byStatusOpen() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
@@ -641,7 +649,6 @@
 
   @Test
   public void byAuthorExact() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.EXACT_AUTHOR)).isTrue();
     byAuthorOrCommitterExact("author:");
   }
 
@@ -652,7 +659,6 @@
 
   @Test
   public void byCommitterExact() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.EXACT_COMMITTER)).isTrue();
     byAuthorOrCommitterExact("committer:");
   }
 
@@ -1471,6 +1477,12 @@
   }
 
   @Test
+  public void startCannotBeLessThanZero() throws Exception {
+    assertFailingQuery(
+        newQuery("owner:self").withStart(-1), "'start' parameter cannot be less than zero");
+  }
+
+  @Test
   public void startWithLimit() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     List<Change> changes = new ArrayList<>();
@@ -1636,11 +1648,9 @@
     assertQuery("ext:.jAvA", change4);
     assertQuery("ext:cc", change3, change2, change1);
 
-    if (getSchemaVersion() >= 56) {
-      // matching changes with files that have no extension is possible
-      assertQuery("ext:\"\"", change5, change4);
-      assertFailingQuery("ext:");
-    }
+    // matching changes with files that have no extension is possible
+    assertQuery("ext:\"\"", change5, change4);
+    assertFailingQuery("ext:");
   }
 
   @Test
@@ -2004,21 +2014,6 @@
   }
 
   @Test
-  public void mergedOperatorSupportedByIndexVersion() throws Exception {
-    if (getSchemaVersion() < 61) {
-      assertMissingField(ChangeField.MERGED_ON);
-      assertFailingQuery(
-          "mergedbefore:2009-10-01",
-          "'mergedbefore' operator is not supported by change index version");
-      assertFailingQuery(
-          "mergedafter:2009-10-01",
-          "'mergedafter' operator is not supported by change index version");
-    } else {
-      assertThat(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
-    }
-  }
-
-  @Test
   public void byMergedBefore() throws Exception {
     assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
@@ -2502,10 +2497,6 @@
 
   @Test
   public void bySubmitRuleResult() throws Exception {
-    if (getSchemaVersion() < 68) {
-      assertMissingField(ChangeField.SUBMIT_RULE_RESULT);
-      return;
-    }
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
       TestRepository<Repo> repo = createProject("repo");
@@ -2526,13 +2517,6 @@
 
   @Test
   public void byNonExistingSubmitRule_returnsEmpty() throws Exception {
-    // Some submit rules could be removed from the gerrit.config but there can be records for
-    // merged changes in NoteDb for these rules. We allow querying for non-existent rules to handle
-    // this case.
-    if (getSchemaVersion() < 68) {
-      assertMissingField(ChangeField.SUBMIT_RULE_RESULT);
-      return;
-    }
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
       TestRepository<Repo> repo = createProject("repo");
@@ -2542,21 +2526,7 @@
   }
 
   @Test
-  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 {
+  public void byHasDraft() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -2626,20 +2596,8 @@
     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 {
+  @Test
+  public void byHasDraftWithManyDrafts() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change[] changesWithDrafts = new Change[30];
 
@@ -2667,21 +2625,7 @@
   }
 
   @Test
-  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 {
+  public void byStarredBy() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -2694,102 +2638,29 @@
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
     assertQuery("has:star", change2, change1);
-    assertQuery("star:star", change2, change1);
 
     requestContext.setContext(newRequestContext(user2));
     assertQuery("has:star");
-    assertQuery("star:star");
   }
 
   @Test
-  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 {
+  public 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));
-    Change change3 = insert(repo, newChange(repo));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     requestContext.setContext(newRequestContext(user2));
 
     gApi.accounts().self().starChange(change1.getId().toString());
-    gApi.changes().id(change3.getChangeId()).ignore(true);
 
     // check default star
     assertQuery("has:star", change1);
     assertQuery("is:starred", change1);
-    assertQuery("star:" + StarredChangesUtil.DEFAULT_LABEL, change1);
-
-    // check ignored
-    assertQuery("is:ignored", change3);
-    assertQuery("-is:ignored", change2, change1);
-    assertQuery("star:ignore", change3);
-    assertQuery("-star:ignore", change2, change1);
   }
 
   @Test
-  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();
-    Change change1 = insert(repo, newChange(repo), user2);
-    Change change2 = insert(repo, newChange(repo), user2);
-
-    gApi.changes().id(change1.getId().toString()).ignore(true);
-    assertQuery("is:ignored", change1);
-    assertQuery("-is:ignored", change2);
-    assertQuery("star:ignore", change1);
-    assertQuery("-star:ignore", change2);
-
-    gApi.changes().id(change1.getId().toString()).ignore(false);
-    assertQuery("is:ignored");
-    assertQuery("-is:ignored", change2, change1);
-    assertQuery("star:ignore");
-    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 {
+  public void byStarWithManyStars() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change[] changesWithDrafts = new Change[30];
     for (int i = 0; i < changesWithDrafts.length; i++) {
@@ -2800,15 +2671,9 @@
       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);
   }
 
@@ -3324,13 +3189,19 @@
         repo.parseBody(repo.commit().message("Change one\n\nBug:QUERY123").create());
     Change change1 = insert(repo, newChangeForCommit(repo, commit1));
     RevCommit commit2 =
-        repo.parseBody(repo.commit().message("Change two\n\nFeature:QUERY456").create());
+        repo.parseBody(repo.commit().message("Change two\n\nIssue: Issue 16038\n").create());
     Change change2 = insert(repo, newChangeForCommit(repo, commit2));
 
+    RevCommit commit3 =
+        repo.parseBody(repo.commit().message("Change two\n\nGoogle-Bug-Id: b/16039\n").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+
     assertQuery("tr:QUERY123", change1);
     assertQuery("bug:QUERY123", change1);
-    assertQuery("tr:QUERY456", change2);
-    assertQuery("bug:QUERY456", change2);
+    assertQuery("tr:16038", change2);
+    assertQuery("bug:16038", change2);
+    assertQuery("tr:16039", change3);
+    assertQuery("bug:16039", change3);
     assertQuery("tr:QUERY-123");
     assertQuery("bug:QUERY-123");
     assertQuery("tr:QUERY12");
@@ -3382,7 +3253,6 @@
     private final Account.Id ownerId;
     private final List<Account.Id> reviewedBy;
     private final List<Account.Id> cced;
-    private final List<Account.Id> ignoredBy;
     private final List<Account.Id> draftCommentBy;
     private final List<Account.Id> deleteDraftCommentBy;
     private boolean wip;
@@ -3396,7 +3266,6 @@
       this.ownerId = ownerId;
       reviewedBy = new ArrayList<>();
       cced = new ArrayList<>();
-      ignoredBy = new ArrayList<>();
       draftCommentBy = new ArrayList<>();
       deleteDraftCommentBy = new ArrayList<>();
     }
@@ -3421,11 +3290,6 @@
       return this;
     }
 
-    DashboardChangeState ignoreBy(Account.Id ignorerId) {
-      ignoredBy.add(ignorerId);
-      return this;
-    }
-
     DashboardChangeState addReviewer(Account.Id reviewerId) {
       reviewedBy.add(reviewerId);
       return this;
@@ -3471,10 +3335,6 @@
         in.state = ReviewerState.CC;
         cApi.addReviewer(in);
       }
-      for (Account.Id ignorerId : ignoredBy) {
-        requestContext.setContext(newRequestContext(ignorerId));
-        gApi.changes().id(change.getChangeId()).ignore(true);
-      }
       DraftInput in = new DraftInput();
       in.path = Patch.COMMIT_MSG;
       in.message = "message";
@@ -3552,9 +3412,6 @@
     new DashboardChangeState(user.getAccountId()).assignTo(user.getAccountId()).abandon();
     new DashboardChangeState(user.getAccountId())
         .assignTo(user.getAccountId())
-        .ignoreBy(user.getAccountId());
-    new DashboardChangeState(user.getAccountId())
-        .assignTo(user.getAccountId())
         .mergeBy(user.getAccountId());
 
     assertDashboardQuery(
@@ -3587,19 +3444,13 @@
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState ownedOpenReviewable =
         new DashboardChangeState(user.getAccountId()).create(repo);
-    DashboardChangeState ownedOpenReviewableIgnoredByOther =
-        new DashboardChangeState(user.getAccountId()).ignoreBy(otherAccountId).create(repo);
 
     // Create changes that should not be returned by any queries in this test.
     new DashboardChangeState(user.getAccountId()).wip().create(repo);
     new DashboardChangeState(otherAccountId).create(repo);
 
     // Viewing one's own dashboard.
-    assertDashboardQuery(
-        "self",
-        IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY,
-        ownedOpenReviewableIgnoredByOther,
-        ownedOpenReviewable);
+    assertDashboardQuery("self", IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY, ownedOpenReviewable);
 
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
@@ -3615,18 +3466,8 @@
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState reviewingReviewable =
         new DashboardChangeState(otherAccountId).addReviewer(user.getAccountId()).create(repo);
-    DashboardChangeState reviewingReviewableIgnoredByReviewer =
-        new DashboardChangeState(otherAccountId)
-            .addReviewer(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState assignedReviewable =
         new DashboardChangeState(otherAccountId).assignTo(user.getAccountId()).create(repo);
-    DashboardChangeState assignedReviewableIgnoredByAssignee =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .create(repo);
 
     // Create changes that should not be returned by any queries in this test.
     new DashboardChangeState(otherAccountId).wip().addReviewer(user.getAccountId()).create(repo);
@@ -3649,9 +3490,7 @@
     assertDashboardQuery(
         user.getUserName().get(),
         IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY,
-        assignedReviewableIgnoredByAssignee,
         assignedReviewable,
-        reviewingReviewableIgnoredByReviewer,
         reviewingReviewable);
   }
 
@@ -3661,22 +3500,11 @@
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState mergedOwned =
         new DashboardChangeState(user.getAccountId()).mergeBy(user.getAccountId()).create(repo);
-    DashboardChangeState mergedOwnedIgnoredByOther =
-        new DashboardChangeState(user.getAccountId())
-            .ignoreBy(otherAccountId)
-            .mergeBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState mergedReviewing =
         new DashboardChangeState(otherAccountId)
             .addReviewer(user.getAccountId())
             .mergeBy(user.getAccountId())
             .create(repo);
-    DashboardChangeState mergedReviewingIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .addReviewer(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .mergeBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState mergedCced =
         new DashboardChangeState(otherAccountId)
             .addCc(user.getAccountId())
@@ -3687,62 +3515,26 @@
             .assignTo(user.getAccountId())
             .mergeBy(user.getAccountId())
             .create(repo);
-    DashboardChangeState mergedAssignedIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .mergeBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState abandonedOwned =
         new DashboardChangeState(user.getAccountId()).abandon().create(repo);
-    DashboardChangeState abandonedOwnedIgnoredByOther =
-        new DashboardChangeState(user.getAccountId())
-            .ignoreBy(otherAccountId)
-            .abandon()
-            .create(repo);
     DashboardChangeState abandonedOwnedWip =
         new DashboardChangeState(user.getAccountId()).wip().abandon().create(repo);
-    DashboardChangeState abandonedOwnedWipIgnoredByOther =
-        new DashboardChangeState(user.getAccountId())
-            .ignoreBy(otherAccountId)
-            .wip()
-            .abandon()
-            .create(repo);
     DashboardChangeState abandonedReviewing =
         new DashboardChangeState(otherAccountId)
             .addReviewer(user.getAccountId())
             .abandon()
             .create(repo);
-    DashboardChangeState abandonedReviewingIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .addReviewer(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .abandon()
-            .create(repo);
     DashboardChangeState abandonedAssigned =
         new DashboardChangeState(otherAccountId)
             .assignTo(user.getAccountId())
             .abandon()
             .create(repo);
-    DashboardChangeState abandonedAssignedIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .abandon()
-            .create(repo);
     DashboardChangeState abandonedAssignedWip =
         new DashboardChangeState(otherAccountId)
             .assignTo(user.getAccountId())
             .wip()
             .abandon()
             .create(repo);
-    DashboardChangeState abandonedAssignedWipIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .wip()
-            .abandon()
-            .create(repo);
 
     // Create changes that should not be returned by any queries in this test.
     new DashboardChangeState(otherAccountId)
@@ -3750,12 +3542,6 @@
         .wip()
         .abandon()
         .create(repo);
-    new DashboardChangeState(otherAccountId)
-        .addReviewer(user.getAccountId())
-        .ignoreBy(user.getAccountId())
-        .wip()
-        .abandon()
-        .create(repo);
 
     // Viewing one's own dashboard.
     assertDashboardQuery(
@@ -3763,39 +3549,24 @@
         IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
         abandonedAssigned,
         abandonedReviewing,
-        abandonedOwnedWipIgnoredByOther,
         abandonedOwnedWip,
-        abandonedOwnedIgnoredByOther,
         abandonedOwned,
         mergedAssigned,
         mergedCced,
         mergedReviewing,
-        mergedOwnedIgnoredByOther);
-
-    assertDashboardQueryWithStart(
-        "self", IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY, 10, mergedOwned);
+        mergedOwned);
 
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
         user.getUserName().get(),
         IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
-        abandonedAssignedWipIgnoredByUser,
         abandonedAssignedWip,
-        abandonedAssignedIgnoredByUser,
         abandonedAssigned,
-        abandonedReviewingIgnoredByUser,
         abandonedReviewing,
         abandonedOwned,
-        mergedAssignedIgnoredByUser,
         mergedAssigned,
-        mergedCced);
-
-    assertDashboardQueryWithStart(
-        user.getUserName().get(),
-        IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
-        10,
-        mergedReviewingIgnoredByUser,
+        mergedCced,
         mergedReviewing,
         mergedOwned);
   }
@@ -4100,7 +3871,7 @@
 
   @Test
   public void selfFailsForAnonymousUser() throws Exception {
-    for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred", "star:star")) {
+    for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred")) {
       assertQuery(query);
       RequestContext oldContext = requestContext.setContext(anonymousUserProvider::get);
 
@@ -4141,7 +3912,6 @@
 
     assertQuery(ChangeIndexPredicate.none());
 
-    ChangeQueryBuilder queryBuilder = queryBuilderProvider.get();
     for (Predicate<ChangeData> matchingOneChange :
         ImmutableList.of(
             // One index query, one post-filtering query.
@@ -4511,16 +4281,19 @@
     }
   }
 
-  protected void assertMissingField(FieldDef<ChangeData, ?> field) {
-    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
-        .that(getSchema().hasField(field))
-        .isFalse();
-  }
-
   protected void assertFailingQuery(String query) throws Exception {
     assertFailingQuery(query, null);
   }
 
+  protected void assertFailingQuery(QueryRequest query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
   protected void assertFailingQuery(String query, @Nullable String expectedMessage)
       throws Exception {
     try {
@@ -4533,10 +4306,6 @@
     }
   }
 
-  protected int getSchemaVersion() {
-    return getSchema().getVersion();
-  }
-
   protected Schema<ChangeData> getSchema() {
     return indexes.getSearchIndex().getSchema();
   }
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index e48d4af..5124021 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -15,18 +15,29 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
+import java.util.UUID;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
+@RunWith(MockitoJUnitRunner.class)
 public class ChangeDataTest {
+  private static final String GERRIT_SERVER_ID = UUID.randomUUID().toString();
+
+  @Mock private ChangeNotes changeNotesMock;
+
   @Test
   public void setPatchSetsClearsCurrentPatchSet() throws Exception {
     Project.NameKey project = Project.nameKey("project");
@@ -41,6 +52,26 @@
     assertThat(curr2).isNotSameInstanceAs(curr1);
   }
 
+  @Test
+  public void getChangeVirtualIdUsingAlgorithm() throws Exception {
+    Project.NameKey project = Project.nameKey("project");
+    final int encodedChangeNum = 12345678;
+
+    when(changeNotesMock.getServerId()).thenReturn(UUID.randomUUID().toString());
+
+    ChangeData cd =
+        ChangeData.createForTest(
+            project,
+            Change.id(1),
+            1,
+            ObjectId.zeroId(),
+            GERRIT_SERVER_ID,
+            (s, c) -> encodedChangeNum,
+            changeNotesMock);
+
+    assertThat(cd.getVirtualId().get()).isEqualTo(encodedChangeNum);
+  }
+
   private static PatchSet newPatchSet(Change.Id changeId, int num) {
     return PatchSet.builder()
         .id(PatchSet.id(changeId, num))
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index 3968a33..d69fe9e 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -129,7 +129,7 @@
     // 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"));
+    queryProvider.get().query(queryBuilder.parse("status:new"));
     assertThat(idx.getQueryCount()).isEqualTo(2);
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 6e4fec1..1ca4571 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -20,6 +20,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.CharMatcher;
 import com.google.gerrit.entities.Account;
@@ -326,6 +327,13 @@
   }
 
   @Test
+  public void withStartCannotBeLessThanZero() throws Exception {
+    GroupInfo group1 = createGroup(name("group1"));
+    assertFailingQuery(
+        newQuery("uuid:" + group1.id).withStart(-1), "'start' parameter cannot be less than zero");
+  }
+
+  @Test
   public void sortedByUuid() throws Exception {
     GroupInfo group1 = createGroup(name("group1"));
     GroupInfo group2 = createGroup(name("group2"));
@@ -377,10 +385,10 @@
                     IndexConfig.fromConfig(config).build(),
                     0,
                     10,
-                    indexes.getSearchIndex().getSchema().getStoredFields().keySet()));
+                    indexes.getSearchIndex().getSchema().getStoredFields()));
 
     assertThat(rawFields).isPresent();
-    assertThat(rawFields.get().getValue(GroupField.UUID)).isEqualTo(uuid.get());
+    assertThat(rawFields.get().getValue(GroupField.UUID_FIELD_SPEC)).isEqualTo(uuid.get());
   }
 
   @Test
@@ -477,6 +485,15 @@
     return result;
   }
 
+  protected void assertFailingQuery(QueryRequest query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
   protected QueryRequest newQuery(Object query) {
     return gApi.groups().query(query.toString());
   }
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index 60d1655..c06fcde 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -19,6 +19,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
@@ -290,6 +291,13 @@
   }
 
   @Test
+  public void withStartCannotBeLessThanZero() throws Exception {
+    assertFailingQuery(
+        newQuery("name:" + allProjects.get()).withStart(-1),
+        "'start' parameter cannot be less than zero");
+  }
+
+  @Test
   public void sortedByName() throws Exception {
     ProjectInfo projectFoo = createProject("foo-" + name("project1"));
     ProjectInfo projectBar = createProject("bar-" + name("project2"));
@@ -396,6 +404,15 @@
     return result;
   }
 
+  protected void assertFailingQuery(QueryRequest query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
   protected QueryRequest newQuery(Object query) {
     return gApi.projects().query(query.toString());
   }
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index 9cba362..1304c53 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
@@ -77,7 +78,11 @@
     assertThat(codeReview.getName()).isEqualTo("Code-Review");
     assertThat(codeReview.getDefaultValue()).isEqualTo(0);
     assertThat(codeReview.getFunction()).isEqualTo(LabelFunction.MAX_WITH_BLOCK);
-    assertThat(codeReview.isCopyMinScore()).isTrue();
+    assertThat(codeReview.getCopyCondition())
+        .hasValue(
+            String.format(
+                "changekind:%s OR changekind:%s OR is:MIN",
+                ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()));
     assertValueRange(codeReview, -2, -1, 0, 1, 2);
   }
 
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index 4fe4ab04..6d96c10 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -1,8 +1,8 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 junit_tests(
-    name = "small_tests",
-    size = "small",
+    name = "update_tests",
+    size = "medium",
     srcs = glob(["*.java"]),
     runtime_deps = [
         "//java/com/google/gerrit/lucene",
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 1f22564..91c8371 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -18,18 +18,28 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.events.AttentionSetListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -51,6 +61,11 @@
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
 
 public class BatchUpdateTest {
   private static final int MAX_UPDATES = 4;
@@ -75,6 +90,15 @@
   @Inject private PatchSetInserter.Factory patchSetInserterFactory;
   @Inject private Provider<CurrentUser> user;
   @Inject private Sequences sequences;
+  @Inject private AddReviewersOp.Factory addReviewersOpFactory;
+  @Inject private DynamicSet<AttentionSetListener> attentionSetListeners;
+  @Inject private AccountManager accountManager;
+  @Inject private AuthRequest.Factory authRequestFactory;
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+  @Captor ArgumentCaptor<AttentionSetListener.Event> attentionSetEventCaptor;
+  @Mock private AttentionSetListener attentionSetListener;
 
   @Inject
   private @Named("diff_summary") Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
@@ -143,6 +167,39 @@
   }
 
   @Test
+  public void attentionSetUpdateEventsFiredForSeveralChangesInSingleBatch() throws Exception {
+    Change.Id id1 = createChangeWithUpdates(1);
+    Change.Id id2 = createChangeWithUpdates(1);
+    attentionSetListeners.add("test", attentionSetListener);
+
+    Account.Id reviewer1 =
+        accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId();
+    Account.Id reviewer2 =
+        accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId();
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+      bu.addOp(
+          id1,
+          addReviewersOpFactory.create(
+              ImmutableSet.of(reviewer1), ImmutableList.of(), ReviewerState.REVIEWER, false));
+      bu.addOp(
+          id2,
+          addReviewersOpFactory.create(
+              ImmutableSet.of(reviewer2), ImmutableList.of(), ReviewerState.REVIEWER, false));
+      bu.execute();
+    }
+    verify(attentionSetListener, times(2)).onAttentionSetChanged(attentionSetEventCaptor.capture());
+    AttentionSetListener.Event event1 = attentionSetEventCaptor.getAllValues().get(0);
+    assertThat(event1.getChange()._number).isEqualTo(id1.get());
+    assertThat(event1.usersAdded()).containsExactly(reviewer1.get());
+    assertThat(event1.usersRemoved()).isEmpty();
+
+    AttentionSetListener.Event event2 = attentionSetEventCaptor.getAllValues().get(1);
+    assertThat(event2.getChange()._number).isEqualTo(id2.get());
+    assertThat(event2.usersRemoved()).isEmpty();
+  }
+
+  @Test
   public void exceedingMaxUpdatesAllowedWithCompleteNoOp() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 18b9b91..e01d91b 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -10,6 +10,23 @@
 )
 
 java_plugin(
+    name = "auto-factory-plugin",
+    generates_api = 1,
+    processor_class = "com.google.auto.factory.processor.AutoFactoryProcessor",
+    visibility = ["//visibility:private"],
+    deps = [
+        "@auto-common//jar",
+        "@auto-factory//jar",
+        "@auto-service-annotations//jar",
+        "@auto-value-annotations//jar",
+        "@auto-value//jar",
+        "@guava//jar",
+        "@javapoet//jar",
+        "@javax_inject//jar",
+    ],
+)
+
+java_plugin(
     name = "auto-value-plugin",
     processor_class = "com.google.auto.value.processor.AutoValueProcessor",
     deps = [
@@ -43,6 +60,16 @@
 )
 
 java_library(
+    name = "auto-factory",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exported_plugins = [
+        ":auto-factory-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = ["@auto-factory//jar"],
+)
+
+java_library(
     name = "auto-value",
     data = ["//lib:LICENSE-Apache2.0"],
     exported_plugins = [
diff --git a/lib/fonts/BUILD b/lib/fonts/BUILD
index 41d0273..f13a064 100644
--- a/lib/fonts/BUILD
+++ b/lib/fonts/BUILD
@@ -26,3 +26,12 @@
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
 )
+
+filegroup(
+    name = "material-icons",
+    srcs = [
+        "material-icons.woff2",
+    ],
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+)
diff --git a/lib/fonts/material-icons.woff2 b/lib/fonts/material-icons.woff2
new file mode 100644
index 0000000..11074da
--- /dev/null
+++ b/lib/fonts/material-icons.woff2
Binary files differ
diff --git a/modules/jgit b/modules/jgit
index d013761..2021ce3 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit d01376106af8800017ac3c08d7c7ac1fd5ccc9ee
+Subproject commit 2021ce3423a7db6949b9e0a71a8c15e5826ccc4c
diff --git a/package.json b/package.json
index 895dd87..cdaf400 100644
--- a/package.json
+++ b/package.json
@@ -3,56 +3,61 @@
   "version": "3.1.0-SNAPSHOT",
   "description": "Gerrit Code Review",
   "dependencies": {
-    "@bazel/concatjs": "^5.1.0",
-    "@bazel/rollup": "^5.1.0",
-    "@bazel/terser": "^5.1.0",
-    "@bazel/typescript": "^5.1.0",
-    "twinkie": "^1.1.3"
+    "@bazel/concatjs": "^5.5.0",
+    "@bazel/rollup": "^5.5.0",
+    "@bazel/terser": "^5.5.0",
+    "@bazel/typescript": "^5.5.0"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^4.29.0",
-    "eslint": "^7.24.0",
+    "@koa/cors": "^3.3.0",
+    "@types/page": "^1.11.5",
+    "@typescript-eslint/eslint-plugin": "^5.27.0",
+    "@web/dev-server": "^0.1.33",
+    "@web/dev-server-esbuild": "^0.3.2",
+    "eslint": "^8.16.0",
     "eslint-config-google": "^0.14.0",
-    "eslint-plugin-html": "^6.1.2",
-    "eslint-plugin-import": "^2.22.1",
-    "eslint-plugin-jsdoc": "^32.3.0",
-    "eslint-plugin-lit": "^1.5.1",
+    "eslint-plugin-html": "^6.2.0",
+    "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-jsdoc": "^39.3.2",
+    "eslint-plugin-lit": "^1.6.1",
     "eslint-plugin-node": "^11.1.0",
-    "eslint-plugin-prettier": "^3.4.0",
-    "eslint-plugin-regex": "^1.8.0",
+    "eslint-plugin-prettier": "^4.0.0",
+    "eslint-plugin-regex": "^1.9.0",
     "gts": "^3.1.0",
     "lit-analyzer": "^1.2.1",
     "npm-run-all": "^4.1.5",
-    "prettier": "2.3.1",
+    "prettier": "2.6.2",
     "rollup": "^2.45.2",
     "terser": "^5.6.1",
     "ts-lit-plugin": "^1.2.1",
-    "typescript": "4.3.2"
+    "typescript": "^4.7.2"
   },
   "scripts": {
     "clean": "git clean -fdx && bazel clean --expunge",
     "compile:local": "tsc --project ./polygerrit-ui/app/tsconfig.json",
     "compile:watch": "npm run compile:local -- --preserveWatchOutput --watch",
-    "start": "polygerrit-ui/run-server.sh",
-    "test": "npm run safe_bazelisk test //polygerrit-ui:karma_test -- --test_verbose_timeout_warnings --test_output=all",
+    "start": "run-p -rl compile:watch start:server",
+    "start:server": "web-dev-server",
+    "test": "yarn --cwd=polygerrit-ui test",
+    "test:screenshot": "yarn --cwd=polygerrit-ui test:screenshot",
+    "test:screenshot-update": "yarn --cwd=polygerrit-ui test:screenshot-update",
+    "test:coverage": "yarn --cwd=polygerrit-ui test:coverage",
+    "test:watch": "yarn --cwd=polygerrit-ui test:watch",
+    "test:single": "yarn --cwd=polygerrit-ui test:single",
+    "test:single:coverage": "yarn --cwd=polygerrit-ui test:single:coverage",
     "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
-    "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",
-    "watch": "npm run compile:local && run-p -r compile:watch \"test:watch -- {*}\" --"
+    "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis"
   },
   "repository": {
     "type": "git",
     "url": "https://gerrit.googlesource.com/gerrit"
   },
   "resolutions": {
-    "lodash": "4.17.21",
-    "twinkie/typescript": "4.3.2"
+    "eslint": "^8.16.0",
+    "@typescript-eslint/eslint-plugin": "^5.27.0",
+    "@typescript-eslint/parser": "^5.27.0"
   },
   "author": "",
   "license": "Apache-2.0"
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index c5bda5b..3af12c5 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit c5bda5b6b5fe91a2f7cd40c5a917dd2280b04814
+Subproject commit 3af12c5a5e65861830b42bd07933e275c33b9159
diff --git a/plugins/delete-project b/plugins/delete-project
index 5717bad..b183ee5 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 5717badf4250dfe900c05fc00d0758a09ba77297
+Subproject commit b183ee5230273670f3235cc5b3cf32562ccfb7ee
diff --git a/plugins/package.json b/plugins/package.json
index 6fdb0fc..79bb7665 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -1,13 +1,15 @@
 {
-    "name": "gerrit-plugin-dependencies",
-    "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
-    "browser": true,
-    "dependencies": {
-        "@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
+  "name": "gerrit-plugin-dependencies",
+  "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
+  "browser": true,
+  "dependencies": {
+    "@gerritcodereview/typescript-api": "3.7.0",
+    "@polymer/decorators": "^3.0.0",
+    "@polymer/polymer": "^3.4.1",
+    "@open-wc/testing": "^3.1.6",
+    "lit": "^2.2.3",
+    "rxjs": "^6.6.7"
+  },
+  "license": "Apache-2.0",
+  "private": true
 }
diff --git a/plugins/replication b/plugins/replication
index 0e1effa..f1aefa2 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 0e1effafeeb9ee468e13c2af028fac5e019e9c25
+Subproject commit f1aefa28f821699cc1ddd37bf0aa85177c775f17
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 6226d01..4198fe8 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 6226d01c563846ae479cb4fdafd698b31472772c
+Subproject commit 4198fe8df1c1b86d812f32da63e891b1c2fc6f3e
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 4cb70a6..e012bd1 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -2,16 +2,127 @@
 # yarn lockfile v1
 
 
-"@gerritcodereview/typescript-api@3.4.4":
-  version "3.4.4"
-  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.4.4.tgz#9f09687038088dd7edd3b4e30d249502eb21bfbc"
-  integrity sha512-MAiQwntcQ59b92yYDsVIXj3oBbAB4C7HELkLFFbYs4ZjzC43XqqtR9VF0dh5OUC8wzFZttgUiOmGehk9edpPuw==
+"@babel/code-frame@^7.12.11":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a"
+  integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==
+  dependencies:
+    "@babel/highlight" "^7.18.6"
+
+"@babel/helper-validator-identifier@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076"
+  integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==
+
+"@babel/highlight@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf"
+  integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.18.6"
+    chalk "^2.0.0"
+    js-tokens "^4.0.0"
+
+"@esm-bundle/chai@^4.3.4-fix.0":
+  version "4.3.4-fix.0"
+  resolved "https://registry.yarnpkg.com/@esm-bundle/chai/-/chai-4.3.4-fix.0.tgz#3084cff7eb46d741749f47f3a48dbbdcbaf30a92"
+  integrity sha512-26SKdM4uvDWlY8/OOOxSB1AqQWeBosCX3wRYUZO7enTAj03CtVxIiCimYVG2WpULcyV51qapK4qTovwkUr5Mlw==
+  dependencies:
+    "@types/chai" "^4.2.12"
+
+"@gerritcodereview/typescript-api@3.7.0":
+  version "3.7.0"
+  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.7.0.tgz#ae3886b5c4ddc6a02659a11d577e1df0b6158727"
+  integrity sha512-8zeZClN1gur+Isrn02bRXJ0wUjYvK99jQxg36ZbDelrGDglXKddf8QQkZmaH9sYIRcCFDLlh5+ZlRUTcXTuDVA==
+
+"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.4.0":
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.4.1.tgz#3f587eec5708692135bc9e94cf396130604979f3"
+  integrity sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw==
 
 "@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==
 
+"@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/chai-dom-equals@^0.12.36":
+  version "0.12.36"
+  resolved "https://registry.yarnpkg.com/@open-wc/chai-dom-equals/-/chai-dom-equals-0.12.36.tgz#ed0eb56b9e98c4d7f7280facce6215654aae9f4c"
+  integrity sha512-Gt1fa37h4rtWPQGETSU4n1L678NmMi9KwHM1sH+JCGcz45rs8DBPx7MUVeGZ+HxRlbEI5t9LU2RGGv6xT2OlyA==
+  dependencies:
+    "@open-wc/semantic-dom-diff" "^0.13.16"
+    "@types/chai" "^4.1.7"
+
+"@open-wc/dedupe-mixin@^1.3.0":
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/@open-wc/dedupe-mixin/-/dedupe-mixin-1.3.1.tgz#5c1a1eeb0386b344290ebe3f1fca0c4869933dbf"
+  integrity sha512-ukowSvzpZQDUH0Y3znJTsY88HkiGk3Khc0WGpIPhap1xlerieYi27QBg6wx/nTurpWfU6XXXsx9ocxDYCdtw0Q==
+
+"@open-wc/scoped-elements@^2.1.3":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.1.3.tgz#c4f06fa16091c6ebf2a69b3f40afc03821f42535"
+  integrity sha512-WoQD5T8Me9obek+iyjgrAMw9wxZZg4ytIteIN1i9LXW2KohezUp0LTOlWgBajWJo0/bpjUKiODX73cMYL2i3hw==
+  dependencies:
+    "@lit/reactive-element" "^1.0.0"
+    "@open-wc/dedupe-mixin" "^1.3.0"
+
+"@open-wc/semantic-dom-diff@^0.13.16":
+  version "0.13.21"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.13.21.tgz#718b9ec5f9a98935fc775e577ad094ae8d8b7dea"
+  integrity sha512-BONpjHcGX2zFa9mfnwBCLEmlDsOHzT+j6Qt1yfK3MzFXFtAykfzFjAgaxPetu0YbBlCfXuMlfxI4vlRGCGMvFg==
+
+"@open-wc/semantic-dom-diff@^0.19.7":
+  version "0.19.7"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.19.7.tgz#92361f0d2dcb54a8d5cf11d5ea40b8e7ffa58eb4"
+  integrity sha512-ahwHb7arQXXnkIGCrOsM895FJQrU47VWZryCsSSzl5nB3tJKcJ8yjzQ3D/yqZn6v8atqOz61vaY05aNsqoz3oA==
+  dependencies:
+    "@types/chai" "^4.3.1"
+    "@web/test-runner-commands" "^0.6.1"
+
+"@open-wc/testing-helpers@^2.1.2":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.1.3.tgz#85a133ac8637ed1d880d523b07650788eab4a128"
+  integrity sha512-hQujGaWncmWLx/974jq5yf2jydBNNTwnkISw2wLGiYgX34+3R6/ns301Oi9S3Il96Kzd8B7avdExp/gDgqcF5w==
+  dependencies:
+    "@open-wc/scoped-elements" "^2.1.3"
+    lit "^2.0.0"
+    lit-html "^2.0.0"
+
+"@open-wc/testing@^3.1.6":
+  version "3.1.6"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.6.tgz#89f71710e5530d74f0c478b0a9239d68dcdb9f5e"
+  integrity sha512-MIf9cBtac4/UBE5a+R5cXiRhOGfzetsV+ZPFc188AfkPDPbmffHqjrRoCyk4B/qS6fLEulSBMLSaQ+6ze971gQ==
+  dependencies:
+    "@esm-bundle/chai" "^4.3.4-fix.0"
+    "@open-wc/chai-dom-equals" "^0.12.36"
+    "@open-wc/semantic-dom-diff" "^0.19.7"
+    "@open-wc/testing-helpers" "^2.1.2"
+    "@types/chai" "^4.2.11"
+    "@types/chai-dom" "^0.0.12"
+    "@types/sinon-chai" "^3.2.3"
+    chai-a11y-axe "^1.3.2"
+
 "@polymer/decorators@^3.0.0":
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/@polymer/decorators/-/decorators-3.0.0.tgz#e4212ac976d9abd1210f560b6e1be4165c1c0183"
@@ -26,16 +137,938 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
+"@types/accepts@*":
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
+  integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==
+  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/body-parser@*":
+  version "1.19.2"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
+  integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==
+  dependencies:
+    "@types/connect" "*"
+    "@types/node" "*"
+
+"@types/chai-dom@^0.0.12":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-0.0.12.tgz#fdd7a52bed4dd235ed1c94d3d2d31d4e7db1d03a"
+  integrity sha512-4rE7sDw713cV61TYzQbMrPjC4DjNk3x4vk9nAVRNXcSD4p0/5lEEfm0OgoCz5eNuWUXNKA0YiKiH/JDTuKivkA==
+  dependencies:
+    "@types/chai" "*"
+
+"@types/chai@*", "@types/chai@^4.1.7", "@types/chai@^4.2.11", "@types/chai@^4.2.12", "@types/chai@^4.3.1":
+  version "4.3.3"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07"
+  integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==
+
+"@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/connect@*":
+  version "3.4.35"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
+  integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/content-disposition@*":
+  version "0.5.5"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.5.tgz#650820e95de346e1f84e30667d168c8fd25aa6e3"
+  integrity sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==
+
+"@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/cookies@*":
+  version "0.7.7"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.7.tgz#7a92453d1d16389c05a5301eef566f34946cfd81"
+  integrity sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==
+  dependencies:
+    "@types/connect" "*"
+    "@types/express" "*"
+    "@types/keygrip" "*"
+    "@types/node" "*"
+
+"@types/debounce@^1.2.0":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.1.tgz#79b65710bc8b6d44094d286aecf38e44f9627852"
+  integrity sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==
+
+"@types/express-serve-static-core@^4.17.18":
+  version "4.17.31"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz#a1139efeab4e7323834bb0226e62ac019f474b2f"
+  integrity sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==
+  dependencies:
+    "@types/node" "*"
+    "@types/qs" "*"
+    "@types/range-parser" "*"
+
+"@types/express@*":
+  version "4.17.14"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.14.tgz#143ea0557249bc1b3b54f15db4c81c3d4eb3569c"
+  integrity sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==
+  dependencies:
+    "@types/body-parser" "*"
+    "@types/express-serve-static-core" "^4.17.18"
+    "@types/qs" "*"
+    "@types/serve-static" "*"
+
+"@types/http-assert@*":
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661"
+  integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==
+
+"@types/http-errors@*":
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.2.tgz#7315b4c4c54f82d13fa61c228ec5c2ea5cc9e0e1"
+  integrity sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==
+
+"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.3":
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
+  integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==
+
+"@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"
+  integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==
+
+"@types/koa-compose@*":
+  version "3.2.5"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d"
+  integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==
+  dependencies:
+    "@types/koa" "*"
+
+"@types/koa@*", "@types/koa@^2.11.6":
+  version "2.13.5"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.5.tgz#64b3ca4d54e08c0062e89ec666c9f45443b21a61"
+  integrity sha512-HSUOdzKz3by4fnqagwthW/1w/yJspTgppyyalPVbgZf8jQWvdIXcVW5h2DGtw4zYntOaeRGx49r1hxoPWrD4aA==
+  dependencies:
+    "@types/accepts" "*"
+    "@types/content-disposition" "*"
+    "@types/cookies" "*"
+    "@types/http-assert" "*"
+    "@types/http-errors" "*"
+    "@types/keygrip" "*"
+    "@types/koa-compose" "*"
+    "@types/node" "*"
+
+"@types/mime@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
+  integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
+
+"@types/node@*":
+  version "18.7.18"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.18.tgz#633184f55c322e4fb08612307c274ee6d5ed3154"
+  integrity sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==
+
+"@types/parse5@^6.0.1":
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb"
+  integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
+
+"@types/qs@*":
+  version "6.9.7"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
+  integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
+
+"@types/range-parser@*":
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
+  integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
+
+"@types/serve-static@*":
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.0.tgz#c7930ff61afb334e121a9da780aac0d9b8f34155"
+  integrity sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==
+  dependencies:
+    "@types/mime" "*"
+    "@types/node" "*"
+
+"@types/sinon-chai@^3.2.3":
+  version "3.2.8"
+  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc"
+  integrity sha512-d4ImIQbT/rKMG8+AXpmcan5T2/PNeSjrYhvkwet6z0p8kzYtfgA32xzOBlbU0yqJfq+/0Ml805iFoODO0LP5/g==
+  dependencies:
+    "@types/chai" "*"
+    "@types/sinon" "*"
+
+"@types/sinon@*":
+  version "10.0.13"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.13.tgz#60a7a87a70d9372d0b7b38cc03e825f46981fb83"
+  integrity sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==
+  dependencies:
+    "@types/sinonjs__fake-timers" "*"
+
+"@types/sinonjs__fake-timers@*":
+  version "8.1.2"
+  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e"
+  integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==
+
 "@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/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" "*"
+
+"@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.18":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.3.19.tgz#b61f9a0b92351371347a758b30ba19e683c72e94"
+  integrity sha512-Q/Xt4RMVebLWvALofz1C0KvP8qHbzU1EmdIA2Y1WMPJwiFJFhPxdr75p9YxK32P2t0hGs6aqqS5zE0HW9wYzYA==
+  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 "^1.0.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.6.1":
+  version "0.6.5"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.6.5.tgz#69a2a06b52fd9d329f9cf1e172cd8fb1d5ffc521"
+  integrity sha512-W+wLg10jEAJY9N6tNWqG1daKmAzxGmTbO/H9fFfcgOgdxdn+hHiR4r2/x1iylKbFLujHUQlnjNQeu2d6eDPFqg==
+  dependencies:
+    "@web/test-runner-core" "^0.10.27"
+    mkdirp "^1.0.4"
+
+"@web/test-runner-core@^0.10.27":
+  version "0.10.27"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.27.tgz#8d1430f2364fb36b3ac15b9b43034fae9d94e177"
+  integrity sha512-ClV/hSxs4wDm/ANFfQOdRRFb/c0sYywC1QfUXG/nS4vTp3nnt7x7mjydtMGGLmvK9f6Zkubkc1aa+7ryfmVwNA==
+  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.18"
+    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/shadycss@^1.9.1":
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
   integrity sha512-L5O/+UPum8erOleNjKq6k58GVl3fNsEQdSOyh0EUhNmi7tHUyRuCJy1uqJiWydWcLARE5IPsMoPYMZmUGrz1JA==
 
+accepts@^1.3.5:
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
+  integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
+  dependencies:
+    mime-types "~2.1.34"
+    negotiator "0.6.3"
+
+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@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+  integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
+ansi-styles@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+  integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
+  dependencies:
+    color-convert "^1.9.0"
+
+ansi-styles@^4.0.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+  dependencies:
+    color-convert "^2.0.1"
+
+anymatch@~3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
+  integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
+  dependencies:
+    normalize-path "^3.0.0"
+    picomatch "^2.0.4"
+
+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==
+
+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==
+
+axe-core@^4.3.3:
+  version "4.4.3"
+  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.3.tgz#11c74d23d5013c0fa5d183796729bc3482bd2f6f"
+  integrity sha512-32+ub6kkdhhWick/UjvEwRchgoetXqTK14INLqbGm5U2TzBkBNF3nQtLYm8ovxSkQWArjEQvftCKryjZaATu3w==
+
+binary-extensions@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
+  integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
+
+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==
+  dependencies:
+    fill-range "^7.0.1"
+
+bytes@3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
+  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
+
+cache-content-type@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c"
+  integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==
+  dependencies:
+    mime-types "^2.1.18"
+    ylru "^1.2.0"
+
+call-bind@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
+  integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==
+  dependencies:
+    function-bind "^1.1.1"
+    get-intrinsic "^1.0.2"
+
+chai-a11y-axe@^1.3.2:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.4.0.tgz#e584af967727a8656e27c32e845f5db21f2bf2e0"
+  integrity sha512-m7J6DVAl1ePL2ifPKHmwQyHXdCZ+Qfv+qduh6ScqcDfBnJEzpV1K49TblujM45j1XciZOFeFNqMb2sShXMg/mw==
+  dependencies:
+    axe-core "^4.3.3"
+
+chalk@^2.0.0:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
+  integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
+  dependencies:
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
+
+chokidar@^3.4.3:
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
+  integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
+  dependencies:
+    anymatch "~3.1.2"
+    braces "~3.0.2"
+    glob-parent "~5.1.2"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.6.0"
+  optionalDependencies:
+    fsevents "~2.3.2"
+
+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"
+
+clone@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
+  integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
+
+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"
+  integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==
+
+color-convert@^1.9.0:
+  version "1.9.3"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+  integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
+  dependencies:
+    color-name "1.1.3"
+
+color-convert@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+  integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+  dependencies:
+    color-name "~1.1.4"
+
+color-name@1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+  integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
+
+color-name@~1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+content-disposition@~0.5.2:
+  version "0.5.4"
+  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
+  integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
+  dependencies:
+    safe-buffer "5.2.1"
+
+content-type@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
+  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
+
+convert-source-map@^1.7.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
+  integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
+  dependencies:
+    safe-buffer "~5.1.1"
+
+cookies@~0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
+  integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==
+  dependencies:
+    depd "~2.0.0"
+    keygrip "~1.1.0"
+
+debounce@^1.2.0:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
+  integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
+
+debug@^3.1.0:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
+  integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
+  dependencies:
+    ms "^2.1.1"
+
+debug@^4.1.1, debug@^4.3.2:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
+  integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
+  dependencies:
+    ms "2.1.2"
+
+deep-equal@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
+  integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==
+
+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==
+
+delegates@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+  integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
+
+depd@2.0.0, depd@^2.0.0, depd@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+  integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+
+depd@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
+  integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
+
+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.2.0"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
+  integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+
+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"
+
+ee-first@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+  integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
+
+emoji-regex@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+  integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+
+encodeurl@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+  integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
+
+errorstacks@^2.2.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.4.0.tgz#2155674dd9e741aacda3f3b8b967d9c40a4a0baf"
+  integrity sha512-5ecWhU5gt0a5G05nmQcgCxP5HperSMxLDzvWlT5U+ZSKkuDK0rJ3dbCQny6/vSCIXjwrhwSecXBbw1alr295hQ==
+
+es-module-lexer@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.0.3.tgz#f0d8d35b36d13024110000d5e6fadc8eeaeb66b8"
+  integrity sha512-iC67eXHToclrlVhQfpRawDiF8D8sQxNxmbqw5oebegOaJkyx/w9C/k57/5e6yJR2zIByRt9OXdqX50DV2t6ZKw==
+
+escape-html@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+  integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
+
+escape-string-regexp@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+  integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
+
+etag@^1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+  integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
+
+fast-glob@^3.2.9:
+  version "3.2.12"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
+  integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==
+  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"
+
+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"
+  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
+  dependencies:
+    to-regex-range "^5.0.1"
+
+fresh@~0.5.2:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+  integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
+
+fsevents@~2.3.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+  integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
+
+function-bind@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
+  integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
+
+get-intrinsic@^1.0.2:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.3.tgz#063c84329ad93e83893c7f4f243ef63ffa351385"
+  integrity sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==
+  dependencies:
+    function-bind "^1.1.1"
+    has "^1.0.3"
+    has-symbols "^1.0.3"
+
+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==
+
+glob-parent@^5.1.2, 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==
+  dependencies:
+    is-glob "^4.0.1"
+
+globby@^11.0.1:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
+  integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==
+  dependencies:
+    array-union "^2.1.0"
+    dir-glob "^3.0.1"
+    fast-glob "^3.2.9"
+    ignore "^5.2.0"
+    merge2 "^1.4.1"
+    slash "^3.0.0"
+
+has-flag@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+  integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==
+
+has-flag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
+has-symbols@^1.0.2, has-symbols@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
+  integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
+
+has-tostringtag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
+  integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==
+  dependencies:
+    has-symbols "^1.0.2"
+
+has@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
+  integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
+  dependencies:
+    function-bind "^1.1.1"
+
+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==
+
+http-assert@^1.3.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f"
+  integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==
+  dependencies:
+    deep-equal "~1.0.1"
+    http-errors "~1.8.0"
+
+http-errors@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
+  integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
+  dependencies:
+    depd "2.0.0"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses "2.0.1"
+    toidentifier "1.0.1"
+
+http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
+  integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.1"
+
+http-errors@~1.6.2:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
+  integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.0"
+    statuses ">= 1.4.0 < 2"
+
+iconv-lite@0.4.24:
+  version "0.4.24"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
+  dependencies:
+    safer-buffer ">= 2.1.2 < 3"
+
+ignore@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
+  integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
+
+inflation@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f"
+  integrity sha512-m3xv4hJYR2oXw4o4Y5l6P5P16WYmazYof+el6Al3f+YlggGj6qT9kImBAnzDelRALnP5d3h4jGBPKzYCizjZZw==
+
+inherits@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+  integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==
+
+inherits@2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+ip@^1.1.5:
+  version "1.1.8"
+  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
+  integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==
+
+is-binary-path@~2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+  integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+  dependencies:
+    binary-extensions "^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==
+
+is-extglob@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
+
+is-fullwidth-code-point@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
+  integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+
+is-generator-function@^1.0.7:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72"
+  integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==
+  dependencies:
+    has-tostringtag "^1.0.0"
+
+is-glob@^4.0.1, is-glob@~4.0.1:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+  integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+  dependencies:
+    is-extglob "^2.1.1"
+
+is-number@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
+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-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==
+  dependencies:
+    is-docker "^2.0.0"
+
+isbinaryfile@^4.0.6:
+  version "4.0.10"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3"
+  integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==
+
+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-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.1.5"
+  resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.5.tgz#cc9a6ab25cb25659810e4785ed9d9fb742578bae"
+  integrity sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==
+  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"
+  integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+
+keygrip@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
+  integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==
+  dependencies:
+    tsscmp "1.0.6"
+
+koa-compose@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877"
+  integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==
+
+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@^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-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==
+  dependencies:
+    debug "^4.1.1"
+    http-errors "^1.7.3"
+    resolve-path "^1.4.0"
+
+koa-static@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/koa-static/-/koa-static-5.0.0.tgz#5e92fc96b537ad5219f425319c95b64772776943"
+  integrity sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==
+  dependencies:
+    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"
+
 lit-element@^3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.0.tgz#9c981c55dfd9a8f124dc863edb62cc529d434db7"
@@ -44,6 +1077,13 @@
     "@lit/reactive-element" "^1.3.0"
     lit-html "^2.2.0"
 
+lit-html@^2.0.0, lit-html@^2.3.0:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.3.1.tgz#56f15104ea75c0a702904893e3409d0e89e2a2b9"
+  integrity sha512-FyKH6LTW6aBdkfNhNSHyZTnLgJSTe5hMk7HFtc/+DcN1w74C215q8B+Cfxc2OuIEpBNcEKxgF64qL8as30FDHA==
+  dependencies:
+    "@types/trusted-types" "^2.0.2"
+
 lit-html@^2.2.0:
   version "2.2.6"
   resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.2.6.tgz#e70679605420a34c4f3cbd0c483b2fb1fff781df"
@@ -51,6 +1091,15 @@
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
+lit@^2.0.0:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.3.1.tgz#2cf1c2042da1e44c7a7cc72dff2d72303fd26f48"
+  integrity sha512-TejktDR4mqG3qB32Y8Lm5Lye3c8SUehqz7qRsxe1PqGYL6me2Ef+jeQAEqh20BnnGncv4Yxy2njEIT0kzK1WCw==
+  dependencies:
+    "@lit/reactive-element" "^1.4.0"
+    lit-element "^3.2.0"
+    lit-html "^2.3.0"
+
 lit@^2.2.3:
   version "2.2.6"
   resolved "https://registry.yarnpkg.com/lit/-/lit-2.2.6.tgz#4ef223e88517c000b0c01baf2e3535e61a75a5b5"
@@ -59,3 +1108,391 @@
     "@lit/reactive-element" "^1.3.0"
     lit-element "^3.2.0"
     lit-html "^2.2.0"
+
+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"
+
+lru-cache@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
+  integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
+  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 sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
+
+merge2@^1.3.0, merge2@^1.4.1:
+  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.5"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
+  integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
+  dependencies:
+    braces "^3.0.2"
+    picomatch "^2.3.1"
+
+mime-db@1.52.0:
+  version "1.52.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34:
+  version "2.1.35"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+  dependencies:
+    mime-db "1.52.0"
+
+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==
+
+mkdirp@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
+  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
+
+ms@2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+ms@^2.1.1:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
+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.25:
+  version "3.3.4"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
+  integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
+
+negotiator@0.6.3:
+  version "0.6.3"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
+  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
+
+normalize-path@^3.0.0, normalize-path@~3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+object-inspect@^1.9.0:
+  version "1.12.2"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
+  integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
+
+on-finished@^2.3.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
+  integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
+  dependencies:
+    ee-first "1.1.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"
+  integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==
+
+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"
+
+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:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
+  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+
+path-is-absolute@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+  integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
+
+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==
+
+picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+  integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+
+qs@^6.5.2:
+  version "6.11.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
+  integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
+  dependencies:
+    side-channel "^1.0.4"
+
+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==
+
+raw-body@^2.3.3:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
+  integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
+  dependencies:
+    bytes "3.1.2"
+    http-errors "2.0.0"
+    iconv-lite "0.4.24"
+    unpipe "1.0.0"
+
+readdirp@~3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+  integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
+  dependencies:
+    picomatch "^2.2.1"
+
+resolve-path@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7"
+  integrity sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==
+  dependencies:
+    http-errors "~1.6.2"
+    path-is-absolute "1.0.1"
+
+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==
+
+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"
+
+rxjs@^6.6.7:
+  version "6.6.7"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
+  integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==
+  dependencies:
+    tslib "^1.9.0"
+
+safe-buffer@5.2.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
+  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
+
+safe-buffer@~5.1.1:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
+
+"safer-buffer@>= 2.1.2 < 3":
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+
+semver@^6.0.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
+  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
+
+setprototypeof@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
+  integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
+
+setprototypeof@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
+  integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
+
+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.7"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
+  integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
+
+slash@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
+  integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
+
+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"
+
+source-map@^0.7.3:
+  version "0.7.4"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656"
+  integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==
+
+statuses@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
+  integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+
+"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
+  integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
+
+string-width@^4.1.0:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
+supports-color@^5.3.0:
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+  integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
+  dependencies:
+    has-flag "^3.0.0"
+
+supports-color@^7.1.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
+  integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
+  dependencies:
+    has-flag "^4.0.0"
+
+to-regex-range@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+  integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+  dependencies:
+    is-number "^7.0.0"
+
+toidentifier@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
+tslib@^1.9.0:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
+  integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
+
+tsscmp@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
+  integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
+
+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:
+  version "1.6.18"
+  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
+  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
+  dependencies:
+    media-typer "0.3.0"
+    mime-types "~2.1.24"
+
+unpipe@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
+  integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
+
+vary@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+  integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
+
+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"
+
+ws@^7.4.2:
+  version "7.5.9"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
+  integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
+
+yallist@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
+  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
+
+ylru@^1.2.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785"
+  integrity sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 62d1d92..049f1d3 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -1,12 +1,12 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_binary")
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:js.bzl", "karma_test")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
 
 package(default_visibility = ["//visibility:public"])
 
 genrule2(
     name = "fonts",
     srcs = [
+        "//lib/fonts:material-icons",
         "//lib/fonts:robotofonts",
     ],
     outs = ["fonts.zip"],
@@ -20,26 +20,38 @@
     output_to_bindir = 1,
 )
 
-go_binary(
-    name = "devserver",
-    srcs = ["server.go"],
+filegroup(
+    name = "web-test-runner_config-sources",
+    srcs = glob([
+        "package.json",
+        "web-test-runner.config.mjs",
+    ]),
+)
+
+nodejs_test(
+    name = "web-test-runner",
+    size = "large",
+    chdir = package_name(),
     data = [
-        ":fonts.zip",
+        ":web-test-runner_config-sources",
+        "//polygerrit-ui/app:web-test-runner_app-sources",
         "@ui_dev_npm//:node_modules",
         "@ui_npm//:node_modules",
     ],
-    deps = [
-        "@org_golang_x_tools//godoc/vfs/httpfs:go_default_library",
-        "@org_golang_x_tools//godoc/vfs/zipfs:go_default_library",
+    entry_point = "@ui_dev_npm//:node_modules/@web/test-runner/dist/bin.js",
+    tags = [
+        "local",
+        "manual",
     ],
 )
 
+# This is a dependency for karma_test rule in js.bzl that is only used by
+# plugins.
 sh_binary(
     name = "karma_bin",
     srcs = ["@ui_dev_npm//:node_modules/karma/bin/karma"],
     data = [
         "@ui_dev_npm//@open-wc/karma-esm",
-        "@ui_dev_npm//chai",
         "@ui_dev_npm//karma-chrome-launcher",
         "@ui_dev_npm//karma-mocha",
         "@ui_dev_npm//karma-mocha-reporter",
@@ -48,8 +60,5 @@
     ],
 )
 
-karma_test(
-    name = "karma_test",
-    srcs = ["karma_test.sh"],
-    data = ["//polygerrit-ui/app:test-srcs-fg"],
-)
+# This is used by plugins.
+exports_files(["karma.conf.js"])
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index cd88f52..ac8712b 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -28,24 +28,7 @@
 
 ## Installing [Node.js](https://nodejs.org/en/download/) and npm packages
 
-**Note**: Switch between an old branch with bower_components and a new branch with ui-npm
-packages (or vice versa) can lead to some build errors. To avoid such errors clean up the build
-repository:
-```sh
-rm -rf node_modules/ \
-    polygerrit-ui/node_modules/ \
-    polygerrit-ui/app/node_modules \
-    tools/node_tools/node_modules
-
-bazel clean
-```
-
-If it doesn't help also try to run
-```sh
-bazel clean --expunge
-```
-
-The minimum nodejs version supported is 8.x+
+The minimum nodejs version supported is 10.x+.
 
 ```sh
 # Debian experimental
@@ -53,7 +36,7 @@
 sudo apt-get install npm
 
 # OS X with Homebrew
-brew install node
+brew install node@16
 brew install npm
 ```
 
@@ -66,9 +49,12 @@
 
 We have several bazel commands to install packages we may need for FE development.
 
-For first time users to get the local server up, `npm start` should be enough and will take care of all of them for you.
+For first time users to get the local server up, `bazel build gerrit` should be enough and will take care of all of them for you.
 
 ```sh
+# Install yarn package manager
+npm install -g yarn
+
 # Install packages from root-level packages.json
 bazel fetch @npm//:node_modules
 
@@ -94,8 +80,8 @@
 
 ## Setup typescript support in the IDE
 
-Modern IDE should automatically handle typescript settings from the 
-`pollygerrit-ui/app/tsconfig.json` files. IDE places compiled files in the
+Modern IDE should automatically handle typescript settings from the
+`polygerrit-ui/app/tsconfig.json` files. IDE places compiled files in the
 `.ts-out/pg` directory at the root of gerrit workspace and you can configure IDE
 to exclude the whole .ts-out directory. To do it in the IntelliJ IDEA click on
 this directory and select "Mark Directory As > Excluded" in the context menu.
@@ -108,39 +94,22 @@
 
 ## Serving files locally
 
-#### Go server
+#### Web Dev Server
 
-To test the local Polymer frontend against production data or a local test site execute:
+To test the local frontend against production data or a local test site execute:
 
 ```sh
-./polygerrit-ui/run-server.sh
-
-// or
-npm run start
+yarn start
 ```
 
-These commands start the [simple hand-written Go webserver](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/server.go).
-Mostly it just switches between serving files locally and proxying the real
-server based on the file name. It also does some basic response rewriting, e.g.
-it patches the `config/server/info` response with plugin information provided on
-the command line:
-
-```sh
-./polygerrit-ui/run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js
-```
+This command starts the [Web Dev Server](https://modern-web.dev/docs/dev-server/overview/).
+To inject plugins or other files, we use the [Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd) Chrome extension.
 
 If any issues occured, please refer to the Troubleshooting section at the bottom or contact the team!
 
 ## Running locally against production data
 
-### Local website
-
-Start [Go server](#go-server) and then visit http://localhost:8081
-
-The biggest draw back of this method is that you cannot log in, so cannot test
-scenarios that require it.
-
-#### Chrome extension: Gerrit FE Dev Helper
+### Chrome extension: Gerrit FE Dev Helper
 
 To be able to bypass the auth and also help improve the productivity of Gerrit FE developers,
 we created this chrome extension: [Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd).
@@ -163,7 +132,7 @@
 [this command](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#run_daemon).
 
 If you want to serve the Polymer frontend directly from the sources in `polygerrit_ui/app/` instead of from the war:
-1. Start [Go server](#go-server)
+1. Start [Web Dev Server](#web-dev-server)
 2. Add the `--dev-cdn` option:
 
 ```sh
@@ -182,90 +151,33 @@
 For daily development you typically only want to run and debug individual tests.
 There are several ways to run tests.
 
-* Run all tests in headless mode (exactly like CI does):
+* Run all tests:
 ```sh
-npm run test
+yarn test
 ```
-This command uses bazel rules for running frontend tests. Bazel fetches
-all nessecary dependencies and runs all required rules.
 
-* Run all tests in debug mode (the command opens Chrome browser with
-the default Karma page; you should click the "Debug" button to start testing):
+* Run all tests under bazel:
 ```sh
-# The following command doesn't compile code before tests
-npm run test:debug
+./polygerrit-ui/app/run_test.sh
 ```
 
 * Run a single test file:
 ```
-# Headless mode (doesn't compile code before run)
-npm run test:single async-foreach-behavior_test.js
-
-# Debug mode (doesn't compile code before run)
-npm run test:debug async-foreach-behavior_test.js
+yarn test:single "**/async-foreach-behavior_test.js"
 ```
 
-When converting a test file to typescript, the command for running tests is
-still using the .js suffix and not the new .ts suffix.
-
-Commands `test:debug` and `test:single` assumes that compiled code is located
-in the `./ts-out/polygerrit-ui/app` directory. It's up to you how to achieve it.
-For example, the following options are possible:
-* You can configure IDE for recompiling source code on changes
-* You can use `compile:local` command for running compiler once and
-`compile:watch` for running compiler in watch mode (`compile:...` places
-compile code exactly in the `./ts-out/polygerrit-ui/app` directory)
-
+Compiling code:
 ```sh
-# Compile frontend once and run tests from a file:
-npm run compile:local && npm run test:single async-foreach-behavior_test.js
+# Compile frontend once to check for type errors:
+yarn compile:local
 
 # Watch mode:
 ## Terminal 1:
-npm run compile:watch
-## Terminal 2:
-npm run test:debug async-foreach-behavior_test.js
+yarn compile:watch
+## Terminal 2, test & watch a file for example:
+yarn test:single "**/async-foreach-behavior_test.js"
 ```
 
-* You can run tests in IDE. :
-  - [IntelliJ: running unit tests on Karma](https://www.jetbrains.com/help/idea/running-unit-tests-on-karma.html#ws_karma_running)
-  - You should configure IDE to compile typescript before running tests.
-
-**NOTE**: Bazel plugin for IntelliJ has a bug - it recompiles typescript
-project only if .ts and/or .d.ts files have been changed. If only .js files
-were changed, the plugin doesn't run compiler. As a workaround, setup
-"Run npm script 'compile:local" action instead of the "Compile Typescript" in
-the "Before launch" section for IntelliJ. This is a temporary problem until
-typescript migration is complete.
-
-## Running Templates Test
-The templates test validates polymer templates. The test convert polymer
-templates into a plain typescript code and then run TS compiler. The test fails
-if TS compiler reports errors; in this case you will see TS errors in
-the log/output. Gerrit-CI automatically runs templates test.
-
-**Note**: Files defined in `ignore_templates_list` (`polygerrit-ui/app/BUILD`)
-are excluded from code generation and checking. If you don't know how to fix
-a problem, you can add a problematic template in the list.
-
-* To run test locally, use npm command:
-``` sh
-npm run polytest
-```
-
-* Often, the output from the previous command is not clear (cryptic TS errors).
-In this case, run the command
-```sh
-npm run polytest:dev
-```
-This command (re)creates the `polygerrit-ui/app/tmpl_out` directory and put
-generated files into it. For each polygerrit .ts file there is a generated file
-in the `tmpl_out` directory. If an original file doesn't contain a polymer
-template, the generated file is empty.
-
-You can open a problematic file in IDE and fix the problem. Ensure, that IDE
-uses `polygerrit-ui/app/tsconfig.json` as a project (usually, this is default).
-
 ### Generated file overview
 
 A generated file starts with imports followed by a static content with
@@ -286,7 +198,7 @@
 additional functions are added. For example, `<element x=[[y.a]]>` converts into
 `el.x = y!.a` if y is a simple type. However, if y has a union type, like - `y:A|B`,
 then the generated code looks like `el.x=__f(y)!.a` (`y!.a` may result in a TS error
-if `a` is defined only in one type of a union). 
+if `a` is defined only in one type of a union).
 
 ## Style guide
 
@@ -313,7 +225,7 @@
 * To run ESLint on the whole app, less some dependency code:
 
 ```sh
-npm run eslint
+yarn eslint
 ```
 
 * To run ESLint on just the subdirectory you modified:
@@ -328,21 +240,6 @@
 git diff --name-only HEAD | xargs node_modules/eslint/bin/eslint.js --ext .html,.js
 ```
 
-We also use the `polylint` tool to lint use of Polymer. To install polylint,
-execute the following command.
-
-To run polylint, execute the following command.
-
-```sh
-bazel test //polygerrit-ui/app:polylint_test
-```
-
-or
-
-```sh
-npm run polylint
-```
-
 ## Migrating tests to Typescript
 
 You can use the following steps for migrating tests to Typescript:
@@ -352,7 +249,7 @@
    ```
    // Before:
    import ... from 'x/y/z.js`
- 
+
    // After
    import .. from 'x/y/z'
    ```
@@ -421,16 +318,16 @@
 ...
 // The _robotCommentThreads declared as _robotCommentThreads?: CommentThread
 assert.equal(element._robotCommentThreads.length, 2);
-  
+
 // Fix with non-null assertion operator:
 const rows = element
   .shadowRoot!.querySelector('table')! // '!' after shadowRoot and querySelector
   .querySelectorAll('tbody tr');
 
-assert.equal(element._robotCommentThreads!.length, 2); 
+assert.equal(element._robotCommentThreads!.length, 2);
 
 // Fix with nullish coalescing operator:
- assert.equal(element._robotCommentThreads?.length, 2); 
+ assert.equal(element._robotCommentThreads?.length, 2);
 ```
 Usually the fix with `!` is preferable, because it gives more clear error
 when an intermediate property is `null/undefined`. If the _robotComments is
@@ -527,7 +424,7 @@
 
 * If a test imports a library from `polygerrit_ui/node_modules` - update
 `paths` in `polygerrit-ui/app/tsconfig_bazel_test.json`.
- 
+
 ## Contributing
 
 Our users report bugs / feature requests related to the UI through [Monorail Issues - PolyGerrit](https://bugs.chromium.org/p/gerrit/issues/list?q=component%3APolyGerrit).
@@ -556,7 +453,7 @@
 git submodule update --init --recursive
 
 // reset the workspace (please save your local changes before running this command)
-npm run clean
+yarn clean
 
 // install all dependencies and start the server
 npm start
diff --git a/polygerrit-ui/app/.eslint-ts-resolver.js b/polygerrit-ui/app/.eslint-ts-resolver.js
index dc578f9..e4ba115 100644
--- a/polygerrit-ui/app/.eslint-ts-resolver.js
+++ b/polygerrit-ui/app/.eslint-ts-resolver.js
@@ -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
  */
 
 /**
diff --git a/polygerrit-ui/app/.eslintignore b/polygerrit-ui/app/.eslintignore
index bb30f23..087a049 100644
--- a/polygerrit-ui/app/.eslintignore
+++ b/polygerrit-ui/app/.eslintignore
@@ -2,4 +2,3 @@
 **/rollup.config.js
 node_modules_licenses
 !.eslintrc-bazel.js
-tmpl_out
diff --git a/polygerrit-ui/app/.eslintrc-bazel.js b/polygerrit-ui/app/.eslintrc-bazel.js
index 9a51242..fa6c274 100644
--- a/polygerrit-ui/app/.eslintrc-bazel.js
+++ b/polygerrit-ui/app/.eslintrc-bazel.js
@@ -1,18 +1,7 @@
 /**
  * @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 has a special settings for bazel.
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 8eaff5c..c519465 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 // Do not add any bazel-specific properties in this file to keep it clean.
@@ -238,9 +227,13 @@
     'import/no-unused-modules': 2,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-default-export.md
     'import/no-default-export': 2,
-    // Prevents certain identifiers being used.
-    // Prefer flush() over flushAsynchronousOperations().
-    'id-blacklist': ['error', 'flushAsynchronousOperations'],
+    'regex/invalid': [
+      'error', [{
+        // eslint-disable-next-line regex/invalid
+        regex: 'Licensed under',
+        message: 'Please use SPDX license headers.',
+      }],
+    ],
   },
 
   // List of allowed globals in all files
@@ -273,9 +266,6 @@
         'jsdoc/require-param-type': 2,
         // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-type
         'jsdoc/require-returns-type': 2,
-        // The rule is required for .js files only, because typescript compiler
-        // always checks import.
-        'import/no-unresolved': 2,
         'import/named': 2,
       },
       globals: {
@@ -298,6 +288,19 @@
       files: ['**/*.ts'],
       extends: [require.resolve('gts/.eslintrc.json')],
       rules: {
+        'regex/invalid': [
+          'error', [{
+            regex: '\'lit/decorators\'',
+            message: 'use \'lit/decorators.js\' instead',
+            replacement: '\'lit/decorators.js\'',
+          }, {
+            regex: '\'lit/directives/([^.\']*)\'',
+            message: 'use \'lit/directives/foo.js\' instead',
+            replacement: {
+              function: 'return "\'lit/directives/" + $[1] + ".js\'"',
+            },
+          }],
+        ],
         'no-restricted-imports': ['error', {
           name: 'lit-html/static',
           message: 'Use lit instead',
@@ -314,6 +317,8 @@
         // The following rules is required to match internal google rules
         '@typescript-eslint/restrict-plus-operands': 'error',
         '@typescript-eslint/no-unnecessary-type-assertion': 'error',
+        'require-await': 'off',
+        '@typescript-eslint/require-await': 'error',
         '@typescript-eslint/no-confusing-void-expression': [
           'error',
           {ignoreArrowShorthand: true},
@@ -326,7 +331,7 @@
         'node/no-unsupported-features/node-builtins': 'off',
         // Disable no-invalid-this for ts files, because it incorrectly reports
         // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
-        // At the same time, we are using typescript in a strict mode and
+        // At the same tigit llme, we are using typescript in a strict mode and
         // it catches almost all errors related to invalid usage of this.
         'no-invalid-this': 'off',
 
@@ -348,6 +353,7 @@
       ],
       rules: {
         '@typescript-eslint/no-explicit-any': 'off',
+        '@typescript-eslint/require-await': 'off',
       },
     },
     {
@@ -367,13 +373,6 @@
         // Global variables from 3rd party test libraries/frameworks.
         // You can extend this list if you want to use other global
         // variables from these libraries and import is not possible
-        MockInteractions: 'readonly',
-        _: 'readonly',
-        axs: 'readonly',
-        a11ySuite: 'readonly',
-        assert: 'readonly',
-        expect: 'readonly',
-        fixture: 'readonly',
         flush: 'readonly',
         setup: 'readonly',
         sinon: 'readonly',
@@ -383,8 +382,6 @@
         suiteTeardown: 'readonly',
         teardown: 'readonly',
         test: 'readonly',
-        fixtureFromElement: 'readonly',
-        fixtureFromTemplate: 'readonly',
       },
     },
     {
diff --git a/polygerrit-ui/app/.gitignore b/polygerrit-ui/app/.gitignore
index c45bac3..38a8371 100644
--- a/polygerrit-ui/app/.gitignore
+++ b/polygerrit-ui/app/.gitignore
@@ -1,4 +1,3 @@
 /node_modules/
 /package-lock.json
 /plugins/
-/tmpl_out/
diff --git a/polygerrit-ui/app/.prettierrc.js b/polygerrit-ui/app/.prettierrc.js
index fbb87c6..8f353bf 100644
--- a/polygerrit-ui/app/.prettierrc.js
+++ b/polygerrit-ui/app/.prettierrc.js
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 module.exports = {
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 084befa..330e616 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -1,7 +1,6 @@
 load(":rules.bzl", "polygerrit_bundle")
 load("//tools/js:eslint.bzl", "eslint")
-load("//tools/js:template_checker.bzl", "transform_polymer_templates")
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test", "npm_package_bin")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
 load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
 
 package(default_visibility = ["//visibility:public"])
@@ -73,15 +72,11 @@
         exclude = [
             "node_modules/**",
             "node_modules_licenses/**",
-            "tmpl_out/**",  # This directory is created by template checker in dev-mode
             "rollup.config.js",
         ],
     ),
     allow_js = True,
     incremental = True,
-    # The same outdir also appears in the following files:
-    # wct_test.sh
-    # karma.conf.js
     out_dir = "_pg_with_tests_out",
     tsc = "//tools/node_tools:tsc-bin",
     tsconfig = ":ts_config_bazel_test",
@@ -91,88 +86,6 @@
     ],
 )
 
-# Template checker reports problems in the following files. Ignore the files,
-# so template tests pass.
-# TODO: fix problems reported by template checker in these files.
-ignore_templates_list = [
-    "elements/admin/gr-permission/gr-permission_html.ts",
-    "elements/admin/gr-repo-access/gr-repo-access_html.ts",
-    "elements/admin/gr-rule-editor/gr-rule-editor_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",
-    "elements/change/gr-change-requirements/gr-change-requirements_html.ts",
-    "elements/change/gr-change-view/gr-change-view_html.ts",
-    "elements/change/gr-file-list-header/gr-file-list-header_html.ts",
-    "elements/change/gr-file-list/gr-file-list_html.ts",
-    "elements/change/gr-label-score-row/gr-label-score-row_html.ts",
-    "elements/change/gr-message/gr-message_html.ts",
-    "elements/change/gr-messages-list/gr-messages-list_html.ts",
-    "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-preferences-dialog/gr-diff-preferences-dialog_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(
-    [src_dir + "/**/*" + ext for src_dir in src_dirs for ext in [
-        ".ts",
-    ]],
-    exclude = [
-        "**/*_test.ts",
-    ] + ignore_templates_list,
-)
-
-# Transform templates into a .ts files.
-templates_srcs = transform_polymer_templates(
-    name = "template_test",
-    srcs = sources_for_template_checking,
-    out_tsconfig = "tsconfig_template_test.json",
-    tsconfig = "tsconfig_bazel.json",
-    deps = [
-        "tsconfig.json",
-        "tsconfig_bazel.json",
-        "@ui_npm//:node_modules",
-    ],
-)
-
-# After templates are converted into a typescript code, the TS compiler should check that the
-# converted code doesn't have the error (i.e. templates don't have problems).
-# The input to the compiler is: the converted (i.e. autogenerated) code + original polygerrit code;
-# the output (i.e. js code) is not needed (we only care wheather the code has error or not).
-# The existing ts_project rule can't compile a mix of a generated and a non-generated code, so it
-# can't be used for the purpose of template checking.
-# Because the output of TS compiler is not needed, the simplest workaround is to run typescript
-# compiler from command line using the sh_test rule. The compiler exits with non-zero return code if
-# errors found and sh_test fails.
-sh_test(
-    name = "polylint_test",
-    srcs = [":compile_generated_templates.sh"],
-    args = [
-        "$(location //tools/node_tools:tsc-bin)",
-        "$(location tsconfig_template_test.json)",
-    ],
-    data = [
-        "tsconfig_template_test.json",
-        "tsconfig_bazel.json",
-        "tsconfig.json",
-        "//tools/node_tools:tsc-bin",
-        "@ui_npm//:node_modules",
-    ] + templates_srcs + sources_for_template_checking,
-    tags = [
-        "local",
-        "manual",
-    ],
-)
-
 polygerrit_bundle(
     name = "polygerrit_ui",
     srcs = [":compile_pg"],
@@ -293,3 +206,16 @@
         "--rules.no-unknown-attribute error",
     ],
 )
+
+# app code including tests and tsconfig.json
+filegroup(
+    name = "web-test-runner_app-sources",
+    srcs = glob(
+        [
+            "**/*.ts",
+            "**/*.js",
+            "**/tsconfig.json",
+        ],
+        exclude = ["node_modules/**/*"],
+    ),
+)
diff --git a/polygerrit-ui/app/api/admin.ts b/polygerrit-ui/app/api/admin.ts
index 0606153..823f3dd 100644
--- a/polygerrit-ui/app/api/admin.ts
+++ b/polygerrit-ui/app/api/admin.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 /** Interface for menu link */
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index 5922e5e..c670c58 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {CoverageRange, Side} from './diff';
 import {ChangeInfo} from './rest-api';
diff --git a/polygerrit-ui/app/api/attribute-helper.ts b/polygerrit-ui/app/api/attribute-helper.ts
index d813beb..56d25d4 100644
--- a/polygerrit-ui/app/api/attribute-helper.ts
+++ b/polygerrit-ui/app/api/attribute-helper.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 export declare interface AttributeHelperPluginApi {
diff --git a/polygerrit-ui/app/api/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts
index e3143b8..721df03 100644
--- a/polygerrit-ui/app/api/change-actions.ts
+++ b/polygerrit-ui/app/api/change-actions.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {HttpMethod} from './rest';
 
diff --git a/polygerrit-ui/app/api/change-reply.ts b/polygerrit-ui/app/api/change-reply.ts
index 3d652db..31a9179 100644
--- a/polygerrit-ui/app/api/change-reply.ts
+++ b/polygerrit-ui/app/api/change-reply.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {ChangeInfo} from './rest-api';
 
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index aa0b05d..b05e70a 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Settings
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT 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 {CommentRange} from './core';
 import {ChangeInfo} from './rest-api';
@@ -441,6 +430,35 @@
    * Make blocking, Downgrade severity.
    */
   actions?: Action[];
+
+  /**
+   * Optionally you can provide fixes that would solve the issue reported. The
+   * user will then see a "SHOW FIX" button for previewing the fix in a dialog,
+   * whichs allows the user to apply the fix. That will create a new EDIT
+   * patchset or use the exiting EDIT patchset, so the user can also apply fixes
+   * from multiple check results.
+   *
+   * Normally, you would only provide one fix, but you can also provide multiple
+   * different options to the user to choose from. Each fix may contain one or
+   * more replacements, each being a modification of one file. These files do
+   * not have to be part of the change yet.
+   */
+  fixes?: Fix[];
+}
+
+export declare interface Fix {
+  description?: string;
+  replacements: Replacement[];
+}
+
+export declare interface Replacement {
+  /**
+   * For example `polygerrit-ui/app/package.json`.
+   * `/COMMIT_MSG` is not supported yet.
+   */
+  path: string;
+  range: CommentRange;
+  replacement: string;
 }
 
 export enum Category {
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
index af7fc40..c44edfb 100644
--- a/polygerrit-ui/app/api/core.ts
+++ b/polygerrit-ui/app/api/core.ts
@@ -4,19 +4,8 @@
  * Core types are types used in many places in Gerrit, such as the Side enum.
  *
  * @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
  */
 
 /**
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 08e2e66..683638e 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -5,19 +5,8 @@
  * which are used as inputs to gr-diff.
  *
  * @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 {CommentRange, CursorMoveResult} from './core';
@@ -38,10 +27,16 @@
  * If the weblinks-only parameter is specified, only the web_links field is set.
  */
 export declare interface DiffInfo {
-  /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
-  meta_a: DiffFileMetaInfo;
-  /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
-  meta_b: DiffFileMetaInfo;
+  /**
+   * Meta information about the file on side A as a DiffFileMetaInfo entity.
+   * Not set when change_type is ADDED.
+   */
+  meta_a?: DiffFileMetaInfo;
+  /**
+   * Meta information about the file on side B as a DiffFileMetaInfo entity.
+   * Not set when change_type is DELETED.
+   */
+  meta_b?: DiffFileMetaInfo;
   /** The type of change (ADDED, MODIFIED, DELETED, RENAMED COPIED, REWRITE). */
   change_type: ChangeType;
   /** Intraline status (OK, ERROR, TIMEOUT). */
@@ -167,7 +162,7 @@
    * Indicates the range (line numbers) on the other side of the comparison
    * where the code related to the current chunk came from/went to.
    */
-  range: {
+  range?: {
     start: number;
     end: number;
   };
@@ -334,6 +329,11 @@
   lineNum: LineNumber;
 }
 
+// TODO: Currently unused and not fired.
+export declare interface RenderProgressEventDetail {
+  linesRendered: number;
+}
+
 export declare interface DisplayLine {
   side: Side;
   lineNum: LineNumber;
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index 520aeec..de2e1bf 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -5,19 +5,8 @@
  * bundles, which cannot directly import the classes from their modules.
  *
  * @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 {
@@ -34,7 +23,7 @@
       GrDiffCursor: {new (): GrDiffCursor};
       TokenHighlightLayer: {
         new (
-          container?: HTMLElement,
+          container: HTMLElement,
           listener?: TokenHighlightListener
         ): DiffLayer;
       };
diff --git a/polygerrit-ui/app/api/event-helper.ts b/polygerrit-ui/app/api/event-helper.ts
index 5dc15dc..5aee59e 100644
--- a/polygerrit-ui/app/api/event-helper.ts
+++ b/polygerrit-ui/app/api/event-helper.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 export type UnsubscribeCallback = () => void;
 
diff --git a/polygerrit-ui/app/api/gerrit.ts b/polygerrit-ui/app/api/gerrit.ts
index 2091eea..a5f7731 100644
--- a/polygerrit-ui/app/api/gerrit.ts
+++ b/polygerrit-ui/app/api/gerrit.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {PluginApi} from './plugin';
 import {Styles} from './styles';
diff --git a/polygerrit-ui/app/api/hook.ts b/polygerrit-ui/app/api/hook.ts
index e75d83a..c511eb2 100644
--- a/polygerrit-ui/app/api/hook.ts
+++ b/polygerrit-ui/app/api/hook.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {ChangeInfo, ConfigInfo, RevisionInfo} from './rest-api';
 import {PluginApi} from './plugin';
diff --git a/polygerrit-ui/app/api/package.json b/polygerrit-ui/app/api/package.json
index ac1e0c8..79c8bb6 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.5",
+  "version": "3.7.0",
   "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 b6fa7ee..b9c065f 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {AdminPluginApi} from './admin';
 import {AnnotationPluginApi} from './annotation';
diff --git a/polygerrit-ui/app/api/popup.ts b/polygerrit-ui/app/api/popup.ts
index d265ee6..d9a9e3c 100644
--- a/polygerrit-ui/app/api/popup.ts
+++ b/polygerrit-ui/app/api/popup.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 export declare interface PopupPluginApi {
diff --git a/polygerrit-ui/app/api/reporting.ts b/polygerrit-ui/app/api/reporting.ts
index 40474e1..59c5cb8 100644
--- a/polygerrit-ui/app/api/reporting.ts
+++ b/polygerrit-ui/app/api/reporting.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 685151b..3c06eb0 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
@@ -99,8 +88,9 @@
   RENAMED = 'R',
   COPIED = 'C',
   REWRITTEN = 'W',
-  // Modifed = 'M', // but API not set it if the file was modified
+  MODIFIED = 'M', // Not returned by BE, M is the default
   UNMODIFIED = 'U', // Not returned by BE, but added by UI for certain files
+  REVERTED = 'X', // Not returned by BE, but added by UI for certain files
 }
 
 /**
@@ -209,8 +199,9 @@
 
 // This is a "meta type", so it comes first and is not sored alphabetically with
 // the other types.
-export type BrandType<T, BrandName extends string> = T &
-  {[__brand in BrandName]: never};
+export type BrandType<T, BrandName extends string> = T & {
+  [__brand in BrandName]: never;
+};
 
 export type AccountId = BrandType<number, '_accountId'>;
 
@@ -345,9 +336,7 @@
   width: number;
 }
 
-export type BasePatchSetNum = BrandType<'PARENT' | number, '_patchSet'>;
 // The refs/heads/ prefix is omitted in Branch name
-
 export type BranchName = BrandType<string, '_branchName'>;
 
 /**
@@ -417,9 +406,8 @@
   revert_of?: NumericChangeId;
   submission_id?: ChangeSubmissionId;
   cherry_pick_of_change?: NumericChangeId;
-  cherry_pick_of_patch_set?: PatchSetNum;
+  cherry_pick_of_patch_set?: RevisionPatchSetNum;
   contains_git_conflicts?: boolean;
-  internalHost?: string; // TODO(TS): provide an explanation what is its
   submit_requirements?: SubmitRequirementResultInfo[];
   submit_records?: SubmitRecordInfo[];
 }
@@ -465,6 +453,9 @@
 export declare interface CommentLinkInfo {
   match: string;
   link?: string;
+  prefix?: string;
+  suffix?: string;
+  text?: string;
   enabled?: boolean;
   html?: string;
 }
@@ -626,8 +617,8 @@
   old_path?: string;
   lines_inserted?: number;
   lines_deleted?: number;
-  size_delta: number; // in bytes
-  size: number; // in bytes
+  size_delta?: number; // in bytes
+  size?: number; // in bytes
 }
 
 /**
@@ -789,7 +780,23 @@
   subject: string;
 }
 
-export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
+export type PatchSetNumber = BrandType<number, '_patchSet'>;
+
+export type EditPatchSet = BrandType<'edit', '_patchSet'>;
+
+export const EDIT = 'edit' as EditPatchSet;
+
+export type ParentPatchSet = BrandType<'PARENT', '_patchSet'>;
+
+export const PARENT = 'PARENT' as ParentPatchSet;
+
+export type PatchSetNum = PatchSetNumber | ParentPatchSet | EditPatchSet;
+
+// for the "left" side of a diff or the base of a patch range
+export type BasePatchSetNum = PatchSetNumber | ParentPatchSet;
+
+// for the "right" side of a diff or the revision of a patch range
+export type RevisionPatchSetNum = PatchSetNumber | EditPatchSet;
 
 /**
  * The PluginConfigInfo entity contains information about Gerrit extensions by
@@ -945,7 +952,7 @@
  */
 export declare interface RevisionInfo {
   kind: RevisionKind;
-  _number: PatchSetNum;
+  _number: RevisionPatchSetNum;
   created: Timestamp;
   uploader: AccountInfo;
   ref: GitRef;
@@ -1176,3 +1183,19 @@
   status: LabelStatus;
   appliedBy: AccountInfo;
 }
+
+/**
+ * Represent a file in a base64 encoding; GrRestApiInterface returns
+ * it from some methods
+ */
+export declare interface Base64FileContent {
+  content: string | null;
+  type: string | null;
+  ok: true;
+}
+
+export function isBase64FileContent(
+  res: Response | Base64FileContent
+): res is Base64FileContent {
+  return (res as Base64FileContent).ok;
+}
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
index 86f33a9..283e029 100644
--- a/polygerrit-ui/app/api/rest.ts
+++ b/polygerrit-ui/app/api/rest.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {AccountDetailInfo, ProjectInfoWithName, ServerInfo} from './rest-api';
 
diff --git a/polygerrit-ui/app/api/styles.ts b/polygerrit-ui/app/api/styles.ts
index 55ac2cc..1e1f60a 100644
--- a/polygerrit-ui/app/api/styles.ts
+++ b/polygerrit-ui/app/api/styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
@@ -36,6 +25,7 @@
 export declare interface Styles {
   font: Style;
   form: Style;
+  icon: Style;
   menuPage: Style;
   spinner: Style;
   subPage: Style;
diff --git a/polygerrit-ui/app/compile_generated_templates.sh b/polygerrit-ui/app/compile_generated_templates.sh
deleted file mode 100755
index 68bf485..0000000
--- a/polygerrit-ui/app/compile_generated_templates.sh
+++ /dev/null
@@ -1 +0,0 @@
-$1 --project $2 --baseUrl ./external/ui_npm/node_modules/ --rootDir null
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 8cdd765..bb7b313 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 /**
@@ -63,7 +52,7 @@
   SERVICE_USER = 'SERVICE_USER',
 }
 
-export enum PrimaryTab {
+export enum Tab {
   FILES = 'files',
   /**
    * When renaming 'comments' or 'findings', UrlFormatter.java must be updated.
@@ -74,18 +63,12 @@
 }
 
 /**
- * Tab names for secondary tabs on change view page.
- */
-export enum SecondaryTab {
-  CHANGE_LOG = '_changeLog',
-}
-
-/**
  * Tag names of change log messages.
  */
 export enum MessageTag {
   TAG_DELETE_REVIEWER = 'autogenerated:gerrit:deleteReviewer',
   TAG_NEW_PATCHSET = 'autogenerated:gerrit:newPatchSet',
+  TAG_NEW_PATCHSET_OUTDATED_VOTES = 'autogenerated:gerrit:newPatchSetOutdatedVotes',
   TAG_NEW_WIP_PATCHSET = 'autogenerated:gerrit:newWipPatchSet',
   TAG_REVIEWER_UPDATE = 'autogenerated:gerrit:reviewerUpdate',
   TAG_SET_PRIVATE = 'autogenerated:gerrit:setPrivate',
@@ -107,6 +90,20 @@
   SUCCESSFUL = 'SUCCESSFUL',
 }
 
+export enum ColumnNames {
+  SUBJECT = 'Subject',
+  // TODO(milutin) - remove once Submit Requirements are rolled out.
+  STATUS = 'Status',
+  OWNER = 'Owner',
+  REVIEWERS = 'Reviewers',
+  COMMENTS = 'Comments',
+  REPO = 'Repo',
+  BRANCH = 'Branch',
+  UPDATED = 'Updated',
+  SIZE = 'Size',
+  STATUS2 = ' Status ', // spaces to differentiate from old 'Status'
+}
+
 /**
  * @description Modes for gr-diff-cursor
  * The scroll behavior for the cursor. Values are 'never' and
@@ -176,6 +173,7 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
  */
 export enum AppTheme {
+  AUTO = 'AUTO',
   DARK = 'DARK',
   LIGHT = 'LIGHT',
 }
@@ -266,12 +264,13 @@
     diff_view: DiffViewMode.SIDE_BY_SIDE,
     size_bar_in_change_table: true,
     my: [],
-    theme: AppTheme.LIGHT,
+    theme: AppTheme.AUTO,
     date_format: DateFormat.EURO,
     time_format: TimeFormat.HHMM_24,
     change_table: [],
     email_strategy: EmailStrategy.ATTENTION_SET_ONLY,
     default_base_for_merges: DefaultBase.AUTO_MERGE,
+    allow_browser_notifications: false,
   };
 }
 
diff --git a/polygerrit-ui/app/constants/messages.ts b/polygerrit-ui/app/constants/messages.ts
index 850a5d2..dca1e61 100644
--- a/polygerrit-ui/app/constants/messages.ts
+++ b/polygerrit-ui/app/constants/messages.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 /** Message shown when no threads in gr-thread-list for robot comments */
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 8818066..0e00d07 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 export enum LifeCycle {
@@ -33,6 +22,8 @@
   METHOD_USED = 'method used',
   CHECKS_API_NOT_LOGGED_IN = 'checks-api not-logged-in',
   CHECKS_API_ERROR = 'checks-api error',
+  USER_PREFERENCES_COLUMNS = 'user-preferences-columns',
+  PREFER_MERGE_FIRST_PARENT = 'prefer-merge-first-parent',
 }
 
 export enum Timing {
@@ -94,12 +85,19 @@
   DRAFT_DISCARD = 'DiscardDraftComment',
   // Time to load checks from all providers for the first time.
   CHECKS_LOAD = 'ChecksLoad',
+  // Webvitals - Cumulative Layout Shift (CLS): measures visual stability
+  CLS = 'CLS',
+  // WebVitals - First Input Delay (FID): measures interactivity
+  FID = 'FID',
+  // WebVitals - Largest Contentful Paint (LCP): measures loading performance.
+  LCP = 'LCP',
 }
 
 export enum Interaction {
   TOGGLE_SHOW_ALL_BUTTON = 'toggle show all button',
   SHOW_TAB = 'show-tab',
   ATTENTION_SET_CHIP = 'attention-set-chip',
+  BULK_ACTION = 'bulk-action',
   SAVE_COMMENT = 'save-comment',
   COMMENT_SAVED = 'comment-saved',
   DISCARD_COMMENT = 'discard-comment',
@@ -122,4 +120,32 @@
   CHECKS_RUNS_PANEL_TOGGLE = 'checks-runs-panel-toggle',
   CHECKS_RUNS_SELECTED_TRIGGERED = 'checks-runs-selected-triggered',
   CHECKS_STATS = 'checks-stats',
+  // The following interactions are logged for investigating a spurious bug of
+  // auto-closing draft comments.
+  COMMENTS_AUTOCLOSE_FIRST_UPDATE = 'comments-autoclose-first-update',
+  COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE = 'comments-autoclose-editing-false-save',
+  COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED = 'comments-autoclose-editing-disconnected',
+  COMMENTS_AUTOCLOSE_EDITING_THREAD_DISCONNECTED = 'comments-autoclose-editing-thread-disconnected',
+  COMMENTS_AUTOCLOSE_CHECKS_UPDATED = 'comments-autoclose-checks-updated',
+  COMMENTS_AUTOCLOSE_THREADS_UPDATED = 'comments-autoclose-threads-updated',
+  COMMENTS_AUTOCLOSE_COMMENT_REMOVED = 'comments-autoclose-comment-removed',
+  COMMENTS_AUTOCLOSE_MESSAGES_LIST_CREATED = 'comments-autoclose-messages-list-created',
+  COMMENTS_AUTOCLOSE_MESSAGES_LIST_UPDATED = 'comments-autoclose-messages-list-updated',
+  COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED = 'comments-autoclose-thread-list-created',
+  COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED = 'comments-autoclose-thread-list-updated',
+  // The following interactions are logged for investigating a spurious bug of
+  // auto-closing diffs.
+  DIFF_AUTOCLOSE_DIFF_UNDEFINED = 'diff-autoclose-diff-undefined',
+  DIFF_AUTOCLOSE_DIFF_ONGOING = 'diff-autoclose-diff-ongoing',
+  DIFF_AUTOCLOSE_RELOAD_ON_WHITESPACE = 'diff-autoclose-reload-on-whitespace',
+  DIFF_AUTOCLOSE_RELOAD_ON_SYNTAX = 'diff-autoclose-reload-on-syntax',
+  DIFF_AUTOCLOSE_RELOAD_FILELIST_PREFS = 'diff-autoclose-reload-filelist-prefs',
+  DIFF_AUTOCLOSE_DIFF_HOST_CREATED = 'diff-autoclose-diff-host-created',
+  DIFF_AUTOCLOSE_DIFF_HOST_NOT_RENDERING = 'diff-autoclose-diff-not-rendering',
+  DIFF_AUTOCLOSE_FILE_LIST_UPDATED = 'diff-autoclose-file-list-updated',
+  DIFF_AUTOCLOSE_SHOWN_FILES_CHANGED = 'diff-autoclose-shown-files-changed',
+  // The following interaction is logged for reporting and counting a suspected
+  // Chrome bug that leads to html`` misbehavior.
+  AUTOCLOSE_HTML_PATCHED = 'autoclose-html-patched',
+  CHANGE_ACTION_FIRED = 'change-action-fired',
 }
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 2c83ed3..d5a83a7 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
@@ -1,23 +1,11 @@
 /**
  * @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 '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../gr-permission/gr-permission';
 import {
   AccessPermissions,
@@ -42,7 +30,7 @@
 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 {customElement, property, query, state} from 'lit/decorators.js';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 
@@ -191,7 +179,7 @@
                 class=${this.section?.id === GLOBAL_NAME ? 'global' : ''}
                 @click=${this.editReference}
               >
-                <iron-icon id="icon" icon="gr-icons:create"></iron-icon>
+                <gr-icon id="icon" icon="edit" filled small></gr-icon>
               </gr-button>
             </div>
             <iron-input
@@ -369,6 +357,7 @@
     if (!this.permissions) {
       return;
     }
+    delete this.section?.value.permissions[this.permissions[index].id];
     this.permissions = this.permissions
       .slice(0, index)
       .concat(this.permissions.slice(index + 1, this.permissions.length));
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
index 1c4c437..593a1ed 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-access-section';
 import {
   AccessPermissions,
@@ -25,7 +13,7 @@
 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';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-access-section tests', () => {
   let element: GrAccessSection;
@@ -82,6 +70,108 @@
       await element.updateComplete;
     });
 
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <fieldset class="gr-form-styles" id="section">
+            <div id="mainContainer">
+              <div class="header">
+                <div class="name">
+                  <h3 class="heading-3">Reference: refs/*</h3>
+                  <gr-button
+                    aria-disabled="false"
+                    id="editBtn"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    <gr-icon icon="edit" id="icon" small filled></gr-icon>
+                  </gr-button>
+                </div>
+                <iron-input class="editRefInput">
+                  <input class="editRefInput" type="text" />
+                </iron-input>
+                <gr-button
+                  aria-disabled="false"
+                  id="deleteBtn"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Remove
+                </gr-button>
+              </div>
+              <div class="sectionContent">
+                <gr-permission> </gr-permission>
+                <div id="addPermission">
+                  Add permission:
+                  <select id="permissionSelect">
+                    <option value="label-Code-Review">Label Code-Review</option>
+                    <option value="labelAs-Code-Review">
+                      Label Code-Review (On Behalf Of)
+                    </option>
+                    <option value="abandon">Abandon</option>
+                    <option value="addPatchSet">Add Patch Set</option>
+                    <option value="create">Create Reference</option>
+                    <option value="createSignedTag">Create Signed Tag</option>
+                    <option value="createTag">Create Annotated Tag</option>
+                    <option value="delete">Delete Reference</option>
+                    <option value="deleteChanges">Delete Changes</option>
+                    <option value="deleteOwnChanges">Delete Own Changes</option>
+                    <option value="editHashtags">Edit Hashtags</option>
+                    <option value="editTopicName">Edit Topic Name</option>
+                    <option value="forgeAuthor">Forge Author Identity</option>
+                    <option value="forgeCommitter">
+                      Forge Committer Identity
+                    </option>
+                    <option value="forgeServerAsCommitter">
+                      Forge Server Identity
+                    </option>
+                    <option value="owner">Owner</option>
+                    <option value="push">Push</option>
+                    <option value="pushMerge">Push Merge Commit</option>
+                    <option value="rebase">Rebase</option>
+                    <option value="removeReviewer">Remove Reviewer</option>
+                    <option value="revert">Revert</option>
+                    <option value="submit">Submit</option>
+                    <option value="submitAs">Submit (On Behalf Of)</option>
+                    <option value="toggleWipState">
+                      Toggle Work In Progress State
+                    </option>
+                    <option value="viewPrivateChanges">
+                      View Private Changes
+                    </option>
+                  </select>
+                  <gr-button
+                    aria-disabled="false"
+                    id="addBtn"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Add
+                  </gr-button>
+                </div>
+              </div>
+            </div>
+            <div id="deletedContainer">
+              <span> Reference: refs/* was deleted </span>
+              <gr-button
+                aria-disabled="false"
+                id="undoRemoveBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Undo
+              </gr-button>
+            </div>
+          </fieldset>
+        `
+      );
+    });
+
     test('updateSection', () => {
       // updateSection was called in setup, so just make assertions.
       const expectedPermissions = [
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 ba737a7..5d32d32 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
@@ -1,26 +1,12 @@
 /**
  * @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 '../../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 {GerritNav} from '../../core/gr-navigation/gr-navigation';
-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';
@@ -30,8 +16,10 @@
 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 {customElement, query, property, state} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
+import {AdminViewState} from '../../../models/views/admin';
+import {createGroupUrl} from '../../../models/views/group';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -48,7 +36,7 @@
   @query('#createNewModal') private createNewModal?: GrCreateGroupDialog;
 
   @property({type: Object})
-  params?: AppElementAdminParams;
+  params?: AdminViewState;
 
   /**
    * Offset of currently visible query results.
@@ -178,7 +166,7 @@
    *
    * private but used in test
    */
-  maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+  maybeOpenCreateOverlay(params?: AdminViewState) {
     if (params?.openCreateModal) {
       assertIsDefined(this.createOverlay, 'createOverlay');
       this.createOverlay.open();
@@ -190,8 +178,9 @@
    *
    * private but used in test
    */
-  computeGroupUrl(id: string) {
-    return GerritNav.getUrlForGroup(decodeURIComponent(id) as GroupId);
+  computeGroupUrl(encodedId: string) {
+    const groupId = decodeURIComponent(encodedId) as GroupId;
+    return createGroupUrl({groupId});
   }
 
   private getCreateGroupCapability() {
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
index 709a0b7..e484489 100644
--- 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
@@ -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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
 function createGroup(name: string, counter: number) {
   return {
@@ -69,36 +55,58 @@
   let element: GrAdminGroupList;
   let groups: GroupNameToGroupInfoMap;
 
-  const value: AppElementAdminParams = {view: GerritView.ADMIN, adminView: ''};
+  const value: AdminViewState = {
+    view: GerritView.ADMIN,
+    adminView: AdminChildView.GROUPS,
+  };
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-admin-group-list></gr-admin-group-list>`);
   });
 
-  test('computeGroupUrl', () => {
-    let urlStub = sinon
-      .stub(GerritNav, 'getUrlForGroup')
-      .callsFake(
-        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5'
-      );
-
-    let group = 'e2cd66f88a2db4d391ac068a92d987effbe872f5';
-    assert.equal(
-      element.computeGroupUrl(group),
-      '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5'
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-list-view>
+          <table class="genericList" id="list">
+            <tbody>
+              <tr class="headerRow">
+                <th class="name topHeader">Group Name</th>
+                <th class="description topHeader">Group Description</th>
+                <th class="topHeader visibleToAll">Visible To All</th>
+              </tr>
+              <tr class="loading loadingMsg" id="loading">
+                <td>Loading...</td>
+              </tr>
+            </tbody>
+            <tbody class="loading"></tbody>
+          </table>
+        </gr-list-view>
+        <gr-overlay
+          aria-hidden="true"
+          id="createOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-dialog
+            class="confirmDialog"
+            confirm-label="Create"
+            confirm-on-enter=""
+            disabled=""
+            id="createDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Create Group</div>
+            <div class="main" slot="main">
+              <gr-create-group-dialog id="createNewModal">
+              </gr-create-group-dialog>
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+      `
     );
-
-    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', () => {
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 154b470..088002c 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
@@ -1,22 +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 '../../shared/gr-dropdown-list/gr-dropdown-list';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-page-nav/gr-page-nav';
 import '../gr-admin-group-list/gr-admin-group-list';
 import '../gr-group/gr-group';
@@ -30,11 +18,7 @@
 import '../gr-repo-detail-list/gr-repo-detail-list';
 import '../gr-repo-list/gr-repo-list';
 import {getBaseUrl} from '../../../utils/url-util';
-import {
-  GerritNav,
-  GroupDetailView,
-  RepoDetailView,
-} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {
   AdminNavLinksOption,
@@ -43,26 +27,38 @@
   SubsectionInterface,
 } from '../../../utils/admin-nav-util';
 import {
-  AppElementAdminParams,
-  AppElementGroupParams,
-  AppElementRepoParams,
-} from '../../gr-app-types';
-import {
   AccountDetailInfo,
   GroupId,
   GroupName,
   RepoName,
 } from '../../../types/common';
 import {GroupNameChangedDetail} from '../gr-group/gr-group';
-import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 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';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {
+  AdminChildView,
+  adminViewModelToken,
+  AdminViewState,
+} from '../../../models/views/admin';
+import {
+  GroupDetailView,
+  groupViewModelToken,
+  GroupViewState,
+} from '../../../models/views/group';
+import {
+  RepoDetailView,
+  repoViewModelToken,
+  RepoViewState,
+} from '../../../models/views/repo';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -75,33 +71,21 @@
   parent?: GroupId | RepoName;
 }
 
-// The type is matched to the _showAdminView function from the gr-app-element
-type AdminViewParams =
-  | AppElementAdminParams
-  | AppElementGroupParams
-  | AppElementRepoParams;
-
-function getAdminViewParamsDetail(
-  params: AdminViewParams
-): GroupDetailView | RepoDetailView | undefined {
-  if (params.view !== GerritView.ADMIN) {
-    return params.detail;
-  }
-  return undefined;
-}
-
 @customElement('gr-admin-view')
 export class GrAdminView extends LitElement {
   private account?: AccountDetailInfo;
 
-  @property({type: Object})
-  params?: AdminViewParams;
+  @state()
+  view?: GerritView;
 
-  @property({type: String})
-  path?: string;
+  @state()
+  adminViewState?: AdminViewState;
 
-  @property({type: String})
-  adminView?: string;
+  @state()
+  groupViewState?: GroupViewState;
+
+  @state()
+  repoViewState?: RepoViewState;
 
   @state() private breadcrumbParentName?: string;
 
@@ -130,6 +114,52 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getAdminViewModel = resolve(this, adminViewModelToken);
+
+  private readonly getGroupViewModel = resolve(this, groupViewModelToken);
+
+  private readonly getRepoViewModel = resolve(this, repoViewModelToken);
+
+  private readonly routerModel = getAppContext().routerModel;
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getAdminViewModel().state$,
+      state => {
+        this.adminViewState = state;
+        if (this.needsReload()) this.reload();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getGroupViewModel().state$,
+      state => {
+        this.groupViewState = state;
+        if (this.needsReload()) this.reload();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getRepoViewModel().state$,
+      state => {
+        this.repoViewState = state;
+        if (this.needsReload()) this.reload();
+      }
+    );
+    subscribe(
+      this,
+      () => this.routerModel.routerView$,
+      view => {
+        this.view = view;
+        if (this.needsReload()) this.reload();
+      }
+    );
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     this.reload();
@@ -145,7 +175,7 @@
           /* Same as dropdown trigger so chevron spacing is consistent. */
           padding: 5px 4px;
         }
-        iron-icon {
+        gr-icon {
           margin: 0 var(--spacing-xs);
         }
         .breadcrumb {
@@ -171,6 +201,7 @@
   }
 
   override render() {
+    if (!this.isAdminView()) return nothing;
     return html`
       <gr-page-nav class="navStyles">
         <ul class="sectionContent">
@@ -207,7 +238,7 @@
   }
 
   private renderAdminNavSubsection(item: NavLink) {
-    if (!item.subsection) return;
+    if (!item.subsection) return nothing;
 
     return html`
       <!--If a section has a subsection, render that.-->
@@ -245,13 +276,13 @@
   }
 
   private renderSubsectionLinks() {
-    if (!this.subsectionLinks?.length) return;
+    if (!this.subsectionLinks?.length) return nothing;
 
     return html`
       <section class="mainHeader">
         <span class="breadcrumb">
           <span class="breadcrumbText">${this.breadcrumbParentName}</span>
-          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+          <gr-icon icon="chevron_right"></gr-icon>
         </span>
         <gr-dropdown-list
           id="pageSelect"
@@ -265,82 +296,67 @@
   }
 
   private renderRepoList() {
-    const params = this.params as AppElementAdminParams;
-    if (
-      !(
-        params?.view === GerritView.ADMIN &&
-        params?.adminView === 'gr-repo-list'
-      )
-    )
-      return;
+    if (this.view !== GerritView.ADMIN) return nothing;
+    if (this.adminViewState?.adminView !== AdminChildView.REPOS) return nothing;
 
     return html`
       <div class="main table">
-        <gr-repo-list class="table" .params=${params}></gr-repo-list>
+        <gr-repo-list
+          class="table"
+          .params=${this.adminViewState}
+        ></gr-repo-list>
       </div>
     `;
   }
 
   private renderGroupList() {
-    const params = this.params as AppElementAdminParams;
-    if (
-      !(
-        params?.view === GerritView.ADMIN &&
-        params?.adminView === 'gr-admin-group-list'
-      )
-    )
-      return;
+    if (this.view !== GerritView.ADMIN) return nothing;
+    if (this.adminViewState?.adminView !== AdminChildView.GROUPS)
+      return nothing;
 
     return html`
       <div class="main table">
-        <gr-admin-group-list class="table" .params=${params}>
+        <gr-admin-group-list class="table" .params=${this.adminViewState}>
         </gr-admin-group-list>
       </div>
     `;
   }
 
   private renderPluginList() {
-    const params = this.params as AppElementAdminParams;
-    if (
-      !(
-        params?.view === GerritView.ADMIN &&
-        params?.adminView === 'gr-plugin-list'
-      )
-    )
-      return;
+    if (this.view !== GerritView.ADMIN) return nothing;
+    if (this.adminViewState?.adminView !== AdminChildView.PLUGINS)
+      return nothing;
 
     return html`
       <div class="main table">
-        <gr-plugin-list class="table" .params=${params}></gr-plugin-list>
+        <gr-plugin-list
+          class="table"
+          .params=${this.adminViewState}
+        ></gr-plugin-list>
       </div>
     `;
   }
 
   private renderRepoMain() {
-    const params = this.params as AppElementRepoParams;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        (!params?.detail || params?.detail === RepoDetailView.GENERAL)
-      )
-    )
-      return;
+    if (this.view !== GerritView.REPO) return nothing;
+    const detail = this.repoViewState?.detail ?? RepoDetailView.GENERAL;
+    if (detail !== RepoDetailView.GENERAL) return nothing;
 
     return html`
       <div class="main breadcrumbs">
-        <gr-repo .repo=${params.repo}></gr-repo>
+        <gr-repo .repo=${this.repoViewState?.repo}></gr-repo>
       </div>
     `;
   }
 
   private renderGroup() {
-    const params = this.params as AppElementGroupParams;
-    if (!(params?.view === GerritView.GROUP && !params?.detail)) return;
+    if (this.view !== GerritView.GROUP) return nothing;
+    if (this.groupViewState?.detail !== undefined) return nothing;
 
     return html`
       <div class="main breadcrumbs">
         <gr-group
-          .groupId=${params.groupId}
+          .groupId=${this.groupViewState?.groupId}
           @name-changed=${(e: CustomEvent<GroupNameChangedDetail>) => {
             this.updateGroupName(e);
           }}
@@ -350,122 +366,86 @@
   }
 
   private renderGroupMembers() {
-    const params = this.params as AppElementGroupParams;
-    if (
-      !(
-        params?.view === GerritView.GROUP &&
-        params?.detail === GroupDetailView.MEMBERS
-      )
-    )
-      return;
+    if (this.view !== GerritView.GROUP) return nothing;
+    if (this.groupViewState?.detail !== GroupDetailView.MEMBERS) return nothing;
 
     return html`
       <div class="main breadcrumbs">
-        <gr-group-members .groupId=${params.groupId}></gr-group-members>
+        <gr-group-members
+          .groupId=${this.groupViewState?.groupId}
+        ></gr-group-members>
       </div>
     `;
   }
 
   private renderGroupAuditLog() {
-    const params = this.params as AppElementGroupParams;
-    if (
-      !(
-        params?.view === GerritView.GROUP &&
-        params?.detail === GroupDetailView.LOG
-      )
-    )
-      return;
+    if (this.view !== GerritView.GROUP) return nothing;
+    if (this.groupViewState?.detail !== GroupDetailView.LOG) return nothing;
 
     return html`
       <div class="main table breadcrumbs">
         <gr-group-audit-log
           class="table"
-          .groupId=${params.groupId}
+          .groupId=${this.groupViewState?.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;
+    if (this.view !== GerritView.REPO) return nothing;
+    const detail = this.repoViewState?.detail;
+    if (detail !== RepoDetailView.BRANCHES && detail !== RepoDetailView.TAGS) {
+      return nothing;
+    }
 
     return html`
       <div class="main table breadcrumbs">
         <gr-repo-detail-list
           class="table"
-          .params=${params}
+          .params=${this.repoViewState}
         ></gr-repo-detail-list>
       </div>
     `;
   }
 
   private renderRepoCommands() {
-    const params = this.params as AppElementRepoParams;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        params?.detail === RepoDetailView.COMMANDS
-      )
-    )
-      return;
+    if (this.view !== GerritView.REPO) return nothing;
+    if (this.repoViewState?.detail !== RepoDetailView.COMMANDS) return nothing;
 
     return html`
       <div class="main breadcrumbs">
-        <gr-repo-commands .repo=${params.repo}></gr-repo-commands>
+        <gr-repo-commands .repo=${this.repoViewState.repo}></gr-repo-commands>
       </div>
     `;
   }
 
   private renderRepoAccess() {
-    const params = this.params as AppElementRepoParams;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        params?.detail === RepoDetailView.ACCESS
-      )
-    )
-      return;
+    if (this.view !== GerritView.REPO) return nothing;
+    if (this.repoViewState?.detail !== RepoDetailView.ACCESS) return nothing;
 
     return html`
       <div class="main breadcrumbs">
-        <gr-repo-access
-          .path=${this.path}
-          .repo=${params.repo}
-        ></gr-repo-access>
+        <gr-repo-access .repo=${this.repoViewState.repo}></gr-repo-access>
       </div>
     `;
   }
 
   private renderRepoDashboards() {
-    const params = this.params as AppElementRepoParams;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        params?.detail === RepoDetailView.DASHBOARDS
-      )
-    )
-      return;
+    if (this.view !== GerritView.REPO) return nothing;
+    if (this.repoViewState?.detail !== RepoDetailView.DASHBOARDS)
+      return nothing;
 
     return html`
       <div class="main table breadcrumbs">
-        <gr-repo-dashboards .repo=${params.repo}></gr-repo-dashboards>
+        <gr-repo-dashboards
+          .repo=${this.repoViewState.repo}
+        ></gr-repo-dashboards>
       </div>
     `;
   }
 
   override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('params')) {
-      this.paramsChanged();
-    }
-
     if (changedProperties.has('groupId')) {
       this.computeGroupName();
     }
@@ -536,24 +516,29 @@
     }
   }
 
+  private getDetailView() {
+    if (this.view === GerritView.REPO) return this.repoViewState?.detail;
+    if (this.view === GerritView.GROUP) return this.groupViewState?.detail;
+    return undefined;
+  }
+
   private computeSelectValue() {
-    if (!this.params?.view) return;
-    return `${this.params.view}${getAdminViewParamsDetail(this.params) ?? ''}`;
+    return `${this.view}${this.getDetailView() ?? ''}`;
   }
 
   // private but used in test
   selectedIsCurrentPage(selected: AdminSubsectionLink) {
-    if (!this.params) return false;
+    if (!this.view) return false;
 
     return (
       selected.parent === (this.repoName ?? this.groupId) &&
-      selected.view === this.params.view &&
-      selected.detailType === getAdminViewParamsDetail(this.params)
+      selected.view === this.view &&
+      selected.detailType === this.getDetailView()
     );
   }
 
   // private but used in test
-  handleSubsectionChange(e: CustomEvent<ValueChangeDetail>) {
+  handleSubsectionChange(e: ValueChangedEvent<string>) {
     if (!this.subsectionLinks) return;
 
     // The GrDropdownList items are subsectionLinks, so find(...) always return
@@ -566,26 +551,30 @@
     if (this.selectedIsCurrentPage(selected)) return;
     if (selected.url === undefined) return;
     if (this.reloading) return;
-    GerritNav.navigateToRelativeUrl(selected.url);
+    this.getNavigation().setUrl(selected.url);
   }
 
-  private async paramsChanged() {
-    if (this.needsReload()) await this.reload();
+  isAdminView(): boolean {
+    return (
+      this.view === GerritView.ADMIN ||
+      this.view === GerritView.GROUP ||
+      this.view === GerritView.REPO
+    );
   }
 
   needsReload(): boolean {
-    if (!this.params) return false;
+    if (!this.isAdminView()) return false;
 
     let needsReload = false;
     const newRepoName =
-      this.params.view === GerritView.REPO ? this.params.repo : undefined;
+      this.view === GerritView.REPO ? this.repoViewState?.repo : undefined;
     if (newRepoName !== this.repoName) {
       this.repoName = newRepoName;
       // Reloads the admin menu.
       needsReload = true;
     }
     const newGroupId =
-      this.params.view === GerritView.GROUP ? this.params.groupId : undefined;
+      this.view === GerritView.GROUP ? this.groupViewState?.groupId : undefined;
     if (newGroupId !== this.groupId) {
       this.groupId = newGroupId;
       // Reloads the admin menu.
@@ -593,8 +582,8 @@
     }
     if (
       this.breadcrumbParentName &&
-      (this.params.view !== GerritView.GROUP || !this.params.groupId) &&
-      (this.params.view !== GerritView.REPO || !this.params.repo)
+      (this.view !== GerritView.GROUP || !this.groupViewState?.groupId) &&
+      (this.view !== GerritView.REPO || !this.repoViewState?.repo)
     ) {
       needsReload = true;
     }
@@ -613,42 +602,34 @@
   }
 
   private computeSelectedClass(
-    itemView?: GerritView,
+    itemView?: GerritView | AdminChildView,
     detailType?: GroupDetailView | RepoDetailView
   ) {
-    const params = this.params;
-    if (!params) return '';
-    // Group params are structured differently from admin params. Compute
+    if (!this.view) return '';
+    // Group view state is structured differently than admin view state. Compute
     // selected differently for groups.
-    // TODO(wyatta): Simplify this when all routes work like group params.
-    if (params.view === GerritView.GROUP && itemView === GerritView.GROUP) {
-      if (!params.detail && !detailType) {
+    // TODO(wyatta): Simplify this when all routes work like group view state.
+    if (this.view === GerritView.GROUP && itemView === GerritView.GROUP) {
+      if (!this.groupViewState?.detail && !detailType) {
         return 'selected';
       }
-      if (params.detail === detailType) {
+      if (this.groupViewState?.detail === detailType) {
         return 'selected';
       }
       return '';
     }
 
-    if (params.view === GerritView.REPO && itemView === GerritView.REPO) {
-      if (!params.detail && !detailType) {
+    if (this.view === GerritView.REPO && itemView === GerritView.REPO) {
+      if (!this.repoViewState?.detail && !detailType) {
         return 'selected';
       }
-      if (params.detail === detailType) {
+      if (this.repoViewState?.detail === detailType) {
         return 'selected';
       }
       return '';
     }
-    // TODO(TS): The following condition seems always false, because params
-    // never has detailType property. Remove it.
-    if (
-      (params as unknown as AdminSubsectionLink).detailType &&
-      (params as unknown as AdminSubsectionLink).detailType !== detailType
-    ) {
-      return '';
-    }
-    return params.view === GerritView.ADMIN && itemView === params.adminView
+    return this.view === GerritView.ADMIN &&
+      itemView === this.adminViewState?.adminView
       ? 'selected'
       : '';
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
index 0f473c3..d65d171 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -1,34 +1,25 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {stubBaseUrl, stubElement, 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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {AdminChildView} from '../../../models/views/admin';
+import {GroupDetailView} from '../../../models/views/group';
+import {RepoDetailView} from '../../../models/views/repo';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 function createAdminCapabilities() {
   return {
@@ -42,7 +33,7 @@
   let element: GrAdminView;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-admin-view></gr-admin-view>`);
     stubRestApi('getProjectConfig').returns(Promise.resolve(undefined));
     const pluginsLoaded = Promise.resolve();
     sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
@@ -86,9 +77,10 @@
       },
     ];
 
-    element.params = {
+    element.view = GerritView.ADMIN;
+    element.adminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-repo-list',
+      adminView: AdminChildView.REPOS,
     };
 
     await element.updateComplete;
@@ -171,6 +163,7 @@
   });
 
   test('Repo shows up in nav', async () => {
+    element.view = GerritView.REPO;
     element.repoName = 'Test Repo' as RepoName;
     stubRestApi('getAccount').returns(
       Promise.resolve({
@@ -220,7 +213,7 @@
     assert.isNotOk(element.filteredLinks![2].subsection);
   });
 
-  test('Nav is reloaded when repo changes', async () => {
+  test('Needs reload when repo changes', async () => {
     stubRestApi('getAccountCapabilities').returns(
       Promise.resolve(createAdminCapabilities())
     );
@@ -230,16 +223,19 @@
         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};
+
+    element.view = GerritView.REPO;
+    element.repoViewState = {repo: 'Repo 1' as RepoName, view: GerritView.REPO};
+    assert.isTrue(element.needsReload());
+    await element.reload();
     await element.updateComplete;
-    assert.equal(reloadStub.callCount, 1);
-    element.params = {repo: 'Test Repo 2' as RepoName, view: GerritView.REPO};
+
+    element.repoViewState = {repo: 'Repo 2' as RepoName, view: GerritView.REPO};
+    assert.isTrue(element.needsReload());
     await element.updateComplete;
-    assert.equal(reloadStub.callCount, 2);
   });
 
-  test('Nav is reloaded when group changes', async () => {
+  test('Needs reload when group changes', async () => {
     sinon.stub(element, 'computeGroupName');
     stubRestApi('getAccountCapabilities').returns(
       Promise.resolve(createAdminCapabilities())
@@ -250,13 +246,12 @@
         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);
+    element.view = GerritView.GROUP;
+    element.groupViewState = {groupId: '1' as GroupId, view: GerritView.GROUP};
+    assert.isTrue(element.needsReload());
   });
 
-  test('Nav is reloaded when changing from repo to group', async () => {
+  test('Needs reload when changing from repo to group', async () => {
     element.repoName = 'Test Repo' as RepoName;
     stubRestApi('getAccount').returns(
       Promise.resolve({
@@ -271,26 +266,25 @@
     await element.updateComplete;
 
     sinon.stub(element, 'computeGroupName');
-    const reloadStub = sinon.stub(element, 'reload');
     const groupId = '1' as GroupId;
-    element.params = {groupId, view: GerritView.GROUP};
+    element.view = GerritView.GROUP;
+    element.groupViewState = {groupId, view: GerritView.GROUP};
     await element.updateComplete;
 
-    assert.equal(reloadStub.callCount, 1);
+    assert.isTrue(element.needsReload());
     assert.equal(element.groupId, groupId);
   });
 
-  test('Nav is reloaded when group name changes', async () => {
+  test('Needs reload 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.view = GerritView.GROUP;
+    element.groupViewState = {groupId: '1' as GroupId, view: GerritView.GROUP};
     element.groupName = 'oldName' as GroupName;
+    assert.isTrue(element.needsReload());
+    await element.reload();
     await element.updateComplete;
+
     queryAndAssert<GrGroup>(element, 'gr-group').dispatchEvent(
       new CustomEvent('name-changed', {
         detail: {name: newName},
@@ -298,11 +292,11 @@
         bubbles: true,
       })
     );
-    await reloadCalled;
     assert.equal(element.groupName, newName);
   });
 
   test('dropdown displays if there is a subsection', async () => {
+    element.view = GerritView.REPO;
     assert.isNotOk(query(element, '.mainHeader'));
     element.subsectionLinks = [
       {
@@ -322,10 +316,11 @@
 
   test('Dropdown only triggers navigation on explicit select', async () => {
     element.repoName = 'my-repo' as RepoName;
-    element.params = {
+    element.view = GerritView.REPO;
+    element.repoViewState = {
       repo: 'my-repo' as RepoName,
-      view: GerritNav.View.REPO,
-      detail: GerritNav.RepoDetailView.ACCESS,
+      view: GerritView.REPO,
+      detail: RepoDetailView.ACCESS,
     };
     stubRestApi('getAccountCapabilities').returns(
       Promise.resolve(createAdminCapabilities())
@@ -337,6 +332,7 @@
       })
     );
     await element.updateComplete;
+
     const expectedFilteredLinks = [
       {
         name: 'Repositories',
@@ -351,38 +347,38 @@
             {
               name: 'General',
               view: GerritView.REPO,
-              url: '',
-              detailType: GerritNav.RepoDetailView.GENERAL,
+              url: '/admin/repos/my-repo,general',
+              detailType: RepoDetailView.GENERAL,
             },
             {
               name: 'Access',
               view: GerritView.REPO,
-              detailType: GerritNav.RepoDetailView.ACCESS,
-              url: '',
+              detailType: RepoDetailView.ACCESS,
+              url: '/admin/repos/my-repo,access',
             },
             {
               name: 'Commands',
               view: GerritView.REPO,
-              detailType: GerritNav.RepoDetailView.COMMANDS,
-              url: '',
+              detailType: RepoDetailView.COMMANDS,
+              url: '/admin/repos/my-repo,commands',
             },
             {
               name: 'Branches',
               view: GerritView.REPO,
-              detailType: GerritNav.RepoDetailView.BRANCHES,
-              url: '',
+              detailType: RepoDetailView.BRANCHES,
+              url: '/admin/repos/my-repo,branches',
             },
             {
               name: 'Tags',
               view: GerritView.REPO,
-              detailType: GerritNav.RepoDetailView.TAGS,
-              url: '',
+              detailType: RepoDetailView.TAGS,
+              url: '/admin/repos/my-repo,tags',
             },
             {
               name: 'Dashboards',
               view: GerritView.REPO,
-              detailType: GerritNav.RepoDetailView.DASHBOARDS,
-              url: '',
+              detailType: RepoDetailView.DASHBOARDS,
+              url: '/admin/repos/my-repo,dashboards',
             },
           ],
         },
@@ -416,81 +412,84 @@
         text: 'General',
         value: 'repogeneral',
         view: GerritView.REPO,
-        url: '',
-        detailType: GerritNav.RepoDetailView.GENERAL,
+        url: '/admin/repos/my-repo,general',
+        detailType: RepoDetailView.GENERAL,
         parent: 'my-repo' as RepoName,
       },
       {
         text: 'Access',
         value: 'repoaccess',
         view: GerritView.REPO,
-        url: '',
-        detailType: GerritNav.RepoDetailView.ACCESS,
+        url: '/admin/repos/my-repo,access',
+        detailType: RepoDetailView.ACCESS,
         parent: 'my-repo' as RepoName,
       },
       {
         text: 'Commands',
         value: 'repocommands',
         view: GerritView.REPO,
-        url: '',
-        detailType: GerritNav.RepoDetailView.COMMANDS,
+        url: '/admin/repos/my-repo,commands',
+        detailType: RepoDetailView.COMMANDS,
         parent: 'my-repo' as RepoName,
       },
       {
         text: 'Branches',
         value: 'repobranches',
         view: GerritView.REPO,
-        url: '',
-        detailType: GerritNav.RepoDetailView.BRANCHES,
+        url: '/admin/repos/my-repo,branches',
+        detailType: RepoDetailView.BRANCHES,
         parent: 'my-repo' as RepoName,
       },
       {
         text: 'Tags',
         value: 'repotags',
         view: GerritView.REPO,
-        url: '',
-        detailType: GerritNav.RepoDetailView.TAGS,
+        url: '/admin/repos/my-repo,tags',
+        detailType: RepoDetailView.TAGS,
         parent: 'my-repo' as RepoName,
       },
       {
         text: 'Dashboards',
         value: 'repodashboards',
         view: GerritView.REPO,
-        url: '',
-        detailType: GerritNav.RepoDetailView.DASHBOARDS,
+        url: '/admin/repos/my-repo,dashboards',
+        detailType: RepoDetailView.DASHBOARDS,
         parent: 'my-repo' as RepoName,
       },
     ];
-    const navigateToRelativeUrlStub = sinon.stub(
-      GerritNav,
-      'navigateToRelativeUrl'
-    );
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     const selectedIsCurrentPageSpy = sinon.spy(
       element,
       'selectedIsCurrentPage'
     );
     sinon.spy(element, 'handleSubsectionChange');
     await element.reload();
+    await element.updateComplete;
     assert.deepEqual(element.filteredLinks, expectedFilteredLinks);
     assert.deepEqual(element.subsectionLinks, expectedSubsectionLinks);
     assert.equal(
       queryAndAssert<GrDropdownList>(element, '#pageSelect').value,
       'repoaccess'
     );
-    assert.isTrue(selectedIsCurrentPageSpy.calledOnce);
+    assert.equal(selectedIsCurrentPageSpy.callCount, 1);
     // Doesn't trigger navigation from the page select menu.
-    assert.isFalse(navigateToRelativeUrlStub.called);
+    assert.isFalse(setUrlStub.called);
 
     // When explicitly changed, navigation is called
     queryAndAssert<GrDropdownList>(element, '#pageSelect').value =
       'repogeneral';
-    assert.isTrue(selectedIsCurrentPageSpy.calledTwice);
-    assert.isTrue(navigateToRelativeUrlStub.calledOnce);
+    await queryAndAssert<GrDropdownList>(element, '#pageSelect').updateComplete;
+    assert.equal(selectedIsCurrentPageSpy.callCount, 2);
+    assert.isTrue(setUrlStub.calledOnce);
   });
 
   test('selectedIsCurrentPage', () => {
     element.repoName = 'my-repo' as RepoName;
-    element.params = {view: GerritView.REPO, repo: 'my-repo' as RepoName};
+    element.view = GerritView.REPO;
+    element.repoViewState = {
+      view: GerritView.REPO,
+      repo: 'my-repo' as RepoName,
+    };
     const selected = {
       view: GerritView.REPO,
       parent: 'my-repo' as RepoName,
@@ -500,7 +499,7 @@
     assert.isTrue(element.selectedIsCurrentPage(selected));
     selected.parent = 'my-second-repo' as RepoName;
     assert.isFalse(element.selectedIsCurrentPage(selected));
-    selected.detailType = GerritNav.RepoDetailView.GENERAL;
+    selected.detailType = RepoDetailView.GENERAL;
     assert.isFalse(element.selectedIsCurrentPage(selected));
   });
 
@@ -518,17 +517,67 @@
       await element.reload();
     });
 
+    test('render', async () => {
+      element.view = GerritView.ADMIN;
+      element.adminViewState = {
+        view: GerritView.ADMIN,
+        adminView: AdminChildView.REPOS,
+        openCreateModal: false,
+      };
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-page-nav class="navStyles">
+            <ul class="sectionContent">
+              <li class="sectionTitle selected">
+                <a
+                  class="title"
+                  href="//localhost:9876/admin/repos"
+                  rel="noopener"
+                >
+                  Repositories
+                </a>
+              </li>
+              <li class="sectionTitle">
+                <a
+                  class="title"
+                  href="//localhost:9876/admin/groups"
+                  rel="noopener"
+                >
+                  Groups
+                </a>
+              </li>
+              <li class="sectionTitle">
+                <a
+                  class="title"
+                  href="//localhost:9876/admin/plugins"
+                  rel="noopener"
+                >
+                  Plugins
+                </a>
+              </li>
+            </ul>
+          </gr-page-nav>
+          <div class="main table">
+            <gr-repo-list class="table"></gr-repo-list>
+          </div>
+        `
+      );
+    });
+
     suite('repos', () => {
       setup(() => {
-        stub('gr-repo-access', '_repoChanged').callsFake(() =>
+        stubElement('gr-repo-access', '_repoChanged').callsFake(() =>
           Promise.resolve()
         );
       });
 
       test('repo list', async () => {
-        element.params = {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-repo-list',
+        element.view = GerritView.ADMIN;
+        element.adminViewState = {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.REPOS,
           openCreateModal: false,
         };
         await element.updateComplete;
@@ -538,8 +587,9 @@
       });
 
       test('repo', async () => {
-        element.params = {
-          view: GerritNav.View.REPO,
+        element.view = GerritView.REPO;
+        element.repoViewState = {
+          view: GerritView.REPO,
           repo: 'foo' as RepoName,
         };
         element.repoName = 'foo' as RepoName;
@@ -551,9 +601,10 @@
       });
 
       test('repo access', async () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.ACCESS,
+        element.view = GerritView.REPO;
+        element.repoViewState = {
+          view: GerritView.REPO,
+          detail: RepoDetailView.ACCESS,
           repo: 'foo' as RepoName,
         };
         element.repoName = 'foo' as RepoName;
@@ -565,9 +616,10 @@
       });
 
       test('repo dashboards', async () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.DASHBOARDS,
+        element.view = GerritView.REPO;
+        element.repoViewState = {
+          view: GerritView.REPO,
+          detail: RepoDetailView.DASHBOARDS,
           repo: 'foo' as RepoName,
         };
         element.repoName = 'foo' as RepoName;
@@ -583,8 +635,8 @@
       let getGroupConfigStub: sinon.SinonStub;
 
       setup(async () => {
-        stub('gr-group', 'loadGroup').callsFake(() => Promise.resolve());
-        stub('gr-group-members', 'loadGroupDetails').callsFake(() =>
+        stubElement('gr-group', 'loadGroup').callsFake(() => Promise.resolve());
+        stubElement('gr-group-members', 'loadGroupDetails').callsFake(() =>
           Promise.resolve()
         );
 
@@ -600,9 +652,10 @@
       });
 
       test('group list', async () => {
-        element.params = {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
+        element.view = GerritView.ADMIN;
+        element.adminViewState = {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.GROUPS,
           openCreateModal: false,
         };
         await element.updateComplete;
@@ -612,12 +665,13 @@
       });
 
       test('internal group', async () => {
-        element.params = {
-          view: GerritNav.View.GROUP,
+        element.view = GerritView.GROUP;
+        element.groupViewState = {
+          view: GerritView.GROUP,
           groupId: '1234' as GroupId,
         };
         element.groupName = 'foo' as GroupName;
-        await element.reload();
+        if (element.needsReload()) await element.reload();
         await element.updateComplete;
         const subsectionItems = queryAll<HTMLLIElement>(
           element,
@@ -637,12 +691,13 @@
             id: 'external-id',
           })
         );
-        element.params = {
-          view: GerritNav.View.GROUP,
+        element.view = GerritView.GROUP;
+        element.groupViewState = {
+          view: GerritView.GROUP,
           groupId: '1234' as GroupId,
         };
         element.groupName = 'foo' as GroupName;
-        await element.reload();
+        if (element.needsReload()) await element.reload();
         await element.updateComplete;
         const subsectionItems = queryAll<HTMLLIElement>(
           element,
@@ -656,13 +711,14 @@
       });
 
       test('group members', async () => {
-        element.params = {
-          view: GerritNav.View.GROUP,
-          detail: GerritNav.GroupDetailView.MEMBERS,
+        element.view = GerritView.GROUP;
+        element.groupViewState = {
+          view: GerritView.GROUP,
+          detail: GroupDetailView.MEMBERS,
           groupId: '1234' as GroupId,
         };
         element.groupName = 'foo' as GroupName;
-        await element.reload();
+        if (element.needsReload()) await element.reload();
         await element.updateComplete;
         const selected = queryAndAssert(element, 'gr-page-nav .selected');
         assert.isOk(selected);
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
index 04d3198..42ec988 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
@@ -1,23 +1,12 @@
 /**
  * @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 '../../shared/gr-dialog/gr-dialog';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
index e286883..d4b5f03 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
@@ -1,34 +1,43 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-confirm-delete-item-dialog';
 import {GrConfirmDeleteItemDialog} from './gr-confirm-delete-item-dialog';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-
-const basicFixture = fixtureFromElement('gr-confirm-delete-item-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-confirm-delete-item-dialog tests', () => {
   let element: GrConfirmDeleteItemDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(
+      html`<gr-confirm-delete-item-dialog></gr-confirm-delete-item-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog
+          confirm-label="Delete UNKNOWN ITEM TYPE"
+          confirm-on-enter=""
+          role="dialog"
+        >
+          <div class="header" slot="header">UNKNOWN ITEM TYPE Deletion</div>
+          <div class="main" slot="main">
+            <label for="branchInput">
+              Do you really want to delete the following UNKNOWN ITEM TYPE?
+            </label>
+            <div>UNKNOWN ITEM</div>
+          </div>
+        </gr-dialog>
+      `
+    );
   });
 
   test('_handleConfirmTap', () => {
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 8e797b7..cee0fa4 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
@@ -1,26 +1,16 @@
 /**
  * @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 '@polymer/iron-input/iron-input';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 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 {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   RepoName,
   BranchName,
@@ -32,12 +22,13 @@
 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 {customElement, property, query, state} from 'lit/decorators.js';
 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';
+import {createChangeUrl} from '../../../models/views/change';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
@@ -81,19 +72,20 @@
 
   private readonly configModel = resolve(this, configModelToken);
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.query = (input: string) => this.getRepoBranchesSuggestions(input);
-  }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    if (!this.repoName) return;
-
-    subscribe(this, this.configModel().serverConfig$, config => {
-      this.privateChangesEnabled =
-        config?.change?.disable_private_changes ?? false;
-    });
+    subscribe(
+      this,
+      () => this.configModel().serverConfig$,
+      config => {
+        this.privateChangesEnabled =
+          config?.change?.disable_private_changes ?? false;
+      }
+    );
   }
 
   static override get styles() {
@@ -185,7 +177,7 @@
               .bindValue=${this.subject}
               placeholder="Insert the description of the change."
               @bind-value-changed=${(e: BindValueChangeEvent) => {
-                this.subject = e.detail.value;
+                this.subject = e.detail.value ?? '';
               }}
             >
             </iron-autogrow-textarea>
@@ -234,9 +226,9 @@
         this.baseChange,
         this.baseCommit || undefined
       )
-      .then(changeCreated => {
-        if (!changeCreated) return;
-        GerritNav.navigateToChange(changeCreated);
+      .then(change => {
+        if (!change) return;
+        this.getNavigation().setUrl(createChangeUrl({change}));
       });
   }
 
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 626b03f..87916b6 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-change-dialog';
 import {GrCreateChangeDialog} from './gr-create-change-dialog';
 import {BranchName, GitRef, RepoName} from '../../../types/common';
@@ -23,8 +11,7 @@
 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');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-create-change-dialog tests', () => {
   let element: GrCreateChangeDialog;
@@ -43,11 +30,79 @@
         return Promise.resolve([]);
       }
     });
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-create-change-dialog></gr-create-change-dialog>`
+    );
     element.repoName = 'test-repo' as RepoName;
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <section>
+            <span class="title"> Select branch for new change </span>
+            <span class="value">
+              <gr-autocomplete
+                id="branchInput"
+                placeholder="Destination branch"
+              >
+              </gr-autocomplete>
+            </span>
+          </section>
+          <section>
+            <span class="title"> Provide base commit sha1 for change </span>
+            <span class="value">
+              <iron-input>
+                <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>
+                <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
+                aria-disabled="false"
+                autocomplete="on"
+                class="message"
+                id="messageInput"
+                maxrows="15"
+                placeholder="Insert the description of the change."
+                rows="4"
+              >
+              </iron-autogrow-textarea>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="privateChangeCheckBox">
+              Private change
+            </label>
+            <span class="value">
+              <input id="privateChangeCheckBox" type="checkbox" />
+            </span>
+          </section>
+        </div>
+      `
+    );
+  });
+
   test('new change created with default', async () => {
     const configInputObj = {
       branch: 'test-branch',
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 9ab7646..4808d00 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
@@ -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 '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
@@ -24,7 +13,7 @@
 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 {customElement, query, property} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {fireEvent} from '../../../utils/event-util';
 
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
index f84c76c..2a0b539 100644
--- 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-group-dialog';
 import {GrCreateGroupDialog} from './gr-create-group-dialog';
 import {page} from '../../../utils/page-wrapper-utils';
@@ -26,8 +14,7 @@
 } from '../../../test/test-utils';
 import {IronInputElement} from '@polymer/iron-input';
 import {GroupId} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-create-group-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-create-group-dialog tests', () => {
   let element: GrCreateGroupDialog;
@@ -35,8 +22,27 @@
   const GROUP_NAME = 'test-group';
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-create-group-dialog></gr-create-group-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <div id="form">
+            <section>
+              <span class="title"> Group name </span>
+              <iron-input>
+                <input />
+              </iron-input>
+            </section>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('name is updated correctly', async () => {
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 63b852c..889a859 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
@@ -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 '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
@@ -21,13 +10,13 @@
 import {page} from '../../../utils/page-wrapper-utils';
 import {BranchName, RepoName} from '../../../types/common';
 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 {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {fireEvent} from '../../../utils/event-util';
+import {RepoDetailView} from '../../../models/views/repo';
 
 declare global {
   interface HTMLElementTagNameMap {
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 b888c348..9e455d1 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-pointer-dialog';
 import {GrCreatePointerDialog} from './gr-create-pointer-dialog';
 import {
@@ -24,10 +12,9 @@
   stubRestApi,
 } from '../../../test/test-utils';
 import {BranchName} from '../../../types/common';
-import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 import {IronInputElement} from '@polymer/iron-input';
-
-const basicFixture = fixtureFromElement('gr-create-pointer-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
+import {RepoDetailView} from '../../../models/views/repo';
 
 suite('gr-create-pointer-dialog tests', () => {
   let element: GrCreatePointerDialog;
@@ -36,8 +23,39 @@
     queryAndAssert<IronInputElement>(element, 'iron-input');
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-create-pointer-dialog></gr-create-pointer-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <div id="form">
+            <section id="itemNameSection">
+              <span class="title"> name </span>
+              <iron-input>
+                <input placeholder=" Name" />
+              </iron-input>
+            </section>
+            <section id="itemRevisionSection">
+              <span class="title"> Initial Revision </span>
+              <iron-input>
+                <input placeholder="Revision (Branch or SHA-1)" />
+              </iron-input>
+            </section>
+            <section id="itemAnnotationSection">
+              <span class="title"> Annotation </span>
+              <iron-input>
+                <input placeholder="Annotation (Optional)" />
+              </iron-input>
+            </section>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('branch created', async () => {
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 617ef99..158419e 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
@@ -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 '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
@@ -32,7 +21,7 @@
 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 {customElement, query, property, state} from 'lit/decorators.js';
 import {fireEvent} from '../../../utils/event-util';
 
 declare global {
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
index d3e2171..63b8c06 100644
--- 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-repo-dialog';
 import {GrCreateRepoDialog} from './gr-create-repo-dialog';
 import {
@@ -26,15 +14,75 @@
 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');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-create-repo-dialog tests', () => {
   let element: GrCreateRepoDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-create-repo-dialog></gr-create-repo-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <div id="form">
+            <section>
+              <span class="title"> Repository name </span>
+              <iron-input>
+                <input autocomplete="on" id="repoNameInput" />
+              </iron-input>
+            </section>
+            <section>
+              <span class="title"> Default Branch </span>
+              <iron-input>
+                <input autocomplete="off" id="defaultBranchNameInput" />
+              </iron-input>
+            </section>
+            <section>
+              <span class="title"> Rights inherit from </span>
+              <span class="value">
+                <gr-autocomplete id="rightsInheritFromInput"> </gr-autocomplete>
+              </span>
+            </section>
+            <section>
+              <span class="title"> Owner </span>
+              <span class="value">
+                <gr-autocomplete id="ownerInput"> </gr-autocomplete>
+              </span>
+            </section>
+            <section>
+              <span class="title"> Create initial empty commit </span>
+              <span class="value">
+                <gr-select id="initialCommit">
+                  <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">
+                  <select>
+                    <option value="false">False</option>
+                    <option value="true">True</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('default values are populated', () => {
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 7a80396..e0c0d30 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
@@ -1,22 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-account-label/gr-account-label';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
   GroupInfo,
   AccountInfo,
@@ -31,7 +18,8 @@
 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';
+import {customElement, property, state} from 'lit/decorators.js';
+import {createGroupUrl} from '../../../models/views/group';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -180,12 +168,9 @@
     return isGroupAuditGroupEventInfo(event);
   }
 
-  private computeGroupUrl(group: GroupInfo) {
-    if (group && group.url && group.id) {
-      return GerritNav.getUrlForGroup(group.id);
-    }
-
-    return '';
+  private computeGroupUrl(group?: GroupInfo) {
+    if (!group?.id) return '';
+    return createGroupUrl({groupId: group.id});
   }
 
   // private but used in test
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 79b635a..828a3c6 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-group-audit-log';
 import {
   addListenerForTest,
@@ -35,15 +23,34 @@
   createGroupInfo,
 } from '../../../test/test-data-generators';
 import {PageErrorEvent} from '../../../types/events';
-
-const basicFixture = fixtureFromElement('gr-group-audit-log');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-group-audit-log tests', () => {
   let element: GrGroupAuditLog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-group-audit-log></gr-group-audit-log>`);
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <table class="genericList" id="list">
+          <tbody>
+            <tr class="headerRow">
+              <th class="date topHeader">Date</th>
+              <th class="topHeader type">Type</th>
+              <th class="member topHeader">Member</th>
+              <th class="by-user topHeader">By User</th>
+            </tr>
+            <tr class="loading loadingMsg" id="loading">
+              <td>Loading...</td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
   });
 
   suite('members', () => {
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 ad90759..c716d65 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
@@ -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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-account-label/gr-account-label';
@@ -28,6 +17,7 @@
   AccountInfo,
   GroupInfo,
   GroupName,
+  ServerInfo,
 } from '../../../types/common';
 import {
   AutocompleteQuery,
@@ -48,10 +38,13 @@
 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';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {getAccountSuggestions} from '../../../utils/account-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {resolve} from '../../../models/dependency';
 
-const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
   'Group may not exist, or you may not have ' + 'permission to add it';
 
@@ -112,9 +105,21 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private serverConfig?: ServerInfo;
+
   constructor() {
     super();
-    this.queryMembers = input => this.getAccountSuggestions(input);
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+    this.queryMembers = input =>
+      getAccountSuggestions(input, this.restApiService, this.serverConfig);
     this.queryIncludedGroup = input => this.getGroupSuggestions(input);
   }
 
@@ -524,32 +529,6 @@
   }
 
   /* private but used in test */
-  getAccountSuggestions(input: string) {
-    if (input.length === 0) {
-      return Promise.resolve([]);
-    }
-    return this.restApiService
-      .getSuggestedAccounts(input, SUGGESTIONS_LIMIT)
-      .then(accounts => {
-        if (!accounts) return [];
-        const accountSuggestions = [];
-        for (const account of accounts) {
-          let nameAndEmail;
-          if (account.email !== undefined) {
-            nameAndEmail = `${account.name} <${account.email}>`;
-          } else {
-            nameAndEmail = account.name;
-          }
-          accountSuggestions.push({
-            name: nameAndEmail,
-            value: account._account_id?.toString(),
-          });
-        }
-        return accountSuggestions;
-      });
-  }
-
-  /* private but used in test */
   getGroupSuggestions(input: string) {
     return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups: AutocompleteSuggestion[] = [];
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
index 762bd2d..6c65dd6 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-group-members';
 import {GrGroupMembers, ItemType} from './gr-group-members';
 import {
@@ -37,10 +25,11 @@
 } 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');
+import {EventType, PageErrorEvent} from '../../../types/events';
+import {getAccountSuggestions} from '../../../utils/account-util';
+import {getAppContext} from '../../../services/app-context';
+import {fixture, html, assert} from '@open-wc/testing';
+import {createServerInfo} from '../../../test/test-data-generators';
 
 suite('gr-group-members tests', () => {
   let element: GrGroupMembers;
@@ -114,6 +103,7 @@
             name: 'test-account',
             email: 'test.account@example.com' as EmailAddress,
             username: 'test123',
+            display_name: 'display-test-account',
           },
           {
             _account_id: 1001439 as AccountId,
@@ -148,14 +138,228 @@
     stubRestApi('getGroupMembers').returns(Promise.resolve(groupMembers));
     stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
     stubRestApi('getIncludedGroup').returns(Promise.resolve(includedGroups));
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-group-members></gr-group-members>`);
     stubBaseUrl('https://test/site');
     element.groupId = 'testId1' as GroupId;
     groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(groups));
     return element.loadGroupDetails();
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles main">
+          <div id="loading">Loading...</div>
+          <div id="loadedContent">
+            <h1 class="heading-1" id="Title">Administrators</h1>
+            <div id="form">
+              <h3 class="heading-3" id="members">Members</h3>
+              <fieldset>
+                <span class="value">
+                  <gr-autocomplete
+                    id="groupMemberSearchInput"
+                    placeholder="Name Or Email"
+                  >
+                  </gr-autocomplete>
+                </span>
+                <gr-button
+                  aria-disabled="true"
+                  disabled=""
+                  id="saveGroupMember"
+                  role="button"
+                  tabindex="-1"
+                >
+                  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>
+                    <tr>
+                      <td class="nameColumn">
+                        <gr-account-label clickable="" deselected="">
+                        </gr-account-label>
+                      </td>
+                      <td>jane.roe@example.com</td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteMembersButton"
+                          data-index="0"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <gr-account-label clickable="" deselected="">
+                        </gr-account-label>
+                      </td>
+                      <td>john.doe@example.com</td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteMembersButton"
+                          data-index="1"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <gr-account-label clickable="" deselected="">
+                        </gr-account-label>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteMembersButton"
+                          data-index="2"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <gr-account-label clickable="" deselected="">
+                        </gr-account-label>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteMembersButton"
+                          data-index="3"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                  </tbody>
+                </table>
+              </fieldset>
+              <h3 class="heading-3" id="includedGroups">Included Groups</h3>
+              <fieldset>
+                <span class="value">
+                  <gr-autocomplete
+                    id="includedGroupSearchInput"
+                    placeholder="Group Name"
+                  >
+                  </gr-autocomplete>
+                </span>
+                <gr-button
+                  aria-disabled="true"
+                  disabled=""
+                  id="saveIncludedGroups"
+                  role="button"
+                  tabindex="-1"
+                >
+                  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>
+                    <tr>
+                      <td class="nameColumn">
+                        <a href="https://group/url" rel="noopener">
+                          testName
+                        </a>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteIncludedGroupButton"
+                          data-index="0"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <a href="https://test/site/group/url" rel="noopener">
+                          testName2
+                        </a>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteIncludedGroupButton"
+                          data-index="1"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <a href="https://test/site/group/url" rel="noopener">
+                          testName3
+                        </a>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteIncludedGroupButton"
+                          data-index="2"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                  </tbody>
+                </table>
+              </fieldset>
+            </div>
+          </div>
+        </div>
+        <gr-overlay
+          aria-hidden="true"
+          id="overlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-confirm-delete-item-dialog class="confirmDialog">
+          </gr-confirm-delete-item-dialog>
+        </gr-overlay>
+      `
+    );
+  });
+
   test('includedGroups', () => {
     assert.equal(element.includedGroups!.length, 3);
     assert.equal(
@@ -248,7 +452,7 @@
 
     const memberName = 'bad-name';
     const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
+    element.addEventListener(EventType.SHOW_ALERT, alertStub);
     const errorResponse = {...new Response(), status: 404, ok: false};
     stubRestApi('saveIncludedGroup').callsFake((_, _non, errFn) => {
       if (errFn !== undefined) {
@@ -294,14 +498,25 @@
   });
 
   test('getAccountSuggestions empty', async () => {
-    const accounts = await element.getAccountSuggestions('nonexistent');
+    const accounts = await getAccountSuggestions(
+      'nonexistent',
+      getAppContext().restApiService,
+      createServerInfo()
+    );
     assert.equal(accounts.length, 0);
   });
 
   test('getAccountSuggestions non-empty', async () => {
-    const accounts = await element.getAccountSuggestions('test-');
+    const accounts = await getAccountSuggestions(
+      'test-',
+      getAppContext().restApiService,
+      createServerInfo()
+    );
     assert.equal(accounts.length, 3);
-    assert.equal(accounts[0].name, 'test-account <test.account@example.com>');
+    assert.equal(
+      accounts[0].name,
+      'display-test-account <test.account@example.com>'
+    );
     assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
     assert.equal(accounts[2].name, 'test-git');
   });
@@ -322,16 +537,16 @@
 
   test('delete member', () => {
     const deleteBtns = queryAll<GrButton>(element, '.deleteMembersButton');
-    MockInteractions.tap(deleteBtns[0]);
+    deleteBtns[0].click();
     assert.equal(element.itemId, 1000097 as AccountId);
     assert.equal(element.itemName, 'jane');
-    MockInteractions.tap(deleteBtns[1]);
+    deleteBtns[1].click();
     assert.equal(element.itemId, 1000096 as AccountId);
     assert.equal(element.itemName, 'Test User');
-    MockInteractions.tap(deleteBtns[2]);
+    deleteBtns[2].click();
     assert.equal(element.itemId, 1000095 as AccountId);
     assert.equal(element.itemName, 'Gerrit');
-    MockInteractions.tap(deleteBtns[3]);
+    deleteBtns[3].click();
     assert.equal(element.itemId, 1000098 as AccountId);
     assert.equal(element.itemName, '1000098');
   });
@@ -341,13 +556,13 @@
       element,
       '.deleteIncludedGroupButton'
     );
-    MockInteractions.tap(deleteBtns[0]);
+    deleteBtns[0].click();
     assert.equal(element.itemId, 'testId' as GroupId);
     assert.equal(element.itemName, 'testName');
-    MockInteractions.tap(deleteBtns[1]);
+    deleteBtns[1].click();
     assert.equal(element.itemId, 'testId2' as GroupId);
     assert.equal(element.itemName, 'testName2');
-    MockInteractions.tap(deleteBtns[2]);
+    deleteBtns[2].click();
     assert.equal(element.itemId, 'testId3' as GroupId);
     assert.equal(element.itemName, 'testName3');
   });
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 fc53ac6..e65b16b 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.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 '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
@@ -35,7 +23,7 @@
 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';
+import {customElement, property, state} from 'lit/decorators.js';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -242,7 +230,7 @@
             rows="4"
             monospace
             ?disabled=${this.computeGroupDisabled()}
-            .text=${this.groupConfig?.description}
+            .text=${this.groupConfig?.description ?? ''}
             @text-changed=${this.handleDescriptionTextChanged}
           ></gr-textarea>
         </div>
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
index a6258b0..256c6a9 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-group';
 import {GrGroup} from './gr-group';
 import {
@@ -25,14 +13,13 @@
   stubRestApi,
   waitUntil,
 } from '../../../test/test-utils';
-import {createGroupInfo} from '../../../test/test-data-generators.js';
+import {createGroupInfo} from '../../../test/test-data-generators';
 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');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-group tests', () => {
   let element: GrGroup;
@@ -52,11 +39,118 @@
   };
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-group></gr-group>`);
     groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(group));
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles main read-only">
+          <div class="loading" id="loading">Loading...</div>
+          <div class="loading" id="loadedContent">
+            <h1 class="heading-1" id="Title"></h1>
+            <h2 class="heading-2" id="configurations">General</h2>
+            <div id="form">
+              <fieldset>
+                <h3 class="heading-3" id="groupUUID">Group UUID</h3>
+                <fieldset>
+                  <gr-copy-clipboard id="uuid"> </gr-copy-clipboard>
+                </fieldset>
+                <h3 class="heading-3" id="groupName">Group Name</h3>
+                <fieldset>
+                  <span class="value">
+                    <gr-autocomplete disabled="" id="groupNameInput">
+                    </gr-autocomplete>
+                  </span>
+                  <span class="value">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      id="inputUpdateNameBtn"
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Rename Group
+                    </gr-button>
+                  </span>
+                </fieldset>
+                <h3 class="heading-3" id="groupOwner">Owners</h3>
+                <fieldset>
+                  <span class="value">
+                    <gr-autocomplete disabled="" id="groupOwnerInput">
+                    </gr-autocomplete>
+                  </span>
+                  <span class="value">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      id="inputUpdateOwnerBtn"
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Change Owners
+                    </gr-button>
+                  </span>
+                </fieldset>
+                <h3 class="heading-3">Description</h3>
+                <fieldset>
+                  <div>
+                    <gr-textarea
+                      autocomplete="on"
+                      class="description monospace"
+                      disabled=""
+                      monospace=""
+                      rows="4"
+                    >
+                    </gr-textarea>
+                  </div>
+                  <span class="value">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Save Description
+                    </gr-button>
+                  </span>
+                </fieldset>
+                <h3 class="heading-3" id="options">Group Options</h3>
+                <fieldset>
+                  <section>
+                    <span class="title">
+                      Make group visible to all registered users
+                    </span>
+                    <span class="value">
+                      <gr-select id="visibleToAll">
+                        <select disabled="">
+                          <option value="false">False</option>
+                          <option value="true">True</option>
+                        </select>
+                      </gr-select>
+                    </span>
+                  </section>
+                  <span class="value">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Save Group Options
+                    </gr-button>
+                  </span>
+                </fieldset>
+              </fieldset>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+
   test('loading displays before group config is loaded', () => {
     assert.isTrue(
       queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
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 ff3df99..35e50ad 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.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 '@polymer/paper-toggle-button/paper-toggle-button';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
@@ -26,7 +14,7 @@
   PermissionArray,
   AccessPermissionId,
 } from '../../../utils/access-util';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {
   LabelNameToLabelTypeInfoMap,
   LabelTypeInfoValues,
@@ -51,7 +39,7 @@
 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 {when} from 'lit/directives/when.js';
 import {ValueChangedEvent} from '../../../types/events';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
index b77a9ef0..b6bc3ed 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
@@ -1,27 +1,14 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-permission';
 import {GrPermission} from './gr-permission';
-import {query, stubRestApi} from '../../../test/test-utils';
+import {query, stubRestApi, waitEventLoop} 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,
@@ -29,14 +16,14 @@
 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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {PaperToggleButtonElement} from '@polymer/paper-toggle-button';
 
 suite('gr-permission tests', () => {
   let element: GrPermission;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-permission></gr-permission>`);
     stubRestApi('getSuggestedGroups').returns(
       Promise.resolve({
         Administrators: {
@@ -326,7 +313,74 @@
       };
       element.setupValues();
       await element.updateComplete;
-      flush();
+      await waitEventLoop();
+    });
+
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <section class="gr-form-styles" id="permission">
+            <div id="mainContainer">
+              <div class="header">
+                <span class="title"> Priority </span>
+                <div class="right">
+                  <paper-toggle-button
+                    aria-disabled="true"
+                    aria-pressed="false"
+                    disabled=""
+                    id="exclusiveToggle"
+                    role="button"
+                    style="pointer-events: none; touch-action: none;"
+                    tabindex="-1"
+                    toggles=""
+                  >
+                  </paper-toggle-button>
+                  Not Exclusive
+                  <gr-button
+                    aria-disabled="false"
+                    id="removeBtn"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Remove
+                  </gr-button>
+                </div>
+              </div>
+              <div class="rules">
+                <gr-rule-editor> </gr-rule-editor>
+                <gr-rule-editor> </gr-rule-editor>
+                <div id="addRule">
+                  <gr-autocomplete
+                    id="groupAutocomplete"
+                    placeholder="Add group"
+                  >
+                  </gr-autocomplete>
+                </div>
+              </div>
+            </div>
+            <div id="deletedContainer">
+              <span> Priority was deleted </span>
+              <gr-button
+                aria-disabled="false"
+                id="undoRemoveBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Undo
+              </gr-button>
+            </div>
+          </section>
+        `,
+        // touch-action varies on paper-toggle-button between local and CI
+        {
+          ignoreAttributes: [
+            {tags: ['paper-toggle-button'], attributes: ['style']},
+          ],
+        }
+      );
     });
 
     test('adding a rule', async () => {
@@ -384,7 +438,7 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       assert.equal(element.rules!.length, 1);
     });
 
@@ -396,7 +450,7 @@
       element.section = 'refs/*' as GitRef;
       element.permission!.value.added = true;
       await element.updateComplete;
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
       await element.updateComplete;
       assert.isTrue(removeStub.called);
     });
@@ -414,13 +468,14 @@
         queryAndAssert(element, '#permission').classList.contains('deleted')
       );
       assert.isFalse(element.deleted);
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
       await element.updateComplete;
       assert.isTrue(
         queryAndAssert(element, '#permission').classList.contains('deleted')
       );
       assert.isTrue(element.deleted);
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#undoRemoveBtn'));
+      queryAndAssert<GrButton>(element, '#undoRemoveBtn').click();
+
       await element.updateComplete;
       assert.isFalse(
         queryAndAssert(element, '#permission').classList.contains('deleted')
@@ -437,7 +492,10 @@
 
       assert.isFalse(element.originalExclusiveValue);
       assert.isNotOk(element.permission!.value.modified);
-      MockInteractions.tap(queryAndAssert(element, '#exclusiveToggle'));
+      queryAndAssert<PaperToggleButtonElement>(
+        element,
+        '#exclusiveToggle'
+      ).click();
       await element.updateComplete;
       assert.isTrue(element.permission!.value.exclusive);
       assert.isTrue(element.permission!.value.modified);
@@ -456,7 +514,10 @@
       element.addEventListener('access-modified', modifiedHandler);
       await element.updateComplete;
       assert.isNotOk(element.permission.value.modified);
-      MockInteractions.tap(queryAndAssert(element, '#exclusiveToggle'));
+      queryAndAssert<PaperToggleButtonElement>(
+        element,
+        '#exclusiveToggle'
+      ).click();
       await element.updateComplete;
       assert.isTrue(element.permission.value.modified);
       assert.isTrue(modifiedHandler.called);
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 2033180..54a83ee 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
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
 import '../../shared/gr-button/gr-button';
@@ -25,7 +13,7 @@
 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 {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
@@ -159,8 +147,7 @@
   }
 
   private handleInputKeydown(e: KeyboardEvent) {
-    // Enter.
-    if (e.keyCode === 13) {
+    if (e.key === 'Enter') {
       e.preventDefault();
       this.handleAdd();
     }
@@ -194,6 +181,6 @@
   }
 
   private handleBindValueChangedNewValue(e: BindValueChangeEvent) {
-    this.newValue = e.detail.value;
+    this.newValue = e.detail.value ?? '';
   }
 }
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
index 5de1f1e..672d58e 100644
--- 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
@@ -1,28 +1,16 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {ConfigParameterInfoType} from '../../../constants/constants.js';
-import '../../../test/common-test-setup-karma';
+import {ConfigParameterInfoType} from '../../../constants/constants';
+import '../../../test/common-test-setup';
 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';
+import {queryAll, queryAndAssert, pressKey} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+import {Key} from '../../../utils/dom-util';
 
 suite('gr-plugin-config-array-editor tests', () => {
   let element: GrPluginConfigArrayEditor;
@@ -42,6 +30,32 @@
     };
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles wrapper">
+          <div class="placeholder row">None configured.</div>
+          <div class="row">
+            <iron-input>
+              <input id="input" />
+            </iron-input>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="addButton"
+              link=""
+              role="button"
+              tabindex="-1"
+            >
+              Add
+            </gr-button>
+          </div>
+        </div>
+      `
+    );
+  });
+
   suite('adding', () => {
     setup(() => {
       dispatchStub = sinon.stub(element, 'dispatchChanged');
@@ -50,10 +64,7 @@
     test('with enter', async () => {
       element.newValue = '';
       await element.updateComplete;
-      MockInteractions.pressAndReleaseKeyOn(
-        queryAndAssert<HTMLInputElement>(element, '#input'),
-        13
-      ); // Enter
+      pressKey(queryAndAssert<HTMLInputElement>(element, '#input'), Key.ENTER);
       await element.updateComplete;
       assert.isFalse(
         queryAndAssert<HTMLInputElement>(element, '#input').hasAttribute(
@@ -66,10 +77,7 @@
       element.newValue = 'test';
       await element.updateComplete;
 
-      MockInteractions.pressAndReleaseKeyOn(
-        queryAndAssert<HTMLInputElement>(element, '#input'),
-        13
-      ); // Enter
+      pressKey(queryAndAssert<HTMLInputElement>(element, '#input'), Key.ENTER);
       await element.updateComplete;
       assert.isFalse(
         queryAndAssert<HTMLInputElement>(element, '#input').hasAttribute(
@@ -115,7 +123,7 @@
     assert.equal(rows.length, 2);
     const button = queryAndAssert<GrButton>(rows[0], 'gr-button');
 
-    MockInteractions.tap(button);
+    button.click();
     await element.updateComplete;
 
     assert.isFalse(dispatchStub.called);
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 3393596..383b4a7 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
@@ -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 '../../shared/gr-list-view/gr-list-view';
 import {PluginInfo} from '../../../types/common';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
@@ -22,11 +10,11 @@
 import {ErrorCallback} from '../../../api/rest';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
-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';
+import {customElement, property, state} from 'lit/decorators.js';
+import {AdminViewState} from '../../../models/views/admin';
 
 // Exported for tests
 export interface PluginInfoWithName extends PluginInfo {
@@ -41,7 +29,7 @@
    * URL params passed from the router.
    */
   @property({type: Object})
-  params?: AppElementAdminParams;
+  params?: AdminViewState;
 
   /**
    * Offset of currently visible query results.
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
index ca77a6d..4057e52 100644
--- 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-plugin-list';
 import {GrPluginList, PluginInfoWithName} from './gr-plugin-list';
 import {
@@ -27,12 +15,11 @@
   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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
 function pluginGenerator(counter: number) {
   const plugin: PluginInfo = {
@@ -72,11 +59,13 @@
   let element: GrPluginList;
   let plugins: {[pluginName: string]: PluginInfo} | undefined;
 
-  const value: AppElementAdminParams = {view: GerritView.ADMIN, adminView: ''};
+  const value: AdminViewState = {
+    view: GerritView.ADMIN,
+    adminView: AdminChildView.PLUGINS,
+  };
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-plugin-list></gr-plugin-list>`);
   });
 
   suite('list with plugins', async () => {
@@ -88,6 +77,228 @@
       await element.updateComplete;
     });
 
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-list-view>
+            <table class="genericList" id="list">
+              <tbody>
+                <tr class="headerRow">
+                  <th class="name topHeader">Plugin Name</th>
+                  <th class="topHeader version">Version</th>
+                  <th class="apiVersion topHeader">API Version</th>
+                  <th class="status topHeader">Status</th>
+                </tr>
+              </tbody>
+              <tbody>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test0/"> test0 </a>
+                  </td>
+                  <td class="version">version-0</td>
+                  <td class="apiVersion">api-version-0</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test1/"> test1 </a>
+                  </td>
+                  <td class="version">version-1</td>
+                  <td class="apiVersion">api-version-1</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">test2</td>
+                  <td class="version">version-2</td>
+                  <td class="apiVersion">api-version-2</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test3/"> test3 </a>
+                  </td>
+                  <td class="version">version-3</td>
+                  <td class="apiVersion">api-version-3</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test4/"> test4 </a>
+                  </td>
+                  <td class="version">version-4</td>
+                  <td class="apiVersion">
+                    <span class="placeholder"> -- </span>
+                  </td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test5/"> test5 </a>
+                  </td>
+                  <td class="version">version-5</td>
+                  <td class="apiVersion">api-version-5</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test6/"> test6 </a>
+                  </td>
+                  <td class="version">version-6</td>
+                  <td class="apiVersion">api-version-6</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test7/"> test7 </a>
+                  </td>
+                  <td class="version">version-7</td>
+                  <td class="apiVersion">api-version-7</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test8/"> test8 </a>
+                  </td>
+                  <td class="version">version-8</td>
+                  <td class="apiVersion">api-version-8</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test9/"> test9 </a>
+                  </td>
+                  <td class="version">version-9</td>
+                  <td class="apiVersion">api-version-9</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test10/"> test10 </a>
+                  </td>
+                  <td class="version">version-10</td>
+                  <td class="apiVersion">api-version-10</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test11/"> test11 </a>
+                  </td>
+                  <td class="version">version-11</td>
+                  <td class="apiVersion">api-version-11</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test12/"> test12 </a>
+                  </td>
+                  <td class="version">version-12</td>
+                  <td class="apiVersion">api-version-12</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test13/"> test13 </a>
+                  </td>
+                  <td class="version">version-13</td>
+                  <td class="apiVersion">api-version-13</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test14/"> test14 </a>
+                  </td>
+                  <td class="version">version-14</td>
+                  <td class="apiVersion">api-version-14</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test15/"> test15 </a>
+                  </td>
+                  <td class="version">version-15</td>
+                  <td class="apiVersion">api-version-15</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test16/"> test16 </a>
+                  </td>
+                  <td class="version">version-16</td>
+                  <td class="apiVersion">api-version-16</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test17/"> test17 </a>
+                  </td>
+                  <td class="version">version-17</td>
+                  <td class="apiVersion">api-version-17</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test18/"> test18 </a>
+                  </td>
+                  <td class="version">version-18</td>
+                  <td class="apiVersion">api-version-18</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test19/"> test19 </a>
+                  </td>
+                  <td class="version">version-19</td>
+                  <td class="apiVersion">api-version-19</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test20/"> test20 </a>
+                  </td>
+                  <td class="version">version-20</td>
+                  <td class="apiVersion">api-version-20</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test21/"> test21 </a>
+                  </td>
+                  <td class="version">version-21</td>
+                  <td class="apiVersion">api-version-21</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test22/"> test22 </a>
+                  </td>
+                  <td class="version">version-22</td>
+                  <td class="apiVersion">api-version-22</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test23/"> test23 </a>
+                  </td>
+                  <td class="version">version-23</td>
+                  <td class="apiVersion">api-version-23</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test24/"> test24 </a>
+                  </td>
+                  <td class="version">version-24</td>
+                  <td class="apiVersion">api-version-24</td>
+                  <td class="status">Enabled</td>
+                </tr>
+              </tbody>
+            </table>
+          </gr-list-view>
+        `
+      );
+    });
+
     test('plugin in the list is formatted correctly', async () => {
       await element.updateComplete;
       assert.equal(element.plugins![5].id, 'test5');
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
index 6136f1e1..c7bd36fe 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 /**
  * @fileoverview This file contains interfaces shared between gr-repo-access
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 ad15ab6..21ab184 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
@@ -1,23 +1,11 @@
 /**
  * @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-access-section/gr-access-section';
 import {encodeURL, getBaseUrl, singleDecodeURL} from '../../../utils/url-util';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {toSortedPermissionsArray} from '../../../utils/access-util';
 import {
   RepoName,
@@ -49,10 +37,12 @@
 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 {customElement, property, query, state} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {ValueChangedEvent} from '../../../types/events';
-import {ifDefined} from 'lit/directives/if-defined';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {resolve} from '../../../models/dependency';
+import {createChangeUrl} from '../../../models/views/change';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
@@ -76,9 +66,6 @@
   @property({type: String})
   repo?: RepoName;
 
-  @property({type: String})
-  path?: string;
-
   // private but used in test
   @state() canUpload?: boolean = false; // restAPI can return undefined
 
@@ -124,6 +111,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.query = () => this.getInheritFromSuggestions();
@@ -428,8 +417,11 @@
     this.editing = !this.editing;
   }
 
-  private handleAddedSectionRemoved(index: number) {
+  // private but used in tests
+  handleAddedSectionRemoved(index: number) {
     if (!this.sections) return;
+    assertIsDefined(this.local, 'local');
+    delete this.local[this.sections[index].id];
     this.sections = this.sections
       .slice(0, index)
       .concat(this.sections.slice(index + 1, this.sections.length));
@@ -623,7 +615,7 @@
     return addRemoveObj;
   }
 
-  private async handleCreateSection() {
+  private handleCreateSection() {
     if (!this.local) return;
     let newRef = 'refs/for/*';
     // Avoid using an already used key for the placeholder, since it
@@ -704,7 +696,7 @@
     return this.restApiService
       .setRepoAccessRightsForReview(this.repo, obj)
       .then(change => {
-        GerritNav.navigateToChange(change);
+        this.getNavigation().setUrl(createChangeUrl({change}));
       })
       .finally(() => {
         this.modified = false;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
index caa2d13..85d5c21 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
@@ -1,24 +1,12 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo-access';
 import {GrRepoAccess} from './gr-repo-access';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {toSortedPermissionsArray} from '../../../utils/access-util';
 import {
   addListenerForTest,
@@ -43,7 +31,8 @@
 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';
+import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-repo-access tests', () => {
   let element: GrRepoAccess;
@@ -125,6 +114,7 @@
       name: 'Create Account',
     },
   };
+
   setup(async () => {
     element = await fixture<GrRepoAccess>(html`
       <gr-repo-access></gr-repo-access>
@@ -137,6 +127,65 @@
     await element.updateComplete;
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="main">
+          <div id="loading">Loading...</div>
+          <div id="loadedContent">
+            <h3 class="heading-3" id="inheritsFrom">
+              <span class="rightsText"> Rights Inherit From </span>
+              <a href="" id="inheritFromName" rel="noopener"> </a>
+              <gr-autocomplete id="editInheritFromInput"> </gr-autocomplete>
+            </h3>
+            <div class="weblinks">History:</div>
+            <div class="referenceContainer">
+              <gr-button
+                aria-disabled="false"
+                id="addReferenceBtn"
+                role="button"
+                tabindex="0"
+              >
+                Add Reference
+              </gr-button>
+            </div>
+            <div>
+              <gr-button
+                aria-disabled="false"
+                id="editBtn"
+                role="button"
+                tabindex="0"
+              >
+                Edit
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                class="invisible"
+                id="saveBtn"
+                primary=""
+                role="button"
+                tabindex="0"
+              >
+                Save
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                class="invisible"
+                id="saveReviewBtn"
+                primary=""
+                role="button"
+                tabindex="0"
+              >
+                Save for review
+              </gr-button>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+
   test('_repoChanged called when repo name changes', async () => {
     const repoChangedStub = sinon.stub(element, '_repoChanged');
     element.repo = 'New Repo' as RepoName;
@@ -922,6 +971,22 @@
       assert.deepEqual(element.computeAddAndRemove(), expectedInput);
     });
 
+    test('add and remove and re-add ref', async () => {
+      // refs/for/* is added
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+
+      // refs/for/* is removed
+      element.handleAddedSectionRemoved(1);
+      await element.updateComplete;
+
+      // refs/for/* is re-added without extra starts
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+
+      assert.equal(element.sections![1].id, 'refs/for/*');
+    });
+
     test('computeAddAndRemove new section', async () => {
       // Add a new permission to a section
       let expectedInput = {};
@@ -1376,7 +1441,7 @@
       stubRestApi('getRepoAccessRights').returns(
         Promise.resolve(JSON.parse(JSON.stringify(accessRes)))
       );
-      const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
       let resolver: (value: Response | PromiseLike<Response>) => void;
       const saveStub = stubRestApi('setRepoAccessRights').returns(
         new Promise(r => (resolver = r))
@@ -1395,7 +1460,7 @@
       resolver!({status: 200} as Response);
       await element.updateComplete;
       assert.isTrue(saveStub.called);
-      assert.isTrue(navigateToChangeStub.notCalled);
+      assert.isTrue(setUrlStub.notCalled);
     });
 
     test('handleSaveForReview', async () => {
@@ -1426,7 +1491,7 @@
       stubRestApi('getRepoAccessRights').returns(
         Promise.resolve(JSON.parse(JSON.stringify(accessRes)))
       );
-      const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
       let resolver: (value: ChangeInfo | PromiseLike<ChangeInfo>) => void;
       const saveForReviewStub = stubRestApi(
         'setRepoAccessRightsForReview'
@@ -1447,8 +1512,9 @@
       resolver!(createChange());
       await element.updateComplete;
       assert.isTrue(saveForReviewStub.called);
+      assert.isTrue(setUrlStub.called);
       assert.isTrue(
-        navigateToChangeStub.lastCall.calledWithExactly(createChange())
+        setUrlStub.lastCall.args?.[0]?.includes(`${createChange()._number}`)
       );
     });
   });
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 f7ad3b8..9867aa5 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
@@ -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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
@@ -22,11 +10,11 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-change-dialog/gr-create-change-dialog';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   BranchName,
   ConfigInfo,
-  PatchSetNum,
+  RevisionPatchSetNum,
   RepoName,
 } from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
@@ -43,14 +31,16 @@
 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 {customElement, query, property, state} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
+import {createEditUrl} from '../../../models/views/edit';
+import {resolve} from '../../../models/dependency';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
 const CONFIG_PATH = 'project.config';
 const EDIT_CONFIG_SUBJECT = 'Edit Repo Config';
-const INITIAL_PATCHSET = 1 as PatchSetNum;
+const INITIAL_PATCHSET = 1 as RevisionPatchSetNum;
 const CREATE_CHANGE_FAILED_MESSAGE = 'Failed to create change.';
 const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
 
@@ -85,6 +75,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Repo Commands');
@@ -97,8 +89,12 @@
       subpageStyles,
       sharedStyles,
       css`
-        #form gr-button {
-          margin-bottom: var(--spacing-xxl);
+        #form h2,
+        h3 {
+          margin-top: var(--spacing-xxl);
+        }
+        p {
+          padding: var(--spacing-m) 0;
         }
       `,
     ];
@@ -112,27 +108,44 @@
           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>
+            <h2 class="heading-2">Create change</h2>
+            <div>
+              <p>
+                Creates an empty work-in-progress change that can be used to
+                edit files online and send the modifications for review.
+              </p>
+            </div>
+            <div>
+              <gr-button
+                ?loading=${this.creatingChange}
+                @click=${() => {
+                  this.createNewChange();
+                }}
+              >
+                Create change
+              </gr-button>
+            </div>
+            <h2 class="heading-2">Edit repo config</h2>
+            <div>
+              <p>
+                Creates a work-in-progress change that allows to edit the
+                <code>project.config</code> file in the
+                <code>refs/meta/config</code> branch and send the modifications
+                for review.
+              </p>
+            </div>
+            <div>
+              <gr-button
+                id="editRepoConfig"
+                ?loading=${this.editingConfig}
+                @click=${() => {
+                  this.handleEditRepoConfig();
+                }}
+              >
+                Edit repo config
+              </gr-button>
+            </div>
             ${this.renderRepoGarbageCollector()}
             <gr-endpoint-decorator name="repo-command">
               <gr-endpoint-param name="config" .value=${this.repoConfig}>
@@ -275,8 +288,13 @@
           return;
         }
 
-        GerritNav.navigateToRelativeUrl(
-          GerritNav.getEditUrlForDiff(change, CONFIG_PATH, INITIAL_PATCHSET)
+        this.getNavigation().setUrl(
+          createEditUrl({
+            changeNum: change._number,
+            project: change.project,
+            path: CONFIG_PATH,
+            patchNum: INITIAL_PATCHSET,
+          })
         );
       })
       .finally(() => {
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
index 42d3333..77caf5e 100644
--- 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
@@ -1,46 +1,30 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
-import './gr-repo-commands.js';
+import '../../../test/common-test-setup';
+import './gr-repo-commands';
 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 {EventType, PageErrorEvent} from '../../../types/events';
 import {RepoName} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
-
-const basicFixture = fixtureFromElement('gr-repo-commands');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-repo-commands tests', () => {
   let element: GrRepoCommands;
   let repoStub: sinon.SinonStub;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-repo-commands></gr-repo-commands>`);
     // 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.
@@ -49,6 +33,79 @@
     );
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles main read-only">
+          <h1 class="heading-1" id="Title">Repository Commands</h1>
+          <div class="loading" id="loading">Loading...</div>
+          <div class="loading" id="loadedContent">
+            <div id="form">
+              <h2 class="heading-2">Create change</h2>
+              <div>
+                <p>
+                  Creates an empty work-in-progress change that can be used to
+                  edit files online and send the modifications for review.
+                </p>
+              </div>
+              <div>
+                <gr-button aria-disabled="false" role="button" tabindex="0">
+                  Create change
+                </gr-button>
+              </div>
+              <h2 class="heading-2">Edit repo config</h2>
+              <div>
+                <p>
+                  Creates a work-in-progress change that allows to edit the
+                  <code> project.config </code>
+                  file in the
+                  <code> refs/meta/config </code>
+                  branch and send the modifications for review.
+                </p>
+              </div>
+              <div>
+                <gr-button
+                  aria-disabled="false"
+                  id="editRepoConfig"
+                  role="button"
+                  tabindex="0"
+                >
+                  Edit repo config
+                </gr-button>
+              </div>
+              <gr-endpoint-decorator name="repo-command">
+                <gr-endpoint-param name="config"> </gr-endpoint-param>
+                <gr-endpoint-param name="repoName"> </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+          </div>
+        </div>
+        <gr-overlay
+          aria-hidden="true"
+          id="createChangeOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-dialog
+            confirm-label="Create"
+            disabled=""
+            id="createChangeDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Create Change</div>
+            <div class="main" slot="main">
+              <gr-create-change-dialog id="createNewChangeModal">
+              </gr-create-change-dialog>
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+      `,
+      {ignoreTags: ['p']}
+    );
+  });
+
   suite('create new change dialog', () => {
     test('createNewChange opens modal', () => {
       const openStub = sinon.stub(
@@ -87,26 +144,21 @@
 
   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);
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
     });
 
     test('successful creation of change', async () => {
       const change = {_number: '1'};
       createChangeStub.returns(Promise.resolve(change));
-      MockInteractions.tap(
-        queryAndAssert<GrButton>(element, '#editRepoConfig')
-      );
+      queryAndAssert<GrButton>(element, '#editRepoConfig').click();
       await element.updateComplete;
       assert.isTrue(
         queryAndAssert<GrButton>(element, '#editRepoConfig').loading
@@ -120,8 +172,6 @@
         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
       );
@@ -129,9 +179,7 @@
 
     test('unsuccessful creation of change', async () => {
       createChangeStub.returns(Promise.resolve(null));
-      MockInteractions.tap(
-        queryAndAssert<GrButton>(element, '#editRepoConfig')
-      );
+      queryAndAssert<GrButton>(element, '#editRepoConfig').click();
       await element.updateComplete;
       assert.isTrue(
         queryAndAssert<GrButton>(element, '#editRepoConfig').loading
@@ -145,7 +193,6 @@
         alertStub.lastCall.args[0].detail.message,
         'Failed to create change.'
       );
-      assert.isFalse(urlStub.called);
       assert.isFalse(
         queryAndAssert<GrButton>(element, '#editRepoConfig').loading
       );
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 c2d7615..bff56bdd 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
@@ -1,21 +1,8 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
 import {firePageError} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
@@ -23,7 +10,8 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
+import {createDashboardUrl} from '../../../models/views/dashboard';
 
 interface DashboardRef {
   section: string;
@@ -165,12 +153,10 @@
       });
   }
 
-  _getUrl(project?: RepoName, id?: DashboardId) {
-    if (!project || !id) {
-      return '';
-    }
+  _getUrl(project?: RepoName, dashboard?: DashboardId) {
+    if (!project || !dashboard) return '';
 
-    return GerritNav.getUrlForRepoDashboard(project, id);
+    return createDashboardUrl({project, dashboard});
   }
 
   _computeLoadingClass(loading: boolean) {
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 7c26909..2bbc28b 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
@@ -1,41 +1,27 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo-dashboards';
 import {GrRepoDashboards} from './gr-repo-dashboards';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
   addListenerForTest,
   mockPromise,
   queryAndAssert,
   stubRestApi,
+  waitEventLoop,
 } from '../../../test/test-utils';
-import {DashboardId, DashboardInfo, RepoName} from '../../../types/common';
-import {PageErrorEvent} from '../../../types/events.js';
-
-const basicFixture = fixtureFromElement('gr-repo-dashboards');
+import {DashboardInfo, RepoName} from '../../../types/common';
+import {PageErrorEvent} from '../../../types/events';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-repo-dashboards tests', () => {
   let element: GrRepoDashboards;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(html`<gr-repo-dashboards></gr-repo-dashboards>`);
   });
 
   suite('dashboard table', () => {
@@ -93,6 +79,29 @@
       );
     });
 
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <table class="genericList loading" id="list">
+            <tbody>
+              <tr class="headerRow">
+                <th class="topHeader">Dashboard name</th>
+                <th class="topHeader">Dashboard title</th>
+                <th class="topHeader">Dashboard description</th>
+                <th class="topHeader">Inherited from</th>
+                <th class="topHeader">Default</th>
+              </tr>
+              <tr id="loadingContainer">
+                <td>Loading...</td>
+              </tr>
+            </tbody>
+            <tbody id="dashboards"></tbody>
+          </table>
+        `
+      );
+    });
+
     test('loading, sections, and ordering', async () => {
       assert.isTrue(element._loading);
       assert.notEqual(
@@ -104,7 +113,7 @@
         'none'
       );
       element.repo = 'test' as RepoName;
-      await flush();
+      await waitEventLoop();
       assert.equal(
         getComputedStyle(queryAndAssert(element, '#loadingContainer')).display,
         'none'
@@ -126,24 +135,6 @@
     });
   });
 
-  suite('test url', () => {
-    test('_getUrl', () => {
-      sinon
-        .stub(GerritNav, 'getUrlForRepoDashboard')
-        .callsFake(() => '/r/p/test/+/dashboard/default:contributor');
-
-      assert.equal(
-        element._getUrl(
-          'test' as RepoName,
-          'default:contributor' as DashboardId
-        ),
-        '/r/p/test/+/dashboard/default:contributor'
-      );
-
-      assert.equal(element._getUrl(undefined, undefined), '');
-    });
-  });
-
   suite('404', () => {
     test('fires page-error', async () => {
       const response = {status: 404} as Response;
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 d72f916..86d4bc5 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
@@ -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 '@polymer/iron-input/iron-input';
 import '../../shared/gr-account-label/gr-account-label';
 import '../../shared/gr-button/gr-button';
@@ -36,8 +24,6 @@
   TagInfo,
   WebLinkInfo,
 } from '../../../types/common';
-import {AppElementRepoParams} from '../../gr-app-types';
-import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 import {firePageError} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
@@ -46,10 +32,11 @@
 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 {customElement, query, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {assertIsDefined} from '../../../utils/common-util';
-import {ifDefined} from 'lit/directives/if-defined';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {RepoDetailView, RepoViewState} from '../../../models/views/repo';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
@@ -63,7 +50,7 @@
   private readonly createNewModal?: GrCreatePointerDialog;
 
   @property({type: Object})
-  params?: AppElementRepoParams;
+  params?: RepoViewState;
 
   // private but used in test
   @state() detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
@@ -419,7 +406,7 @@
     repo: RepoName | undefined,
     itemsPerPage: number,
     offset: number | undefined,
-    detailType: string
+    detailType?: string
   ) {
     if (filter === undefined || !repo || offset === undefined) {
       return Promise.reject(new Error('filter or repo or offset undefined'));
@@ -526,7 +513,7 @@
           this.repo,
           this.itemsPerPage,
           this.offset,
-          this.detailType!
+          this.detailType
         );
       }
     });
@@ -559,7 +546,7 @@
               this.repo,
               this.itemsPerPage,
               this.offset,
-              this.detailType!
+              this.detailType
             );
           }
         });
@@ -573,7 +560,7 @@
               this.repo,
               this.itemsPerPage,
               this.offset,
-              this.detailType!
+              this.detailType
             );
           }
         });
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
index a82c4e3..13f6b2b 100644
--- 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
@@ -1,22 +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 '../../../test/common-test-setup-karma';
-import './gr-repo-detail-list.js';
+import '../../../test/common-test-setup';
+import './gr-repo-detail-list';
 import {GrRepoDetailList} from './gr-repo-detail-list';
 import {page} from '../../../utils/page-wrapper-utils';
 import {
@@ -26,7 +14,6 @@
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
-import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 import {
   BranchInfo,
   EmailAddress,
@@ -42,14 +29,13 @@
 } 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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {RepoDetailView} from '../../../models/views/repo';
 
 function branchGenerator(counter: number) {
   return {
@@ -107,8 +93,9 @@
     let branches: BranchInfo[];
 
     setup(async () => {
-      element = basicFixture.instantiate();
-      await element.updateComplete;
+      element = await fixture(
+        html`<gr-repo-detail-list></gr-repo-detail-list>`
+      );
       element.detailType = RepoDetailView.BRANCHES;
       sinon.stub(page, 'show');
     });
@@ -132,6 +119,1948 @@
         await element.updateComplete;
       });
 
+      test('render', () => {
+        assert.shadowDom.equal(
+          element,
+          /* HTML */ `
+            <gr-list-view>
+              <table class="genericList gr-form-styles" id="list">
+                <tbody>
+                  <tr class="headerRow">
+                    <th class="name topHeader">Name</th>
+                    <th class="revision topHeader">Revision</th>
+                    <th class="hideItem message topHeader">Message</th>
+                    <th class="hideItem tagger topHeader">Tagger</th>
+                    <th class="repositoryBrowser topHeader">
+                      Repository Browser
+                    </th>
+                    <th class="delete topHeader"></th>
+                  </tr>
+                  <tr class="loadingMsg" id="loading">
+                    <td>Loading...</td>
+                  </tr>
+                </tbody>
+                <tbody>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a> HEAD </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing"> master </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing"> master </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="0"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="0"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser"></td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="0"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test0"
+                      >
+                        test0
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="1"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="1"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test0"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="1"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test1"
+                      >
+                        test1
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="2"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="2"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test1"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="2"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test2"
+                      >
+                        test2
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="3"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="3"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test2"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="3"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test3"
+                      >
+                        test3
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="4"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="4"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test3"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="4"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test4"
+                      >
+                        test4
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="5"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="5"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test4"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="5"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test5"
+                      >
+                        test5
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="6"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="6"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test5"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="6"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test6"
+                      >
+                        test6
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="7"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="7"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test6"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="7"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test7"
+                      >
+                        test7
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="8"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="8"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test7"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="8"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test8"
+                      >
+                        test8
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="9"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="9"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test8"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="9"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test9"
+                      >
+                        test9
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="10"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="10"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test9"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="10"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test10"
+                      >
+                        test10
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="11"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="11"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test10"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="11"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test11"
+                      >
+                        test11
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="12"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="12"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test11"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="12"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test12"
+                      >
+                        test12
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="13"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="13"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test12"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="13"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test13"
+                      >
+                        test13
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="14"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="14"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test13"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="14"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test14"
+                      >
+                        test14
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="15"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="15"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test14"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="15"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test15"
+                      >
+                        test15
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="16"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="16"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test15"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="16"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test16"
+                      >
+                        test16
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="17"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="17"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test16"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="17"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test17"
+                      >
+                        test17
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="18"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="18"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test17"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="18"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test18"
+                      >
+                        test18
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="19"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="19"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test18"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="19"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test19"
+                      >
+                        test19
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="20"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="20"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test19"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="20"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test20"
+                      >
+                        test20
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="21"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="21"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test20"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="21"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test21"
+                      >
+                        test21
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="22"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="22"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test21"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="22"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test22"
+                      >
+                        test22
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="23"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="23"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test22"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="23"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test23"
+                      >
+                        test23
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="24"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="24"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test23"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="24"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                </tbody>
+              </table>
+              <gr-overlay
+                aria-hidden="true"
+                id="overlay"
+                style="outline: none; display: none;"
+                tabindex="-1"
+                with-backdrop=""
+              >
+                <gr-confirm-delete-item-dialog class="confirmDialog">
+                </gr-confirm-delete-item-dialog>
+              </gr-overlay>
+            </gr-list-view>
+            <gr-overlay
+              aria-hidden="true"
+              id="createOverlay"
+              style="outline: none; display: none;"
+              tabindex="-1"
+              with-backdrop=""
+            >
+              <gr-dialog
+                confirm-label="Create"
+                disabled=""
+                id="createDialog"
+                role="dialog"
+              >
+                <div class="header" slot="header">Create Branch</div>
+                <div class="main" slot="main">
+                  <gr-create-pointer-dialog id="createNewModal">
+                  </gr-create-pointer-dialog>
+                </div>
+              </gr-dialog>
+            </gr-overlay>
+          `
+        );
+      });
+
       test('test for branch in the list', () => {
         assert.equal(element.items![3].ref, 'refs/heads/test2');
       });
@@ -264,7 +2193,7 @@
           assert.equal(getComputedStyle(item).display, 'none');
         }
 
-        MockInteractions.tap(editBtn);
+        editBtn.click();
         await element.updateComplete;
         // The revision and edit button are not visible.
         assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
@@ -293,11 +2222,11 @@
 
         // Save button calls handleSave. since this is stubbed, the edit
         // section remains open.
-        MockInteractions.tap(saveBtn);
+        saveBtn.click();
         assert.isTrue(handleSaveRevisionStub.called);
 
-        // When cancel is tapped, the edit secion closes.
-        MockInteractions.tap(cancelBtn);
+        // When cancel is tapped, the edit section closes.
+        cancelBtn.click();
         await element.updateComplete;
 
         // The revision and edit button are visible.
@@ -419,8 +2348,9 @@
     let tags: TagInfo[];
 
     setup(async () => {
-      element = basicFixture.instantiate();
-      await element.updateComplete;
+      element = await fixture(
+        html`<gr-repo-detail-list></gr-repo-detail-list>`
+      );
       element.detailType = RepoDetailView.TAGS;
       sinon.stub(page, 'show');
     });
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 07e89a4..f0bd520 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
@@ -1,25 +1,12 @@
 /**
  * @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 '../../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 {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {AppElementAdminParams} from '../../gr-app-types';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {
   RepoName,
@@ -34,7 +21,9 @@
 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';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {AdminViewState} from '../../../models/views/admin';
+import {createSearchUrl} from '../../../models/views/search';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -51,7 +40,7 @@
   @query('#createNewModal') private createNewModal?: GrCreateRepoDialog;
 
   @property({type: Object})
-  params?: AppElementAdminParams;
+  params?: AdminViewState;
 
   // private but used in test
   @state() offset = 0;
@@ -215,7 +204,7 @@
    *
    * private but used in test
    */
-  maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+  maybeOpenCreateOverlay(params?: AdminViewState) {
     if (params?.openCreateModal) {
       this.createOverlay?.open();
     }
@@ -226,7 +215,7 @@
   }
 
   private computeChangesLink(name: string) {
-    return GerritNav.getUrlForProjectChanges(name as RepoName);
+    return createSearchUrl({project: name as RepoName});
   }
 
   private async getCreateRepoCapability() {
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
index 25052a3..29d378d 100644
--- 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo-list';
 import {GrRepoList} from './gr-repo-list';
 import {page} from '../../../utils/page-wrapper-utils';
@@ -29,14 +17,13 @@
   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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
 function createRepo(name: string, counter: number) {
   return {
@@ -66,8 +53,7 @@
 
   setup(async () => {
     sinon.stub(page, 'show');
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-repo-list></gr-repo-list>`);
   });
 
   suite('list with repos', () => {
@@ -78,6 +64,555 @@
       await element.updateComplete;
     });
 
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-list-view>
+            <table class="genericList" id="list">
+              <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="readOnly topHeader">Read only</th>
+                  <th class="description topHeader">Repository Description</th>
+                </tr>
+                <tr class="loadingMsg" id="loading">
+                  <td>Loading...</td>
+                </tr>
+              </tbody>
+              <tbody>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test0"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test1"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test2"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test3"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test4"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test5"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test6"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test7"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test8"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test9"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test10"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test11"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test12"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test13"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test14"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test15"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test16"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test17"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test18"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test19"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test20"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test21"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test22"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test23"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test24"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+              </tbody>
+            </table>
+          </gr-list-view>
+          <gr-overlay
+            aria-hidden="true"
+            id="createOverlay"
+            style="outline: none; display: none;"
+            tabindex="-1"
+            with-backdrop=""
+          >
+            <gr-dialog
+              class="confirmDialog"
+              confirm-label="Create"
+              disabled=""
+              id="createDialog"
+              role="dialog"
+            >
+              <div class="header" slot="header">Create Repository</div>
+              <div class="main" slot="main">
+                <gr-create-repo-dialog id="createNewModal">
+                </gr-create-repo-dialog>
+              </div>
+            </gr-dialog>
+          </gr-overlay>
+        `
+      );
+    });
+
     test('test for test repo in the list', async () => {
       await element.updateComplete;
       assert.equal(element.repos[0].id, 'test0');
@@ -98,9 +633,9 @@
       assert.isFalse(overlayOpen.called);
       element.maybeOpenCreateOverlay(undefined);
       assert.isFalse(overlayOpen.called);
-      const params: AppElementAdminParams = {
+      const params: AdminViewState = {
         view: GerritView.ADMIN,
-        adminView: '',
+        adminView: AdminChildView.REPOS,
         openCreateModal: true,
       };
       element.maybeOpenCreateOverlay(params);
@@ -134,10 +669,10 @@
       repoStub.returns(Promise.resolve(repos));
       element.params = {
         view: GerritView.ADMIN,
-        adminView: '',
+        adminView: AdminChildView.REPOS,
         filter: 'test',
         offset: 25,
-      } as AppElementAdminParams;
+      } as AdminViewState;
       await element._paramsChanged();
       assert.isTrue(repoStub.lastCall.calledWithExactly('test', 25, 25));
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
index d5515f9..319c030 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 /**
  * @fileoverview This file contains interfaces shared between
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 d5e5027..f8dcd32 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
@@ -1,30 +1,18 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
-import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {ConfigParameterInfoType} from '../../../constants/constants';
 import {
   ConfigParameterInfo,
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
index fa3a635..3dc6f1e 100644
--- 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
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo-plugin-config';
 import {GrRepoPluginConfig} from './gr-repo-plugin-config';
 import {PluginParameterToConfigParameterInfoMap} from '../../../types/common';
@@ -23,15 +11,46 @@
 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');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-repo-plugin-config tests', () => {
   let element: GrRepoPluginConfig;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<gr-repo-plugin-config></gr-repo-plugin-config>`
+    );
+  });
+
+  test('render', async () => {
+    element.pluginData = {
+      name: 'testName',
+      config: {
+        plugin: {type: 'STRING' as ConfigParameterInfoType, value: 'test'},
+      },
+    };
     await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <fieldset>
+            <h4>testName</h4>
+            <section class="STRING section">
+              <span class="title">
+                <span> </span>
+              </span>
+              <span class="value">
+                <iron-input data-option-key="plugin">
+                  <input data-option-key="plugin" disabled="" is="iron-input" />
+                </iron-input>
+              </span>
+            </section>
+          </fieldset>
+        </div>
+      `
+    );
   });
 
   test('_computePluginConfigOptions', () => {
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 06691d7..01d452a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.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 '@polymer/iron-input/iron-input';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
@@ -22,7 +11,6 @@
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-textarea/gr-textarea';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
   ConfigInfo,
   RepoName,
@@ -49,8 +37,9 @@
 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 {customElement, property, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
+import {createSearchUrl} from '../../../models/views/search';
 
 const STATES = {
   active: {value: ProjectState.ACTIVE, label: 'Active'},
@@ -127,12 +116,16 @@
 
   constructor() {
     super();
-    subscribe(this, this.userModel.preferences$, prefs => {
-      if (prefs?.download_scheme) {
-        // Note (issue 5180): normalize the download scheme with lower-case.
-        this.selectedScheme = prefs.download_scheme.toLowerCase();
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
+        if (prefs?.download_scheme) {
+          // Note (issue 5180): normalize the download scheme with lower-case.
+          this.selectedScheme = prefs.download_scheme.toLowerCase();
+        }
       }
-    });
+    );
   }
 
   override connectedCallback() {
@@ -268,7 +261,7 @@
           rows="4"
           monospace
           ?disabled=${this.readOnly}
-          .text=${this.repoConfig?.description}
+          .text=${this.repoConfig?.description ?? ''}
           @text-changed=${this.handleDescriptionTextChanged}
         ></gr-textarea>
       </fieldset>
@@ -1105,7 +1098,7 @@
 
   private computeChangesUrl(name?: RepoName) {
     if (!name) return '';
-    return GerritNav.getUrlForProjectChanges(name);
+    return createSearchUrl({project: name});
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index 65eee70..38c4a5d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo';
 import {GrRepo} from './gr-repo';
 import {mockPromise} from '../../../test/test-utils';
@@ -51,13 +39,12 @@
   createConfig,
   createDownloadSchemes,
 } from '../../../test/test-data-generators';
-import {PageErrorEvent} from '../../../types/events.js';
+import {PageErrorEvent} from '../../../types/events';
 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');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-repo tests', () => {
   let element: GrRepo;
@@ -167,8 +154,252 @@
     repoStub = stubRestApi('getProjectConfig').returns(
       Promise.resolve(repoConf)
     );
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-repo></gr-repo>`);
+  });
+
+  test('render', () => {
+    // prettier and shadowDom assert do not agree about span.title wrapping
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <div class="gr-form-styles main read-only">
+        <div class="info">
+          <h1 class="heading-1" id="Title"></h1>
+          <hr />
+          <div>
+            <a href="">
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                link=""
+                role="button"
+                tabindex="-1"
+              >
+                Browse
+              </gr-button>
+            </a>
+            <a href="">
+              <gr-button
+                aria-disabled="false"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                View Changes
+              </gr-button>
+            </a>
+          </div>
+        </div>
+        <div class="loading" id="loading">Loading...</div>
+        <div class="loading" id="loadedContent">
+          <div class="hide" id="downloadContent">
+            <h2 class="heading-2" id="download">Download</h2>
+            <fieldset>
+              <gr-download-commands id="downloadCommands">
+              </gr-download-commands>
+            </fieldset>
+          </div>
+          <h2 class="heading-2" id="configurations">Configurations</h2>
+          <div id="form">
+            <fieldset>
+              <h3 class="heading-3" id="Description">Description</h3>
+              <fieldset>
+                <gr-textarea
+                  autocomplete="on"
+                  class="description monospace"
+                  disabled=""
+                  id="descriptionInput"
+                  monospace=""
+                  placeholder="<Insert repo description here>"
+                  rows="4"
+                >
+                </gr-textarea>
+              </fieldset>
+              <h3 class="heading-3" id="Options">Repository Options</h3>
+              <fieldset id="options">
+                <section>
+                  <span class="title"> State </span>
+                  <span class="value">
+                    <gr-select id="stateSelect">
+                      <select disabled="">
+                        <option value="ACTIVE">Active</option>
+                        <option value="READ_ONLY">Read Only</option>
+                        <option value="HIDDEN">Hidden</option>
+                      </select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title"> Submit type </span>
+                  <span class="value">
+                    <gr-select id="submitTypeSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title"> Allow content merges </span>
+                  <span class="value">
+                    <gr-select id="contentMergeSelect">
+                      <select disabled=""></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">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Require Change-Id in commit message
+                  </span>
+                  <span class="value">
+                    <gr-select id="requireChangeIdSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section
+                  class="repositorySettings"
+                  id="enableSignedPushSettings"
+                >
+                  <span class="title"> Enable signed push </span>
+                  <span class="value">
+                    <gr-select id="enableSignedPush">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section
+                  class="repositorySettings"
+                  id="requireSignedPushSettings"
+                >
+                  <span class="title"> Require signed push </span>
+                  <span class="value">
+                    <gr-select id="requireSignedPush">
+                      <select disabled=""></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">
+                      <select disabled=""></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">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Set all new changes private by default
+                  </span>
+                  <span class="value">
+                    <gr-select id="setAllnewChangesPrivateByDefaultSelect">
+                      <select disabled=""></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"
+                    >
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title"> Maximum Git object size limit </span>
+                  <span class="value">
+                    <iron-input id="maxGitObjSizeIronInput">
+                      <input disabled="" id="maxGitObjSizeInput" type="text" />
+                    </iron-input>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Match authored date with committer date upon submit
+                  </span>
+                  <span class="value">
+                    <gr-select id="matchAuthoredDateWithCommitterDateSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title"> Reject empty commit upon submit </span>
+                  <span class="value">
+                    <gr-select id="rejectEmptyCommitSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+              </fieldset>
+              <h3 class="heading-3" id="Options">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">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Require Signed-off-by in commit message
+                  </span>
+                  <span class="value">
+                    <gr-select id="useSignedOffBySelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+              </fieldset>
+              <div class="hide pluginConfig">
+                <h3 class="heading-3">Plugins</h3>
+              </div>
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                role="button"
+                tabindex="-1"
+              >
+                Save changes
+              </gr-button>
+            </fieldset>
+            <gr-endpoint-decorator name="repo-config">
+              <gr-endpoint-param name="repoName"> </gr-endpoint-param>
+              <gr-endpoint-param name="readOnly"> </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+        </div>
+      </div>
+    `
+    );
   });
 
   test('_computePluginData', async () => {
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 1685ca4..975dd3b 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
@@ -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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-button/gr-button';
@@ -23,9 +12,9 @@
 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 {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
-import {ifDefined} from 'lit/directives/if-defined';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
 import {PermissionAction} from '../../../constants/constants';
 
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
index 047bbb6..8066289 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
@@ -1,29 +1,16 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {fixture, html, assert} from '@open-wc/testing';
 import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
 import {PermissionAction} from '../../../constants/constants';
 
@@ -39,44 +26,47 @@
 
   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>
+      assert.shadowDom.equal(
+        element,
+        /* 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>
-          <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>
-      `);
+          <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 () => {
@@ -92,26 +82,31 @@
           .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>
-      `);
+      assert.dom.equal(
+        queryAndAssert(element, '#options'),
+        /* 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>
+        `
+      );
     });
   });
 
@@ -367,7 +362,7 @@
           '#deletedContainer'
         ).classList.contains('deleted')
       );
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
       await element.updateComplete;
       assert.isTrue(
         queryAndAssert<HTMLDivElement>(
@@ -378,7 +373,7 @@
       assert.isTrue(element.deleted);
       assert.isTrue(element.rule.value!.deleted);
 
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#undoRemoveBtn'));
+      queryAndAssert<GrButton>(element, '#undoRemoveBtn').click();
       await element.updateComplete;
       assert.isFalse(element.deleted);
       assert.isNotOk(element.rule.value!.deleted);
@@ -401,7 +396,7 @@
 
       element.rule = {value: {action: PermissionAction.ALLOW}};
       await element.updateComplete;
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
       await element.updateComplete;
       assert.notEqual(
         getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
@@ -466,16 +461,16 @@
         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
-        );
-      });
+
+      // values are set correctly
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        expectedRuleValue.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        expectedRuleValue.force
+      );
     });
 
     test('modify value', async () => {
@@ -493,7 +488,7 @@
       element.editing = true;
       const removeStub = sinon.stub();
       element.addEventListener('added-rule-removed', removeStub);
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
       await element.updateComplete;
       assert.isTrue(removeStub.called);
     });
@@ -601,20 +596,20 @@
         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
-        );
-      });
+
+      // 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 () => {
@@ -700,16 +695,15 @@
         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
-        );
-      });
+      // values are set correctly
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        expectedRuleValue.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        expectedRuleValue.force
+      );
     });
 
     test('modify value', async () => {
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
index fdd7502..318a33b 100644
--- 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
@@ -3,17 +3,17 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import {css, html, LitElement, nothing} from 'lit';
-import {customElement, state} from 'lit/decorators';
+import {customElement, state} from 'lit/decorators.js';
 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';
+import '../gr-change-list-topic-flow/gr-change-list-topic-flow';
+import '../gr-change-list-hashtag-flow/gr-change-list-hashtag-flow';
 
 /**
  * An action bar for the top of a <gr-change-list-section> element. Assumes it
@@ -26,24 +26,16 @@
       :host {
         display: contents;
       }
+      td {
+        padding: 0;
+      }
       .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;
+      .actionButtons {
+        margin-right: var(--spacing-l);
       }
     `;
   }
@@ -51,23 +43,15 @@
   @state()
   private numSelected = 0;
 
-  @state()
-  private totalChangeCount = 0;
-
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChangeNums$,
+      () => this.getBulkActionsModel().selectedChangeNums$,
       selectedChangeNums => (this.numSelected = selectedChangeNums.length)
     );
-    subscribe(
-      this,
-      this.getBulkActionsModel().totalChangeCount$,
-      totalChangeCount => (this.totalChangeCount = totalChangeCount)
-    );
   }
 
   override render() {
@@ -75,27 +59,7 @@
       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
@@ -110,9 +74,9 @@
           </div>
           <div class="actionButtons">
             <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
+            <gr-change-list-topic-flow></gr-change-list-topic-flow>
+            <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
             <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
-            <gr-change-list-bulk-abandon-flow>
-            </gr-change-list-bulk-abandon-flow>
           </div>
         </div>
       </td>
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
index 5804b99..8badced 100644
--- 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
@@ -3,14 +3,14 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 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 '../../../test/common-test-setup';
 import {createChange} from '../../../test/test-data-generators';
 import {
   query,
@@ -55,23 +55,24 @@
   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>
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <td>
+          <div class="container">
+            <div class="selectionInfo">
+              <span>1 change selected</span>
+            </div>
+            <div class="actionButtons">
+              <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
+              <gr-change-list-topic-flow></gr-change-list-topic-flow>
+              <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
+              <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
+            </div>
           </div>
-          <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>
-    `);
+        </td>
+      `
+    );
   });
 
   test('label reflects number of selected changes', async () => {
@@ -99,42 +100,4 @@
     );
     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
index 4fd65df..1582b0a 100644
--- 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
@@ -3,8 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
-import {customElement, state, query} from 'lit/decorators';
+import {customElement, state, query} from 'lit/decorators.js';
 import {LitElement, html, css} from 'lit';
 import {resolve} from '../../../models/dependency';
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
@@ -35,11 +34,11 @@
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChanges$,
+      () => this.getBulkActionsModel().selectedChanges$,
       selectedChanges => (this.selectedChanges = selectedChanges)
     );
   }
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
index 9c7523e..4fc2cd8 100644
--- 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
@@ -3,7 +3,6 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import {createChange} from '../../../test/test-data-generators';
 import {
   NumericChangeId,
@@ -13,14 +12,14 @@
   PatchSetNum,
 } from '../../../api/rest-api';
 import {GrChangeListBulkAbandonFlow} from './gr-change-list-bulk-abandon-flow';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {fixture, waitUntil, assert} from '@open-wc/testing';
 import {wrapInProvider} from '../../../models/di-provider-element';
 import {html} from 'lit';
 import {getAppContext} from '../../../services/app-context';
@@ -32,7 +31,6 @@
   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';
@@ -69,6 +67,60 @@
     await element.updateComplete;
   });
 
+  test('render', 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.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-button
+          aria-disabled="false"
+          flatten=""
+          id="abandon"
+          role="button"
+          tabindex="0"
+        >
+          Abandon
+        </gr-button>
+        <gr-overlay
+          aria-hidden="true"
+          id="actionOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-dialog role="dialog">
+            <div slot="header">1 changes to abandon</div>
+            <div slot="main">
+              <table>
+                <thead>
+                  <tr>
+                    <th>Subject</th>
+                    <th>Status</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr>
+                    <td>Change: Test subject</td>
+                    <td id="status">Status: NOT STARTED</td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+      `
+    );
+  });
+
   test('button state updates as changes are updated', async () => {
     const changes: ChangeInfo[] = [{...change1, actions: {abandon: {}}}];
     getChangesStub.returns(changes);
@@ -79,8 +131,7 @@
     );
     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: {}});
@@ -109,10 +160,10 @@
     );
     await selectChange(change1);
     await element.updateComplete;
-    await flush();
+
     assert.isFalse(queryAndAssert<GrButton>(element, '#abandon').disabled);
 
-    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
 
     await waitUntil(
       () =>
@@ -152,7 +203,7 @@
       queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
     );
 
-    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
     await element.updateComplete;
 
     assert.isTrue(
@@ -226,7 +277,7 @@
         })
     );
 
-    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
     await element.updateComplete;
 
     assert.equal(
@@ -283,7 +334,7 @@
     await selectChange(change2);
     await element.updateComplete;
 
-    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
 
     await waitUntil(
       () => element.progress.get(2 as NumericChangeId) === ProgressStatus.FAILED
@@ -291,7 +342,7 @@
 
     assert.isFalse(fireStub.called);
 
-    tap(queryAndAssert(query(element, 'gr-dialog'), '#cancel'));
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
 
     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
index 6790b15..5728529 100644
--- 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
@@ -3,8 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
-import {customElement, query, state} from 'lit/decorators';
+import {customElement, query, state} from 'lit/decorators.js';
 import {LitElement, html, css, nothing} from 'lit';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {resolve} from '../../../models/dependency';
@@ -16,7 +15,6 @@
   computeLabels,
   computeOrderedLabelValues,
   mergeLabelInfoMaps,
-  getDefaultValue,
   mergeLabelMaps,
   Label,
   StandardLabels,
@@ -33,8 +31,14 @@
 import {ProgressStatus} from '../../../constants/constants';
 import {fireAlert, fireReload} from '../../../utils/event-util';
 import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-icon/gr-icon';
 import '../../change/gr-label-score-row/gr-label-score-row';
 import {getOverallStatus} from '../../../utils/bulk-flow-util';
+import {allSettled} from '../../../utils/async-util';
+import {pluralize} from '../../../utils/string-util';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {Interaction} from '../../../constants/reporting';
+import {createChangeUrl} from '../../../models/views/change';
 
 @customElement('gr-change-list-bulk-vote-flow')
 export class GrChangeListBulkVoteFlow extends LitElement {
@@ -42,20 +46,28 @@
 
   private readonly userModel = getAppContext().userModel;
 
+  private readonly reportingService = getAppContext().reportingService;
+
   @state() selectedChanges: ChangeInfo[] = [];
 
   @state() progressByChange: Map<NumericChangeId, ProgressStatus> = new Map();
 
   @query('#actionOverlay') actionOverlay!: GrOverlay;
 
+  @query('gr-dialog') dialog?: GrDialog;
+
   @state() account?: AccountInfo;
 
   static override get styles() {
     return [
       fontStyles,
       css`
+        gr-dialog {
+          width: 840px;
+        }
         .scoresTable {
           display: table;
+          width: 100%;
         }
         .scoresTable.newSubmitRequirements {
           table-layout: fixed;
@@ -66,24 +78,62 @@
         gr-label-score-row {
           display: table-row;
         }
-        .heading-3 {
-          padding-left: var(--spacing-xl);
-          margin-bottom: var(--spacing-m);
-          margin-top: var(--spacing-l);
+        /* TODO(dhruvsri): Consider using flex column with gap */
+        .scoresTable:not(:first-of-type) {
+          margin-top: var(--spacing-m);
+        }
+        .vote-type {
+          margin-bottom: var(--spacing-s);
+          margin-top: 0;
           display: table-caption;
         }
-        .heading-3:first-of-type {
-          margin-top: 0;
+        .main-heading {
+          margin-bottom: var(--spacing-m);
+          font-weight: var(--font-weight-h2);
+        }
+        .error-container {
+          background-color: var(--error-background);
+          margin-top: var(--spacing-l);
+        }
+        .code-review-message-container gr-icon,
+        .error-container gr-icon {
+          padding: 10px var(--spacing-xl);
+        }
+        .error-container gr-icon {
+          color: var(--error-foreground);
+        }
+        .code-review-message-container gr-icon {
+          color: var(--selected-foreground);
+        }
+        .error-container .error-text,
+        .code-review-message-container .warning-text {
+          position: relative;
+          top: 10px;
+        }
+        .code-review-message-container {
+          display: table-caption;
+          background-color: var(--code-review-warning-background);
+          margin-bottom: var(--spacing-m);
+        }
+        .code-review-message-layout-container {
+          display: flex;
+        }
+        .code-review-message-container gr-button {
+          margin-top: 6px;
+          margin-right: var(--spacing-xl);
+        }
+        .flex-space {
+          flex-grow: 1;
         }
       `,
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChanges$,
+      () => this.getBulkActionsModel().selectedChanges$,
       selectedChanges => {
         this.selectedChanges = selectedChanges;
         this.resetFlow();
@@ -91,7 +141,7 @@
     );
     subscribe(
       this,
-      this.userModel.account$,
+      () => this.userModel.account$,
       account => (this.account = account)
     );
   }
@@ -103,46 +153,113 @@
       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()}
+      <gr-button id="voteFlowButton" flatten @click=${this.openOverlay}
         >Vote</gr-button
       >
       <gr-overlay id="actionOverlay" with-backdrop="">
         <gr-dialog
           .disableCancel=${!this.isCancelEnabled()}
           .disabled=${!this.isConfirmEnabled()}
+          ?loading=${this.isLoading()}
+          .loadingLabel=${'Voting in progress...'}
           @confirm=${() => this.handleConfirm()}
           @cancel=${() => this.handleClose()}
-          .cancelLabel=${'Close'}
+          .confirmLabel=${'Vote'}
+          .cancelLabel=${'Cancel'}
         >
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
           <div slot="main">
             ${this.renderLabels(
               nonTriggerLabels,
               'Submit requirements votes',
-              permittedLabels
+              permittedLabels,
+              true
             )}
             ${this.renderLabels(
               triggerLabels,
               'Trigger Votes',
               permittedLabels
             )}
+            ${this.renderErrors()}
           </div>
-          <!-- TODO: Add error handling status if something fails -->
         </gr-dialog>
       </gr-overlay>
     `;
   }
 
+  private renderCodeReviewMessage() {
+    return html`
+      <div class="code-review-message-container">
+        <div class="code-review-message-layout-container">
+          <div>
+            <gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
+            <span class="warning-text">
+              Code Review vote is only available on the individual change page
+            </span>
+          </div>
+          <div class="flex-space"></div>
+          <div>
+            <gr-button
+              aria-label=${`Open ${pluralize(
+                this.selectedChanges.length,
+                'change'
+              )} in different tabs`}
+              flatten
+              link
+              @click=${this.handleOpenChanges}
+              >Open ${pluralize(this.selectedChanges.length, 'change')}
+            </gr-button>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private handleOpenChanges() {
+    for (const change of this.selectedChanges) {
+      window.open(createChangeUrl({change, usp: 'bulk-vote'}));
+    }
+  }
+
+  private async openOverlay() {
+    await this.actionOverlay.open();
+    this.actionOverlay.setFocusStops({
+      start: queryAndAssert(this.dialog, 'header'),
+      end: queryAndAssert(this.dialog, 'footer'),
+    });
+  }
+
+  private renderErrors() {
+    if (getOverallStatus(this.progressByChange) !== ProgressStatus.FAILED) {
+      return nothing;
+    }
+    return html`
+      <div class="error-container">
+        <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+        <span class="error-text">
+          <!-- prettier-ignore -->
+          Failed to vote on ${pluralize(
+            Array.from(this.progressByChange.values()).filter(
+              status => status === ProgressStatus.FAILED
+            ).length,
+            'change'
+          )}
+        </span>
+      </div>
+    `;
+  }
+
   private renderLabels(
     labels: Label[],
     heading: string,
-    permittedLabels?: LabelNameToValuesMap
+    permittedLabels?: LabelNameToValuesMap,
+    showCodeReviewWarning?: boolean
   ) {
     return html` <div class="scoresTable newSubmitRequirements">
-      <h3 class="heading-3">${labels.length ? heading : nothing}</h3>
+      <h3 class="heading-4 vote-type">${labels.length ? heading : nothing}</h3>
+      ${showCodeReviewWarning ? this.renderCodeReviewMessage() : nothing}
       ${labels
         .filter(
           label =>
@@ -170,6 +287,10 @@
     );
   }
 
+  private isLoading() {
+    return getOverallStatus(this.progressByChange) === ProgressStatus.RUNNING;
+  }
+
   private isConfirmEnabled() {
     // Action is allowed if none of the changes have any bulk action performed
     // on them. In case an error happens then we keep the button disabled.
@@ -186,12 +307,15 @@
     this.actionOverlay.close();
     if (getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED)
       return;
-    fireAlert(this, 'Reloading page..');
     fireReload(this, true);
   }
 
-  private handleConfirm() {
+  private async handleConfirm() {
     this.progressByChange.clear();
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'vote',
+      selectedChangeCount: this.selectedChanges.length,
+    });
     const reviewInput: ReviewInput = {
       labels: this.getLabelValues(
         this.computeCommonPermittedLabels(this.computePermittedLabels())
@@ -202,18 +326,36 @@
     }
     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();
-        });
+
+    await allSettled(
+      promises.map((promise, index) => {
+        const changeNum = this.selectedChanges[index]._number;
+        return promise
+          .then(() => {
+            this.progressByChange.set(changeNum, ProgressStatus.SUCCESSFUL);
+          })
+          .catch(() => {
+            this.progressByChange.set(changeNum, ProgressStatus.FAILED);
+          })
+          .finally(() => {
+            this.requestUpdate();
+            if (
+              getOverallStatus(this.progressByChange) ===
+              ProgressStatus.SUCCESSFUL
+            ) {
+              fireAlert(this, 'Votes added');
+              this.handleClose();
+            }
+          });
+      })
+    );
+    if (getOverallStatus(this.progressByChange) === ProgressStatus.FAILED) {
+      this.reportingService.reportInteraction('bulk-action-failure', {
+        type: 'vote',
+        count: Array.from(this.progressByChange.values()).filter(
+          status => status === ProgressStatus.FAILED
+        ).length,
+      });
     }
   }
 
@@ -234,14 +376,7 @@
           : selectorEl.selectedValue;
 
       if (selectedVal === undefined) continue;
-
-      const defValNum = getDefaultValue(
-        this.selectedChanges[0].labels,
-        label.name
-      );
-      if (selectedVal !== defValNum) {
-        labels[label.name] = selectedVal;
-      }
+      labels[label.name] = selectedVal;
     }
     return labels;
   }
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
index 5a965d9..0ca3976 100644
--- 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
@@ -3,8 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {GrChangeListBulkVoteFlow} from './gr-change-list-bulk-vote-flow';
 import {
   BulkActionsModel,
@@ -18,22 +17,26 @@
   query,
   mockPromise,
   queryAll,
+  stubReporting,
+  waitEventLoop,
 } 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 {fixture, waitUntil, assert} from '@open-wc/testing';
 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,
+  createDetailedLabelInfo,
   createSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import './gr-change-list-bulk-vote-flow';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {ProgressStatus} from '../../../constants/constants';
 import {StandardLabels} from '../../../utils/label-util';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 const change1: ChangeInfo = {
   ...createChange(),
@@ -92,9 +95,11 @@
 suite('gr-change-list-bulk-vote-flow tests', () => {
   let element: GrChangeListBulkVoteFlow;
   let model: BulkActionsModel;
+  let dispatchEventStub: sinon.SinonStub;
   let getChangesStub: SinonStubbedMember<
     RestApiService['getDetailedChangesWithActions']
   >;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
 
   async function selectChange(change: ChangeInfo) {
     model.addSelectedChangeNum(change._number);
@@ -107,7 +112,7 @@
   setup(async () => {
     model = new BulkActionsModel(getAppContext().restApiService);
     getChangesStub = stubRestApi('getDetailedChangesWithActions');
-
+    reportingStub = stubReporting('reportInteraction');
     element = (
       await fixture(
         wrapInProvider(
@@ -118,6 +123,7 @@
       )
     ).querySelector('gr-change-list-bulk-vote-flow')!;
     await element.updateComplete;
+    dispatchEventStub = sinon.stub(element, 'dispatchEvent');
   });
 
   test('renders', async () => {
@@ -130,7 +136,9 @@
     );
     await selectChange(change1);
     await element.updateComplete;
-    expect(element).shadowDom.to.equal(/* HTML */ `<gr-button
+    assert.shadowDom.equal(
+      element,
+      `<gr-button
         aria-disabled="false"
         flatten=""
         id="voteFlowButton"
@@ -147,9 +155,35 @@
         with-backdrop=""
       >
         <gr-dialog role="dialog">
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
           <div slot="main">
             <div class="newSubmitRequirements scoresTable">
-              <h3 class="heading-3">Submit requirements votes</h3>
+              <h3 class="heading-4 vote-type">Submit requirements votes</h3>
+              <div class="code-review-message-container">
+                <div class="code-review-message-layout-container">
+                <div>
+                  <gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
+                  <span class="warning-text">
+                    Code Review vote is only available on the individual change page
+                  </span>
+                </div>
+                <div class="flex-space"></div>
+                <div>
+                  <gr-button
+                    aria-disabled="false"
+                    flatten=""
+                    link=""
+                    role="button"
+                    aria-label="Open 1 change in different tabs"
+                    tabindex="0"
+                  >
+                    Open 1 change
+                  </gr-button>
+                </div>
+                </div>
+              </div>
               <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>
@@ -157,13 +191,109 @@
               </gr-label-score-row>
             </div>
             <div class="newSubmitRequirements scoresTable">
-              <h3 class="heading-3">Trigger Votes</h3>
+              <h3 class="heading-4 vote-type">Trigger Votes</h3>
               <gr-label-score-row name="change1OnlyTriggerLabelE">
               </gr-label-score-row>
             </div>
           </div>
         </gr-dialog>
-      </gr-overlay> `);
+      </gr-overlay> `
+    );
+  });
+
+  test('renders with errors', async () => {
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    stubRestApi('saveChangeReview').callsFake(
+      (_changeNum, _patchNum, _review, errFn) =>
+        Promise.resolve(new Response()).then(res => {
+          errFn && errFn();
+          return res;
+        })
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+
+    await waitUntil(
+      () =>
+        element.progressByChange.get(1 as NumericChangeId) ===
+        ProgressStatus.FAILED
+    );
+
+    assert.shadowDom.equal(
+      element,
+      `<gr-button
+        aria-disabled="false"
+        flatten=""
+        id="voteFlowButton"
+        role="button"
+        tabindex="0"
+      >
+        Vote
+      </gr-button>
+      <gr-overlay
+        aria-hidden="true"
+        id="actionOverlay"
+        style="outline: none; display: none;"
+        tabindex="-1"
+        with-backdrop=""
+      >
+        <gr-dialog role="dialog">
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
+          <div slot="main">
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Submit requirements votes</h3>
+              <div class="code-review-message-container">
+                <div class="code-review-message-layout-container">
+                <div>
+                  <gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
+                  <span class="warning-text">
+                    Code Review vote is only available on the individual change page
+                  </span>
+                </div>
+                <div class="flex-space"></div>
+                <div>
+                  <gr-button
+                    aria-disabled="false"
+                    flatten=""
+                    link=""
+                    role="button"
+                    aria-label="Open 1 change in different tabs"
+                    tabindex="0"
+                  >
+                    Open 1 change
+                  </gr-button>
+                </div>
+                </div>
+              </div>
+              <gr-label-score-row name="A"> </gr-label-score-row>
+              <gr-label-score-row name="B"> </gr-label-score-row>
+              <gr-label-score-row name="C"> </gr-label-score-row>
+              <gr-label-score-row name="change1OnlyLabelD">
+              </gr-label-score-row>
+            </div>
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Trigger Votes</h3>
+              <gr-label-score-row name="change1OnlyTriggerLabelE">
+              </gr-label-score-row>
+            </div>
+            <div class="error-container">
+              <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+              <span class="error-text"> Failed to vote on 1 change </span>
+            </div>
+          </div>
+        </gr-dialog>
+      </gr-overlay> `
+    );
   });
 
   test('button state updates as changes are updated', async () => {
@@ -176,9 +306,9 @@
     );
     await selectChange(change1);
     await element.updateComplete;
-    await flush();
+    await waitEventLoop();
 
-    assert.isFalse(
+    assert.isNotOk(
       queryAndAssert<GrButton>(element, '#voteFlowButton').disabled
     );
 
@@ -204,24 +334,46 @@
     await selectChange(change2);
     await element.updateComplete;
 
-    assert.isTrue(
+    assert.isNotOk(
       queryAndAssert<GrButton>(element, '#voteFlowButton').disabled
     );
   });
 
   test('progress updates as request is resolved', async () => {
-    const changes: ChangeInfo[] = [{...change1}];
+    const change = {
+      ...change1,
+      labels: {
+        ...change1.labels,
+        C: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...element.account!,
+              value: -1,
+            },
+          ],
+        },
+      },
+    };
+    const changes: ChangeInfo[] = [{...change}];
     getChangesStub.returns(Promise.resolve(changes));
     model.sync(changes);
     await waitUntilObserved(
       model.loadingState$,
       state => state === LoadingState.LOADED
     );
-    await selectChange(change1);
+    await selectChange(change);
     await element.updateComplete;
     const saveChangeReview = mockPromise<Response>();
     stubRestApi('saveChangeReview').returns(saveChangeReview);
 
+    const stopsStub = sinon.stub(element.actionOverlay, 'setFocusStops');
+
+    queryAndAssert<GrButton>(element, '#voteFlowButton').click();
+    await waitUntil(() => stopsStub.called);
+
+    await element.updateComplete;
+
     assert.isNotOk(
       queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
     );
@@ -232,6 +384,7 @@
     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();
+    queryAndAssert<GrButton>(scores[2], 'gr-button[data-value="0"]').click();
 
     await element.updateComplete;
 
@@ -242,6 +395,7 @@
       {
         A: 1,
         B: -1,
+        C: 0,
       }
     );
 
@@ -255,6 +409,11 @@
       queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
     );
 
+    assert.deepEqual(reportingStub.lastCall.args[1], {
+      type: 'vote',
+      selectedChangeCount: 1,
+    });
+
     assert.equal(
       element.progressByChange.get(1 as NumericChangeId),
       ProgressStatus.RUNNING
@@ -278,6 +437,13 @@
       element.progressByChange.get(1 as NumericChangeId),
       ProgressStatus.SUCCESSFUL
     );
+
+    // reload event is fired automatically when all requests succeed
+    assert.equal(dispatchEventStub.lastCall.args[0].type, 'reload');
+    assert.equal(
+      dispatchEventStub.firstCall.args[0].detail.message,
+      'Votes added'
+    );
   });
 
   suite('closing dialog triggers reloads', () => {
@@ -285,8 +451,6 @@
       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 => {
@@ -312,20 +476,27 @@
           ProgressStatus.FAILED
       );
 
-      assert.isFalse(fireStub.called);
+      // Dialog does not autoclose and fire reload event if some request fails
+      assert.isFalse(dispatchEventStub.called);
+
+      assert.deepEqual(reportingStub.lastCall.args, [
+        'bulk-action-failure',
+        {
+          type: 'vote',
+          count: 2,
+        },
+      ]);
 
       queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
 
-      await waitUntil(() => fireStub.called);
-      assert.equal(fireStub.lastCall.args[0].type, 'reload');
+      await waitUntil(() => dispatchEventStub.called);
+      assert.equal(dispatchEventStub.lastCall.args[0].type, 'reload');
     });
 
     test('closing dialog does not trigger reload if no request made', async () => {
       const changes: ChangeInfo[] = [change1, change2];
       getChangesStub.returns(Promise.resolve(changes));
 
-      const fireStub = sinon.stub(element, 'dispatchEvent');
-
       model.sync(changes);
       await waitUntilObserved(
         model.loadingState$,
@@ -337,7 +508,7 @@
 
       queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
 
-      assert.isFalse(fireStub.called);
+      assert.isFalse(dispatchEventStub.called);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
index 09b5cc9..13807c8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
@@ -1,24 +1,13 @@
 /**
  * @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.
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
 import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-icon/gr-icon';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {
   ApprovalInfo,
   ChangeInfo,
@@ -39,7 +28,7 @@
   iconForStatus,
 } from '../../../utils/label-util';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {ifDefined} from 'lit/directives/if-defined';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {capitalizeFirstLetter} from '../../../utils/string-util';
 
 @customElement('gr-change-list-column-requirement')
@@ -55,9 +44,6 @@
       submitRequirementsStyles,
       sharedStyles,
       css`
-        iron-icon {
-          vertical-align: top;
-        }
         .container {
           display: flex;
           align-items: center;
@@ -152,8 +138,14 @@
   }
 
   private renderStatusIcon(status: SubmitRequirementStatus) {
-    const icon = iconForStatus(status ?? SubmitRequirementStatus.ERROR);
-    return html`<iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>`;
+    const icon = iconForStatus(status);
+    return html`
+      <gr-icon
+        class=${icon.icon}
+        icon=${icon.icon}
+        ?filled=${icon.filled}
+      ></gr-icon>
+    `;
   }
 
   private computeClass(): string {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
index 96240de..82e8048 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
@@ -1,22 +1,10 @@
 /**
  * @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.
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {fixture} from '@open-wc/testing-helpers';
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import './gr-change-list-column-requirement';
 import {GrChangeListColumnRequirement} from './gr-change-list-column-requirement';
@@ -66,14 +54,11 @@
       >
       </gr-change-list-column-requirement>`
     );
-    expect(element).shadowDom.to.equal(
+    assert.shadowDom.equal(
+      element,
       /* HTML */
       ` <div class="container" title="Satisfied">
-        <iron-icon
-          class="check-circle-filled"
-          icon="gr-icons:check-circle-filled"
-        >
-        </iron-icon>
+        <gr-icon class="check_circle" filled icon="check_circle"></gr-icon>
       </div>`
     );
   });
@@ -113,14 +98,16 @@
       >
       </gr-change-list-column-requirement>`
     );
-    expect(element).shadowDom.to.equal(
+    assert.shadowDom.equal(
+      element,
       /* 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(
+    assert.shadowDom.equal(
+      voteChip,
       /* HTML */
       ` <gr-tooltip-content
         class="container"
@@ -160,14 +147,16 @@
       >
       </gr-change-list-column-requirement>`
     );
-    expect(element).shadowDom.to.equal(
+    assert.shadowDom.equal(
+      element,
       /* 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(
+    assert.shadowDom.equal(
+      voteChip,
       /* HTML */
       ` <gr-tooltip-content
         class="container"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
index 1f8bf51..dbde061 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
@@ -1,27 +1,20 @@
 /**
  * @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 '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
 import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-icon/gr-icon';
 import {LitElement, css, html, TemplateResult} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {ChangeInfo, SubmitRequirementStatus} from '../../../api/rest-api';
 import {changeStatuses} from '../../../utils/change-util';
-import {getRequirements, iconForStatus} from '../../../utils/label-util';
+import {
+  getRequirements,
+  iconForStatus,
+  SubmitRequirementsIcon,
+} from '../../../utils/label-util';
 import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
 import {pluralize} from '../../../utils/string-util';
 
@@ -34,39 +27,18 @@
     return [
       submitRequirementsStyles,
       css`
-        iron-icon {
-          width: var(--line-height-normal, 20px);
-          height: var(--line-height-normal, 20px);
-          vertical-align: top;
+        :host {
+          display: inline-block;
         }
-        iron-icon.block,
-        iron-icon.check-circle-filled {
-          margin-right: var(--spacing-xs);
+        gr-change-status {
+          display: inline-block;
         }
-        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);
+        gr-icon.commentIcon {
+          color: var(--warning-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;
@@ -85,10 +57,9 @@
     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>
-        `
+        status =>
+          html`<span class="comma">, </span
+            ><gr-change-status flat .status=${status}></gr-change-status>`
       );
     }
     return this.renderActiveStatus();
@@ -117,36 +88,43 @@
 
     return this.renderState(
       iconForStatus(SubmitRequirementStatus.UNSATISFIED),
-      this.renderSummary(numUnsatisfied, numRequirements)
+      this.renderSummary(numUnsatisfied)
     );
   }
 
-  renderState(icon: string, aggregation: string | TemplateResult) {
-    return html`<span class=${icon} role="button" tabindex="0">
+  renderState(
+    icon: SubmitRequirementsIcon,
+    aggregation: string | TemplateResult
+  ) {
+    return html`<span class=${icon.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
+      <gr-icon
+        class=${icon.icon}
+        icon=${icon.icon}
+        ?filled=${icon.filled}
+        role="img"
+      ></gr-icon>
+      ${aggregation}</span
     >`;
   }
 
-  renderSummary(numUnsatisfied: number, numRequirements: number) {
-    return html`<span
-      ><span class="unsatisfied">${numUnsatisfied}</span
-      ><span class="total">(of ${numRequirements})</span></span
-    >`;
+  renderSummary(numUnsatisfied: number) {
+    return html`<span class="unsatisfied">${numUnsatisfied} missing</span>`;
   }
 
   renderCommentIcon() {
     if (!this.change?.unresolved_comment_count) return;
-    return html`<iron-icon
-      icon="gr-icons:comment"
+    return html`<gr-icon
       class="commentIcon"
+      icon="chat_bubble"
+      small
+      filled
       .title=${pluralize(
         this.change?.unresolved_comment_count,
         'unresolved comment'
       )}
-    ></iron-icon>`;
+    ></gr-icon>`;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
index 5cc4e6d..6da91be2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
@@ -1,22 +1,10 @@
 /**
  * @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.
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {fixture} from '@open-wc/testing-helpers';
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import './gr-change-list-column-requirements-summary';
 import {GrChangeListColumnRequirementsSummary} from './gr-change-list-column-requirements-summary';
@@ -69,19 +57,15 @@
       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>`);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ` <span class="block" role="button" tabindex="0">
+        <gr-submit-requirement-dashboard-hovercard>
+        </gr-submit-requirement-dashboard-hovercard>
+        <gr-icon class="block" role="img" icon="block"></gr-icon>
+        <span class="unsatisfied">1 missing</span>
+      </span>`
+    );
   });
 
   test('renders comment count', async () => {
@@ -93,23 +77,21 @@
       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>
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ` <span class="block" role="button" tabindex="0">
+          <gr-submit-requirement-dashboard-hovercard>
+          </gr-submit-requirement-dashboard-hovercard>
+          <gr-icon class="block" role="img" icon="block"></gr-icon>
+          <span class="unsatisfied">1 missing</span>
         </span>
-      </span>
-      <iron-icon
-        class="commentIcon"
-        icon="gr-icons:comment"
-        title="5 unresolved comments"
-      ></iron-icon>`);
+        <gr-icon
+          class="commentIcon"
+          small
+          filled
+          icon="chat_bubble"
+          title="5 unresolved comments"
+        ></gr-icon>`
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
new file mode 100644
index 0000000..2b3c3df4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -0,0 +1,373 @@
+/**
+ * @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.js';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {ChangeInfo, Hashtag} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '@polymer/iron-dropdown/iron-dropdown';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {getAppContext} from '../../../services/app-context';
+import {notUndefined} from '../../../types/types';
+import {unique} from '../../../utils/common-util';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {when} from 'lit/directives/when.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map.js';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+import {fireAlert} from '../../../utils/event-util';
+import {pluralize} from '../../../utils/string-util';
+import {Interaction} from '../../../constants/reporting';
+
+@customElement('gr-change-list-hashtag-flow')
+export class GrChangeListHashtagFlow extends LitElement {
+  @state() private selectedChanges: ChangeInfo[] = [];
+
+  @state() private hashtagToAdd: Hashtag = '' as Hashtag;
+
+  @state() private existingHashtagSuggestions: Hashtag[] = [];
+
+  @state() private loadingText?: string;
+
+  @state() private errorText?: string;
+
+  /** dropdown status is tracked here to lazy-load the inner DOM contents */
+  @state() private isDropdownOpen = false;
+
+  @state() private overallProgress: ProgressStatus = ProgressStatus.NOT_STARTED;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  private selectedExistingHashtags: Set<Hashtag> = new Set();
+
+  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  private readonly reportingService = getAppContext().reportingService;
+
+  static override get styles() {
+    return [
+      spinnerStyles,
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: 400px;
+          background-color: var(--dialog-background-color);
+          border-radius: 4px;
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l);
+        }
+        gr-autocomplete {
+          --prominent-border-color: var(--gray-800);
+        }
+        .footer {
+          display: flex;
+          justify-content: space-between;
+          align-items: baseline;
+        }
+        .buttons {
+          padding-top: var(--spacing-m);
+          display: flex;
+          justify-content: flex-end;
+          gap: var(--spacing-m);
+        }
+        .chips {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+          padding-bottom: var(--spacing-l);
+        }
+        .chip {
+          padding: var(--spacing-s) var(--spacing-xl);
+          border-radius: 10px;
+          width: fit-content;
+          cursor: pointer;
+          color: var(--primary-text-color);
+        }
+        .chip:not(.selected) {
+          border: var(--spacing-xxs) solid var(--border-color);
+          background: none;
+        }
+        .chip.selected {
+          border: 0;
+          color: var(--selected-foreground);
+          background-color: var(--selected-chip-background);
+          margin: var(--spacing-xxs);
+        }
+        .loadingOrError {
+          display: flex;
+          gap: var(--spacing-s);
+          align-items: baseline;
+        }
+
+        /* The basics of .loadingSpin are defined in spinnerStyles. */
+        .loadingSpin {
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+        }
+        .error {
+          color: var(--deemphasized-text-color);
+        }
+        gr-icon {
+          color: var(--error-color);
+          /* Center with text by aligning it to the top and then pushing it down
+             to match the text */
+          vertical-align: top;
+          position: relative;
+          top: 7px;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+      }
+    );
+  }
+
+  override render() {
+    const isFlowDisabled = this.selectedChanges.length === 0;
+    return html`
+      <gr-button
+        id="start-flow"
+        flatten
+        down-arrow
+        @click=${this.toggleDropdown}
+        .disabled=${isFlowDisabled}
+        >Hashtag</gr-button
+      >
+      <iron-dropdown
+        .horizontalAlign=${'auto'}
+        .verticalAlign=${'auto'}
+        .verticalOffset=${24}
+        @opened-changed=${(e: CustomEvent) =>
+          (this.isDropdownOpen = e.detail.value)}
+      >
+        ${when(
+          this.isDropdownOpen,
+          () => html`
+            <div slot="dropdown-content">
+              ${this.renderExistingHashtags()}
+              <!--
+                The .query function needs to be bound to this because lit's
+                autobind seems to work only for @event handlers.
+              -->
+              <gr-autocomplete
+                .text=${this.hashtagToAdd}
+                .query=${(query: string) => this.getHashtagSuggestions(query)}
+                show-blue-focus-border
+                placeholder="Type hashtag name to create or filter hashtags"
+                @text-changed=${(e: ValueChangedEvent<Hashtag>) =>
+                  (this.hashtagToAdd = e.detail.value)}
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError" role="progressbar">
+                  ${this.renderLoadingOrError()}
+                </div>
+                <div class="buttons">
+                  ${when(
+                    this.overallProgress !== ProgressStatus.FAILED,
+                    () => html`
+                      <gr-button
+                        id="add-hashtag-button"
+                        flatten
+                        @click=${() => this.applyHashtags('Adding hashtag...')}
+                        .disabled=${this.isAddHashtagDisabled()}
+                        >Add Hashtag</gr-button
+                      >
+                    `,
+                    () => html`
+                      <gr-button
+                        id="cancel-button"
+                        flatten
+                        @click=${this.closeDropdown}
+                        >Cancel</gr-button
+                      >
+                    `
+                  )}
+                </div>
+              </div>
+            </div>
+          `
+        )}
+      </iron-dropdown>
+    `;
+  }
+
+  private renderExistingHashtags() {
+    const hashtags = this.selectedChanges
+      .flatMap(change => change.hashtags ?? [])
+      .filter(notUndefined)
+      .filter(unique);
+    return html`
+      <div class="chips">
+        ${hashtags.map(name => this.renderExistingHashtagChip(name))}
+      </div>
+    `;
+  }
+
+  private renderExistingHashtagChip(name: Hashtag) {
+    const chipClasses = {
+      chip: true,
+      selected: this.selectedExistingHashtags.has(name),
+    };
+    return html`
+      <button
+        role="listbox"
+        aria-label=${`${name as string} selection`}
+        class=${classMap(chipClasses)}
+        @click=${() => this.toggleExistingHashtagSelected(name)}
+      >
+        ${name}
+      </button>
+    `;
+  }
+
+  private renderLoadingOrError() {
+    switch (this.overallProgress) {
+      case ProgressStatus.RUNNING:
+        return html`
+          <span class="loadingSpin"></span>
+          <span class="loadingText">${this.loadingText}</span>
+        `;
+      case ProgressStatus.FAILED:
+        return html`
+          <gr-icon icon="error" filled></gr-icon>
+          <div class="error">${this.errorText}</div>
+        `;
+      default:
+        return nothing;
+    }
+  }
+
+  private isAddHashtagDisabled() {
+    const allHashtagsToAdd = [
+      ...this.selectedExistingHashtags.values(),
+      ...(this.hashtagToAdd === '' ? [] : [this.hashtagToAdd]),
+    ];
+    const allHashtagsAreAlreadyAdded = allHashtagsToAdd.every(hashtag =>
+      this.selectedChanges.every(change => change.hashtags?.includes(hashtag))
+    );
+    return (
+      allHashtagsAreAlreadyAdded ||
+      this.overallProgress === ProgressStatus.RUNNING
+    );
+  }
+
+  private toggleDropdown() {
+    this.isDropdownOpen ? this.closeDropdown() : this.openDropdown();
+  }
+
+  private reset() {
+    this.hashtagToAdd = '' as Hashtag;
+    this.selectedExistingHashtags = new Set();
+    this.overallProgress = ProgressStatus.NOT_STARTED;
+    this.errorText = undefined;
+  }
+
+  private closeDropdown() {
+    this.isDropdownOpen = false;
+    this.dropdown?.close();
+  }
+
+  private openDropdown() {
+    this.reset();
+    this.isDropdownOpen = true;
+    this.dropdown?.open();
+  }
+
+  private async getHashtagSuggestions(
+    query: string
+  ): Promise<AutocompleteSuggestion[]> {
+    const suggestions = await this.restApiService.getChangesWithSimilarHashtag(
+      query
+    );
+    this.existingHashtagSuggestions = (suggestions ?? [])
+      .flatMap(change => change.hashtags ?? [])
+      .filter(notUndefined)
+      .filter(unique);
+    return this.existingHashtagSuggestions.map(hashtag => {
+      return {name: hashtag, value: hashtag};
+    });
+  }
+
+  private applyHashtags(loadingText: string) {
+    let alert = '';
+    const allHashtagsToAdd = [
+      ...this.selectedExistingHashtags.values(),
+      ...(this.hashtagToAdd === '' ? [] : [this.hashtagToAdd]),
+    ];
+
+    if (allHashtagsToAdd.length > 1) {
+      alert = `${allHashtagsToAdd.length} hashtags added to changes`;
+    } else {
+      alert = `${pluralize(this.selectedChanges.length, 'Change')} added to ${
+        allHashtagsToAdd[0]
+      }`;
+    }
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'add-hashtag',
+      selectedChangeCount: this.selectedChanges.length,
+      hashtagsApplied: allHashtagsToAdd.length,
+    });
+    this.loadingText = loadingText;
+    this.trackPromises(
+      this.getBulkActionsModel().addHashtags(allHashtagsToAdd),
+      alert,
+      'Failed to add'
+    );
+  }
+
+  private async trackPromises(
+    promises: Promise<Hashtag[]>[],
+    alert: string,
+    errorText: string
+  ) {
+    this.overallProgress = ProgressStatus.RUNNING;
+    const results = await allSettled(promises);
+    if (results.every(result => result.status === 'fulfilled')) {
+      this.overallProgress = ProgressStatus.SUCCESSFUL;
+      fireAlert(this, alert);
+      this.reset();
+      // iron-dropdown doesn't automatically expand when the new chip adds more
+      // vertical space.
+      this.dropdown?.notifyResize();
+    } else {
+      this.overallProgress = ProgressStatus.FAILED;
+      this.errorText = errorText;
+    }
+  }
+
+  private toggleExistingHashtagSelected(name: Hashtag) {
+    if (this.selectedExistingHashtags.has(name)) {
+      this.selectedExistingHashtags.delete(name);
+    } else {
+      this.selectedExistingHashtags.add(name);
+    }
+    this.requestUpdate();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-hashtag-flow': GrChangeListHashtagFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
new file mode 100644
index 0000000..f7a2531
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
@@ -0,0 +1,599 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html, assert} from '@open-wc/testing';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {SinonStubbedMember} from 'sinon';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import '../../../test/common-test-setup';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+  waitEventLoop,
+  waitUntil,
+  waitUntilCalled,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, Hashtag} from '../../../types/common';
+import {EventType} from '../../../types/events';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import './gr-change-list-hashtag-flow';
+import type {GrChangeListHashtagFlow} from './gr-change-list-hashtag-flow';
+
+suite('gr-change-list-hashtag-flow tests', () => {
+  let element: GrChangeListHashtagFlow;
+  let model: BulkActionsModel;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
+
+  setup(() => {
+    reportingStub = stubReporting('reportInteraction');
+  });
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChanges$, selected =>
+      selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  suite('dropdown closed', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+    });
+
+    test('skips dropdown render when closed', async () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Hashtag</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            aria-hidden="true"
+            style="outline: none; display: none;"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+          </iron-dropdown>
+        `
+      );
+    });
+
+    test('dropdown hidden before flow button clicked', async () => {
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+    });
+
+    test('flow button click shows dropdown', async () => {
+      const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+
+      button.click();
+      await element.updateComplete;
+
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isTrue(dropdown.opened);
+    });
+
+    test('flow button click when open hides dropdown', async () => {
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(() =>
+        Boolean(
+          queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+        )
+      );
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(
+        () =>
+          !queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+  });
+
+  suite('hashtag flow', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        hashtags: ['hashtag1' as Hashtag, 'sharedHashtag' as Hashtag],
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        hashtags: ['hashtag2' as Hashtag, 'sharedHashtag' as Hashtag],
+      },
+      {
+        ...createChange(),
+        _number: 3 as NumericChangeId,
+        subject: 'Subject 3',
+        hashtags: ['sharedHashtag' as Hashtag],
+      },
+    ];
+    let setChangeHashtagPromises: MockPromise<Hashtag[]>[];
+    let setChangeHashtagStub: sinon.SinonStub;
+
+    async function resolvePromises(newHashtags: Hashtag[]) {
+      setChangeHashtagPromises[0].resolve([
+        ...(changes[0].hashtags ?? []),
+        ...newHashtags,
+      ]);
+      setChangeHashtagPromises[1].resolve([
+        ...(changes[1].hashtags ?? []),
+        ...newHashtags,
+      ]);
+      setChangeHashtagPromises[2].resolve([
+        ...(changes[2].hashtags ?? []),
+        ...newHashtags,
+      ]);
+      await element.updateComplete;
+    }
+
+    async function rejectPromises() {
+      setChangeHashtagPromises[0].reject(new Error('error'));
+      setChangeHashtagPromises[1].reject(new Error('error'));
+      setChangeHashtagPromises[2].reject(new Error('error'));
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      setChangeHashtagPromises = [];
+      setChangeHashtagStub = stubRestApi('setChangeHashtag');
+      for (let i = 0; i < changes.length; i++) {
+        const promise = mockPromise<Hashtag[]>();
+        setChangeHashtagPromises.push(promise);
+        setChangeHashtagStub
+          .withArgs(changes[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+
+      // select changes
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await selectChange(changes[2]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 3);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await waitEventLoop();
+    });
+
+    test('renders hashtags flow', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Hashtag</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <div class="chips">
+                <button
+                  role="listbox"
+                  aria-label="hashtag1 selection"
+                  class="chip"
+                >
+                  hashtag1
+                </button>
+                <button
+                  role="listbox"
+                  aria-label="sharedHashtag selection"
+                  class="chip"
+                >
+                  sharedHashtag
+                </button>
+                <button
+                  role="listbox"
+                  aria-label="hashtag2 selection"
+                  class="chip"
+                >
+                  hashtag2
+                </button>
+              </div>
+              <gr-autocomplete
+                placeholder="Type hashtag name to create or filter hashtags"
+                show-blue-focus-border=""
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError" role="progressbar"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="add-hashtag-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Add Hashtag</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('add hashtag from selected change', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      // selects "hashtag1"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await resolvePromises(['hashtag1' as Hashtag]);
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['hashtag1']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['hashtag1']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['hashtag1']},
+      ]);
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '3 Changes added to hashtag1',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-hashtag',
+        selectedChangeCount: 3,
+        hashtagsApplied: 1,
+      });
+      assert.isTrue(
+        queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+
+    test('shows error when add hashtag fails', async () => {
+      // selects "hashtag1"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+      await waitUntil(() => query(element, '.error') !== undefined);
+
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to add'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
+    test('add multiple hashtag from selected change', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      // selects "hashtag1"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      // selects "hashtag2"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[2].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await resolvePromises(['hashtag1' as Hashtag, 'hashtag2' as Hashtag]);
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['hashtag1', 'hashtag2']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['hashtag1', 'hashtag2']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['hashtag1', 'hashtag2']},
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '2 hashtags added to changes',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-hashtag',
+        selectedChangeCount: 3,
+        hashtagsApplied: 2,
+      });
+    });
+
+    test('add existing hashtag not on selected changes', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([{...createChange(), hashtags: ['foo' as Hashtag]}]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await resolvePromises(['foo' as Hashtag]);
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['foo']},
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '3 Changes added to foo',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-hashtag',
+        selectedChangeCount: 3,
+        hashtagsApplied: 1,
+      });
+      assert.isTrue(
+        queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+
+    test('add new hashtag', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#add-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await resolvePromises(['foo' as Hashtag]);
+      await waitUntilObserved(model.selectedChanges$, selected =>
+        selected.every(change => change.hashtags?.includes('foo' as Hashtag))
+      );
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['foo']},
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '3 Changes added to foo',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-hashtag',
+        selectedChangeCount: 3,
+        hashtagsApplied: 1,
+      });
+      assert.isTrue(
+        queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+      assert.equal(
+        queryAll<HTMLButtonElement>(element, 'button.chip')[2].innerText,
+        'foo'
+      );
+    });
+
+    test('shows error when add hashtag fails', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#add-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+      await waitUntil(() => query(element, '.error') !== undefined);
+
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to add'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
+    test('cannot add existing hashtag already on selected changes', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      // selects "sharedHashtag"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[1].click();
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#add-hashtag-button').disabled
+      );
+    });
+  });
+});
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 15532d2..04f282a 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
@@ -1,25 +1,13 @@
 /**
  * @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 '../../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-icon/gr-icon';
 import '../../shared/gr-limited-text/gr-limited-text';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
@@ -27,7 +15,7 @@
 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 {navigationToken} 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';
@@ -40,23 +28,22 @@
   ChangeInfo,
   ServerInfo,
   AccountInfo,
-  QuickLabelInfo,
   Timestamp,
 } from '../../../types/common';
 import {hasOwnProperty, assertIsDefined} from '../../../utils/common-util';
-import {pluralize} from '../../../utils/string-util';
-import {showNewSubmitRequirements} from '../../../utils/label-util';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
 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 {ifDefined} from 'lit/directives/if-defined.js';
+import {ChangeStatus, ColumnNames, WAITING} from '../../../constants/constants';
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
+import {classMap} from 'lit/directives/class-map.js';
+import {createSearchUrl} from '../../../models/views/search';
+import {createChangeUrl} from '../../../models/views/change';
 
 enum ChangeSize {
   XS = 10,
@@ -84,9 +71,7 @@
     '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. */
@@ -115,16 +100,42 @@
   @property({type: Boolean})
   showNumber = false;
 
+  @property({type: String})
+  usp?: string;
+
+  /** Index of the item in the overall list. */
+  @property({type: Number})
+  globalIndex = 0;
+
+  /** Callback to call to request the item to be selected in the list. */
+  @property({type: Function})
+  triggerSelectionCallback?: (globalIndex: number) => void;
+
+  @property({type: Boolean, reflect: true}) selected = false;
+
+  // private but used in tests
+  @property({type: Boolean, reflect: true}) checked = false;
+
   @state() private dynamicCellEndpoints?: string[];
 
   reporting: ReportingService = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  @state() private checked = false;
-
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChangeNums$,
+      selectedChangeNums => {
+        if (!this.change) return;
+        this.checked = selectedChangeNums.includes(this.change._number);
+      }
+    );
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     getPluginLoader()
@@ -134,14 +145,19 @@
           'change-list-item-cell'
         );
       });
-    subscribe(
-      this,
-      this.getBulkActionsModel().selectedChangeNums$,
-      selectedChangeNums => {
-        if (!this.change) return;
-        this.checked = selectedChangeNums.includes(this.change._number);
-      }
-    );
+    this.addEventListener('click', this.onItemClick);
+  }
+
+  override disconnectedCallback() {
+    this.removeEventListener('click', this.onItemClick);
+  }
+
+  override willUpdate(changedProperties: PropertyValues<this>) {
+    // When the cursor selects this item, give it focus so that the item is read
+    // out by screen readers and lets users start tabbing through the item
+    if (this.selected && !changedProperties.get('selected')) {
+      this.focus();
+    }
   }
 
   static override get styles() {
@@ -157,12 +173,17 @@
         :host(:focus) {
           outline: none;
         }
+        :host([checked]),
         :host(:hover) {
           background-color: var(--hover-background-color);
         }
         .container {
           position: relative;
         }
+        .strikethrough {
+          color: var(--deemphasized-text-color);
+          text-decoration: line-through;
+        }
         .content {
           overflow: hidden;
           position: absolute;
@@ -244,22 +265,6 @@
         .subject:hover .content {
           text-decoration: underline;
         }
-        .u-monospace {
-          font-family: var(--monospace-font-family);
-          font-size: var(--font-size-mono);
-          line-height: var(--line-height-mono);
-        }
-        .u-green,
-        .u-green iron-icon {
-          color: var(--positive-green-text-color);
-        }
-        .u-red,
-        .u-red iron-icon {
-          color: var(--negative-red-text-color);
-        }
-        .u-gray-background {
-          background-color: var(--table-header-background-color);
-        }
         .comma,
         .placeholder {
           color: var(--deemphasized-text-color);
@@ -267,10 +272,14 @@
         .cell.selection input {
           vertical-align: middle;
         }
+        .selectionLabel {
+          padding: 10px;
+          margin: -10px;
+        }
         .cell.label {
           font-weight: var(--font-weight-normal);
         }
-        .cell.label iron-icon {
+        .cell.label gr-icon {
           vertical-align: top;
         }
         /* Requirement child needs whole area */
@@ -307,7 +316,6 @@
   }
 
   private renderCellSelectionBox() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) return;
     return html`
       <td class="cell selection">
         <!--
@@ -316,11 +324,13 @@
           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()}
-        />
+        <label class="selectionLabel">
+          <input
+            type="checkbox"
+            .checked=${this.checked}
+            @click=${this.toggleCheckbox}
+          />
+        </label>
       </td>
     `;
   }
@@ -346,7 +356,12 @@
   }
 
   private renderCellSubject(changeUrl: string) {
-    if (this.computeIsColumnHidden('Subject', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.SUBJECT,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -354,10 +369,17 @@
         <a
           title=${ifDefined(this.change?.subject)}
           href=${changeUrl}
-          @click=${() => this.handleChangeClick()}
+          @click=${this.handleChangeClick}
         >
           <div class="container">
-            <div class="content">${this.change?.subject}</div>
+            <div
+              class=${classMap({
+                content: true,
+                strikethrough: this.change?.status === ChangeStatus.ABANDONED,
+              })}
+            >
+              ${this.change?.subject}
+            </div>
             <div class="spacer">${this.change?.subject}</div>
             <span>&nbsp;</span>
           </div>
@@ -367,7 +389,12 @@
   }
 
   private renderCellStatus() {
-    if (this.computeIsColumnHidden('Status', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.STATUS,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html` <td class="cell status">${this.renderChangeStatus()}</td> `;
@@ -387,7 +414,12 @@
   }
 
   private renderCellOwner() {
-    if (this.computeIsColumnHidden('Owner', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.OWNER,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -403,7 +435,12 @@
   }
 
   private renderCellReviewers() {
-    if (this.computeIsColumnHidden('Reviewers', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.REVIEWERS,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -445,7 +482,7 @@
     return html`
       <td class="cell comments">
         ${this.change?.unresolved_comment_count
-          ? html`<iron-icon icon="gr-icons:comment"></iron-icon>`
+          ? html`<gr-icon icon="mode_comment" filled></gr-icon>`
           : ''}
         <span
           >${this.computeComments(this.change?.unresolved_comment_count)}</span
@@ -455,27 +492,33 @@
   }
 
   private renderCellRepo() {
-    if (this.computeIsColumnHidden('Repo', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.REPO,
+        this.visibleChangeTableColumns
+      )
+    ) {
       return;
+    }
 
+    const repo = this.change?.project ?? '';
     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 class="fullRepo" href=${this.computeRepoUrl()}> ${repo} </a>
+        <a class="truncatedRepo" href=${this.computeRepoUrl()} title=${repo}>
+          ${truncatePath(repo, 2)}
         </a>
       </td>
     `;
   }
 
   private renderCellBranch() {
-    if (this.computeIsColumnHidden('Branch', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.BRANCH,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -567,7 +610,12 @@
   }
 
   private renderCellRequirements() {
-    if (this.computeIsColumnHidden(' Status ', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.STATUS2,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -579,32 +627,13 @@
   }
 
   private renderChangeLabels(labelName: string) {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return html` <td class="cell label requirement">
-        <gr-change-list-column-requirement
-          .change=${this.change}
-          .labelName=${labelName}
-        >
-        </gr-change-list-column-requirement>
-      </td>`;
-    }
-    return html`
-      <td
-        title=${this.computeLabelTitle(labelName)}
-        class=${this.computeLabelClass(labelName)}
+    return html` <td class="cell label requirement">
+      <gr-change-list-column-requirement
+        .change=${this.change}
+        .labelName=${labelName}
       >
-        ${this.renderChangeHasLabelIcon(labelName)}
-      </td>
-    `;
-  }
-
-  private renderChangeHasLabelIcon(labelName: string) {
-    if (this.computeLabelIcon(labelName) === '')
-      return html`<span>${this.computeLabelValue(labelName)}</span>`;
-
-    return html`
-      <iron-icon icon=${this.computeLabelIcon(labelName)}></iron-icon>
-    `;
+      </gr-change-list-column-requirement>
+    </td>`;
   }
 
   private renderChangePluginEndpoint(pluginEndpointName: string) {
@@ -618,14 +647,17 @@
     `;
   }
 
-  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 readonly onItemClick = (e: Event) => {
+    // Check the path to verify that the item row itself was directly clicked.
+    // This will allow users using screen readers like VoiceOver to select an
+    // item with j/k and go to the selected change with Ctrl+Option+Space, but
+    // not interfere with clicks on interactive elements within the
+    // gr-change-list-item such as account links, which will bubble through
+    // without triggering this extra navigation.
+    if (this.change && e.composedPath()[0] === this) {
+      this.getNavigation().setUrl(createChangeUrl({change: this.change}));
+    }
+  };
 
   private changeStatuses() {
     if (!this.change) return [];
@@ -634,178 +666,32 @@
 
   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 = this.change?.unresolved_comment_count ?? 0;
-      titleParts.push(pluralize(num, 'unresolved comment'));
-    }
-    const significantLabel =
-      label.rejected || label.approved || label.disliked || label.recommended;
-    if (significantLabel?.name) {
-      titleParts.push(`${labelName} by ${significantLabel.name}`);
-    }
-    if (titleParts.length > 0) {
-      return titleParts.join(',\n');
-    }
-    return labelName;
-  }
-
-  // private but used in test
-  computeLabelClass(labelName: string) {
-    const classes = ['cell', 'label'];
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.NOT_APPLICABLE:
-        classes.push('u-gray-background');
-        break;
-      case LabelCategory.APPROVED:
-        classes.push('u-green');
-        break;
-      case LabelCategory.POSITIVE:
-        classes.push('u-monospace');
-        classes.push('u-green');
-        break;
-      case LabelCategory.NEGATIVE:
-        classes.push('u-monospace');
-        classes.push('u-red');
-        break;
-      case LabelCategory.REJECTED:
-        classes.push('u-red');
-        break;
-    }
-    return classes.sort().join(' ');
-  }
-
-  // private but used in test
-  computeLabelIcon(labelName: string): string {
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.APPROVED:
-        return 'gr-icons:check';
-      case LabelCategory.UNRESOLVED_COMMENTS:
-        return 'gr-icons:comment';
-      case LabelCategory.REJECTED:
-        return 'gr-icons:close';
-      default:
-        return '';
-    }
-  }
-
-  // private but used in test
-  computeLabelCategory(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    if (!label) {
-      return LabelCategory.NOT_APPLICABLE;
-    }
-    if (label.rejected) {
-      return LabelCategory.REJECTED;
-    }
-    if (label.value && label.value < 0) {
-      return LabelCategory.NEGATIVE;
-    }
-    if (this.change?.unresolved_comment_count && labelName === 'Code-Review') {
-      return LabelCategory.UNRESOLVED_COMMENTS;
-    }
-    if (label.approved) {
-      return LabelCategory.APPROVED;
-    }
-    if (label.value && label.value > 0) {
-      return LabelCategory.POSITIVE;
-    }
-    return LabelCategory.NEUTRAL;
-  }
-
-  // private but used in test
-  computeLabelValue(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.NOT_APPLICABLE:
-        return '';
-      case LabelCategory.APPROVED:
-        return '\u2713'; // ✓
-      case LabelCategory.POSITIVE:
-        return `+${label?.value}`;
-      case LabelCategory.NEUTRAL:
-        return '';
-      case LabelCategory.UNRESOLVED_COMMENTS:
-        return 'u';
-      case LabelCategory.NEGATIVE:
-        return `${label?.value}`;
-      case LabelCategory.REJECTED:
-        return '\u2715'; // ✕
-      default:
-        return '';
-    }
+    return createChangeUrl({change: this.change, usp: this.usp});
   }
 
   private computeRepoUrl() {
     if (!this.change) return '';
-    return GerritNav.getUrlForProjectChanges(
-      this.change.project,
-      true,
-      this.change.internalHost
-    );
+    return createSearchUrl({project: this.change.project, statuses: ['open']});
   }
 
   private computeRepoBranchURL() {
     if (!this.change) return '';
-    return GerritNav.getUrlForBranch(
-      this.change.branch,
-      this.change.project,
-      undefined,
-      this.change.internalHost
-    );
+    return createSearchUrl({
+      branch: this.change.branch,
+      project: this.change.project,
+    });
   }
 
   private computeTopicURL() {
     if (!this.change?.topic) return '';
-    return GerritNav.getUrlForTopic(
-      this.change.topic,
-      this.change.internalHost
-    );
+    return createSearchUrl({topic: this.change.topic});
   }
 
-  /**
-   * Computes the display string for the project column. If there is a host
-   * specified in the change detail, the string will be prefixed with it.
-   *
-   * @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() {
-    if (!this.change?.project) return '';
-    let str = '';
-    if (this.change.internalHost) {
-      str += this.change.internalHost + '/';
-    }
-    str += this.change.project;
-    return str;
-  }
-
-  // private but used in test
-  computeTruncatedRepoDisplay() {
-    if (!this.change?.project) {
-      return '';
-    }
-    let str = '';
-    if (this.change.internalHost) {
-      str += this.change.internalHost + '/';
-    }
-    str += truncatePath(this.change.project, 2);
-    return str;
+  private toggleCheckbox() {
+    assertIsDefined(this.change, 'change');
+    this.checked = !this.checked;
+    this.triggerSelectionCallback?.(this.globalIndex);
+    this.getBulkActionsModel().toggleSelectedChangeNum(this.change._number);
   }
 
   // private but used in test
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 95a851e..cbb0d36 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
@@ -1,28 +1,15 @@
 /**
  * @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 {fixture} from '@open-wc/testing-helpers';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import {
   SubmitRequirementResultInfo,
   NumericChangeId,
 } from '../../../api/rest-api';
-import {getAppContext} from '../../../services/app-context';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {
   createAccountWithId,
   createChange,
@@ -35,7 +22,6 @@
   query,
   queryAndAssert,
   stubRestApi,
-  stubFlags,
   waitUntilObserved,
 } from '../../../test/test-utils';
 import {
@@ -46,10 +32,9 @@
   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 {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import './gr-change-list-item';
-import {GrChangeListItem, LabelCategory} from './gr-change-list-item';
+import {GrChangeListItem} from './gr-change-list-item';
 import {
   DIProviderElement,
   wrapInProvider,
@@ -59,13 +44,13 @@
   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';
+import {ColumnNames} from '../../../constants/constants';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-change-list-item tests', () => {
   const account = createAccountWithId();
   const change: ChangeInfo = {
     ...createChange(),
-    internalHost: 'host',
     project: 'a/test/repo' as RepoName,
     topic: 'test-topic' as TopicName,
     branch: 'test-branch' as BranchName,
@@ -92,226 +77,23 @@
     await element.updateComplete;
   });
 
-  test('computeLabelCategory', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.NOT_APPLICABLE
-    );
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.APPROVED
-    );
-    element.change.labels = {Verified: {rejected: account, value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.REJECTED
-    );
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.UNRESOLVED_COMMENTS
-    );
-    element.change.labels = {'Code-Review': {value: 1}};
-    element.change.unresolved_comment_count = 0;
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.POSITIVE
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.NEGATIVE
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.NOT_APPLICABLE
-    );
-  });
-
-  test('computeLabelClass', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(
-      element.computeLabelClass('Verified'),
-      'cell label u-gray-background'
-    );
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelClass('Verified'), 'cell label u-green');
-    element.change.labels = {Verified: {rejected: account, value: -1}};
-    assert.equal(element.computeLabelClass('Verified'), 'cell label u-red');
-    element.change.labels = {'Code-Review': {value: 1}};
-    assert.equal(
-      element.computeLabelClass('Code-Review'),
-      'cell label u-green u-monospace'
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelClass('Code-Review'),
-      'cell label u-monospace u-red'
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelClass('Verified'),
-      'cell label u-gray-background'
-    );
-  });
-
-  test('computeLabelTitle', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelTitle('Verified'), 'Label not applicable');
-
-    element.change.labels = {Verified: {approved: {name: 'Diffy'}}};
-    assert.equal(element.computeLabelTitle('Verified'), 'Verified by Diffy');
-
-    element.change.labels = {Verified: {approved: {name: 'Diffy'}}};
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Label not applicable'
-    );
-
-    element.change.labels = {Verified: {rejected: {name: 'Diffy'}}};
-    assert.equal(element.computeLabelTitle('Verified'), 'Verified by Diffy');
-
-    element.change.labels = {
-      'Code-Review': {disliked: {name: 'Diffy'}, value: -1},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {
-      'Code-Review': {recommended: {name: 'Diffy'}, value: 1},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {
-      'Code-Review': {recommended: {name: 'Diffy'}, rejected: {name: 'Admin'}},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {approved: {name: 'Diffy'}, rejected: {name: 'Admin'}},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {
-        recommended: {name: 'Diffy'},
-        disliked: {name: 'Admin'},
-        value: -1,
-      },
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {
-        approved: {name: 'Diffy'},
-        disliked: {name: 'Admin'},
-        value: -1,
-      },
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '1 unresolved comment'
-    );
-
-    element.change.labels = {
-      'Code-Review': {approved: {name: 'Diffy'}, value: 1},
-    };
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '1 unresolved comment,\nCode-Review by Diffy'
-    );
-
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 2;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '2 unresolved comments'
-    );
-  });
-
-  test('computeLabelIcon', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelIcon('missingLabel'), '');
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelIcon('Verified'), 'gr-icons:check');
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(element.computeLabelIcon('Code-Review'), 'gr-icons:comment');
-  });
-
-  test('computeLabelValue', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelValue('Verified'), '');
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelValue('Verified'), '✓');
-    element.change.labels = {Verified: {value: 1}};
-    assert.equal(element.computeLabelValue('Verified'), '+1');
-    element.change.labels = {Verified: {value: -1}};
-    assert.equal(element.computeLabelValue('Verified'), '-1');
-    element.change.labels = {Verified: {approved: account}};
-    assert.equal(element.computeLabelValue('Verified'), '✓');
-    element.change.labels = {Verified: {rejected: account}};
-    assert.equal(element.computeLabelValue('Verified'), '✕');
-  });
-
   test('no hidden columns', async () => {
     element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-      ' Status ',
+      ColumnNames.SUBJECT,
+      ColumnNames.STATUS,
+      ColumnNames.OWNER,
+      ColumnNames.REVIEWERS,
+      ColumnNames.COMMENTS,
+      ColumnNames.REPO,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+      ColumnNames.SIZE,
+      ColumnNames.STATUS2,
     ];
 
     await element.updateComplete;
 
-    for (const column of columnNames) {
+    for (const column of Object.values(ColumnNames)) {
       const elementClass = '.' + column.trim().toLowerCase();
       assert.isFalse(
         queryAndAssert(element, elementClass).hasAttribute('hidden')
@@ -320,25 +102,16 @@
   });
 
   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'
+        '.selection > label > input'
       );
-      tap(checkbox);
+      checkbox.click();
       let selectedChangeNums = await waitUntilObserved(
         bulkActionsModel.selectedChangeNums$,
         s => s.length === 1
@@ -346,7 +119,7 @@
 
       assert.deepEqual(selectedChangeNums, [1]);
 
-      tap(checkbox);
+      checkbox.click();
       selectedChangeNums = await waitUntilObserved(
         bulkActionsModel.selectedChangeNums$,
         s => s.length === 0
@@ -355,8 +128,25 @@
       assert.deepEqual(selectedChangeNums, []);
     });
 
+    test('checkbox click calls list selection callback', async () => {
+      const selectionCallback = sinon.stub();
+      element.triggerSelectionCallback = selectionCallback;
+      element.globalIndex = 5;
+      element.change = {...createChange(), _number: 1 as NumericChangeId};
+      bulkActionsModel.sync([element.change]);
+      await element.updateComplete;
+
+      const checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '.selection > label > input'
+      );
+      checkbox.click();
+      await element.updateComplete;
+
+      assert.isTrue(selectionCallback.calledWith(5));
+    });
+
     test('checkbox state updates with model updates', async () => {
-      stubFlags('isEnabled').returns(true);
       element.requestUpdate();
       await element.updateComplete;
 
@@ -371,7 +161,7 @@
 
       const checkbox = queryAndAssert<HTMLInputElement>(
         element,
-        '.selection > input'
+        '.selection > label > input'
       );
       assert.isTrue(checkbox.checked);
 
@@ -388,20 +178,20 @@
 
   test('repo column hidden', async () => {
     element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Reviewers',
-      'Comments',
-      'Branch',
-      'Updated',
-      'Size',
-      ' Status ',
+      ColumnNames.SUBJECT,
+      ColumnNames.STATUS,
+      ColumnNames.OWNER,
+      ColumnNames.REVIEWERS,
+      ColumnNames.COMMENTS,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+      ColumnNames.SIZE,
+      ColumnNames.STATUS2,
     ];
 
     await element.updateComplete;
 
-    for (const column of columnNames) {
+    for (const column of Object.values(ColumnNames)) {
       const elementClass = '.' + column.trim().toLowerCase();
       if (column === 'Repo') {
         assert.isNotOk(query(element, elementClass));
@@ -537,37 +327,17 @@
     assert.equal(element.computeChangeSize(), 'XL');
   });
 
-  test('change params passed to gr-navigation', async () => {
-    const navStub = sinon.stub(GerritNav);
+  test('clicking item navigates to change', async () => {
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
     element.change = change;
     await element.updateComplete;
 
-    assert.deepEqual(navStub.getUrlForChange.lastCall.args, [change]);
-    assert.deepEqual(navStub.getUrlForProjectChanges.lastCall.args, [
-      change.project,
-      true,
-      change.internalHost,
-    ]);
-    assert.deepEqual(navStub.getUrlForBranch.lastCall.args, [
-      change.branch,
-      change.project,
-      undefined,
-      change.internalHost,
-    ]);
-    assert.deepEqual(navStub.getUrlForTopic.lastCall.args, [
-      change.topic,
-      change.internalHost,
-    ]);
-  });
+    element.click();
+    await element.updateComplete;
 
-  test('computeRepoDisplay', () => {
-    element.change = {...change};
-    assert.equal(element.computeRepoDisplay(), 'host/a/test/repo');
-    assert.equal(element.computeTruncatedRepoDisplay(), 'host/…/test/repo');
-    delete change.internalHost;
-    element.change = {...change};
-    assert.equal(element.computeRepoDisplay(), 'a/test/repo');
-    assert.equal(element.computeTruncatedRepoDisplay(), '…/test/repo');
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/a/test/repo/+/42');
   });
 
   test('renders', async () => {
@@ -576,42 +346,65 @@
     element.account = createAccountWithId(1);
     element.config = createServerInfo();
     element.change = createChange();
+    element.checked = true;
     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">
+    assert.isTrue(element.hasAttribute('checked'));
+
+    // TODO: Check table elements. The shadowDom helper does not understand
+    // tables interacting with display: contents, even wrapping the element in a
+    // table, does not help.
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <label class="selectionLabel">
+          <input type="checkbox" />
+        </label>
+        <gr-change-star></gr-change-star>
+        <a href="/c/test-project/+/42">42</a>
+        <a href="/c/test-project/+/42" 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-tooltip-content>
-      <gr-change-list-column-requirements-summary>
-      </gr-change-list-column-requirements-summary>
-    `);
+        <gr-account-label
+          deselected=""
+          clickable=""
+          highlightattention=""
+        ></gr-account-label>
+        <div></div>
+        <span></span>
+        <a class="fullRepo" href="/q/project:test-project+status:open">
+          test-project
+        </a>
+        <a
+          class="truncatedRepo"
+          href="/q/project:test-project+status:open"
+          title="test-project"
+        >
+          test-project
+        </a>
+        <a href="/q/project:test-project+branch:test-branch"> 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,
@@ -639,8 +432,10 @@
     ).element as GrChangeListItem;
 
     const requirement = queryAndAssert(element, '.requirement');
-    expect(requirement).dom.to
-      .equal(/* HTML */ ` <gr-change-list-column-requirement>
-    </gr-change-list-column-requirement>`);
+    assert.dom.equal(
+      requirement,
+      /* 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
index 458fe9a..436e435 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -4,42 +4,58 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {css, html, LitElement, nothing} from 'lit';
-import {customElement, query, state} from 'lit/decorators';
+import {customElement, query, state} from 'lit/decorators.js';
 import {ProgressStatus, ReviewerState} from '../../../constants/constants';
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {configModelToken} from '../../../models/config/config-model';
 import {resolve} from '../../../models/dependency';
-import {AccountInfo, ChangeInfo, NumericChangeId} from '../../../types/common';
+import {
+  AccountDetailInfo,
+  AccountInfo,
+  ChangeInfo,
+  NumericChangeId,
+  ServerInfo,
+  SuggestedReviewerGroupInfo,
+} from '../../../types/common';
 import {subscribe} from '../../lit/subscription-controller';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 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,
-};
+import {allSettled} from '../../../utils/async-util';
+import {listForSentence, pluralize} from '../../../utils/string-util';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {
+  AccountInput,
+  GrAccountList,
+} from '../../shared/gr-account-list/gr-account-list';
+import {getReplyByReason} from '../../../utils/attention-set-util';
+import {intersection, queryAndAssert} from '../../../utils/common-util';
+import {accountKey, getUserId} from '../../../utils/account-util';
+import {ValueChangedEvent} from '../../../types/events';
+import {fireAlert, fireReload} from '../../../utils/event-util';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {Interaction} from '../../../constants/reporting';
 
 @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();
+  // private but used in tests
+  @state() updatedAccountsByReviewerState: Map<ReviewerState, AccountInput[]> =
+    new Map([
+      [ReviewerState.REVIEWER, []],
+      [ReviewerState.CC, []],
+    ]);
 
   @state() private suggestionsProviderByReviewerState: Map<
     ReviewerState,
@@ -53,42 +69,117 @@
 
   @state() private isOverlayOpen = false;
 
-  @query('gr-overlay') private overlay!: GrOverlay;
+  @state() private serverConfig?: ServerInfo;
+
+  @state()
+  private groupPendingConfirmationByReviewerState: Map<
+    ReviewerState,
+    SuggestedReviewerGroupInfo | null
+  > = new Map([
+    [ReviewerState.REVIEWER, null],
+    [ReviewerState.CC, null],
+  ]);
+
+  @query('gr-overlay#flow') private overlay?: GrOverlay;
+
+  @query('gr-account-list#reviewer-list') private reviewerList?: GrAccountList;
+
+  @query('gr-account-list#cc-list') private ccList?: GrAccountList;
+
+  @query('gr-overlay#confirm-reviewer')
+  private reviewerConfirmOverlay?: GrOverlay;
+
+  @query('gr-overlay#confirm-cc') private ccConfirmOverlay?: GrOverlay;
+
+  @query('gr-dialog') dialog?: GrDialog;
+
+  private readonly reportingService = getAppContext().reportingService;
 
   private getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
+  private getConfigModel = resolve(this, configModelToken);
+
   private restApiService = getAppContext().restApiService;
 
+  private isLoggedIn = false;
+
+  private account?: AccountDetailInfo;
+
   static override get styles() {
-    return css`
-      gr-dialog {
-        width: 60em;
-      }
-      .grid {
-        display: grid;
-        grid-template-columns: min-content 1fr;
-        column-gap: var(--spacing-l);
-      }
-      gr-account-list {
-        display: flex;
-        flex-wrap: wrap;
-      }
-    `;
+    return [
+      css`
+        gr-dialog {
+          width: 60em;
+        }
+        .grid {
+          display: grid;
+          grid-template-columns: min-content 1fr;
+          column-gap: var(--spacing-l);
+        }
+        gr-account-list {
+          display: flex;
+          flex-wrap: wrap;
+        }
+        .warning,
+        .error {
+          display: flex;
+          align-items: center;
+          gap: var(--spacing-xl);
+          padding: var(--spacing-l);
+          padding-left: var(--spacing-xl);
+          background-color: var(--yellow-50);
+        }
+        .error {
+          background-color: var(--error-background);
+        }
+        .grid + .warning,
+        .error {
+          margin-top: var(--spacing-l);
+        }
+        .warning + .warning {
+          margin-top: var(--spacing-s);
+        }
+        gr-icon {
+          color: var(--orange-800);
+          font-size: 18px;
+        }
+        gr-overlay#confirm-cc,
+        gr-overlay#confirm-reviewer {
+          padding: var(--spacing-l);
+          text-align: center;
+        }
+        .confirmation-buttons {
+          margin-top: var(--spacing-l);
+        }
+      `,
+    ];
   }
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChanges$,
-      selectedChanges => {
-        this.selectedChanges = selectedChanges;
-      }
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => (this.selectedChanges = selectedChanges)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      serverConfig => (this.serverConfig = serverConfig)
+    );
+    subscribe(
+      this,
+      () => getAppContext().userModel.loggedIn$,
+      isLoggedIn => (this.isLoggedIn = isLoggedIn)
+    );
+    subscribe(
+      this,
+      () => getAppContext().userModel.account$,
+      account => (this.account = account)
     );
   }
 
   override render() {
-    // TODO: factor out button+dialog component with promise-progress tracking
     return html`
       <gr-button
         id="start-flow"
@@ -97,7 +188,7 @@
         @click=${() => this.openOverlay()}
         >add reviewer/cc</gr-button
       >
-      <gr-overlay with-backdrop>
+      <gr-overlay id="flow" with-backdrop>
         ${this.isOverlayOpen ? this.renderDialog() : nothing}
       </gr-overlay>
     `;
@@ -109,19 +200,25 @@
       <gr-dialog
         @cancel=${() => this.closeOverlay()}
         @confirm=${() => this.onConfirm(overallStatus)}
-        .confirmLabel=${this.getConfirmLabel(overallStatus)}
+        .confirmLabel=${'Add'}
         .disabled=${overallStatus === ProgressStatus.RUNNING}
+        .loadingLabel=${'Adding Reviewer and CC in progress...'}
+        ?loading=${getOverallStatus(this.progressByChangeNum) ===
+        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 slot="header">Add reviewer / CC</div>
+        <div slot="main">
+          <div class="grid">
+            <span>Reviewers</span>
+            ${this.renderAccountList(
+              ReviewerState.REVIEWER,
+              'reviewer-list',
+              'Add reviewer'
+            )}
+            <span>CC</span>
+            ${this.renderAccountList(ReviewerState.CC, 'cc-list', 'Add CC')}
+          </div>
+          ${this.renderAnyOverwriteWarnings()} ${this.renderErrors()}
         </div>
       </gr-dialog>
     `;
@@ -146,20 +243,151 @@
         .removableValues=${[]}
         .suggestionsProvider=${suggestionsProvider}
         .placeholder=${placeholder}
+        .pendingConfirmation=${this.groupPendingConfirmationByReviewerState.get(
+          reviewerState
+        )}
+        @accounts-changed=${() => this.onAccountsChanged(reviewerState)}
+        @pending-confirmation-changed=${(
+          ev: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
+        ) => this.onPendingConfirmationChanged(reviewerState, ev)}
       >
       </gr-account-list>
+      ${this.renderConfirmationDialog(reviewerState)}
     `;
   }
 
-  private openOverlay() {
+  private renderConfirmationDialog(reviewerState: ReviewerState) {
+    const id =
+      reviewerState === ReviewerState.CC ? 'confirm-cc' : 'confirm-reviewer';
+    const suggestion =
+      this.groupPendingConfirmationByReviewerState.get(reviewerState);
+    return html`
+      <gr-overlay
+        id=${id}
+        @iron-overlay-canceled=${() => this.cancelPendingGroup(reviewerState)}
+      >
+        <div class="confirmation-text">
+          Group
+          <span class="groupName"> ${suggestion?.group.name} </span>
+          has
+          <span class="groupSize"> ${suggestion?.count} </span>
+          members.
+          <br />
+          Are you sure you want to add them all?
+        </div>
+        <div class="confirmation-buttons">
+          <gr-button
+            @click=${() => this.confirmPendingGroup(reviewerState, suggestion)}
+            >Yes</gr-button
+          >
+          <gr-button @click=${() => this.cancelPendingGroup(reviewerState)}
+            >No</gr-button
+          >
+        </div>
+      </gr-overlay>
+    `;
+  }
+
+  private renderAnyOverwriteWarnings() {
+    return html`
+      ${this.renderAnyOverwriteWarning(ReviewerState.REVIEWER)}
+      ${this.renderAnyOverwriteWarning(ReviewerState.CC)}
+    `;
+  }
+
+  private renderErrors() {
+    if (getOverallStatus(this.progressByChangeNum) !== ProgressStatus.FAILED)
+      return nothing;
+    const failedAccounts = [
+      ...(this.updatedAccountsByReviewerState.get(ReviewerState.REVIEWER) ??
+        []),
+      ...(this.updatedAccountsByReviewerState.get(ReviewerState.CC) ?? []),
+    ].map(account => getDisplayName(this.serverConfig, account));
+    if (failedAccounts.length === 0) {
+      return nothing;
+    }
+    return html`
+      <div class="error">
+        <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+        Failed to add ${listForSentence(failedAccounts)} to changes.
+      </div>
+    `;
+  }
+
+  private renderAnyOverwriteWarning(currentReviewerState: ReviewerState) {
+    const updatedReviewerState =
+      currentReviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const overwrittenNames =
+      this.getOverwrittenDisplayNames(currentReviewerState);
+    if (overwrittenNames.length === 0) {
+      return nothing;
+    }
+    const pluralizedVerb = overwrittenNames.length === 1 ? 'is a' : 'are';
+    const currentLabel = `${
+      currentReviewerState === ReviewerState.CC ? 'CC' : 'reviewer'
+    }${overwrittenNames.length > 1 ? 's' : ''}`;
+    const updatedLabel =
+      updatedReviewerState === ReviewerState.CC ? 'CC' : 'reviewer';
+    return html`
+      <div class="warning">
+        <gr-icon
+          icon="warning"
+          filled
+          role="img"
+          aria-label="Warning"
+        ></gr-icon>
+        ${listForSentence(overwrittenNames)} ${pluralizedVerb} ${currentLabel}
+        on some selected changes and will be moved to ${updatedLabel} on all
+        changes.
+      </div>
+    `;
+  }
+
+  private getAccountsInCurrentState(currentReviewerState: ReviewerState) {
+    return this.selectedChanges
+      .flatMap(
+        change =>
+          change.reviewers[currentReviewerState]?.filter(isNotOwner(change)) ??
+          []
+      )
+      .filter(account => account?._account_id !== undefined);
+  }
+
+  private getOverwrittenDisplayNames(
+    currentReviewerState: ReviewerState
+  ): string[] {
+    const updatedReviewerState =
+      currentReviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const accountsInCurrentState =
+      this.getAccountsInCurrentState(currentReviewerState);
+    return this.updatedAccountsByReviewerState
+      .get(updatedReviewerState)!
+      .filter(account =>
+        accountsInCurrentState.some(
+          otherAccount => getUserId(otherAccount) === getUserId(account)
+        )
+      )
+      .map(reviewer => getDisplayName(this.serverConfig, reviewer));
+  }
+
+  private async openOverlay() {
     this.resetFlow();
     this.isOverlayOpen = true;
-    this.overlay.open();
+    // Must await the overlay opening because the dialog is lazily rendered.
+    await this.overlay?.open();
+    this.overlay?.setFocusStops({
+      start: queryAndAssert(this.dialog, 'header'),
+      end: queryAndAssert(this.dialog, 'footer'),
+    });
   }
 
   private closeOverlay() {
     this.isOverlayOpen = false;
-    this.overlay.close();
+    this.overlay?.close();
   }
 
   private resetFlow() {
@@ -169,7 +397,7 @@
         ProgressStatus.NOT_STARTED,
       ])
     );
-    for (const state of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+    for (const state of [ReviewerState.REVIEWER, ReviewerState.CC] as const) {
       this.updatedAccountsByReviewerState.set(
         state,
         this.getCurrentAccounts(state)
@@ -184,21 +412,119 @@
     this.requestUpdate();
   }
 
+  /*
+   * Removes accounts from one list when they are added to the other. Also
+   * trigger re-render so warnings will update as accounts are added, removed,
+   * and confirmed.
+   */
+  private onAccountsChanged(reviewerState: ReviewerState) {
+    const reviewerStateKeys = this.updatedAccountsByReviewerState
+      .get(reviewerState)!
+      .map(getUserId);
+    const oppositeReviewerState =
+      reviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const oppositeUpdatedAccounts = this.updatedAccountsByReviewerState.get(
+      oppositeReviewerState
+    )!;
+
+    const notOverwrittenOppositeAccounts = oppositeUpdatedAccounts.filter(
+      acc => !reviewerStateKeys.includes(getUserId(acc))
+    );
+    if (
+      notOverwrittenOppositeAccounts.length !== oppositeUpdatedAccounts.length
+    ) {
+      this.updatedAccountsByReviewerState.set(
+        oppositeReviewerState,
+        notOverwrittenOppositeAccounts
+      );
+    }
+    this.requestUpdate();
+  }
+
+  private async onPendingConfirmationChanged(
+    reviewerState: ReviewerState,
+    ev: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
+  ) {
+    this.groupPendingConfirmationByReviewerState.set(
+      reviewerState,
+      ev.detail.value
+    );
+    this.requestUpdate();
+    await this.updateComplete;
+
+    const overlay =
+      reviewerState === ReviewerState.CC
+        ? this.ccConfirmOverlay
+        : this.reviewerConfirmOverlay;
+    if (ev.detail.value === null) {
+      overlay?.close();
+    } else {
+      await overlay?.open();
+    }
+  }
+
+  private cancelPendingGroup(reviewerState: ReviewerState) {
+    const overlay =
+      reviewerState === ReviewerState.CC
+        ? this.ccConfirmOverlay
+        : this.reviewerConfirmOverlay;
+    overlay?.close();
+    this.groupPendingConfirmationByReviewerState.set(reviewerState, null);
+    this.requestUpdate();
+  }
+
+  private confirmPendingGroup(
+    reviewerState: ReviewerState,
+    suggestion: SuggestedReviewerGroupInfo | null | undefined
+  ) {
+    if (!suggestion) return;
+    const accountList =
+      reviewerState === ReviewerState.CC ? this.ccList : this.reviewerList;
+    accountList?.confirmGroup(suggestion.group);
+  }
+
   private onConfirm(overallStatus: ProgressStatus) {
     switch (overallStatus) {
       case ProgressStatus.NOT_STARTED:
         this.saveReviewers();
         break;
       case ProgressStatus.SUCCESSFUL:
-        this.overlay.close();
+        this.overlay?.close();
         break;
       case ProgressStatus.FAILED:
-        this.overlay.close();
+        this.overlay?.close();
         break;
     }
   }
 
-  private saveReviewers() {
+  private fireSuccessToasts() {
+    const numReviewersAdded =
+      (this.updatedAccountsByReviewerState.get(ReviewerState.REVIEWER)
+        ?.length ?? 0) - this.getCurrentAccounts(ReviewerState.REVIEWER).length;
+    const numCcsAdded =
+      (this.updatedAccountsByReviewerState.get(ReviewerState.CC)?.length ?? 0) -
+      this.getCurrentAccounts(ReviewerState.CC).length;
+    let alert = '';
+    if (numReviewersAdded && numCcsAdded) {
+      alert = `${pluralize(numReviewersAdded, 'reviewer')} and ${pluralize(
+        numCcsAdded,
+        'CC'
+      )} added`;
+    } else if (numReviewersAdded) {
+      alert = `${pluralize(numReviewersAdded, 'reviewer')} added`;
+    } else {
+      alert = `${pluralize(numCcsAdded, 'CC')} added`;
+    }
+    fireAlert(this, alert);
+  }
+
+  private async saveReviewers() {
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'add-reviewer',
+      selectedChangeCount: this.selectedChanges.length,
+    });
     this.progressByChangeNum = new Map(
       this.selectedChanges.map(change => [
         change._number,
@@ -206,22 +532,38 @@
       ])
     );
     const inFlightActions = this.getBulkActionsModel().addReviewers(
-      this.updatedAccountsByReviewerState
+      this.updatedAccountsByReviewerState,
+      getReplyByReason(this.account, this.serverConfig)
     );
-    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();
-        });
+
+    await allSettled(
+      inFlightActions.map((promise, index) => {
+        const change = this.selectedChanges[index];
+        return promise
+          .then(() => {
+            this.progressByChangeNum.set(
+              change._number,
+              ProgressStatus.SUCCESSFUL
+            );
+            this.requestUpdate();
+          })
+          .catch(() => {
+            this.progressByChangeNum.set(change._number, ProgressStatus.FAILED);
+            this.requestUpdate();
+          });
+      })
+    );
+    if (getOverallStatus(this.progressByChangeNum) === ProgressStatus.FAILED) {
+      this.reportingService.reportInteraction('bulk-action-failure', {
+        type: 'add-reviewer',
+        count: Array.from(this.progressByChangeNum.values()).filter(
+          status => status === ProgressStatus.FAILED
+        ).length,
+      });
+    } else {
+      this.fireSuccessToasts();
+      this.closeOverlay();
+      fireReload(this);
     }
   }
 
@@ -231,41 +573,37 @@
     return this.selectedChanges.length === 0;
   }
 
-  private getConfirmLabel(overallStatus: ProgressStatus) {
-    return overallStatus === ProgressStatus.NOT_STARTED
-      ? 'Add'
-      : overallStatus === ProgressStatus.RUNNING
-      ? 'Running'
-      : 'Close';
-  }
-
-  private getCurrentAccounts(reviewerState: ReviewerState) {
+  // private but used in tests
+  getCurrentAccounts(reviewerState: ReviewerState) {
     const reviewersPerChange = this.selectedChanges.map(
-      change => change.reviewers[reviewerState] ?? []
+      change =>
+        change.reviewers[reviewerState]?.filter(isNotOwner(change)) ?? []
     );
-    if (reviewersPerChange.length === 0) {
-      return [];
-    }
-    // Gets reviewers present in all changes
-    return reviewersPerChange.reduce((a, b) =>
-      a.filter(reviewer => b.includes(reviewer))
+    return intersection(
+      reviewersPerChange,
+      (account1, account2) => accountKey(account1) === accountKey(account2)
     );
   }
 
   private createSuggestionsProvider(
-    state: ReviewerState
+    state: ReviewerState.CC | ReviewerState.REVIEWER
   ): ReviewerSuggestionsProvider {
-    const suggestionsProvider = GrReviewerSuggestionsProvider.create(
+    const suggestionsProvider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      // TODO: fan out and get suggestions allowed by all changes
-      this.selectedChanges[0]._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE[state]
+      state,
+      this.serverConfig,
+      this.isLoggedIn,
+      ...this.selectedChanges
     );
-    suggestionsProvider.init();
     return suggestionsProvider;
   }
 }
 
+function isNotOwner(change: ChangeInfo) {
+  return (account: AccountInfo) =>
+    accountKey(change.owner) !== accountKey(account);
+}
+
 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
index afc7b4b..a96085c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -3,27 +3,40 @@
  * 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 {fixture, html, assert} from '@open-wc/testing';
+import {SinonStubbedMember} from 'sinon';
+import {
+  AccountInfo,
+  GroupId,
+  GroupInfo,
+  GroupName,
+  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 {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import '../../../test/common-test-setup';
 import {
   createAccountWithIdNameAndEmail,
   createChange,
+  createGroupInfo,
 } from '../../../test/test-data-generators';
 import {
   MockPromise,
   mockPromise,
   queryAndAssert,
+  stubReporting,
   stubRestApi,
+  waitUntil,
   waitUntilObserved,
 } from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import {ValueChangedEvent} from '../../../types/events';
+import {query} from '../../../utils/common-util';
 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';
@@ -38,14 +51,19 @@
   createAccountWithIdNameAndEmail(3),
   createAccountWithIdNameAndEmail(4),
   createAccountWithIdNameAndEmail(5),
+  createAccountWithIdNameAndEmail(6),
+];
+const groups: GroupInfo[] = [
+  {...createGroupInfo('groupId'), name: 'Group 0' as GroupName},
 ];
 const changes: ChangeInfo[] = [
   {
     ...createChange(),
     _number: 1 as NumericChangeId,
     subject: 'Subject 1',
+    owner: accounts[6],
     reviewers: {
-      REVIEWER: [accounts[0], accounts[1]],
+      REVIEWER: [accounts[0], accounts[1], accounts[6]],
       CC: [accounts[3], accounts[4]],
     },
   },
@@ -53,13 +71,15 @@
     ...createChange(),
     _number: 2 as NumericChangeId,
     subject: 'Subject 2',
-    reviewers: {REVIEWER: [accounts[0]], CC: [accounts[3]]},
+    owner: accounts[6],
+    reviewers: {REVIEWER: [accounts[0], accounts[6]], CC: [accounts[3]]},
   },
 ];
 
 suite('gr-change-list-reviewer-flow tests', () => {
   let element: GrChangeListReviewerFlow;
   let model: BulkActionsModel;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
 
   async function selectChange(change: ChangeInfo) {
     model.addSelectedChangeNum(change._number);
@@ -71,6 +91,7 @@
 
   setup(async () => {
     stubRestApi('getDetailedChangesWithActions').resolves(changes);
+    reportingStub = stubReporting('reportInteraction');
     model = new BulkActionsModel(getAppContext().restApiService);
     model.sync(changes);
 
@@ -90,22 +111,26 @@
   });
 
   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>
-    `);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-button
+          id="start-flow"
+          flatten=""
+          aria-disabled="false"
+          role="button"
+          tabindex="0"
+          >add reviewer/cc</gr-button
+        >
+        <gr-overlay
+          id="flow"
+          aria-hidden="true"
+          with-backdrop=""
+          tabindex="-1"
+          style="outline: none; display: none;"
+        ></gr-overlay>
+      `
+    );
   });
 
   test('flow button enabled when changes selected', async () => {
@@ -166,31 +191,99 @@
     });
 
     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>
-      `);
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >add reviewer/cc</gr-button
+          >
+          <gr-overlay
+            id="flow"
+            with-backdrop=""
+            tabindex="-1"
+            style="outline: none; display: none;"
+          >
+            <gr-dialog role="dialog">
+              <div slot="header">Add reviewer / CC</div>
+              <div slot="main">
+                <div class="grid">
+                  <span>Reviewers</span>
+                  <gr-account-list id="reviewer-list"></gr-account-list>
+                  <gr-overlay
+                    aria-hidden="true"
+                    id="confirm-reviewer"
+                    style="outline: none; display: none;"
+                  >
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"></span>
+                      has
+                      <span class="groupSize"></span>
+                      members.
+                      <br />
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        No
+                      </gr-button>
+                    </div>
+                  </gr-overlay>
+                  <span>CC</span>
+                  <gr-account-list id="cc-list"></gr-account-list>
+                  <gr-overlay
+                    aria-hidden="true"
+                    id="confirm-cc"
+                    style="outline: none; display: none;"
+                  >
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"></span>
+                      has
+                      <span class="groupSize"></span>
+                      members.
+                      <br />
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        No
+                      </gr-button>
+                    </div>
+                  </gr-overlay>
+                </div>
+              </div>
+            </gr-dialog>
+          </gr-overlay>
+        `
+      );
     });
 
     test('only lists reviewers/CCs shared by all changes', async () => {
@@ -202,7 +295,8 @@
         dialog,
         'gr-account-list#cc-list'
       );
-      // does not include account 1
+      // does not include account 1 because it is not shared, does not include
+      // account 6 because it is the owner
       assert.sameMembers(reviewerList.accounts, [accounts[0]]);
       // does not include account 4
       assert.sameMembers(ccList.accounts, [accounts[3]]);
@@ -217,12 +311,26 @@
         dialog,
         'gr-account-list#cc-list'
       );
-      reviewerList.accounts.push(accounts[2]);
+      reviewerList.accounts.push(accounts[2], groups[0]);
       ccList.accounts.push(accounts[5]);
-      await flush();
+
+      assert.isFalse(dialog.loading);
+
+      await element.updateComplete;
       dialog.confirmButton!.click();
       await element.updateComplete;
 
+      assert.isTrue(dialog.loading);
+      assert.equal(
+        dialog.loadingLabel,
+        'Adding Reviewer and CC in progress...'
+      );
+
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-reviewer',
+        selectedChangeCount: 2,
+      });
+
       assert.isTrue(saveChangeReviewStub.calledTwice);
       assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
         changes[0]._number,
@@ -230,8 +338,21 @@
         {
           reviewers: [
             {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
       assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
@@ -240,24 +361,644 @@
         {
           reviewers: [
             {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
     });
 
-    test('confirm button text updates', async () => {
-      assert.equal(dialog.confirmLabel, 'Add');
+    test('removes from reviewer list when added to cc', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      assert.sameOrderedMembers(reviewerList.accounts, [accounts[0]]);
 
-      dialog.confirmButton!.click();
+      ccList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[0],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
       await element.updateComplete;
 
-      assert.equal(dialog.confirmLabel, 'Running');
+      assert.isEmpty(reviewerList.accounts);
+    });
+
+    test('removes from cc list when added to reviewer', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      assert.sameOrderedMembers(ccList.accounts, [accounts[3]]);
+
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[3],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await element.updateComplete;
+
+      assert.isEmpty(ccList.accounts);
+    });
+
+    suite('success toasts', () => {
+      test('reviewer only', async () => {
+        const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+        const existingReviewers = element.getCurrentAccounts(
+          ReviewerState.REVIEWER
+        );
+        const getCurrentAccountsStub = sinon.stub(
+          element,
+          'getCurrentAccounts'
+        );
+        getCurrentAccountsStub.withArgs(ReviewerState.CC).returns([]);
+        getCurrentAccountsStub
+          .withArgs(ReviewerState.REVIEWER)
+          .returns(existingReviewers);
+        const reviewerList = queryAndAssert<GrAccountList>(
+          dialog,
+          'gr-account-list#reviewer-list'
+        );
+        reviewerList.accounts.push(accounts[2], groups[0]);
+        element.updatedAccountsByReviewerState.set(ReviewerState.CC, []);
+        await element.updateComplete;
+        dialog.confirmButton!.click();
+
+        await resolvePromises();
+        await element.updateComplete;
+
+        await waitUntil(
+          () => dispatchEventStub.callCount > 0,
+          'dispatchEventStub never called'
+        );
+
+        assert.equal(
+          (dispatchEventStub.firstCall.args[0] as CustomEvent).detail.message,
+          '2 reviewers added'
+        );
+      });
+
+      test('ccs only', async () => {
+        const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+        const existingCCs = element.getCurrentAccounts(ReviewerState.CC);
+        const getCurrentAccountsStub = sinon.stub(
+          element,
+          'getCurrentAccounts'
+        );
+        getCurrentAccountsStub.withArgs(ReviewerState.CC).returns(existingCCs);
+        getCurrentAccountsStub.withArgs(ReviewerState.REVIEWER).returns([]);
+        const ccsList = queryAndAssert<GrAccountList>(
+          dialog,
+          'gr-account-list#cc-list'
+        );
+        ccsList.accounts.push(accounts[2], groups[0]);
+        ccsList.accounts = [];
+        element.updatedAccountsByReviewerState.set(ReviewerState.REVIEWER, []);
+        await element.updateComplete;
+        dialog.confirmButton!.click();
+
+        await resolvePromises();
+        await element.updateComplete;
+
+        await waitUntil(
+          () => dispatchEventStub.callCount > 0,
+          'dispatchEventStub never called'
+        );
+
+        assert.equal(
+          (dispatchEventStub.firstCall.args[0] as CustomEvent).detail.message,
+          '2 CCs added'
+        );
+      });
+
+      test('reviewers and CC', async () => {
+        const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+        const existingReviewers = element.getCurrentAccounts(
+          ReviewerState.REVIEWER
+        );
+        const existingCCs = element.getCurrentAccounts(ReviewerState.CC);
+        const getCurrentAccountsStub = sinon.stub(
+          element,
+          'getCurrentAccounts'
+        );
+        getCurrentAccountsStub.withArgs(ReviewerState.CC).returns(existingCCs);
+        getCurrentAccountsStub
+          .withArgs(ReviewerState.REVIEWER)
+          .returns(existingReviewers);
+
+        const reviewerList = queryAndAssert<GrAccountList>(
+          dialog,
+          'gr-account-list#reviewer-list'
+        );
+        const ccsList = queryAndAssert<GrAccountList>(
+          dialog,
+          'gr-account-list#cc-list'
+        );
+
+        reviewerList.accounts.push(accounts[2], groups[0]);
+        ccsList.accounts.push(accounts[2], groups[0]);
+        await element.updateComplete;
+        dialog.confirmButton!.click();
+
+        await resolvePromises();
+        await element.updateComplete;
+
+        await waitUntil(
+          () => dispatchEventStub.callCount > 0,
+          'dispatchEventStub never called'
+        );
+
+        assert.equal(
+          (dispatchEventStub.firstCall.args[0] as CustomEvent).detail.message,
+          '2 reviewers and 2 CCs added'
+        );
+      });
+    });
+
+    test('reloads page on success', async () => {
+      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.accounts.push(accounts[2], groups[0]);
+      await element.updateComplete;
+      dialog.confirmButton!.click();
+      await element.updateComplete;
 
       await resolvePromises();
       await element.updateComplete;
 
-      assert.equal(dialog.confirmLabel, 'Close');
+      await waitUntil(
+        () => dispatchEventStub.callCount > 0,
+        'dispatchEventStub never called'
+      );
+
+      assert.isTrue(dispatchEventStub.calledTwice);
+      assert.equal(dispatchEventStub.secondCall.args[0].type, 'reload');
+    });
+
+    test('does not reload page on failure', async () => {
+      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.accounts.push(accounts[2], groups[0]);
+      await element.updateComplete;
+      dialog.confirmButton!.click();
+      await element.updateComplete;
+      saveChangesPromises[0].reject(new Error('failed!'));
+      saveChangesPromises[1].reject(new Error('failed!'));
+      await element.updateComplete;
+
+      await waitUntil(
+        () => reportingStub.calledWith('bulk-action-failure'),
+        'reporting stub never called'
+      );
+
+      assert.deepEqual(reportingStub.lastCall.args, [
+        'bulk-action-failure',
+        {
+          type: 'add-reviewer',
+          count: 2,
+        },
+      ]);
+      assert.isTrue(dispatchEventStub.notCalled);
+    });
+
+    test('renders warnings when reviewer/cc are overwritten', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[4],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      ccList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[1],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await element.updateComplete;
+
+      // prettier and shadowDom string don't agree on the long text in divs
+      assert.shadowDom.equal(
+        element,
+        /* prettier-ignore */
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >add reviewer/cc</gr-button
+          >
+          <gr-overlay id="flow" with-backdrop="" tabindex="-1">
+            <gr-dialog role="dialog">
+              <div slot="header">Add reviewer / CC</div>
+              <div slot="main">
+                <div class="grid">
+                  <span>Reviewers</span>
+                  <gr-account-list id="reviewer-list"></gr-account-list>
+                  <gr-overlay aria-hidden="true" id="confirm-reviewer">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"></span>
+                      has
+                      <span class="groupSize"></span>
+                      members.
+                      <br>
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0">
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0">
+                        No
+                      </gr-button>
+                    </div>
+                  </gr-overlay>
+                  <span>CC</span>
+                  <gr-account-list id="cc-list"></gr-account-list>
+                  <gr-overlay aria-hidden="true" id="confirm-cc">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"></span>
+                      has
+                      <span class="groupSize"></span>
+                      members.
+                      <br>
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0">
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0">
+                        No
+                      </gr-button>
+                    </div>
+                  </gr-overlay>
+                </div>
+                <div class="warning">
+                  <gr-icon icon="warning" filled role="img" aria-label="Warning"
+                  ></gr-icon>
+                  User-1 is a reviewer
+        on some selected changes and will be moved to CC on all
+        changes.
+                </div>
+                <div class="warning">
+                  <gr-icon icon="warning" filled role="img" aria-label="Warning"
+                  ></gr-icon>
+                  User-4 is a CC
+        on some selected changes and will be moved to reviewer on all
+        changes.
+                </div>
+              </div>
+            </gr-dialog>
+          </gr-overlay>
+        `,
+        {
+          // gr-overlay sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['gr-overlay'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('renders errors when requests fail', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.accounts.push(accounts[2], groups[0]);
+      await element.updateComplete;
+      dialog.confirmButton!.click();
+      await element.updateComplete;
+      saveChangesPromises[0].reject(new Error('failed!'));
+      saveChangesPromises[1].reject(new Error('failed!'));
+
+      await waitUntil(() => !!query(dialog, '.error'));
+
+      // prettier and shadowDom string don't agree on the long text in divs
+      assert.shadowDom.equal(
+        element,
+        /* prettier-ignore */
+        /* HTML */ `
+          <gr-button
+            aria-disabled="false"
+            flatten=""
+            id="start-flow"
+            role="button"
+            tabindex="0"
+          >
+            add reviewer/cc
+          </gr-button>
+          <gr-overlay
+            id="flow"
+            tabindex="-1"
+            with-backdrop=""
+          >
+            <gr-dialog role="dialog">
+              <div slot="header">Add reviewer / CC</div>
+              <div slot="main">
+                <div class="grid">
+                  <span> Reviewers </span>
+                  <gr-account-list id="reviewer-list"> </gr-account-list>
+                  <gr-overlay aria-hidden="true" id="confirm-reviewer">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"> </span>
+                      has
+                      <span class="groupSize"> </span>
+                      members.
+                      <br />
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        No
+                      </gr-button>
+                    </div>
+                  </gr-overlay>
+                  <span> CC </span>
+                  <gr-account-list id="cc-list"> </gr-account-list>
+                  <gr-overlay aria-hidden="true" id="confirm-cc">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"> </span>
+                      has
+                      <span class="groupSize"> </span>
+                      members.
+                      <br />
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        No
+                      </gr-button>
+                    </div>
+                  </gr-overlay>
+                </div>
+                <div class="error">
+                  <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+                  Failed to add User-0, User-2, Group 0, and User-3 to changes.
+                </div>
+              </div>
+            </gr-dialog>
+          </gr-overlay>
+        `,
+        {
+          // gr-overlay sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['gr-overlay'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('shows confirmation dialog when large group is added', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              group: {
+                id: '5',
+                name: 'large-group',
+              },
+              count: 12,
+              confirm: true,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      // reviewerList needs to set the pendingConfirmation property which
+      // triggers an update of ReviewerFlow
+      await reviewerList.updateComplete;
+      await element.updateComplete;
+
+      const confirmDialog = queryAndAssert(
+        element,
+        'gr-overlay#confirm-reviewer'
+      );
+      await waitUntil(
+        () =>
+          getComputedStyle(confirmDialog).getPropertyValue('display') !== 'none'
+      );
+    });
+
+    test('"yes" button confirms large group', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              group: {
+                id: '5',
+                name: 'large-group',
+              },
+              count: 12,
+              confirm: true,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      // reviewerList needs to set the pendingConfirmation property which
+      // triggers an update of ReviewerFlow
+      await reviewerList.updateComplete;
+      await element.updateComplete;
+      // "Yes" button is first
+      queryAndAssert<GrButton>(
+        element,
+        '.confirmation-buttons > gr-button:first-of-type'
+      ).click();
+      await element.updateComplete;
+
+      const confirmDialog = queryAndAssert(
+        element,
+        'gr-overlay#confirm-reviewer'
+      );
+      assert.isTrue(
+        getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
+      );
+
+      assert.deepEqual(reviewerList.accounts[1], {
+        confirmed: true,
+        id: '5' as GroupId,
+        name: 'large-group',
+      });
+    });
+
+    test('confirmation dialog skipped for small group', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      // "confirm" field is used to decide whether to use the confirmation flow,
+      // not the count. "confirm" value comes from server based on count
+      // threshold
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              group: {
+                id: '5',
+                name: 'small-group',
+              },
+              count: 2,
+              confirm: false,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      // reviewerList needs to set the pendingConfirmation property which
+      // triggers an update of ReviewerFlow
+      await reviewerList.updateComplete;
+      await element.updateComplete;
+      const confirmDialog = queryAndAssert(
+        element,
+        'gr-overlay#confirm-reviewer'
+      );
+      assert.isTrue(
+        getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
+      );
+      assert.deepEqual(reviewerList.accounts[1], {
+        id: '5' as GroupId,
+        name: 'small-group',
+      });
+    });
+
+    test('"no" button cancels large group', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              group: {
+                id: '5',
+                name: 'large-group',
+              },
+              count: 12,
+              confirm: true,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      // reviewerList needs to set the pendingConfirmation property which
+      // triggers an update of ReviewerFlow
+      await reviewerList.updateComplete;
+      await element.updateComplete;
+      // "No" button is last
+      queryAndAssert<GrButton>(
+        element,
+        '.confirmation-buttons > gr-button:last-of-type'
+      ).click();
+      await element.updateComplete;
+
+      const confirmDialog = queryAndAssert(
+        element,
+        'gr-overlay#confirm-reviewer'
+      );
+      assert.isTrue(
+        getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
+      );
+      // Group not present
+      assert.sameDeepMembers(reviewerList.accounts, [accounts[0]]);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index 85ea644..8227e11 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -3,17 +3,11 @@
  * 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 {customElement, property, state} from 'lit/decorators.js';
 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 {CLOSED, YOUR_TURN} from '../../../utils/dashboard-util';
 import {getAppContext} from '../../../services/app-context';
 import {ChangeInfo, ServerInfo, AccountInfo} from '../../../api/rest-api';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
@@ -21,15 +15,16 @@
 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';
+import {classMap} from 'lit/directives/class-map.js';
+import {createSearchUrl} from '../../../models/views/search';
 
-const NUMBER_FIXED_COLUMNS = 3;
+const NUMBER_FIXED_COLUMNS = 4;
 const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
 const MAX_SHORTCUT_CHARS = 5;
 const INVALID_TOKENS = ['limit:', 'age:', '-age:'];
@@ -87,9 +82,23 @@
   @property({type: Object})
   account: AccountInfo | undefined = undefined;
 
-  @state() showBulkActionsHeader = false;
+  @property({type: String})
+  usp?: string;
 
-  private readonly flagsService = getAppContext().flagsService;
+  /** Index of the first element in the section in the overall list order. */
+  @property({type: Number})
+  startIndex = 0;
+
+  /** Callback to call to request the item to be selected in the list. */
+  @property({type: Function})
+  triggerSelectionCallback?: (globalIndex: number) => void;
+
+  // private but used in tests
+  @state()
+  numSelected = 0;
+
+  @state()
+  private totalChangeCount = 0;
 
   bulkActionsModel: BulkActionsModel = new BulkActionsModel(
     getAppContext().restApiService
@@ -111,6 +120,23 @@
           font-weight: var(--font-weight-normal);
           line-height: var(--line-height-small);
         }
+        /*
+         * checkbox styles match checkboxes in <gr-change-list-item> rows to
+         * vertically align with them.
+         */
+        input.selection-checkbox {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          box-sizing: border-box;
+          color: var(--primary-text-color);
+          margin: 0px;
+          padding: var(--spacing-s);
+          vertical-align: middle;
+        }
+        .showSelectionBorder {
+          border-bottom: 2px solid var(--input-focus-border-color);
+        }
       `,
     ];
   }
@@ -118,15 +144,17 @@
   constructor() {
     super();
     provide(this, bulkActionsModelToken, () => this.bulkActionsModel);
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
     subscribe(
       this,
-      this.bulkActionsModel.selectedChangeNums$,
-      selectedChanges =>
-        (this.showBulkActionsHeader = selectedChanges.length > 0)
+      () => this.bulkActionsModel.selectedChangeNums$,
+      selectedChanges => {
+        this.numSelected = selectedChanges.length;
+      }
+    );
+    subscribe(
+      this,
+      () => this.bulkActionsModel.totalChangeCount$,
+      totalChangeCount => (this.totalChangeCount = totalChangeCount)
     );
   }
 
@@ -135,9 +163,7 @@
       // 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);
-      }
+      this.bulkActionsModel.sync(this.changeSection.results);
     }
   }
 
@@ -187,7 +213,6 @@
       <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">
@@ -208,14 +233,19 @@
   }
 
   private renderColumnHeaders(columns: string[]) {
+    const showBulkActionsHeader = this.numSelected > 0;
     return html`
-      <tr class="groupTitle">
-        ${this.showBulkActionsHeader &&
-        this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)
+      <tr
+        class=${classMap({
+          groupTitle: true,
+          showSelectionBorder: showBulkActionsHeader,
+        })}
+      >
+        <td class="leftPadding"></td>
+        ${this.renderSelectionHeader()}
+        ${showBulkActionsHeader
           ? html`<gr-change-list-action-bar></gr-change-list-action-bar>`
-          : html` <td class="leftPadding" aria-hidden="true"></td>
-              ${this.renderSelectionHeader()}
-              <td
+          : html` <td
                 class="star"
                 aria-label="Star status column"
                 ?hidden=${!this.showStar}
@@ -233,8 +263,26 @@
   }
 
   private renderSelectionHeader() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) return;
-    return html`<td aria-hidden="true" class="selection"></td>`;
+    const checked = this.numSelected > 0;
+    const indeterminate =
+      this.numSelected > 0 && this.numSelected !== this.totalChangeCount;
+    return html`
+      <td class="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
+          class="selection-checkbox"
+          type="checkbox"
+          .checked=${checked}
+          .indeterminate=${indeterminate}
+          @click=${this.handleSelectAllCheckboxClicked}
+        />
+      </td>
+    `;
   }
 
   private renderHeaderCell(item: string) {
@@ -264,24 +312,35 @@
   ) {
     const ariaLabel = this.computeAriaLabel(change);
     const selected = this.computeItemSelected(index);
-    const tabindex = this.computeTabIndex(index);
     return html`
       <gr-change-list-item
+        tabindex="0"
         .account=${this.account}
-        ?selected=${selected}
+        .selected=${selected}
         .change=${change}
         .config=${this.config}
         .sectionName=${this.changeSection.name}
         .visibleChangeTableColumns=${columns}
         .showNumber=${this.showNumber}
         ?showStar=${this.showStar}
-        tabindex=${ifDefined(tabindex)}
+        .usp=${this.usp}
         .labelNames=${this.labelNames}
+        .globalIndex=${this.startIndex + index}
+        .triggerSelectionCallback=${this.triggerSelectionCallback}
         aria-label=${ariaLabel}
+        role="button"
       ></gr-change-list-item>
     `;
   }
 
+  private handleSelectAllCheckboxClicked() {
+    if (this.numSelected === 0) {
+      this.bulkActionsModel.selectAll();
+    } else {
+      this.bulkActionsModel.clearSelectedChangeNums();
+    }
+  }
+
   /**
    * This methods allows us to customize the columns per section.
    * Private but used in test
@@ -301,16 +360,17 @@
     return cols;
   }
 
+  toggleChange(index: number) {
+    this.bulkActionsModel.toggleSelectedChangeNum(
+      this.changeSection.results[index]._number
+    );
+  }
+
   // 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;
@@ -328,8 +388,8 @@
   }
 
   private sectionHref(query?: string) {
-    if (!query) return;
-    return GerritNav.getUrlForSearchQuery(this.processQuery(query));
+    if (!query) return '';
+    return createSearchUrl({query: this.processQuery(query)});
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
index 6a09c45..8dfecdc 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
@@ -3,13 +3,13 @@
  * 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 '../../../test/common-test-setup';
 import './gr-change-list-section';
+import '../gr-change-list-item/gr-change-list-item';
 import {
   createChange,
   createAccountDetailWithId,
@@ -24,8 +24,9 @@
   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';
+import {ChangeListSection} from '../gr-change-list/gr-change-list';
+import {fixture, html, assert} from '@open-wc/testing';
+import {ColumnNames} from '../../../constants/constants';
 
 suite('gr-change-list section', () => {
   let element: GrChangeListSection;
@@ -52,23 +53,42 @@
       html`<gr-change-list-section
         .account=${createAccountDetailWithId(1)}
         .config=${createServerInfo()}
-        .visibleChangeTableColumns=${columnNames}
+        .visibleChangeTableColumns=${Object.values(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('renders headers when no changes are selected', () => {
+    // TODO: Check table elements. The shadowDom helper does not understand
+    // tables interacting with display: contents, even wrapping the element in a
+    // table, does not help.
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <td class="selection">
+        <input class="selection-checkbox" type="checkbox"/>
+      </td>
+      #
+              SubjectStatusOwnerReviewersCommentsRepoBranchUpdatedSize Status
+      <gr-change-list-item
+        aria-label="Test subject, section: test"
+        role="button"
+        tabindex="0"
+      >
+      </gr-change-list-item>
+      <gr-change-list-item
+        aria-label="Test subject, section: test"
+        role="button"
+        tabindex="0"
+      >
+      </gr-change-list-item>
+    `
+    );
   });
 
-  test('selection header is only shown if experiment is enabled', async () => {
+  test('renders action bar when some changes are selected', async () => {
+    assert.isNotOk(query(element, 'gr-change-list-action-bar'));
     element.bulkActionsModel.setState({
       ...element.bulkActionsModel.getState(),
       selectedChangeNums: [1 as NumericChangeId],
@@ -78,11 +98,30 @@
       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');
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+        <td class="selection">
+          <input class="selection-checkbox" type="checkbox" />
+        </td>
+        <gr-change-list-action-bar></gr-change-list-action-bar>
+        <gr-change-list-item
+          aria-label="Test subject, section: test"
+          role="button"
+          tabindex="0"
+        >
+        </gr-change-list-item>
+        <gr-change-list-item
+          aria-label="Test subject, section: test"
+          checked=""
+          role="button"
+          tabindex="0"
+        >
+        </gr-change-list-item>
+      `
+    );
   });
 
   suite('bulk actions selection', () => {
@@ -114,27 +153,6 @@
       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(),
@@ -144,7 +162,7 @@
         element.bulkActionsModel.selectedChangeNums$,
         s => s.length === 0
       );
-      assert.isFalse(element.showBulkActionsHeader);
+      assert.isFalse(element.numSelected > 0);
 
       element.bulkActionsModel.setState({
         ...element.bulkActionsModel.getState(),
@@ -154,7 +172,104 @@
         element.bulkActionsModel.selectedChangeNums$,
         s => s.length === 1
       );
-      assert.isTrue(element.showBulkActionsHeader);
+      assert.isTrue(element.numSelected > 0);
+    });
+
+    test('select all checkbox checks all when none are selected', async () => {
+      element.changeSection = {
+        name: 'test',
+        query: 'test',
+        results: [
+          {
+            ...createChange(),
+            _number: 1 as NumericChangeId,
+            id: '1' as ChangeInfoId,
+          },
+          {
+            ...createChange(),
+            _number: 2 as NumericChangeId,
+            id: '2' as ChangeInfoId,
+          },
+        ],
+        emptyStateSlotName: 'test',
+      };
+      await element.updateComplete;
+      let rows = queryAll(element, 'gr-change-list-item');
+      assert.lengthOf(rows, 2);
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[0], 'input').checked
+      );
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[1], 'input').checked
+      );
+
+      const checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      checkbox.click();
+      await waitUntilObserved(
+        element.bulkActionsModel.selectedChangeNums$,
+        s => s.length === 2
+      );
+      await element.updateComplete;
+
+      rows = queryAll(element, 'gr-change-list-item');
+      assert.lengthOf(rows, 2);
+      assert.isTrue(queryAndAssert<HTMLInputElement>(rows[0], 'input').checked);
+      assert.isTrue(queryAndAssert<HTMLInputElement>(rows[1], 'input').checked);
+    });
+
+    test('checkbox matches partial and fully selected state', async () => {
+      element.changeSection = {
+        name: 'test',
+        query: 'test',
+        results: [
+          {
+            ...createChange(),
+            _number: 1 as NumericChangeId,
+            id: '1' as ChangeInfoId,
+          },
+          {
+            ...createChange(),
+            _number: 2 as NumericChangeId,
+            id: '2' as ChangeInfoId,
+          },
+        ],
+        emptyStateSlotName: 'test',
+      };
+      await element.updateComplete;
+      const rows = queryAll(element, 'gr-change-list-item');
+
+      // zero case
+      let checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      assert.isFalse(checkbox.checked);
+      assert.isFalse(checkbox.indeterminate);
+
+      // partial case
+      queryAndAssert<HTMLInputElement>(rows[0], 'input').click();
+      await element.updateComplete;
+
+      checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      assert.isTrue(checkbox.indeterminate);
+
+      // plural case
+      queryAndAssert<HTMLInputElement>(rows[1], 'input').click();
+      await element.updateComplete;
+
+      checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      assert.isFalse(checkbox.indeterminate);
+      assert.isTrue(checkbox.checked);
+
+      // Clicking Check All checkbox when all checkboxes selected unselects
+      // all checkboxes
+      queryAndAssert<HTMLInputElement>(element, 'input');
+      checkbox.click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[0], 'input').checked
+      );
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[1], 'input').checked
+      );
     });
   });
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
new file mode 100644
index 0000000..ac1ba23
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -0,0 +1,446 @@
+/**
+ * @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.js';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {ChangeInfo, TopicName} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '@polymer/iron-dropdown/iron-dropdown';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {getAppContext} from '../../../services/app-context';
+import {notUndefined} from '../../../types/types';
+import {unique} from '../../../utils/common-util';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {when} from 'lit/directives/when.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map.js';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+import {fireReload} from '../../../utils/event-util';
+import {fireAlert} from '../../../utils/event-util';
+import {pluralize} from '../../../utils/string-util';
+import {Interaction} from '../../../constants/reporting';
+
+@customElement('gr-change-list-topic-flow')
+export class GrChangeListTopicFlow extends LitElement {
+  @state() private selectedChanges: ChangeInfo[] = [];
+
+  @state() private topicToAdd: TopicName = '' as TopicName;
+
+  @state() private existingTopicSuggestions: TopicName[] = [];
+
+  @state() private loadingText?: string;
+
+  @state() private errorText?: string;
+
+  /** dropdown status is tracked here to lazy-load the inner DOM contents */
+  @state() private isDropdownOpen = false;
+
+  @state() private overallProgress: ProgressStatus = ProgressStatus.NOT_STARTED;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  private selectedExistingTopics: Set<TopicName> = new Set();
+
+  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  private readonly reportingService = getAppContext().reportingService;
+
+  static override get styles() {
+    return [
+      spinnerStyles,
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: 400px;
+          background-color: var(--dialog-background-color);
+          border-radius: 4px;
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l);
+        }
+        gr-autocomplete {
+          --prominent-border-color: var(--gray-800);
+        }
+        .footer {
+          display: flex;
+          justify-content: space-between;
+          align-items: baseline;
+        }
+        .buttons {
+          padding-top: var(--spacing-m);
+          display: flex;
+          justify-content: flex-end;
+          gap: var(--spacing-m);
+        }
+        .chips {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+        }
+        .chip {
+          padding: var(--spacing-s) var(--spacing-xl);
+          border-radius: 10px;
+          width: fit-content;
+          cursor: pointer;
+          color: var(--primary-text-color);
+        }
+        .chip:not(.selected) {
+          border: var(--spacing-xxs) solid var(--border-color);
+          background: none;
+        }
+        .chip.selected {
+          border: 0;
+          color: var(--selected-foreground);
+          background-color: var(--selected-chip-background);
+          margin: var(--spacing-xxs);
+        }
+        .loadingOrError {
+          display: flex;
+          align-items: baseline;
+          gap: var(--spacing-s);
+        }
+
+        /* The basics of .loadingSpin are defined in spinnerStyles. */
+        .loadingSpin {
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+        }
+        .error {
+          color: var(--deemphasized-text-color);
+        }
+        gr-icon {
+          color: var(--error-color);
+          /* Center with text by aligning it to the top and then pushing it down
+             to match the text */
+          vertical-align: top;
+          position: relative;
+          top: 7px;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+      }
+    );
+  }
+
+  override render() {
+    const isFlowDisabled = this.selectedChanges.length === 0;
+    return html`
+      <gr-button
+        id="start-flow"
+        flatten
+        down-arrow
+        @click=${this.toggleDropdown}
+        .disabled=${isFlowDisabled}
+        >Topic</gr-button
+      >
+      <iron-dropdown
+        .horizontalAlign=${'auto'}
+        .verticalAlign=${'auto'}
+        .verticalOffset=${24}
+        @opened-changed=${(e: CustomEvent) =>
+          (this.isDropdownOpen = e.detail.value)}
+      >
+        ${when(
+          this.isDropdownOpen,
+          () => html`
+            <div slot="dropdown-content">
+              ${when(
+                this.selectedChanges.some(change => change.topic),
+                () => this.renderExistingTopicsMode(),
+                () => this.renderNoExistingTopicsMode()
+              )}
+            </div>
+          `
+        )}
+      </iron-dropdown>
+    `;
+  }
+
+  private disableApplyToAllButton() {
+    if (this.selectedExistingTopics.size !== 1) return true;
+    // Ensure there is one selected change that does not have this topic
+    // already
+    return !this.selectedChanges
+      .map(change => change.topic)
+      .filter(unique)
+      .some(topic => !topic || !this.selectedExistingTopics.has(topic));
+  }
+
+  private renderExistingTopicsMode() {
+    const topics = this.selectedChanges
+      .map(change => change.topic)
+      .filter(notUndefined)
+      .filter(unique);
+    const removeDisabled =
+      this.selectedExistingTopics.size === 0 ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    return html`
+      <div class="chips">
+        ${topics.map(name => this.renderExistingTopicChip(name))}
+      </div>
+      <div class="footer">
+        <div class="loadingOrError" role="progressbar">
+          ${this.renderLoadingOrError()}
+        </div>
+        <div class="buttons">
+          ${when(
+            this.overallProgress !== ProgressStatus.FAILED,
+            () => html` <gr-button
+                id="apply-to-all-button"
+                flatten
+                ?disabled=${this.disableApplyToAllButton()}
+                @click=${this.applyTopicToAll}
+                >Apply${this.selectedChanges.length > 1
+                  ? ' to all'
+                  : nothing}</gr-button
+              >
+              <gr-button
+                id="remove-topics-button"
+                flatten
+                ?disabled=${removeDisabled}
+                @click=${this.removeTopics}
+                >Remove</gr-button
+              >`,
+            () =>
+              html`
+                <gr-button
+                  id="cancel-button"
+                  flatten
+                  @click=${this.closeDropdown}
+                  >Cancel</gr-button
+                >
+              `
+          )}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderExistingTopicChip(name: TopicName) {
+    const chipClasses = {
+      chip: true,
+      selected: this.selectedExistingTopics.has(name),
+    };
+    return html`
+      <button
+        role="listbox"
+        aria-label=${`${name as string} selection`}
+        class=${classMap(chipClasses)}
+        @click=${() => this.toggleExistingTopicSelected(name)}
+      >
+        ${name}
+      </button>
+    `;
+  }
+
+  private renderLoadingOrError() {
+    switch (this.overallProgress) {
+      case ProgressStatus.RUNNING:
+        return html`
+          <span class="loadingSpin"></span>
+          <span class="loadingText">${this.loadingText}</span>
+        `;
+      case ProgressStatus.FAILED:
+        return html`
+          <gr-icon icon="error" filled></gr-icon>
+          <div class="error">${this.errorText}</div>
+        `;
+      default:
+        return nothing;
+    }
+  }
+
+  private renderNoExistingTopicsMode() {
+    const isApplyTopicDisabled =
+      this.topicToAdd === '' || this.overallProgress === ProgressStatus.RUNNING;
+    return html`
+      <!--
+        The .query function needs to be bound to this because lit's autobind
+        seems to work only for @event handlers.
+        'this.getTopicSuggestions.bind(this)' gets in trouble with our linter
+        even though the bind is necessary here, so an anonymous function is used
+        instead.
+      -->
+      <gr-autocomplete
+        .text=${this.topicToAdd}
+        .query=${(query: string) => this.getTopicSuggestions(query)}
+        show-blue-focus-border
+        placeholder="Type topic name to create or filter topics"
+        @text-changed=${(e: ValueChangedEvent<TopicName>) =>
+          (this.topicToAdd = e.detail.value)}
+      ></gr-autocomplete>
+      <div class="footer">
+        <div class="loadingOrError" role="progressbar">
+          ${this.renderLoadingOrError()}
+        </div>
+        <div class="buttons">
+          ${when(
+            this.overallProgress !== ProgressStatus.FAILED,
+            () => html`
+              <gr-button
+                id="set-topic-button"
+                flatten
+                @click=${() => this.setTopic('Setting topic...')}
+                .disabled=${isApplyTopicDisabled}
+                >Set Topic</gr-button
+              >
+            `,
+            () => html`
+              <gr-button id="cancel-button" flatten @click=${this.closeDropdown}
+                >Cancel</gr-button
+              >
+            `
+          )}
+        </div>
+      </div>
+    `;
+  }
+
+  private toggleDropdown() {
+    this.isDropdownOpen ? this.closeDropdown() : this.openDropdown();
+  }
+
+  private reset() {
+    this.topicToAdd = '' as TopicName;
+    this.selectedExistingTopics = new Set();
+    this.overallProgress = ProgressStatus.NOT_STARTED;
+    this.errorText = undefined;
+  }
+
+  private closeDropdown() {
+    this.isDropdownOpen = false;
+    this.dropdown?.close();
+  }
+
+  private openDropdown() {
+    this.reset();
+    this.isDropdownOpen = true;
+    this.dropdown?.open();
+  }
+
+  private async getTopicSuggestions(
+    query: string
+  ): Promise<AutocompleteSuggestion[]> {
+    const suggestions = await this.restApiService.getChangesWithSimilarTopic(
+      query
+    );
+    this.existingTopicSuggestions = (suggestions ?? [])
+      .map(change => change.topic)
+      .filter(notUndefined)
+      .filter(unique);
+    return this.existingTopicSuggestions.map(topic => {
+      return {name: topic, value: topic};
+    });
+  }
+
+  private removeTopics() {
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'removing-topic',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    this.loadingText = `Removing topic${
+      this.selectedExistingTopics.size > 1 ? 's' : ''
+    }...`;
+    this.trackPromises(
+      this.selectedChanges
+        .filter(
+          change =>
+            change.topic && this.selectedExistingTopics.has(change.topic)
+        )
+        .map(change => this.restApiService.setChangeTopic(change._number, '')),
+      `${this.selectedChanges[0].topic} removed from changes`,
+      'Failed to remove topic'
+    );
+  }
+
+  private applyTopicToAll() {
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'apply-topic-to-all',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    this.loadingText = 'Applying to all';
+    const topic = Array.from(this.selectedExistingTopics.values())[0];
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeTopic(change._number, topic)
+      ),
+      `${topic} applied to all changes`,
+      'Failed to apply topic'
+    );
+  }
+
+  private setTopic(loadingText: string) {
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'add-topic',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    const alert = `${pluralize(
+      this.selectedChanges.length,
+      'Change'
+    )} added to ${this.topicToAdd}`;
+    this.loadingText = loadingText;
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeTopic(change._number, this.topicToAdd)
+      ),
+      alert,
+      'Failed to set topic'
+    );
+  }
+
+  private async trackPromises(
+    promises: Promise<string>[],
+    alert: string,
+    errorMessage: string
+  ) {
+    this.overallProgress = ProgressStatus.RUNNING;
+    const results = await allSettled(promises);
+    if (results.every(result => result.status === 'fulfilled')) {
+      this.overallProgress = ProgressStatus.SUCCESSFUL;
+      this.closeDropdown();
+      if (alert) {
+        fireAlert(this, alert);
+      }
+      fireReload(this);
+    } else {
+      this.overallProgress = ProgressStatus.FAILED;
+      this.errorText = errorMessage;
+    }
+  }
+
+  private toggleExistingTopicSelected(name: TopicName) {
+    if (this.selectedExistingTopics.has(name)) {
+      this.selectedExistingTopics.delete(name);
+    } else {
+      this.selectedExistingTopics.add(name);
+    }
+    this.requestUpdate();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-topic-flow': GrChangeListTopicFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
new file mode 100644
index 0000000..9125cfd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
@@ -0,0 +1,771 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html, assert} from '@open-wc/testing';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {SinonStubbedMember} from 'sinon';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import '../../../test/common-test-setup';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+  waitEventLoop,
+  waitUntil,
+  waitUntilCalled,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, TopicName} from '../../../types/common';
+import {EventType} from '../../../types/events';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import './gr-change-list-topic-flow';
+import type {GrChangeListTopicFlow} from './gr-change-list-topic-flow';
+
+suite('gr-change-list-topic-flow tests', () => {
+  let element: GrChangeListTopicFlow;
+  let model: BulkActionsModel;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
+
+  setup(() => {
+    reportingStub = stubReporting('reportInteraction');
+  });
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChanges$, selected =>
+      selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  async function deselectChange(change: ChangeInfo) {
+    model.removeSelectedChangeNum(change._number);
+    await waitUntilObserved(
+      model.selectedChanges$,
+      selected => !selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  suite('dropdown closed', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-topic-flow></gr-change-list-topic-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-topic-flow')!;
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+    });
+
+    test('skips dropdown render when closed', async () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Topic</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            aria-hidden="true"
+            style="outline: none; display: none;"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+          </iron-dropdown>
+        `
+      );
+    });
+
+    test('dropdown hidden before flow button clicked', async () => {
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+    });
+
+    test('flow button click shows dropdown', async () => {
+      const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+
+      button.click();
+      await element.updateComplete;
+
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isTrue(dropdown.opened);
+    });
+
+    test('flow button click when open hides dropdown', async () => {
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(() =>
+        Boolean(
+          queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+        )
+      );
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(
+        () =>
+          !queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+  });
+
+  suite('changes in existing topics', () => {
+    const changesWithTopics: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        topic: 'topic1' as TopicName,
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        topic: 'topic2' as TopicName,
+      },
+    ];
+    let setChangeTopicPromises: MockPromise<string>[];
+    let setChangeTopicStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeTopicPromises[0].resolve('foo');
+      setChangeTopicPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    async function rejectPromises() {
+      setChangeTopicPromises[0].reject(new Error('error'));
+      setChangeTopicPromises[1].reject(new Error('error'));
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changesWithTopics);
+      setChangeTopicPromises = [];
+      setChangeTopicStub = stubRestApi('setChangeTopic');
+      for (let i = 0; i < changesWithTopics.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeTopicPromises.push(promise);
+        setChangeTopicStub
+          .withArgs(changesWithTopics[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithTopics);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-topic-flow></gr-change-list-topic-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-topic-flow')!;
+
+      // select changes
+      await selectChange(changesWithTopics[0]);
+      await selectChange(changesWithTopics[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await waitEventLoop();
+    });
+
+    test('renders existing-topics flow', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Topic</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <div class="chips">
+                <button
+                  role="listbox"
+                  aria-label="topic1 selection"
+                  class="chip"
+                >
+                  topic1
+                </button>
+                <button
+                  role="listbox"
+                  aria-label="topic2 selection"
+                  class="chip"
+                >
+                  topic2
+                </button>
+              </div>
+              <div class="footer">
+                <div class="loadingOrError" role="progressbar"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="apply-to-all-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply to all</gr-button
+                  >
+                  <gr-button
+                    id="remove-topics-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Remove</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('apply all button is disabled if all changes have the same topic', async () => {
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      await deselectChange(changesWithTopics[1]);
+
+      const allChanges = model.getState().allChanges;
+      const change2 = {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        topic: 'topic1' as TopicName, // same as changesWithTopics[0]
+      };
+      allChanges.set(2 as NumericChangeId, change2);
+      model.setState({
+        ...model.getState(),
+        allChanges,
+      });
+
+      await selectChange(change2);
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+    });
+
+    test('remove single topic', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing topic...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different topic
+      assert.isTrue(setChangeTopicStub.calledOnce);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        '',
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: 'topic1 removed from changes',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'removing-topic',
+        selectedChangeCount: 2,
+      });
+    });
+
+    test('remove multiple topics', async () => {
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      queryAll<HTMLButtonElement>(element, 'button.chip')[1].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing topics...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different topic
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        '',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithTopics[1]._number,
+        '',
+      ]);
+    });
+
+    test('shows error when remove topic fails', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing topic...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+
+      await waitUntil(() => query(element, '.error') !== undefined);
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to remove topic'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
+    test('can only apply a single topic', async () => {
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLButtonElement>(element, 'button.chip')[1].click();
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+    });
+
+    test('applies topic to all changes', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#apply-to-all-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying to all'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        'topic1',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithTopics[1]._number,
+        'topic1',
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: 'topic1 applied to all changes',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'apply-topic-to-all',
+        selectedChangeCount: 2,
+      });
+    });
+  });
+
+  suite('change have no existing topics', () => {
+    const changesWithNoTopics: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+    let setChangeTopicPromises: MockPromise<string>[];
+    let setChangeTopicStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeTopicPromises[0].resolve('foo');
+      setChangeTopicPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    async function rejectPromises() {
+      setChangeTopicPromises[0].reject(new Error('error'));
+      setChangeTopicPromises[1].reject(new Error('error'));
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(
+        changesWithNoTopics
+      );
+      setChangeTopicPromises = [];
+      setChangeTopicStub = stubRestApi('setChangeTopic');
+      for (let i = 0; i < changesWithNoTopics.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeTopicPromises.push(promise);
+        setChangeTopicStub
+          .withArgs(changesWithNoTopics[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithNoTopics);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-topic-flow></gr-change-list-topic-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-topic-flow')!;
+
+      // select changes
+      await selectChange(changesWithNoTopics[0]);
+      await selectChange(changesWithNoTopics[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await waitEventLoop();
+    });
+
+    test('renders no-existing-topics flow', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Topic</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <gr-autocomplete
+                placeholder="Type topic name to create or filter topics"
+                show-blue-focus-border=""
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError" role="progressbar"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="set-topic-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Set Topic</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('create new topic', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
+        []
+      );
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#set-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Setting topic...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithNoTopics[0]._number,
+        'foo',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithNoTopics[1]._number,
+        'foo',
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '2 Changes added to foo',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-topic',
+        selectedChangeCount: 2,
+      });
+    });
+
+    test('shows error when create topic fails', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
+        []
+      );
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
+      );
+      queryAndAssert<GrButton>(element, '#set-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Setting topic...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+      await waitUntil(() => query(element, '.error') !== undefined);
+
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to set topic'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
+    test('apply topic', async () => {
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves([
+        {...createChange(), topic: 'foo' as TopicName},
+      ]);
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
+      );
+      queryAndAssert<GrButton>(element, '#set-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Setting topic...'
+      );
+
+      await resolvePromises();
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithNoTopics[0]._number,
+        'foo',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithNoTopics[1]._number,
+        'foo',
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '2 Changes added to foo',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-topic',
+        selectedChangeCount: 2,
+      });
+    });
+
+    test('shows error when setting topic fails', async () => {
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves([
+        {...createChange(), topic: 'foo' as TopicName},
+      ]);
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
+      );
+      queryAndAssert<GrButton>(element, '#set-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Setting topic...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+
+      await waitUntil(() => query(element, '.error') !== undefined);
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to set topic'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+  });
+});
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 9743987..a58c7bb 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
@@ -1,27 +1,13 @@
 /**
  * @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 '../../shared/gr-icons/gr-icons';
 import '../gr-change-list/gr-change-list';
 import '../gr-repo-header/gr-repo-header';
 import '../gr-user-header/gr-user-header';
 import {page} from '../../../utils/page-wrapper-utils';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {AppElementParams} from '../../gr-app-types';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   AccountDetailInfo,
   AccountId,
@@ -31,14 +17,23 @@
   RepoName,
 } from '../../../types/common';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {ChangeListViewState} from '../../../types/types';
-import {fire, fireTitleChange} from '../../../utils/event-util';
+import {fireAlert, fireEvent, 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 {LitElement, PropertyValues, html, css, nothing} from 'lit';
+import {customElement, state, query} from 'lit/decorators.js';
 import {ValueChangedEvent} from '../../../types/events';
+import {
+  createSearchUrl,
+  searchViewModelToken,
+  SearchViewState,
+} from '../../../models/views/search';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {createChangeUrl} from '../../../models/views/change';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+
+const GET_CHANGES_DEBOUNCE_INTERVAL_MS = 10;
 
 const LOOKUP_QUERY_PATTERNS: RegExp[] = [
   /^\s*i?[0-9a-f]{7,40}\s*$/i, // CHANGE_ID
@@ -65,17 +60,29 @@
 
   @query('#nextArrow') protected nextArrow?: HTMLAnchorElement;
 
-  @property({type: Object})
-  params?: AppElementParams;
+  private _viewState?: SearchViewState;
 
-  @property({type: Object})
-  account: AccountDetailInfo | null = null;
+  @state()
+  get viewState() {
+    return this._viewState;
+  }
 
-  @property({type: Object})
-  viewState: ChangeListViewState = {};
+  set viewState(viewState: SearchViewState | undefined) {
+    if (this._viewState === viewState) return;
+    const oldViewState = this._viewState;
+    this._viewState = viewState;
+    this.viewStateChanged();
+    this.requestUpdate('viewState', oldViewState);
+  }
 
-  @property({type: Object})
-  preferences?: PreferencesInput;
+  // private but used in test
+  @state() account?: AccountDetailInfo;
+
+  // private but used in test
+  @state() loggedIn = false;
+
+  // private but used in test
+  @state() preferences?: PreferencesInput;
 
   // private but used in test
   @state() changesPerPage?: number;
@@ -98,23 +105,53 @@
   // private but used in test
   @state() repo: RepoName | null = null;
 
+  @state() selectedIndex = 0;
+
   private readonly restApiService = getAppContext().restApiService;
 
   private reporting = getAppContext().reportingService;
 
+  private userModel = getAppContext().userModel;
+
+  private readonly getViewModel = resolve(this, searchViewModelToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.addEventListener('next-page', () => this.handleNextPage());
     this.addEventListener('previous-page', () => this.handlePreviousPage());
     this.addEventListener('reload', () => this.reload());
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.loadPreferences();
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      x => (this.viewState = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.loggedIn$,
+      x => (this.loggedIn = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      x => {
+        this.preferences = x;
+        if (this.changesPerPage !== x.changes_per_page) {
+          this.changesPerPage = x.changes_per_page;
+          this.debouncedGetChanges();
+        }
+      }
+    );
   }
 
   override disconnectedCallback() {
+    this.getChangesTask?.flush();
     super.disconnectedCallback();
   }
 
@@ -142,15 +179,11 @@
           height: 3rem;
           justify-content: flex-end;
           margin-right: 20px;
-        }
-        nav,
-        iron-icon {
           color: var(--deemphasized-text-color);
         }
-        iron-icon {
-          height: 1.85rem;
+        gr-icon {
+          font-size: 1.85rem;
           margin-left: 16px;
-          width: 1.85rem;
         }
         .hide {
           display: none;
@@ -166,26 +199,26 @@
   }
 
   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)}
+        ${this.renderRepoHeader()} ${this.renderUserHeader()}
         <gr-change-list
           .account=${this.account}
           .changes=${this.changes}
           .preferences=${this.preferences}
-          .selectedIndex=${this.viewState.selectedChangeIndex}
-          .showStar=${loggedIn}
+          .showStar=${this.loggedIn}
+          .selectedIndex=${this.selectedIndex}
           @selected-index-changed=${(e: ValueChangedEvent<number>) => {
-            this.handleSelectedIndexChanged(e);
+            this.selectedIndex = e.detail.value;
           }}
           @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
             this.handleToggleStar(e);
           }}
+          .usp=${'search'}
         ></gr-change-list>
         ${this.renderChangeListViewNav()}
       </div>
@@ -193,25 +226,25 @@
   }
 
   private renderRepoHeader() {
-    if (!this.repo) return;
+    if (!this.repo) return nothing;
 
     return html` <gr-repo-header .repo=${this.repo}></gr-repo-header> `;
   }
 
-  private renderUserHeader(loggedIn: boolean) {
-    if (!this.userId) return;
+  private renderUserHeader() {
+    if (!this.userId) return nothing;
 
     return html`
       <gr-user-header
         .userId=${this.userId}
         showDashboardLink
-        .loggedIn=${loggedIn}
+        .loggedIn=${this.loggedIn}
       ></gr-user-header>
     `;
   }
 
   private renderChangeListViewNav() {
-    if (this.loading || !this.changes || !this.changes.length) return;
+    if (this.loading || !this.changes || !this.changes.length) return nothing;
 
     return html`
       <nav>
@@ -222,124 +255,92 @@
   }
 
   private renderPrevArrow() {
-    if (this.offset === 0) return;
+    if (this.offset === 0) return nothing;
 
     return html`
       <a id="prevArrow" href=${this.computeNavLink(-1)}>
-        <iron-icon icon="gr-icons:chevron-left" aria-label="Older"> </iron-icon>
+        <gr-icon icon="chevron_left" aria-label="Older"></gr-icon>
       </a>
     `;
   }
 
   private renderNextArrow() {
-    if (
-      !(
-        this.changes?.length &&
-        this.changes[this.changes.length - 1]._more_changes
-      )
-    )
-      return;
+    const changesCount = this.changes?.length ?? 0;
+    if (changesCount === 0) return nothing;
+    if (!this.changes?.[changesCount - 1]._more_changes) return nothing;
 
     return html`
       <a id="nextArrow" href=${this.computeNavLink(1)}>
-        <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
-        </iron-icon>
+        <gr-icon icon="chevron_right" aria-label="Newer"></gr-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) this.debouncedGetChanges();
   }
 
-  private paramsChanged() {
-    const value = this.params;
-    if (!value || value.view !== GerritView.SEARCH) return;
+  // private, but visible for testing
+  viewStateChanged() {
+    if (!this.viewState) return;
 
+    let offset = Number(this.viewState.offset);
+    if (isNaN(offset)) offset = 0;
+    const query = this.viewState.query ?? '';
+
+    if (this.query !== query) this.selectedIndex = 0;
     this.loading = true;
-    this.query = value.query;
-    const offset = Number(value.offset);
-    this.offset = isNaN(offset) ? 0 : offset;
-    if (
-      this.viewState.query !== this.query ||
-      this.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,
-      });
-    }
+    this.query = query;
+    this.offset = offset;
 
     // 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));
 
-    this.restApiService
-      .getPreferences()
-      .then(prefs => {
-        if (!prefs) {
-          throw new Error('getPreferences returned undefined');
-        }
-        this.changesPerPage = prefs.changes_per_page;
-        return this.getChanges();
-      })
-      .then(changes => {
-        changes = changes || [];
-        if (this.query && changes.length === 1) {
-          for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
-            if (this.query.match(queryPattern)) {
-              // "Back"/"Forward" buttons work correctly only with
-              // opt_redirect options
-              GerritNav.navigateToChange(changes[0], {
-                redirect: true,
-              });
-              return;
-            }
-          }
-        }
-        this.changes = changes;
-        this.loading = false;
-      });
+    this.debouncedGetChanges(true);
   }
 
-  private loadPreferences() {
-    return this.restApiService.getLoggedIn().then(loggedIn => {
-      if (loggedIn) {
-        this.restApiService.getPreferences().then(preferences => {
-          this.preferences = preferences;
-        });
-      } else {
-        this.preferences = {};
-      }
-    });
-  }
+  private getChangesTask?: DelayedTask;
 
-  // private but used in test
-  getChanges() {
-    return this.restApiService.getChanges(
-      this.changesPerPage,
-      this.query,
-      this.offset
+  private debouncedGetChanges(shouldSingleMatchRedirect = false) {
+    this.getChangesTask = debounce(
+      this.getChangesTask,
+      () => {
+        this.getChanges(shouldSingleMatchRedirect);
+      },
+      GET_CHANGES_DEBOUNCE_INTERVAL_MS
     );
   }
 
+  async getChanges(shouldSingleMatchRedirect = false) {
+    this.loading = true;
+    const changes =
+      (await this.restApiService.getChanges(
+        this.changesPerPage,
+        this.query,
+        this.offset
+      )) ?? [];
+    if (shouldSingleMatchRedirect && this.query && changes.length === 1) {
+      for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
+        if (this.query.match(queryPattern)) {
+          // "Back"/"Forward" buttons work correctly only with replaceUrl()
+          this.getNavigation().replaceUrl(
+            createChangeUrl({change: changes[0]})
+          );
+          return;
+        }
+      }
+    }
+    this.changes = changes;
+    this.loading = false;
+  }
+
   // private but used in test
   limitFor(query: string, defaultLimit?: number) {
     if (defaultLimit === undefined) return 0;
@@ -355,7 +356,7 @@
     const offset = this.offset ?? 0;
     const limit = this.limitFor(this.query, this.changesPerPage);
     const newOffset = Math.max(0, offset + limit * direction);
-    return GerritNav.getUrlForSearchQuery(this.query, newOffset);
+    return createSearchUrl({query: this.query, offset: newOffset});
   }
 
   // private but used in test
@@ -396,27 +397,23 @@
     return this.offset / this.changesPerPage + 1;
   }
 
-  private handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+  private async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-change-list');
     }
-    this.restApiService.saveChangeStarred(
+    const msg = e.detail.starred
+      ? 'Starring change...'
+      : 'Unstarring change...';
+    fireAlert(this, msg);
+    await this.restApiService.saveChangeStarred(
       e.detail.change._number,
       e.detail.starred
     );
-  }
-
-  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});
+    fireEvent(this, 'hide-alert');
   }
 }
 
 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_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index 0633f46..b003b66 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -1,56 +1,51 @@
 /**
  * @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 '../../../test/common-test-setup';
 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 {navigationToken} 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 {createChange} from '../../../test/test-data-generators';
 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');
+} from '../../../api/rest-api';
+import {fixture, html, waitUntil, assert} from '@open-wc/testing';
+import {GerritView} from '../../../services/router/router-model';
+import {testResolver} from '../../../test/common-test-setup';
+import {SinonFakeTimers, SinonStub} from 'sinon';
+import {GrChangeList} from '../gr-change-list/gr-change-list';
+import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
+import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 
 const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
 const COMMIT_HASH = '12345678';
 
 suite('gr-change-list-view tests', () => {
   let element: GrChangeListView;
+  let changes: ChangeInfo[] | undefined = [];
+  let clock: SinonFakeTimers;
 
   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();
+    clock = sinon.useFakeTimers();
+    stubRestApi('getChanges').callsFake(() => Promise.resolve(changes));
+    element = await fixture(html`<gr-change-list-view></gr-change-list-view>`);
+    element.viewState = {
+      view: GerritView.SEARCH,
+      query: 'test-query',
+      offset: '0',
+    };
     await element.updateComplete;
   });
 
@@ -58,33 +53,60 @@
     await element.updateComplete;
   });
 
+  test('render', async () => {
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
+    element.changesPerPage = 10;
+    element.loading = false;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="loading" hidden="">Loading...</div>
+        <div>
+          <gr-change-list> </gr-change-list>
+          <nav>Page 1</nav>
+        </div>
+      `
+    );
+  });
+
   suite('bulk actions', () => {
-    let getChangesStub: sinon.SinonStub;
     setup(async () => {
       stubFlags('isEnabled').returns(true);
-      getChangesStub = sinon.stub(element, 'getChanges');
-      getChangesStub.returns(Promise.resolve([createChange()]));
+      changes = [createChange()];
       element.loading = false;
       element.reload();
-      await waitUntil(() => element.loading === false);
-      element.requestUpdate();
+      clock.tick(100);
       await element.updateComplete;
+      await waitUntil(() => element.loading === false);
     });
 
     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'
+      const changeListEl = queryAndAssert<GrChangeList>(
+        element,
+        'gr-change-list'
       );
-      tap(checkbox);
+      await changeListEl.updateComplete;
+      const changeListSectionEl = queryAndAssert<GrChangeListSection>(
+        changeListEl,
+        'gr-change-list-section'
+      );
+      await changeListSectionEl.updateComplete;
+      const changeListItemEl = queryAndAssert<GrChangeListItem>(
+        changeListSectionEl,
+        'gr-change-list-item'
+      );
+      await changeListItemEl.updateComplete;
+      let checkbox = queryAndAssert<HTMLInputElement>(
+        changeListItemEl,
+        '.selection > label > input'
+      );
+      checkbox.click();
       await waitUntil(() => checkbox.checked);
 
-      getChangesStub.restore();
-      getChangesStub.returns(Promise.resolve([[createChange()]]));
-
       element.reload();
       await element.updateComplete;
       checkbox = queryAndAssert<HTMLInputElement>(
@@ -92,7 +114,7 @@
           query(query(element, 'gr-change-list'), 'gr-change-list-section'),
           'gr-change-list-item'
         ),
-        '.selection > input'
+        '.selection > label > input'
       );
       assert.isTrue(checkbox.checked);
     });
@@ -117,25 +139,19 @@
   });
 
   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);
+    assert.equal(element.computeNavLink(direction), '/q/status:open,5');
 
     direction = -1;
-    element.computeNavLink(direction);
-    assert.equal(getUrlStub.lastCall.args[1], 0);
+    assert.equal(element.computeNavLink(direction), '/q/status:open');
 
     element.offset = 5;
     direction = 1;
-    element.computeNavLink(direction);
-    assert.equal(getUrlStub.lastCall.args[1], 10);
+    assert.equal(element.computeNavLink(direction), '/q/status:open,10');
   });
 
   test('prevArrow', async () => {
@@ -273,7 +289,10 @@
   });
 
   suite('query based navigation', () => {
-    setup(() => {});
+    let replaceUrlStub: SinonStub;
+    setup(() => {
+      replaceUrlStub = sinon.stub(testResolver(navigationToken), 'replaceUrl');
+    });
 
     teardown(async () => {
       await element.updateComplete;
@@ -282,83 +301,58 @@
 
     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();
-      });
+      changes = [change];
 
-      element.params = {
-        view: GerritNav.View.SEARCH,
-        query: CHANGE_ID,
-        offset: '',
-      };
-      await promise;
+      element.viewState = {view: GerritView.SEARCH, query: CHANGE_ID};
+      clock.tick(100);
+      await element.updateComplete;
+
+      assert.isTrue(replaceUrlStub.called);
+      assert.equal(replaceUrlStub.lastCall.firstArg, '/c/test-project/+/1');
     });
 
     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();
-      });
+      changes = [change];
 
-      element.params = {view: GerritNav.View.SEARCH, query: '1', offset: ''};
-      await promise;
+      element.viewState = {view: GerritView.SEARCH, query: '1'};
+      clock.tick(100);
+      await element.updateComplete;
+
+      assert.isTrue(replaceUrlStub.called);
+      assert.equal(replaceUrlStub.lastCall.firstArg, '/c/test-project/+/1');
     });
 
     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();
-      });
+      changes = [change];
 
-      element.params = {
-        view: GerritNav.View.SEARCH,
-        query: COMMIT_HASH,
-        offset: '',
-      };
-      await promise;
+      element.viewState = {view: GerritView.SEARCH, query: COMMIT_HASH};
+      clock.tick(100);
+      await element.updateComplete;
+
+      assert.isTrue(replaceUrlStub.called);
+      assert.equal(replaceUrlStub.lastCall.firstArg, '/c/test-project/+/1');
     });
 
     test('Searching for an invalid change ID searches', async () => {
-      sinon.stub(element, 'getChanges').returns(Promise.resolve([]));
-      const stub = sinon.stub(GerritNav, 'navigateToChange');
+      changes = [];
 
-      element.params = {
-        view: GerritNav.View.SEARCH,
-        query: CHANGE_ID,
-        offset: '',
-      };
+      element.viewState = {view: GerritView.SEARCH, query: CHANGE_ID};
+      clock.tick(100);
       await element.updateComplete;
 
-      assert.isFalse(stub.called);
+      assert.isFalse(replaceUrlStub.called);
     });
 
     test('Change ID with multiple search results searches', async () => {
-      sinon.stub(element, 'getChanges').returns(Promise.resolve(undefined));
-      const stub = sinon.stub(GerritNav, 'navigateToChange');
+      changes = undefined;
 
-      element.params = {
-        view: GerritNav.View.SEARCH,
-        query: CHANGE_ID,
-        offset: '',
-      };
+      element.viewState = {view: GerritView.SEARCH, query: CHANGE_ID};
+      clock.tick(100);
       await element.updateComplete;
 
-      assert.isFalse(stub.called);
+      assert.isFalse(replaceUrlStub.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 2c10c1e..1eebf88 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
@@ -1,27 +1,15 @@
 /**
  * @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 '../../shared/gr-cursor-manager/gr-cursor-manager';
 import '../gr-change-list-item/gr-change-list-item';
 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 {getAppContext} from '../../../services/app-context';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} 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 {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
@@ -32,35 +20,22 @@
   PreferencesInput,
 } from '../../../types/common';
 import {fire, fireEvent, fireReload} from '../../../utils/event-util';
-import {ScrollMode} from '../../../constants/constants';
+import {ColumnNames, ScrollMode} from '../../../constants/constants';
 import {getRequirements} from '../../../utils/label-util';
-import {addGlobalShortcut, Key} from '../../../utils/dom-util';
-import {unique} from '../../../utils/common-util';
+import {Key} from '../../../utils/dom-util';
+import {assertIsDefined, 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 {customElement, property, state} from 'lit/decorators.js';
+import {Shortcut, ShortcutController} from '../../lit/shortcut-controller';
 import {queryAll} from '../../../utils/common-util';
-import {ValueChangedEvent} from '../../../types/events';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
-
-export const columnNames = [
-  'Subject',
-  // TODO(milutin) - remove once Submit Requirements are rolled out.
-  'Status',
-  'Owner',
-  'Reviewers',
-  'Comments',
-  'Repo',
-  'Branch',
-  'Updated',
-  'Size',
-  ' Status ', // spaces to differentiate from old 'Status'
-];
+import {Execution} from '../../../constants/reporting';
+import {ValueChangedEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {createChangeUrl} from '../../../models/views/change';
 
 export interface ChangeListSection {
   countLabel?: string;
@@ -133,8 +108,7 @@
 
   @state() private dynamicHeaderEndpoints?: string[];
 
-  @property({type: Number, attribute: 'selected-index'})
-  selectedIndex?: number;
+  @property({type: Number}) selectedIndex?: number;
 
   @property({type: Boolean})
   showNumber?: boolean; // No default value to prevent flickering.
@@ -148,6 +122,9 @@
   @property({type: Array})
   changeTableColumns?: string[];
 
+  @property({type: String})
+  usp?: string;
+
   @property({type: Array})
   visibleChangeTableColumns?: string[];
 
@@ -164,8 +141,12 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly reporting = getAppContext().reportingService;
+
   private readonly shortcuts = new ShortcutController(this);
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   private cursor = new GrCursorManager();
 
   constructor() {
@@ -187,7 +168,10 @@
     this.shortcuts.addAbstract(Shortcut.REFRESH_CHANGE_LIST, () =>
       this.refreshChangeList()
     );
-    addGlobalShortcut({key: Key.ENTER}, () => this.openChange());
+    this.shortcuts.addAbstract(Shortcut.TOGGLE_CHECKBOX, () =>
+      this.toggleCheckbox()
+    );
+    this.shortcuts.addGlobal({key: Key.ENTER}, () => this.openChange());
   }
 
   override connectedCallback() {
@@ -241,19 +225,34 @@
   override render() {
     if (!this.sections) return;
     const labelNames = this.computeLabelNames(this.sections);
+    const startIndices = this.calculateStartIndices(this.sections);
     return html`
       <table id="changeList">
         ${this.sections.map((changeSection, sectionIndex) =>
-          this.renderSection(changeSection, sectionIndex, labelNames)
+          this.renderSection(
+            changeSection,
+            sectionIndex,
+            labelNames,
+            startIndices[sectionIndex]
+          )
         )}
       </table>
     `;
   }
 
+  private calculateStartIndices(sections: ChangeListSection[]): number[] {
+    const startIndices: number[] = new Array(sections.length).fill(0);
+    for (let i = 1; i < sections.length; ++i) {
+      startIndices[i] = startIndices[i - 1] + sections[i - 1].results.length;
+    }
+    return startIndices;
+  }
+
   private renderSection(
     changeSection: ChangeListSection,
     sectionIndex: number,
-    labelNames: string[]
+    labelNames: string[],
+    startIndex: number
   ) {
     return html`
       <gr-change-list-section
@@ -271,6 +270,12 @@
         ?showStar=${this.showStar}
         .showNumber=${this.showNumber}
         .visibleChangeTableColumns=${this.visibleChangeTableColumns}
+        .usp=${this.usp}
+        .startIndex=${startIndex}
+        .triggerSelectionCallback=${(index: number) => {
+          this.selectedIndex = index;
+          this.cursor.setCursorAtIndex(this.selectedIndex);
+        }}
       >
         ${changeSection.emptyStateSlotName
           ? html`<slot
@@ -289,7 +294,7 @@
       changedProperties.has('config') ||
       changedProperties.has('sections')
     ) {
-      this.computePreferences();
+      this.computeVisibleChangeTableColumns();
     }
 
     if (changedProperties.has('changes')) {
@@ -301,15 +306,39 @@
     if (changedProperties.has('sections')) {
       this.sectionsChanged();
     }
+    if (changedProperties.has('selectedIndex')) {
+      fire(this, 'selected-index-changed', {
+        value: this.selectedIndex ?? 0,
+      });
+    }
   }
 
-  private computePreferences() {
+  private toggleCheckbox() {
+    assertIsDefined(this.selectedIndex, 'selectedIndex');
+    let selectedIndex = this.selectedIndex;
+    assertIsDefined(this.sections, 'sections');
+    const changeSections = queryAll<GrChangeListSection>(
+      this,
+      'gr-change-list-section'
+    );
+    for (let i = 0; i < this.sections.length; i++) {
+      if (selectedIndex >= this.sections[i].results.length) {
+        selectedIndex -= this.sections[i].results.length;
+        continue;
+      }
+      changeSections[i].toggleChange(selectedIndex);
+      return;
+    }
+    throw new Error('invalid selected index');
+  }
+
+  private computeVisibleChangeTableColumns() {
     if (!this.config) return;
 
-    this.changeTableColumns = columnNames;
+    this.changeTableColumns = Object.values(ColumnNames);
     this.showNumber = false;
     this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
-      this._isColumnEnabled(col, this.config)
+      this.isColumnEnabled(col, this.config)
     );
     if (this.account && this.preferences) {
       this.showNumber = !!this.preferences?.legacycid_in_change_table;
@@ -317,12 +346,19 @@
         this.preferences?.change_table &&
         this.preferences.change_table.length > 0
       ) {
-        const prefColumns = this.preferences.change_table.map(column =>
-          column === 'Project' ? 'Repo' : column
-        );
-        this.visibleChangeTableColumns = prefColumns.filter(col =>
-          this._isColumnEnabled(col, this.config)
-        );
+        const prefColumns = this.preferences.change_table
+          .map(column => (column === 'Project' ? ColumnNames.REPO : column))
+          .map(column =>
+            column === ColumnNames.STATUS ? ColumnNames.STATUS2 : column
+          );
+        this.reporting.reportExecution(Execution.USER_PREFERENCES_COLUMNS, {
+          statusColumn: prefColumns.includes(ColumnNames.STATUS2),
+        });
+        // Order visible column names by columnNames, filter only one that
+        // are in prefColumns and enabled by config
+        this.visibleChangeTableColumns = Object.values(ColumnNames)
+          .filter(col => prefColumns.includes(col))
+          .filter(col => this.isColumnEnabled(col, this.config));
       }
     }
   }
@@ -330,55 +366,29 @@
   /**
    * Is the column disabled by a server config or experiment?
    */
-  _isColumnEnabled(column: string, config?: ServerInfo) {
-    if (!columnNames.includes(column)) return false;
+  isColumnEnabled(column: string, config?: ServerInfo) {
+    if (!Object.values(ColumnNames).includes(column as unknown as ColumnNames))
+      return false;
     if (!config || !config.change) return true;
     if (column === 'Comments')
       return this.flagsService.isEnabled('comments-column');
-    if (column === 'Status') {
-      return !this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
-    }
-    if (column === ' Status ')
-      return this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
+    if (column === 'Status') return false;
+    if (column === ColumnNames.STATUS2) return true;
     return true;
   }
 
   // private but used in test
   computeLabelNames(sections: ChangeListSection[]) {
     if (!sections) return [];
-    let labels: string[] = [];
-    const nonExistingLabel = function (item: string) {
-      return !labels.includes(item);
-    };
-    for (const section of sections) {
-      if (!section.results) {
-        continue;
-      }
-      for (const change of section.results) {
-        if (!change.labels) {
-          continue;
-        }
-        const currentLabels = Object.keys(change.labels);
-        labels = labels.concat(currentLabels.filter(nonExistingLabel));
-      }
+    if (this.config?.submit_requirement_dashboard_columns?.length) {
+      return this.config?.submit_requirement_dashboard_columns;
     }
-
-    if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
-      if (this.config?.submit_requirement_dashboard_columns?.length) {
-        return this.config?.submit_requirement_dashboard_columns;
-      } else {
-        const changes = sections.map(section => section.results).flat();
-        labels = (changes ?? [])
-          .map(change => getRequirements(change))
-          .flat()
-          .map(requirement => requirement.name)
-          .filter(unique);
-      }
-    }
+    const changes = sections.map(section => section.results).flat();
+    const labels = (changes ?? [])
+      .map(change => getRequirements(change))
+      .flat()
+      .map(requirement => requirement.name)
+      .filter(unique);
     return labels.sort();
   }
 
@@ -391,7 +401,6 @@
     this.cursor.next();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
-    fire(this, 'selected-index-changed', {value: this.cursor.index});
   }
 
   private prevChange() {
@@ -399,12 +408,11 @@
     this.cursor.previous();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
-    fire(this, 'selected-index-changed', {value: this.cursor.index});
   }
 
   private async openChange() {
     const change = await this.changeForIndex(this.selectedIndex);
-    if (change) GerritNav.navigateToChange(change);
+    if (change) this.getNavigation().setUrl(createChangeUrl({change}));
   }
 
   private nextPage() {
@@ -472,10 +480,10 @@
 }
 
 declare global {
-  interface HTMLElementEventMap {
-    'selected-index-changed': ValueChangedEvent<number>;
-  }
   interface HTMLElementTagNameMap {
     'gr-change-list': GrChangeList;
   }
+  interface HTMLElementEventMap {
+    'selected-index-changed': ValueChangedEvent<number>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index 365ba72..312f384 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -1,23 +1,12 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-change-list';
 import {GrChangeList, computeRelativeIndex} from './gr-change-list';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   pressKey,
   query,
@@ -27,22 +16,28 @@
   waitUntil,
 } from '../../../test/test-utils';
 import {Key} from '../../../utils/dom-util';
-import {TimeFormat} from '../../../constants/constants';
+import {
+  ColumnNames,
+  createDefaultPreferences,
+  TimeFormat,
+} from '../../../constants/constants';
 import {AccountId, NumericChangeId} from '../../../types/common';
 import {
   createChange,
   createServerInfo,
+  createSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
-
-const basicFixture = fixtureFromElement('gr-change-list');
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-change-list basic tests', () => {
   let element: GrChangeList;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-change-list></gr-change-list>`);
   });
 
   test('renders', async () => {
@@ -53,68 +48,91 @@
     };
     element.account = {_account_id: 1001 as AccountId};
     element.config = createServerInfo();
-    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+    element.sections = [
+      {
+        results: [{...createChange(), _number: 0 as NumericChangeId}],
+      },
+      {
+        results: [
+          {...createChange(), _number: 1 as NumericChangeId},
+          {...createChange(), _number: 2 as NumericChangeId},
+        ],
+      },
+    ];
     element.selectedIndex = 0;
-    element.changes = [
-      {...createChange(), _number: 0 as NumericChangeId},
-      {...createChange(), _number: 1 as NumericChangeId},
-      {...createChange(), _number: 2 as NumericChangeId},
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-change-list-section> </gr-change-list-section>
+        <gr-change-list-section> </gr-change-list-section>
+        <table id="changeList"></table>
+      `
+    );
+  });
+
+  test('sections receive global startIndex', async () => {
+    element.selectedIndex = 0;
+    element.sections = [
+      {
+        results: [{...createChange(), _number: 0 as NumericChangeId}],
+      },
+      {
+        results: [
+          {...createChange(), _number: 1 as NumericChangeId},
+          {...createChange(), _number: 2 as NumericChangeId},
+        ],
+      },
+      {
+        results: [
+          {...createChange(), _number: 3 as NumericChangeId},
+          {...createChange(), _number: 4 as NumericChangeId},
+        ],
+      },
     ];
     await element.updateComplete;
-    expect(element).shadowDom.to.equal(/* HTML */ `
-      <gr-change-list-section> </gr-change-list-section>
-      <table id="changeList"></table>
-    `);
+
+    assert.deepEqual(
+      [...element.shadowRoot!.querySelectorAll('gr-change-list-section')].map(
+        section => section.startIndex
+      ),
+      [0, 1, 3]
+    );
   });
 
-  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 change number disabled when not logged in', async () => {
+    element.account = undefined;
+    element.preferences = undefined;
+    element.config = createServerInfo();
+    await element.updateComplete;
 
-    test('show number disabled', () => {
-      assert.isFalse(element.showNumber);
-    });
+    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 legacy change num when legacycid preference enabled', 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();
+    await element.updateComplete;
 
-    test('show number enabled', () => {
-      assert.isTrue(element.showNumber);
-    });
+    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('hide legacy change num if legacycid preference disabled', async () => {
+    // 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);
-    });
+    assert.isFalse(element.showNumber);
   });
 
   test('computeRelativeIndex', () => {
@@ -165,23 +183,36 @@
             {
               ...createChange(),
               _number: 0 as NumericChangeId,
-              labels: {Verified: {approved: {}}},
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Verified',
+                },
+              ],
             },
             {
               ...createChange(),
               _number: 1 as NumericChangeId,
-              labels: {
-                Verified: {approved: {}},
-                'Code-Review': {approved: {}},
-              },
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Verified',
+                },
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Code-Review',
+                },
+              ],
             },
             {
               ...createChange(),
               _number: 2 as NumericChangeId,
-              labels: {
-                Verified: {approved: {}},
-                'Library-Compliance': {approved: {}},
-              },
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Library-Compliance',
+                },
+              ],
             },
           ],
         },
@@ -194,22 +225,7 @@
     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.preferences = createDefaultPreferences();
     element.config = createServerInfo();
     element.changes = [
       {...createChange(), _number: 0 as NumericChangeId},
@@ -231,29 +247,24 @@
     );
     assert.equal(elementItems.length, 3);
 
-    assert.isTrue(elementItems[0].hasAttribute('selected'));
+    assert.isTrue(elementItems[0].selected);
     await element.updateComplete;
     pressKey(element, 'j');
     await element.updateComplete;
     await section.updateComplete;
 
     assert.equal(element.selectedIndex, 1);
-    assert.isTrue(elementItems[1].hasAttribute('selected'));
+    assert.isTrue(elementItems[1].selected);
     pressKey(element, 'j');
     await element.updateComplete;
     assert.equal(element.selectedIndex, 2);
-    assert.isTrue(elementItems[2].hasAttribute('selected'));
+    assert.isTrue(elementItems[2].selected);
 
-    const navStub = sinon.stub(GerritNav, 'navigateToChange');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     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/'
-    );
+    await waitUntil(() => setUrlStub.callCount >= 1);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/2');
 
     pressKey(element, 'k');
     await element.updateComplete;
@@ -261,15 +272,11 @@
 
     assert.equal(element.selectedIndex, 1);
 
-    const prevCount = navStub.callCount;
+    const prevCount = setUrlStub.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/'
-    );
+    await waitUntil(() => setUrlStub.callCount > prevCount);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/1');
 
     pressKey(element, 'k');
     pressKey(element, 'k');
@@ -277,6 +284,77 @@
     assert.equal(element.selectedIndex, 0);
   });
 
+  test('toggle checkbox keyboard shortcut', async () => {
+    const getCheckbox = (item: GrChangeListItem) =>
+      queryAndAssert<HTMLInputElement>(query(item, '.selection'), 'input');
+
+    sinon.stub(element, 'computeLabelNames');
+    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+    element.selectedIndex = 0;
+    element.preferences = createDefaultPreferences();
+    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].selected);
+    await element.updateComplete;
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+
+    assert.isTrue(getCheckbox(elementItems[0]).checked);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+    assert.isFalse(getCheckbox(elementItems[0]).checked);
+
+    pressKey(element, 'j');
+    await element.updateComplete;
+
+    assert.equal(element.selectedIndex, 1);
+    assert.isTrue(elementItems[1].selected);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+
+    assert.isTrue(getCheckbox(elementItems[1]).checked);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+    assert.isFalse(getCheckbox(elementItems[1]).checked);
+
+    pressKey(element, 'j');
+    await element.updateComplete;
+    assert.equal(element.selectedIndex, 2);
+    assert.isTrue(elementItems[2].selected);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+
+    assert.isTrue(getCheckbox(elementItems[2]).checked);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+    assert.isFalse(getCheckbox(elementItems[2]).checked);
+  });
+
   test('no changes', async () => {
     element.changes = [];
     await element.updateComplete;
@@ -315,7 +393,7 @@
 
     setup(async () => {
       stubFlags('isEnabled').returns(true);
-      element = basicFixture.instantiate();
+      element = await fixture(html`<gr-change-list></gr-change-list>`);
       element.sections = [{results: [{...createChange()}]}];
       element.account = {_account_id: 1001 as AccountId};
       element.preferences = {
@@ -347,7 +425,7 @@
 
     setup(async () => {
       stubFlags('isEnabled').returns(true);
-      element = basicFixture.instantiate();
+      element = await fixture(html`<gr-change-list></gr-change-list>`);
       element.sections = [{results: [{...createChange()}]}];
       element.account = {_account_id: 1001 as AccountId};
       element.preferences = {
@@ -363,7 +441,7 @@
           'Branch',
           'Updated',
           'Size',
-          ' Status ',
+          ColumnNames.STATUS2,
         ],
       };
       element.config = createServerInfo();
@@ -386,7 +464,7 @@
 
     setup(async () => {
       stubFlags('isEnabled').returns(true);
-      element = basicFixture.instantiate();
+      element = await fixture(html`<gr-change-list></gr-change-list>`);
       element.sections = [{results: [{...createChange()}]}];
       element.account = {_account_id: 1001 as AccountId};
       element.preferences = {
@@ -401,7 +479,7 @@
           'Branch',
           'Updated',
           'Size',
-          ' Status ',
+          ColumnNames.STATUS2,
         ],
       };
       element.config = createServerInfo();
@@ -419,15 +497,26 @@
         }
       }
     });
+
+    test('show default order not preferences order', async () => {
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: ['Owner', 'Subject'],
+      };
+      element.config = createServerInfo();
+      await element.updateComplete;
+      assert.equal(element.visibleChangeTableColumns?.[0], 'Subject');
+      assert.equal(element.visibleChangeTableColumns?.[1], 'Owner');
+    });
   });
 
   test('obsolete column in preferences not visible', () => {
-    assert.isTrue(element._isColumnEnabled('Subject'));
-    assert.isFalse(element._isColumnEnabled('Assignee'));
+    assert.isTrue(element.isColumnEnabled('Subject'));
+    assert.isFalse(element.isColumnEnabled('Assignee'));
   });
 
   test('showStar and showNumber', async () => {
-    element = basicFixture.instantiate();
     element.sections = [{results: [{...createChange()}], name: 'a'}];
     element.account = {_account_id: 1001 as AccountId};
     element.preferences = {
@@ -442,7 +531,7 @@
         'Branch',
         'Updated',
         'Size',
-        ' Status ',
+        ColumnNames.STATUS2,
       ],
     };
     element.config = createServerInfo();
@@ -472,24 +561,38 @@
     assert.isOk(query(query(section, 'gr-change-list-item'), '.number'));
   });
 
-  suite('random column does not exist', () => {
-    let element: GrChangeList;
+  test('garbage columns in preference are not shown', async () => {
+    // This would only exist if somebody manually updated the config file.
+    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;
 
-    /* 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;
-    });
+    assert.isNotOk(query<HTMLElement>(element, '.bad'));
+  });
 
-    test('bad column does not exist', () => {
-      assert.isNotOk(query<HTMLElement>(element, '.bad'));
-    });
+  test('Show new status with feature flag', async () => {
+    stubFlags('isEnabled').returns(true);
+    const element: GrChangeList = await fixture(
+      html`<gr-change-list></gr-change-list>`
+    );
+    element.sections = [{results: [{...createChange()}]}];
+    element.account = {_account_id: 1001 as AccountId};
+    element.preferences = {
+      change_table: [
+        'Status', // old status
+      ],
+    };
+    element.config = createServerInfo();
+    await element.updateComplete;
+    assert.isTrue(
+      element.visibleChangeTableColumns?.includes(ColumnNames.STATUS2),
+      'Show new status'
+    );
+    const section = queryAndAssert(element, 'gr-change-list-section');
+    queryAndAssert<HTMLElement>(section, '.status');
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
index fd2a5d7..9c53fea 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -1,26 +1,13 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
 import {fireEvent} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement} from 'lit/decorators';
+import {customElement} from 'lit/decorators.js';
 import {fontStyles} from '../../../styles/gr-font-styles';
 
 declare global {
@@ -53,10 +40,9 @@
           justify-content: center;
           width: 10em;
         }
-        #graphic iron-icon {
+        #graphic gr-icon {
           color: var(--gray-foreground);
-          height: 5em;
-          width: 5em;
+          font-size: 5em;
         }
         #graphic p {
           color: var(--deemphasized-text-color);
@@ -82,9 +68,10 @@
   }
 
   override render() {
-    return html` <div id="graphic">
+    return html`
+      <div id="graphic">
         <div id="circle">
-          <iron-icon id="icon" icon="gr-icons:zeroState"></iron-icon>
+          <gr-icon id="icon" icon="empty_dashboard"></gr-icon>
         </div>
         <p>No outgoing changes yet</p>
       </div>
@@ -96,7 +83,8 @@
           the step by step instructions.
         </p>
         <gr-button @click=${this._handleCreateTap}>Create Change</gr-button>
-      </div>`;
+      </div>
+    `;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts
index e170a74..d5ab511 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts
@@ -1,41 +1,53 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-change-help';
 import {GrCreateChangeHelp} from './gr-create-change-help';
 import {mockPromise, queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-create-change-help');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-create-change-help tests', () => {
   let element: GrCreateChangeHelp;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(
+      html`<gr-create-change-help></gr-create-change-help>`
+    );
   });
 
   test('Create change tap', async () => {
     const promise = mockPromise();
     element.addEventListener('create-tap', () => promise.resolve());
-    MockInteractions.tap(queryAndAssert<GrButton>(element, 'gr-button'));
+    queryAndAssert<GrButton>(element, 'gr-button').click();
     await promise;
   });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+        <div id="graphic">
+          <div id="circle">
+            <gr-icon icon="empty_dashboard" id="icon"> </gr-icon>
+          </div>
+          <p>No outgoing changes yet</p>
+        </div>
+        <div id="help">
+          <h2 class="heading-3">Push your first change for code review</h2>
+          <p>
+            Pushing a change for review is easy, but a little different from other
+          git code review tools. Click on the \`Create Change' button and follow
+          the step by step instructions.
+          </p>
+          <gr-button aria-disabled="false" role="button" tabindex="0">
+            Create Change
+          </gr-button>
+        </div>
+      `
+    );
+  });
 });
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 c41ad70..567c508 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
@@ -1,27 +1,15 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-shell-command/gr-shell-command';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
 
 enum Commands {
   CREATE = 'git commit',
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
index ea367f7..96ec9eb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
@@ -1,35 +1,78 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import './gr-create-commands-dialog';
 import {GrCreateCommandsDialog} from './gr-create-commands-dialog';
 
-const basicFixture = fixtureFromElement('gr-create-commands-dialog');
-
 suite('gr-create-commands-dialog tests', () => {
   let element: GrCreateCommandsDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-create-commands-dialog></gr-create-commands-dialog>`
+    );
   });
 
   test('branch', () => {
     element.branch = 'master';
     assert.equal(element.branch, 'master');
   });
+
+  test('render', () => {
+    // prettier and shadowDom assert don't agree about wrapping in the <p> tags
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <gr-overlay
+        aria-hidden="true"
+        id="commandsOverlay"
+        style="outline: none; display: none;"
+        tabindex="-1"
+        with-backdrop=""
+      >
+        <gr-dialog
+          cancel-label=""
+          confirm-label="Done"
+          confirm-on-enter=""
+          id="commandsDialog"
+          role="dialog"
+        >
+          <div class="header" slot="header">Create change commands</div>
+          <div class="main" slot="main">
+            <ol>
+              <li>
+                <p>Make the changes to the files on your machine</p>
+              </li>
+              <li>
+                <p>If you are making a new commit use</p>
+                <gr-shell-command> </gr-shell-command>
+                <p>Or to amend an existing commit use</p>
+                <gr-shell-command> </gr-shell-command>
+                <p>
+                  Please make sure you add a commit message as it becomes the
+                description for your change.
+                </p>
+              </li>
+              <li>
+                <p>Push the change for code review</p>
+                <gr-shell-command> </gr-shell-command>
+              </li>
+              <li>
+                <p>
+                  Close this dialog and you should be able to see your recently
+                created change in the 'Outgoing changes' section on the 'Your
+                changes' page.
+                </p>
+              </li>
+            </ol>
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `
+    );
+  });
 });
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 c686d70..983a0d9 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
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker';
@@ -22,7 +10,7 @@
 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 {customElement, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts
new file mode 100644
index 0000000..44b3183
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import './gr-create-destination-dialog';
+import {GrCreateDestinationDialog} from './gr-create-destination-dialog';
+
+suite('gr-create-destination-dialog tests', () => {
+  let element: GrCreateDestinationDialog;
+
+  setup(async () => {
+    element = await fixture(
+      html`<gr-create-destination-dialog></gr-create-destination-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-overlay
+          aria-hidden="true"
+          id="createOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-dialog confirm-label="View commands" disabled="" role="dialog">
+            <div class="header" slot="header">Create change</div>
+            <div class="main" slot="main">
+              <gr-repo-branch-picker> </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 6929ca2..d178880 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
@@ -1,20 +1,8 @@
 /**
  * @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 '../gr-change-list/gr-change-list';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
@@ -23,12 +11,6 @@
 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 {
-  GerritNav,
-  OUTGOING,
-  UserDashboard,
-  YOUR_TURN,
-} from '../../core/gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
 import {changeIsOpen} from '../../../utils/change-util';
 import {parseDate} from '../../../utils/date-util';
@@ -39,7 +21,6 @@
   PreferencesInput,
   RepoName,
 } from '../../../types/common';
-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 {
@@ -48,17 +29,34 @@
 } from '../gr-create-destination-dialog/gr-create-destination-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {DashboardViewState} from '../../../types/types';
-import {firePageError, fireTitleChange} from '../../../utils/event-util';
-import {GerritView} from '../../../services/router/router-model';
+import {
+  fireAlert,
+  fireEvent,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
 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 {LitElement, html, css, nothing} from 'lit';
+import {customElement, property, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
+import {Shortcut} from '../../../services/shortcuts/shortcuts-config';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {
+  dashboardViewModelToken,
+  DashboardViewState,
+} from '../../../models/views/dashboard';
+import {createSearchUrl} from '../../../models/views/search';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {
+  getUserDashboard,
+  OUTGOING,
+  UserDashboard,
+  YOUR_TURN,
+} from '../../../utils/dashboard-util';
 
 const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
 
@@ -85,17 +83,14 @@
   @query('#confirmDeleteOverlay') protected confirmDeleteOverlay?: GrOverlay;
 
   @property({type: Object})
-  account: AccountDetailInfo | null = null;
+  account?: AccountDetailInfo;
 
   @property({type: Object})
   preferences?: PreferencesInput;
 
-  @property({type: Object})
+  @state()
   viewState?: DashboardViewState;
 
-  @property({type: Object})
-  params?: AppElementDashboardParams;
-
   // private but used in test
   @state() results?: ChangeListSection[];
 
@@ -108,18 +103,35 @@
   // private but used in test
   @state() showNewUserHelp = false;
 
-  // private but used in test
-  @state() selectedChangeIndex?: number;
-
   private reporting = getAppContext().reportingService;
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly getViewModel = resolve(this, dashboardViewModelToken);
+
   private lastVisibleTimestampMs = 0;
 
+  private readonly shortcuts = new ShortcutController(this);
+
   constructor() {
     super();
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      x => {
+        this.viewState = x;
+        this.reload();
+      }
+    );
     this.addEventListener('reload', () => this.reload());
+    this.shortcuts.addAbstract(Shortcut.UP_TO_DASHBOARD, () => this.reload());
   }
 
   private readonly visibilityChangeListener = () => {
@@ -193,6 +205,7 @@
   }
 
   override render() {
+    if (!this.viewState) return nothing;
     return html`
       ${this.renderBanner()} ${this.renderContent()}
       <gr-overlay id="confirmDeleteOverlay" with-backdrop>
@@ -261,11 +274,8 @@
           ?showStar=${true}
           .account=${this.account}
           .preferences=${this.preferences}
-          .selectedIndex=${this.selectedChangeIndex}
           .sections=${this.results}
-          @selected-index-changed=${(e: ValueChangedEvent<number>) => {
-            this.handleSelectedIndexChanged(e);
-          }}
+          .usp=${'dashboard'}
           @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
             this.handleToggleStar(e);
           }}
@@ -283,17 +293,15 @@
 
   private renderUserHeader() {
     if (
-      !this.params ||
-      this.params.view !== GerritView.DASHBOARD ||
-      !!this.params.project ||
-      !this.params.user ||
-      this.params.user === 'self'
+      !!this.viewState?.project ||
+      !this.viewState?.user ||
+      this.viewState?.user === 'self'
     ) {
       return;
     }
 
     return html`
-      <gr-user-header .userId=${this.params?.user}></gr-user-header>
+      <gr-user-header .userId=${this.viewState?.user}></gr-user-header>
     `;
   }
 
@@ -309,16 +317,6 @@
     `;
   }
 
-  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) {
@@ -334,11 +332,12 @@
   // private but used in test
   getProjectDashboard(
     project: RepoName,
-    dashboard: DashboardId
+    dashboard?: DashboardId
   ): Promise<UserDashboard | undefined> {
     const errFn = (response?: Response | null) => {
       firePageError(response);
     };
+    assertIsDefined(dashboard, 'project dashboard must have id');
     return this.restApiService
       .getDashboard(project, dashboard, errFn)
       .then(response => {
@@ -369,53 +368,20 @@
     return 'Dashboard for ' + user;
   }
 
-  private isViewActive(params: AppElementDashboardParams) {
-    return params.view === GerritView.DASHBOARD;
-  }
-
-  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] = this.selectedChangeIndex;
-  }
-
-  // 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() {
-    if (!this.params || !this.isViewActive(this.params)) {
-      return Promise.resolve();
-    }
+    if (!this.viewState) return Promise.resolve();
     this.loading = true;
-    const {project, dashboard, title, user, sections} = this.params;
+    const {project, dashboard, title, user, sections} = this.viewState;
+
     const dashboardPromise: Promise<UserDashboard | undefined> = project
       ? this.getProjectDashboard(project, dashboard)
       : Promise.resolve(
-          GerritNav.getUserDashboard(
-            user,
-            sections,
-            title || this.computeTitle(user)
-          )
+          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.
@@ -433,7 +399,7 @@
       })
       .catch(err => {
         fireTitleChange(this, title || this.computeTitle(user));
-        this.reporting.error(err);
+        this.reporting.error('Dashboard reload', err);
       })
       .finally(() => {
         this.loading = false;
@@ -510,7 +476,7 @@
    * And then we want to emphasize the changes where the waiting time is larger.
    */
   private maybeSortResults(name: string, results: ChangeInfo[]) {
-    const userId = this.account && this.account._account_id;
+    const userId = this.account?._account_id;
     const sortedResults = [...results];
     if (name === YOUR_TURN.name && userId) {
       sortedResults.sort((c1, c2) => {
@@ -543,11 +509,16 @@
   }
 
   // private but used in test
-  handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
-    this.restApiService.saveChangeStarred(
+  async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+    const msg = e.detail.starred
+      ? 'Starring change...'
+      : 'Unstarring change...';
+    fireAlert(this, msg);
+    await this.restApiService.saveChangeStarred(
       e.detail.change._number,
       e.detail.starred
     );
+    fireEvent(this, 'hide-alert');
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-dashboard');
     }
@@ -573,9 +544,7 @@
    */
   maybeShowDraftsBanner() {
     this.showDraftsBanner = false;
-    if (!(this.params?.user === 'self')) {
-      return;
-    }
+    if (!(this.viewState?.user === 'self')) return;
 
     if (!this.results) {
       throw new Error('this.results must be set. restAPI returned undefined');
@@ -584,16 +553,12 @@
     const draftSection = this.results.find(
       section => section.query === 'has:draft'
     );
-    if (!draftSection || !draftSection.results.length) {
-      return;
-    }
+    if (!draftSection || !draftSection.results.length) return;
 
     const closedChanges = draftSection.results.filter(
       change => !changeIsOpen(change)
     );
-    if (!closedChanges.length) {
-      return;
-    }
+    if (!closedChanges.length) return;
 
     this.showDraftsBanner = true;
   }
@@ -620,7 +585,7 @@
   }
 
   private computeDraftsLink() {
-    return GerritNav.getUrlForSearchQuery('has:draft -is:open');
+    return createSearchUrl({query: 'has:draft -is:open'});
   }
 
   private handleCreateChangeTap() {
@@ -635,10 +600,6 @@
     this.commandsDialog.branch = e.detail.branch;
     this.commandsDialog.open();
   }
-
-  private handleSelectedIndexChanged(e: ValueChangedEvent<number>) {
-    this.selectedChangeIndex = e.detail.value;
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
index e5aff61..7889e20 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-dashboard-view';
 import {GrDashboardView} from './gr-dashboard-view';
 import {GerritView} from '../../../services/router/router-model';
@@ -41,19 +29,18 @@
   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 {fixture, html, assert} from '@open-wc/testing';
 import {SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 suite('gr-dashboard-view tests', () => {
   let element: GrDashboardView;
 
-  let paramsChangedPromise: Promise<any>;
   let getChangesStub: SinonStubbedMember<
     RestApiService['getChangesForMultipleQueries']
   >;
@@ -66,33 +53,84 @@
         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()));
+  });
+
+  test('render', async () => {
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+      user: 'self',
+      sections: [
+        {name: 'test1', query: 'test1', hideIfEmpty: true},
+        {name: 'test2', query: 'test2', hideIfEmpty: true},
+      ],
+    };
+    getChangesStub.returns(Promise.resolve([[createChange()]]));
+    await element.reload();
+    element.loading = false;
+    stubFlags('isEnabled').returns(true);
+    element.requestUpdate();
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+        <div class="loading" hidden="">Loading...</div>
+        <div>
+          <h1 class="assistive-tech-only">Dashboard</h1>
+          <gr-change-list showstar="">
+            <div id="emptyOutgoing" slot="outgoing-slot">No changes</div>
+            <div id="emptyYourTurn" slot="your-turn-slot">
+              <span> No changes need your attention &nbsp🎉 </span>
+            </div>
+          </gr-change-list>
+        </div>
+        <gr-overlay
+          aria-hidden="true"
+          id="confirmDeleteOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-dialog
+            confirm-label="Delete"
+            id="confirmDeleteDialog"
+            role="dialog"
+          >
+            <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">
+        </gr-create-destination-dialog>
+        <gr-create-commands-dialog id="commandsDialog">
+        </gr-create-commands-dialog>
+      `
+    );
   });
 
   suite('bulk actions', () => {
     setup(async () => {
-      const sections = [
-        {name: 'test1', query: 'test1', hideIfEmpty: true},
-        {name: 'test2', query: 'test2', hideIfEmpty: true},
-      ];
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        user: 'user',
+        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);
+      await element.reload();
+      element.loading = false;
       element.requestUpdate();
       await element.updateComplete;
     });
@@ -103,19 +141,14 @@
           query(query(element, 'gr-change-list'), 'gr-change-list-section'),
           'gr-change-list-item'
         ),
-        '.selection > input'
+        '.selection > label > input'
       );
-      MockInteractions.tap(checkbox);
+      checkbox.click();
       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);
@@ -123,9 +156,20 @@
   });
 
   suite('drafts banner functionality', () => {
+    setup(async () => {
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        user: 'self',
+        sections: [
+          {name: 'test1', query: 'test1', hideIfEmpty: true},
+          {name: 'test2', query: 'test2', hideIfEmpty: true},
+        ],
+      };
+    });
+
     suite('maybeShowDraftsBanner', () => {
       test('not dashboard/self', () => {
-        element.params = {
+        element.viewState = {
           view: GerritView.DASHBOARD,
           user: 'notself',
           dashboard: '' as DashboardId,
@@ -136,7 +180,7 @@
 
       test('no drafts at all', () => {
         element.results = [];
-        element.params = {
+        element.viewState = {
           view: GerritView.DASHBOARD,
           user: 'self',
           dashboard: '' as DashboardId,
@@ -150,7 +194,7 @@
         element.results = [
           {countLabel: '', name: '', query: 'has:draft', results: [openChange]},
         ];
-        element.params = {
+        element.viewState = {
           view: GerritView.DASHBOARD,
           user: 'self',
           dashboard: '' as DashboardId,
@@ -170,7 +214,7 @@
           },
         ];
         assert.isFalse(changeIsOpen(element.results[0].results[0]));
-        element.params = {
+        element.viewState = {
           view: GerritView.DASHBOARD,
           user: 'self',
           dashboard: '' as DashboardId,
@@ -198,7 +242,7 @@
       element.showDraftsBanner = true;
       await element.updateComplete;
 
-      MockInteractions.tap(queryAndAssert(element, '.banner .delete'));
+      queryAndAssert<GrButton>(element, '.banner .delete').click();
       assert.isTrue(handleOpenDeleteDialogStub.called);
     });
 
@@ -223,9 +267,10 @@
 
       // Open confirmation dialog and tap confirm button.
       await queryAndAssert<GrOverlay>(element, '#confirmDeleteOverlay').open();
-      MockInteractions.tap(
-        queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').confirmButton!
-      );
+      queryAndAssert<GrDialog>(
+        element,
+        '#confirmDeleteDialog'
+      ).confirmButton!.click();
       await element.updateComplete;
       assert.isTrue(deleteStub.calledWithExactly('-is:open'));
       assert.isTrue(
@@ -273,27 +318,10 @@
     });
   });
 
-  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 = {
+      element.account = undefined;
+      element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'self',
         dashboard: '' as DashboardId,
@@ -302,13 +330,13 @@
           {name: '', query: '2', selfOnly: true},
         ],
       };
-      await paramsChangedPromise;
+      await element.reload();
       assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2']));
     });
 
     test('viewing dashboard when logged in includes owner:self query', async () => {
       element.account = createAccountDetailWithId(1);
-      element.params = {
+      element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'self',
         dashboard: '' as DashboardId,
@@ -317,14 +345,14 @@
           {name: '', query: '2', selfOnly: true},
         ],
       };
-      await paramsChangedPromise;
+      await element.reload();
       assert.isTrue(
         getChangesStub.calledWith(undefined, ['1', '2', 'owner:self limit:1'])
       );
     });
 
     test("viewing another user's dashboard omits selfOnly sections", async () => {
-      element.params = {
+      element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'user',
         dashboard: '' as DashboardId,
@@ -333,13 +361,13 @@
           {name: '', query: '2', selfOnly: true},
         ],
       };
-      await paramsChangedPromise;
+      await element.reload();
       assert.isTrue(getChangesStub.calledWith(undefined, ['1']));
     });
   });
 
   test('suffixForDashboard is included in getChanges query', async () => {
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: '' as DashboardId,
       sections: [
@@ -347,7 +375,7 @@
         {name: '', query: '2', suffixForDashboard: 'suffix'},
       ],
     };
-    await paramsChangedPromise;
+    await element.reload();
     assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2 suffix']));
   });
 
@@ -440,7 +468,7 @@
     assert.isNotOk(element.results![1].emptyStateSlotName);
   });
 
-  test('toggling star will update change everywhere', () => {
+  test('toggling star will update change everywhere', async () => {
     // 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};
@@ -464,7 +492,7 @@
       },
     ];
 
-    element.handleToggleStar(
+    await element.handleToggleStar(
       new CustomEvent('toggle-star', {
         detail: {
           change,
@@ -479,6 +507,9 @@
   });
 
   test('showNewUserHelp', async () => {
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+    };
     element.loading = false;
     element.showNewUserHelp = false;
     await element.updateComplete;
@@ -506,11 +537,11 @@
   });
 
   test('gr-user-header', async () => {
-    element.params = undefined;
+    element.viewState = undefined;
     await element.updateComplete;
     assert.isNotOk(query(element, 'gr-user-header'));
 
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: '' as DashboardId,
       user: 'self',
@@ -519,7 +550,7 @@
     assert.isNotOk(query(element, 'gr-user-header'));
 
     element.loading = false;
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: '' as DashboardId,
       user: 'user',
@@ -527,7 +558,7 @@
     await element.updateComplete;
     assert.isOk(query(element, 'gr-user-header'));
 
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: '' as DashboardId,
       project: 'p' as RepoName,
@@ -552,16 +583,16 @@
       assert.strictEqual((e as PageErrorEvent).detail.response, response);
       promise.resolve();
     });
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: 'dashboard' as DashboardId,
       project: 'project' as RepoName,
       user: '',
     };
-    await Promise.all([paramsChangedPromise, promise]);
+    await Promise.all([element.reload(), promise]);
   });
 
-  test('params change triggers dashboardDisplayed()', async () => {
+  test('viewState change triggers dashboardDisplayed()', async () => {
     stubRestApi('getDashboard').returns(
       Promise.resolve({
         id: '' as DashboardId,
@@ -577,42 +608,13 @@
     );
     getChangesStub.returns(Promise.resolve([]));
     const dashboardDisplayedStub = stubReporting('dashboardDisplayed');
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: 'dashboard' as DashboardId,
       project: 'project' as RepoName,
       user: '',
     };
-    await paramsChangedPromise;
+    await element.reload();
     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 dec1656..e27274b 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
@@ -1,21 +1,8 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {RepoName} from '../../../types/common';
 import {WebLinkInfo} from '../../../types/diff';
 import {getAppContext} from '../../../services/app-context';
@@ -23,7 +10,8 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {dashboardHeaderStyles} from '../../../styles/dashboard-header-styles';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
+import {createRepoUrl} from '../../../models/views/repo';
 
 @customElement('gr-repo-header')
 export class GrRepoHeader extends LitElement {
@@ -85,15 +73,15 @@
   }
 
   _repoChanged() {
-    const repoName = this.repo;
-    if (!repoName) {
+    const repo = this.repo;
+    if (!repo) {
       this._repoUrl = null;
       return;
     }
 
-    this._repoUrl = GerritNav.getUrlForRepo(repoName);
+    this._repoUrl = createRepoUrl({repo});
 
-    this.restApiService.getRepo(repoName).then(repo => {
+    this.restApiService.getRepo(repo).then(repo => {
       if (!repo?.web_links) return;
       this._webLinks = repo.web_links;
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts
index 56ad8bc..9054474 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts
@@ -1,44 +1,52 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
 import './gr-repo-header';
 import {GrRepoHeader} from './gr-repo-header';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {stubRestApi} from '../../../test/test-utils';
 import {RepoName, UrlEncodedRepoName} from '../../../types/common';
 
-const basicFixture = fixtureFromElement('gr-repo-header');
-
 suite('gr-repo-header tests', () => {
   let element: GrRepoHeader;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-repo-header .repo=${'test' as RepoName}></gr-repo-header>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="info">
+          <h1 class="heading-1">test</h1>
+          <hr />
+          <div>
+            <span> Detail: </span>
+            <a href="/admin/repos/test"> Repo settings </a>
+          </div>
+          <div>
+            <span class="browse"> Browse: </span>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('repoUrl reset once repo changed', async () => {
-    sinon
-      .stub(GerritNav, 'getUrlForRepo')
-      .callsFake(repoName => `http://test.com/${repoName},general`);
+    element.repo = undefined;
+    await element.updateComplete;
     assert.equal(element._repoUrl, undefined);
+
     element.repo = 'test' as RepoName;
-    await flush();
-    assert.equal(element._repoUrl, 'http://test.com/test,general');
+    await element.updateComplete;
+
+    assert.equal(element._repoUrl, '/admin/repos/test');
   });
 
   test('webLinks set', async () => {
@@ -51,13 +59,15 @@
         },
       ],
     };
-
     stubRestApi('getRepo').returns(Promise.resolve(repoRes));
+    element.repo = undefined;
+    await element.updateComplete;
 
     assert.deepEqual(element._webLinks, []);
 
     element.repo = 'test' as RepoName;
-    await flush();
+    await element.updateComplete;
+
     assert.deepEqual(element._webLinks, repoRes.web_links);
   });
 });
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 72f2a44..57f9ee6 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
@@ -1,25 +1,12 @@
 /**
  * @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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-avatar/gr-avatar';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {AccountDetailInfo, AccountId} from '../../../types/common';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {getAppContext} from '../../../services/app-context';
@@ -27,7 +14,8 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
+import {createDashboardUrl} from '../../../models/views/dashboard';
 
 @customElement('gr-user-header')
 export class GrUserHeader extends LitElement {
@@ -152,18 +140,15 @@
   }
 
   _computeDashboardUrl(accountDetails: AccountDetailInfo | undefined) {
-    if (!accountDetails) {
-      return undefined;
-    }
+    if (!accountDetails) return '';
+
     const id = accountDetails._account_id;
-    if (id) {
-      return GerritNav.getUrlForUserDashboard(String(id));
-    }
+    if (id) return createDashboardUrl({user: String(id)});
+
     const email = accountDetails.email;
-    if (email) {
-      return GerritNav.getUrlForUserDashboard(email);
-    }
-    return undefined;
+    if (email) return createDashboardUrl({user: email});
+
+    return '';
   }
 
   _computeDashboardLinkClass(showDashboardLink: boolean, loggedIn: boolean) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.ts
index 0e35000..7d204d9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.ts
@@ -1,33 +1,53 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
 import './gr-user-header';
 import {GrUserHeader} from './gr-user-header';
-import {stubRestApi} from '../../../test/test-utils';
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
 import {AccountId, EmailAddress, Timestamp} from '../../../types/common';
 
-const basicFixture = fixtureFromElement('gr-user-header');
-
 suite('gr-user-header tests', () => {
   let element: GrUserHeader;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-user-header></gr-user-header>`);
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-avatar aria-label="Account avatar" hidden=""> </gr-avatar>
+        <div class="info">
+          <h1 class="heading-1"></h1>
+          <hr />
+          <div class="hide status">
+            <span> Status: </span>
+          </div>
+          <div>
+            <span> Email: </span>
+            <a href="mailto:"> </a>
+          </div>
+          <div>
+            <span> Joined: </span>
+            <gr-date-formatter datestr=""> </gr-date-formatter>
+          </div>
+          <gr-endpoint-decorator name="user-header">
+            <gr-endpoint-param name="accountDetails"> </gr-endpoint-param>
+            <gr-endpoint-param name="loggedIn"> </gr-endpoint-param>
+          </gr-endpoint-decorator>
+        </div>
+        <div class="info">
+          <div class="dashboardLink hide">
+            <a href=""> View dashboard </a>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('loads and clears account info', async () => {
@@ -41,13 +61,13 @@
     );
 
     element.userId = 10 as AccountId;
-    await flush();
+    await waitEventLoop();
 
     assert.isOk(element._accountDetails);
     assert.isOk(element._status);
 
     element.userId = undefined;
-    await flush();
+    await waitEventLoop();
 
     assert.isUndefined(element._accountDetails);
     assert.equal(element._status, '');
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 10fd50e..917b7ca 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
@@ -1,24 +1,13 @@
 /**
  * @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 '../../admin/gr-create-change-dialog/gr-create-change-dialog';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-dropdown/gr-dropdown';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
 import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
@@ -28,8 +17,7 @@
 import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAppContext} from '../../../services/app-context';
 import {CURRENT} from '../../../utils/patch-set-util';
@@ -98,7 +86,7 @@
   getVotingRange,
   StandardLabels,
 } from '../../../utils/label-util';
-import {ShowAlertEventDetail} from '../../../types/events';
+import {EventType, ShowAlertEventDetail} from '../../../types/events';
 import {
   ActionPriority,
   ActionType,
@@ -112,9 +100,13 @@
 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 {customElement, property, query, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {assertIsDefined, queryAll} from '../../../utils/common-util';
+import {Interaction} from '../../../constants/reporting';
+import {rootUrl} from '../../../utils/url-util';
+import {createSearchUrl} from '../../../models/views/search';
+import {createChangeUrl} from '../../../models/views/change';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -246,21 +238,23 @@
   __type: ActionType.CHANGE,
 };
 
-// Set of keys that have icons. As more icons are added to gr-icons.html, this
-// set should be expanded.
-const ACTIONS_WITH_ICONS = new Set([
-  ChangeActions.ABANDON,
-  ChangeActions.DELETE_EDIT,
-  ChangeActions.EDIT,
-  ChangeActions.PUBLISH_EDIT,
-  ChangeActions.READY,
-  ChangeActions.REBASE_EDIT,
-  ChangeActions.RESTORE,
-  ChangeActions.REVERT,
-  ChangeActions.STOP_EDIT,
-  QUICK_APPROVE_ACTION.key,
-  RevisionActions.REBASE,
-  RevisionActions.SUBMIT,
+// Set of keys that have icons.
+const ACTIONS_WITH_ICONS = new Map<
+  string,
+  Pick<UIActionInfo, 'filled' | 'icon'>
+>([
+  [ChangeActions.ABANDON, {icon: 'block'}],
+  [ChangeActions.DELETE_EDIT, {icon: 'delete', filled: true}],
+  [ChangeActions.EDIT, {icon: 'edit', filled: true}],
+  [ChangeActions.PUBLISH_EDIT, {icon: 'publish', filled: true}],
+  [ChangeActions.READY, {icon: 'visibility', filled: true}],
+  [ChangeActions.REBASE_EDIT, {icon: 'rebase_edit'}],
+  [RevisionActions.REBASE, {icon: 'rebase'}],
+  [ChangeActions.RESTORE, {icon: 'history'}],
+  [ChangeActions.REVERT, {icon: 'undo'}],
+  [ChangeActions.STOP_EDIT, {icon: 'stop', filled: true}],
+  [QUICK_APPROVE_ACTION.key, {icon: 'check'}],
+  [RevisionActions.SUBMIT, {icon: 'done_all'}],
 ]);
 
 const EDIT_ACTIONS: Set<string> = new Set([
@@ -404,7 +398,7 @@
   @property({type: Object})
   change?: ChangeViewChangeInfo;
 
-  @property({type: Object})
+  @state()
   actions: ActionNameToActionInfoMap = {};
 
   @property({type: Array})
@@ -548,6 +542,8 @@
 
   private readonly storage = getAppContext().storageService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.addEventListener('fullscreen-overlay-opened', () =>
@@ -594,11 +590,11 @@
           margin: var(--spacing-l);
           text-align: center;
         }
-        iron-icon {
+        gr-icon {
           color: inherit;
           margin-right: var(--spacing-xs);
         }
-        #moreActions iron-icon {
+        #moreActions gr-icon {
           margin: 0;
         }
         #moreMessage,
@@ -643,7 +639,7 @@
           !this.topLevelActions.length}
         >
           ${this.topLevelPrimaryActions?.map(action =>
-            this.renderTopPrimaryActions(action)
+            this.renderUIAction(action)
           )}
         </section>
         <section
@@ -653,7 +649,7 @@
           !this.topLevelActions.length}
         >
           ${this.topLevelSecondaryActions?.map(action =>
-            this.renderTopSecondaryActions(action)
+            this.renderUIAction(action)
           )}
         </section>
         <gr-button ?hidden=${!this.loading}>Loading actions...</gr-button>
@@ -669,8 +665,7 @@
           .disabledIds=${this.disabledMenuActions}
           .items=${this.menuActions}
         >
-          <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
-          </iron-icon>
+          <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
           <span id="moreMessage">More</span>
         </gr-dropdown>
       </div>
@@ -777,7 +772,7 @@
     `;
   }
 
-  private renderTopPrimaryActions(action: UIActionInfo) {
+  private renderUIAction(action: UIActionInfo) {
     return html`
       <gr-tooltip-content
         title=${ifDefined(action.title)}
@@ -793,39 +788,16 @@
           @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}
+          ${this.renderUIActionIcon(action)} ${action.label}
         </gr-button>
       </gr-tooltip-content>
     `;
   }
 
-  private renderTopSecondaryActions(action: UIActionInfo) {
+  private renderUIActionIcon(action: UIActionInfo) {
+    if (!action.icon) return nothing;
     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>
+      <gr-icon icon=${action.icon} ?filled=${action.filled}></gr-icon>
     `;
   }
 
@@ -875,7 +847,7 @@
       return undefined;
     }
     if (revisionActions[actionName] === undefined) {
-      // Return null to fire an event when reveisionActions was loaded
+      // Return null to fire an event when revisionActions was loaded
       // but doesn't contain actionName. undefined doesn't fire an event
       return null;
     }
@@ -1343,7 +1315,7 @@
   }
 
   /**
-   * Capitalize the first letter and lowecase all others.
+   * Capitalize the first letter and lowercase all others.
    *
    * private but used in test
    */
@@ -1389,7 +1361,10 @@
     revert dialog after revert button is pressed. */
     this.restApiService.getChanges(0, query).then(changes => {
       if (!changes) {
-        this.reporting.error(new Error('changes is undefined'));
+        this.reporting.error(
+          'Change Actions',
+          new Error('getChanges returns undefined')
+        );
         return;
       }
       assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
@@ -1434,7 +1409,7 @@
 
   private handleOverflowItemTap(e: CustomEvent<MenuAction>) {
     e.preventDefault();
-    const el = (dom(e) as EventApi).localTarget as Element;
+    const el = e.target as Element;
     const key = e.detail.action.__key;
     if (
       key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
@@ -1599,14 +1574,18 @@
     assertIsDefined(this.confirmRebase, 'confirmRebase');
     assertIsDefined(this.overlay, 'overlay');
     const el = this.confirmRebase;
-    const payload = {base: e.detail.base};
+    const payload = {
+      base: e.detail.base,
+      allow_conflicts: e.detail.allowConflicts,
+    };
     this.overlay.close();
     el.hidden = true;
     this.fireAction(
       '/rebase',
       assertUIActionInfo(this.revisionActions.rebase),
       true,
-      payload
+      payload,
+      {allow_conflicts: payload.allow_conflicts}
     );
   }
 
@@ -1692,7 +1671,10 @@
         );
         break;
       default:
-        this.reporting.error(new Error('invalid revert type'));
+        this.reporting.error(
+          'Change Actions',
+          new Error('invalid revert type')
+        );
     }
   }
 
@@ -1808,12 +1790,17 @@
     endpoint: string,
     action: UIActionInfo,
     revAction: boolean,
-    payload?: RequestPayload
+    payload?: RequestPayload,
+    toReport?: Object
   ) {
     const cleanupFn = this.setLoadingOnButtonWithKey(
       action.__type,
       action.__key
     );
+    this.reporting.reportInteraction(Interaction.CHANGE_ACTION_FIRED, {
+      endpoint,
+      toReport,
+    });
 
     this.send(
       action.method,
@@ -1861,20 +1848,24 @@
           this.waitForChangeReachable(revertChangeInfo._number)
             .then(() => this.setReviewOnRevert(revertChangeInfo._number))
             .then(() => {
-              GerritNav.navigateToChange(revertChangeInfo);
+              this.getNavigation().setUrl(
+                createChangeUrl({change: revertChangeInfo})
+              );
             });
           break;
         }
         case RevisionActions.CHERRYPICK: {
           const cherrypickChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
           this.waitForChangeReachable(cherrypickChangeInfo._number).then(() => {
-            GerritNav.navigateToChange(cherrypickChangeInfo);
+            this.getNavigation().setUrl(
+              createChangeUrl({change: cherrypickChangeInfo})
+            );
           });
           break;
         }
         case ChangeActions.DELETE:
           if (action.__type === ActionType.CHANGE) {
-            GerritNav.navigateToRelativeUrl(GerritNav.getUrlForRoot());
+            this.getNavigation().setUrl(rootUrl());
           }
           break;
         case ChangeActions.WIP:
@@ -1894,9 +1885,9 @@
             return;
           /* If there is only 1 change then gerrit will automatically
             redirect to that change */
-          GerritNav.navigateToSearchQuery(
-            `topic: ${revertSubmistionInfo.revert_changes[0].topic}`
-          );
+          const topic = revertSubmistionInfo.revert_changes[0].topic;
+          const query = `topic:${topic}`;
+          if (topic) this.getNavigation().setUrl(createSearchUrl({query}));
           break;
         }
         default:
@@ -1976,7 +1967,7 @@
       .then(result => {
         if (!result.isLatest) {
           this.dispatchEvent(
-            new CustomEvent<ShowAlertEventDetail>('show-alert', {
+            new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
               detail: {
                 message:
                   'Cannot set label: a newer patch has been ' +
@@ -2028,7 +2019,10 @@
       .getChanges(0, query, undefined, options)
       .then(changes => {
         if (!changes) {
-          this.reporting.error(new Error('getChanges returns undefined'));
+          this.reporting.error(
+            'Change Actions',
+            new Error('getChanges returns undefined')
+          );
           return;
         }
         this.confirmCherrypick!.updateChanges(changes);
@@ -2121,7 +2115,6 @@
    * values.
    */
   private computeAllActions(): UIActionInfo[] {
-    // Polymer 2: check for undefined
     if (this.change === undefined) {
       return [];
     }
@@ -2147,10 +2140,10 @@
       .concat(changeActionValues)
       .sort((a, b) => this.actionComparator(a, b))
       .map(action => {
-        if (ACTIONS_WITH_ICONS.has(action.__key)) {
-          action.icon = action.__key;
-        }
-        return action;
+        return {
+          ...action,
+          ...(ACTIONS_WITH_ICONS.get(action.__key) ?? {}),
+        };
       })
       .filter(action => !this.shouldSkipAction(action));
   }
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 3207279..1b98c5d 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
@@ -1,23 +1,11 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-change-actions';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {
   createAccountWithId,
@@ -49,6 +37,7 @@
   CommitId,
   NumericChangeId,
   PatchSetNum,
+  PatchSetNumber,
   RepoName,
   ReviewInput,
   TopicName,
@@ -60,7 +49,7 @@
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {UIActionInfo} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
 import {getAppContext} from '../../../services/app-context';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 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';
@@ -69,6 +58,8 @@
 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';
+import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
 
 // TODO(dhruvsri): remove use of _populateRevertMessage as it's private
 suite('gr-change-actions tests', () => {
@@ -157,6 +148,143 @@
       await element.reload();
     });
 
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div id="mainContent">
+            <span hidden="" id="actionLoadingMessage"> </span>
+            <section id="primaryActions">
+              <gr-tooltip-content
+                has-tooltip=""
+                position-below=""
+                title="Submit patch set 2 into master"
+              >
+                <gr-button
+                  aria-disabled="false"
+                  class="submit"
+                  data-action-key="submit"
+                  data-label="Submit"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  <gr-icon icon="done_all"></gr-icon>
+                  Submit
+                </gr-button>
+              </gr-tooltip-content>
+            </section>
+            <section id="secondaryActions">
+              <gr-tooltip-content
+                has-tooltip=""
+                position-below=""
+                title="Rebase onto tip of branch or parent change"
+              >
+                <gr-button
+                  aria-disabled="true"
+                  class="rebase"
+                  data-action-key="rebase"
+                  data-label="Rebase"
+                  disabled=""
+                  link=""
+                  role="button"
+                  tabindex="-1"
+                >
+                  <gr-icon icon="rebase"> </gr-icon>
+                  Rebase
+                </gr-button>
+              </gr-tooltip-content>
+            </section>
+            <gr-button
+              aria-disabled="false"
+              hidden=""
+              role="button"
+              tabindex="0"
+            >
+              Loading actions...
+            </gr-button>
+            <gr-dropdown id="moreActions" link="">
+              <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
+              <span id="moreMessage"> More </span>
+            </gr-dropdown>
+          </div>
+          <gr-overlay
+            aria-hidden="true"
+            id="overlay"
+            style="outline: none; display: none;"
+            tabindex="-1"
+            with-backdrop=""
+          >
+            <gr-confirm-rebase-dialog class="confirmDialog" id="confirmRebase">
+            </gr-confirm-rebase-dialog>
+            <gr-confirm-cherrypick-dialog
+              class="confirmDialog"
+              id="confirmCherrypick"
+            >
+            </gr-confirm-cherrypick-dialog>
+            <gr-confirm-cherrypick-conflict-dialog
+              class="confirmDialog"
+              id="confirmCherrypickConflict"
+            >
+            </gr-confirm-cherrypick-conflict-dialog>
+            <gr-confirm-move-dialog class="confirmDialog" id="confirmMove">
+            </gr-confirm-move-dialog>
+            <gr-confirm-revert-dialog
+              class="confirmDialog"
+              id="confirmRevertDialog"
+            >
+            </gr-confirm-revert-dialog>
+            <gr-confirm-abandon-dialog
+              class="confirmDialog"
+              id="confirmAbandonDialog"
+            >
+            </gr-confirm-abandon-dialog>
+            <gr-confirm-submit-dialog
+              class="confirmDialog"
+              id="confirmSubmitDialog"
+            >
+            </gr-confirm-submit-dialog>
+            <gr-dialog
+              class="confirmDialog"
+              confirm-label="Create"
+              id="createFollowUpDialog"
+              role="dialog"
+            >
+              <div class="header" slot="header">Create Follow-Up Change</div>
+              <div class="main" slot="main">
+                <gr-create-change-dialog id="createFollowUpChange">
+                </gr-create-change-dialog>
+              </div>
+            </gr-dialog>
+            <gr-dialog
+              class="confirmDialog"
+              confirm-label="Delete"
+              confirm-on-enter=""
+              id="confirmDeleteDialog"
+              role="dialog"
+            >
+              <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
+              class="confirmDialog"
+              confirm-label="Delete"
+              confirm-on-enter=""
+              id="confirmDeleteEditDialog"
+              role="dialog"
+            >
+              <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>
+        `
+      );
+    });
+
     test('show-revision-actions event should fire', async () => {
       const spy = sinon.spy(element, 'sendShowRevisionActions');
       element.reload();
@@ -311,18 +439,20 @@
 
     test('get revision object from change', () => {
       const revObj = {
-        ...createRevision(),
-        _number: 2 as PatchSetNum,
+        ...createRevision(2),
         foo: 'bar',
       };
       const change = {
         ...createChangeViewChange(),
         revisions: {
-          rev1: {...createRevision(), _number: 1 as PatchSetNum},
+          rev1: createRevision(1),
           rev2: revObj,
         },
       };
-      assert.deepEqual(element.getRevision(change, 2 as PatchSetNum), revObj);
+      assert.deepEqual(
+        element.getRevision(change, 2 as PatchSetNumber),
+        revObj
+      );
     });
 
     test('actionComparator sort order', () => {
@@ -356,11 +486,11 @@
       element.change = {
         ...createChangeViewChange(),
         revisions: {
-          rev1: {...createRevision(), _number: 1 as PatchSetNum},
-          rev2: {...createRevision(), _number: 2 as PatchSetNum},
+          rev1: {...createRevision(), _number: 1 as PatchSetNumber},
+          rev2: {...createRevision(), _number: 2 as PatchSetNumber},
         },
       };
-      element.latestPatchNum = 2 as PatchSetNum;
+      element.latestPatchNum = 2 as PatchSetNumber;
 
       queryAndAssert<GrButton>(
         element,
@@ -395,19 +525,37 @@
       element.change = {
         ...createChangeViewChange(),
         revisions: {
-          rev1: {...createRevision(), _number: 1 as PatchSetNum},
-          rev2: {...createRevision(), _number: 2 as PatchSetNum},
+          rev1: {...createRevision(), _number: 1 as PatchSetNumber},
+          rev2: {...createRevision(), _number: 2 as PatchSetNumber},
         },
       };
       element.latestPatchNum = 2 as PatchSetNum;
 
       queryAndAssert<GrButton>(
         element,
-        'gr-button[data-action-key="submit"] iron-icon'
+        'gr-button[data-action-key="submit"] gr-icon'
       ).click();
       await submitted;
     });
 
+    test('correct icons', async () => {
+      element.loggedIn = true;
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(
+        element,
+        'gr-button[data-action-key="submit"] gr-icon'
+      );
+      queryAndAssert<GrButton>(
+        element,
+        'gr-button[data-action-key="rebase"] gr-icon'
+      );
+      queryAndAssert<GrButton>(
+        element,
+        'gr-button[data-action-key="edit"] gr-icon[filled]'
+      );
+    });
+
     test('handleSubmitConfirm', () => {
       const fireStub = sinon.stub(element, 'fireAction');
       sinon.stub(element, 'canSubmitChange').returns(true);
@@ -491,13 +639,14 @@
       };
       assert.isTrue(fetchChangesStub.called);
       element.handleRebaseConfirm(
-        new CustomEvent('', {detail: {base: '1234'}})
+        new CustomEvent('', {detail: {base: '1234', allowConflicts: false}})
       );
       assert.deepEqual(fireActionStub.lastCall.args, [
         '/rebase',
         assertUIActionInfo(rebaseAction),
         true,
-        {base: '1234'},
+        {base: '1234', allow_conflicts: false},
+        {allow_conflicts: false},
       ]);
     });
 
@@ -2290,7 +2439,7 @@
         onShowError = sinon.stub();
         element.addEventListener('show-error', onShowError);
         onShowAlert = sinon.stub();
-        element.addEventListener('show-alert', onShowAlert);
+        element.addEventListener(EventType.SHOW_ALERT, onShowAlert);
       });
 
       suite('happy path', () => {
@@ -2308,7 +2457,6 @@
           sendStub = stubRestApi('executeChangeAction').returns(
             Promise.resolve(new Response())
           );
-          sinon.stub(GerritNav, 'navigateToChange');
         });
 
         test('change action', async () => {
@@ -2358,15 +2506,14 @@
         });
 
         suite('single changes revert', () => {
-          let navigateToSearchQueryStub: sinon.SinonStub;
+          let setUrlStub: sinon.SinonStub;
           setup(() => {
             getResponseObjectStub.returns(
-              Promise.resolve({revert_changes: [{change_id: 12345}]})
+              Promise.resolve({
+                revert_changes: [{change_id: 12345, topic: 'T'}],
+              })
             );
-            navigateToSearchQueryStub = sinon.stub(
-              GerritNav,
-              'navigateToSearchQuery'
-            );
+            setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
           });
 
           test('revert submission single change', async () => {
@@ -2386,13 +2533,14 @@
               },
               new Response()
             );
-            assert.isTrue(navigateToSearchQueryStub.called);
+            assert.isTrue(setUrlStub.called);
+            assert.equal(setUrlStub.lastCall.args[0], '/q/topic:T');
           });
         });
 
         suite('multiple changes revert', () => {
           let showActionDialogStub: sinon.SinonStub;
-          let navigateToSearchQueryStub: sinon.SinonStub;
+          let setUrlStub: sinon.SinonStub;
           setup(() => {
             getResponseObjectStub.returns(
               Promise.resolve({
@@ -2403,10 +2551,7 @@
               })
             );
             showActionDialogStub = sinon.stub(element, 'showActionDialog');
-            navigateToSearchQueryStub = sinon.stub(
-              GerritNav,
-              'navigateToSearchQuery'
-            );
+            setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
           });
 
           test('revert submission multiple change', async () => {
@@ -2427,7 +2572,8 @@
               new Response()
             );
             assert.isFalse(showActionDialogStub.called);
-            assert.isTrue(navigateToSearchQueryStub.calledWith('topic: T'));
+            assert.isTrue(setUrlStub.called);
+            assert.equal(setUrlStub.lastCall.args[0], '/q/topic:T');
           });
         });
 
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 2c80a86..228e7ce 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
@@ -1,18 +1,7 @@
 /**
  * @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 '../../../styles/gr-font-styles';
@@ -24,16 +13,13 @@
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-editable-label/gr-editable-label';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-limited-text/gr-limited-text';
 import '../../shared/gr-linked-chip/gr-linked-chip';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-submit-requirements/gr-submit-requirements';
-import '../gr-change-requirements/gr-change-requirements';
 import '../gr-commit-info/gr-commit-info';
 import '../gr-reviewer-list/gr-reviewer-list';
-import '../../shared/gr-account-list/gr-account-list';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
   ChangeStatus,
   GpgKeyInfoStatus,
@@ -58,13 +44,12 @@
   LabelNameToInfoMap,
   NumericChangeId,
   ParentCommitInfo,
-  PatchSetNum,
+  RevisionPatchSetNum,
   RepoName,
   RevisionInfo,
   ServerInfo,
-  TopicName,
 } from '../../../types/common';
-import {assertNever, unique} from '../../../utils/common-util';
+import {assertIsDefined, 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 {getAppContext} from '../../../services/app-context';
@@ -73,7 +58,7 @@
   isSectionSet,
   DisplayRules,
 } from '../../../utils/change-metadata-util';
-import {fireEvent} from '../../../utils/event-util';
+import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
 import {
   EditRevisionInfo,
   notUndefined,
@@ -85,18 +70,17 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {Interaction} from '../../../constants/reporting';
-import {
-  getApprovalInfo,
-  getCodeReviewLabel,
-  showNewSubmitRequirements,
-} from '../../../utils/label-util';
+import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
 import {LitElement, css, html, nothing, PropertyValues} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 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';
+import {when} from 'lit/directives/when.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {createSearchUrl} from '../../../models/views/search';
+import {createChangeUrl} from '../../../models/views/change';
+import {GeneratedWebLink, getChangeWeblinks} from '../../../utils/weblink-util';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -131,11 +115,6 @@
 
 @customElement('gr-change-metadata')
 export class GrChangeMetadata extends LitElement {
-  /**
-   * Fired when the change topic is changed.
-   *
-   * @event topic-changed
-   */
   @query('#webLinks') webLinks?: HTMLElement;
 
   @property({type: Object}) change?: ParsedChangeInfo;
@@ -177,15 +156,16 @@
 
   @state() private queryTopic?: AutocompleteQuery;
 
+  @state() private queryHashtag?: AutocompleteQuery;
+
   private restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   constructor() {
     super();
     this.queryTopic = (input: string) => this.getTopicSuggestions(input);
+    this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
 
   static override styles = [
@@ -196,7 +176,6 @@
       :host {
         display: table;
       }
-      gr-change-requirements,
       gr-submit-requirements {
         --requirements-horizontal-padding: var(--metadata-horizontal-padding);
       }
@@ -277,10 +256,9 @@
          commit message box. Their top border should be on the same line. */
         margin-bottom: var(--spacing-s);
       }
-      .show-all-button iron-icon {
+      .show-all-button gr-icon {
         color: inherit;
-        --iron-icon-height: 18px;
-        --iron-icon-width: 18px;
+        font-size: 18px;
       }
       gr-vote-chip {
         --gr-vote-chip-width: 14px;
@@ -328,14 +306,8 @@
       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-icon icon="expand_more" ?hidden=${this.showAllSections}></gr-icon>
+      <gr-icon icon="expand_less" ?hidden=${!this.showAllSections}></gr-icon>
     </gr-button>`;
   }
 
@@ -405,11 +377,10 @@
             has-tooltip
             title=${this.pushCertificateValidation!.message}
           >
-            <iron-icon
-              class="icon ${this.pushCertificateValidation!.class}"
+            <gr-icon
               icon=${this.pushCertificateValidation!.icon}
-            >
-            </iron-icon>
+              class="icon ${this.pushCertificateValidation!.class}"
+            ></gr-icon>
           </gr-tooltip-content>`
         )}
       </span>
@@ -539,11 +510,7 @@
         <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-commit-info .commitInfo=${parent}></gr-commit-info>
               <gr-tooltip-content
                 id="parentNotCurrentMessage"
                 has-tooltip
@@ -564,12 +531,10 @@
       <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>`;
@@ -584,9 +549,7 @@
       <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>`;
@@ -606,7 +569,7 @@
           () => html` <gr-linked-chip
             .text=${this.change?.topic}
             limit="40"
-            href=${GerritNav.getUrlForTopic(this.change!.topic!)}
+            href=${createSearchUrl({topic: this.change!.topic!})}
             ?removable=${!this.topicReadOnly}
             @remove=${this.handleTopicRemoved}
           ></gr-linked-chip>`
@@ -616,7 +579,8 @@
           () =>
             html` <gr-editable-label
               class="topicEditableLabel"
-              labelText="Add a topic"
+              labelText="Set topic"
+              .confirmLabel=${'Set Topic'}
               .value=${this.change?.topic}
               maxLength="1024"
               .placeholder=${this.computeTopicPlaceholder()}
@@ -693,6 +657,8 @@
               .readOnly=${this.hashtagReadOnly}
               @changed=${this.handleHashtagChanged}
               showAsEditPencil
+              autocomplete
+              .query=${this.queryHashtag}
             ></gr-editable-label>
           `
         )}
@@ -701,23 +667,13 @@
   }
 
   private renderSubmitRequirements() {
-    if (this.showNewSubmitRequirements()) {
-      return html`<div class="separatedSection">
-        <gr-submit-requirements
-          .change=${this.change}
-          .account=${this.account}
-          .mutable=${this.mutable}
-        ></gr-submit-requirements>
-      </div>`;
-    } else {
-      return html` <div class="oldSeparatedSection">
-        <gr-change-requirements
-          .change=${this.change}
-          .account=${this.account}
-          .mutable=${this.mutable}
-        ></gr-change-requirements>
-      </div>`;
-    }
+    return html`<div class="separatedSection">
+      <gr-submit-requirements
+        .change=${this.change}
+        .account=${this.account}
+        .mutable=${this.mutable}
+      ></gr-submit-requirements>
+    </div>`;
   }
 
   private renderWeblinks() {
@@ -763,23 +719,9 @@
     }
   }
 
-  /**
-   * @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() {
-    if (!this.commitInfo) return [];
-    const weblinks = GerritNav.getChangeWeblinks(
-      this.change ? this.change.project : ('' as RepoName),
-      this.commitInfo.commit,
-      {
-        weblinks: this.commitInfo.web_links,
-        config: this.serverConfig,
-      }
-    );
-    return weblinks.length ? weblinks : [];
+  // private but used in test
+  computeWebLinks(): GeneratedWebLink[] {
+    return getChangeWeblinks(this.commitInfo?.web_links, this.serverConfig);
   }
 
   private computeStrategy() {
@@ -796,28 +738,18 @@
   }
 
   // private but used in test
-  handleTopicChanged(e: CustomEvent<string>) {
-    if (!this.change) {
-      throw new Error('change must be set');
-    }
-    const lastTopic = this.change.topic;
+  async handleTopicChanged(e: CustomEvent<string>) {
+    assertIsDefined(this.change, 'change');
     const topic = e.detail.length ? e.detail : undefined;
     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;
-        if (this.change === change) {
-          this.change.topic = newTopic as TopicName;
-          this.requestUpdate();
-        }
-        if (newTopic !== lastTopic) {
-          fireEvent(this, 'topic-changed');
-        }
-      });
+    try {
+      fireAlert(this, 'Saving topic and reloading ...');
+      await this.restApiService.setChangeTopic(this.change._number, topic);
+    } finally {
+      this.settingTopic = false;
+    }
+    fireEvent(this, 'hide-alert');
+    fireReload(this);
   }
 
   // private but used in test
@@ -841,24 +773,16 @@
   }
 
   // private but used in test
-  handleHashtagChanged(e: CustomEvent<string>) {
-    if (!this.change) {
-      throw new Error('change must be set');
-    }
+  async handleHashtagChanged(e: CustomEvent<string>) {
+    assertIsDefined(this.change, 'change');
     const newHashtag = e.detail.length ? e.detail : undefined;
-    if (!newHashtag?.length) {
-      return;
-    }
-    const change = this.change;
-    this.restApiService
-      .setChangeHashtag(this.change._number, {add: [newHashtag as Hashtag]})
-      .then(newHashtag => {
-        if (this.change === change) {
-          this.change.hashtags = newHashtag;
-          this.requestUpdate();
-          fireEvent(this, 'hashtag-changed');
-        }
-      });
+    if (!newHashtag?.length) return;
+    fireAlert(this, 'Saving hashtag and reloading ...');
+    await this.restApiService.setChangeHashtag(this.change._number, {
+      add: [newHashtag as Hashtag],
+    });
+    fireEvent(this, 'hide-alert');
+    fireReload(this);
   }
 
   // private but used in test
@@ -874,7 +798,7 @@
   private computeTopicPlaceholder() {
     // Action items in Material Design are uppercase -- placeholder label text
     // is sentence case.
-    return this.topicReadOnly ? 'No topic' : 'ADD TOPIC';
+    return this.topicReadOnly ? 'No topic' : 'Set Topic';
   }
 
   private computeHashtagPlaceholder() {
@@ -898,8 +822,8 @@
     const rev = this.change.revisions[this.change.current_revision];
     if (!rev.push_certificate?.key) {
       return {
-        class: 'help',
-        icon: 'gr-icons:help',
+        class: 'help filled',
+        icon: 'help',
         message: 'This patch set was created without a push certificate',
       };
     }
@@ -909,13 +833,13 @@
       case GpgKeyInfoStatus.BAD:
         return {
           class: 'invalid',
-          icon: 'gr-icons:close',
+          icon: 'close',
           message: this.problems('Push certificate is invalid', key),
         };
       case GpgKeyInfoStatus.OK:
         return {
-          class: 'notTrusted',
-          icon: 'gr-icons:info',
+          class: 'notTrusted filled',
+          icon: 'info',
           message: this.problems(
             'Push certificate is valid, but key is not trusted',
             key
@@ -924,7 +848,7 @@
       case GpgKeyInfoStatus.TRUSTED:
         return {
           class: 'trusted',
-          icon: 'gr-icons:check',
+          icon: 'check',
           message: this.problems(
             'Push certificate is valid and key is trusted',
             key
@@ -967,78 +891,71 @@
 
   private computeProjectUrl(project?: RepoName) {
     if (!project) return '';
-    return GerritNav.getUrlForProjectChanges(project);
+    return createSearchUrl({project});
   }
 
   private computeBranchUrl(project?: RepoName, branch?: BranchName) {
     if (!project || !branch || !this.change || !this.change.status) return '';
-    return GerritNav.getUrlForBranch(
+    return createSearchUrl({
       branch,
       project,
-      this.change.status === ChangeStatus.NEW
-        ? 'open'
-        : this.change.status.toLowerCase()
-    );
+      statuses:
+        this.change.status === ChangeStatus.NEW
+          ? ['open']
+          : [this.change.status.toLowerCase()],
+    });
   }
 
   private computeCherryPickOfUrl(
     change?: NumericChangeId,
-    patchset?: PatchSetNum,
+    patchset?: RevisionPatchSetNum,
     project?: RepoName
   ) {
     if (!change || !project) {
       return '';
     }
-    return GerritNav.getUrlForChangeById(change, project, patchset);
+    return createChangeUrl({
+      changeNum: change,
+      project,
+      usp: 'metadata',
+      patchNum: patchset,
+    });
   }
 
   private computeHashtagUrl(hashtag: Hashtag) {
-    return GerritNav.getUrlForHashtag(hashtag);
+    return createSearchUrl({hashtag, statuses: ['open', 'merged']});
   }
 
-  private handleTopicRemoved(e: CustomEvent) {
-    if (!this.change) {
-      throw new Error('change must be set');
-    }
+  private async handleTopicRemoved(e: CustomEvent) {
+    assertIsDefined(this.change, 'change');
     const target = e.composedPath()[0] as GrLinkedChip;
     target.disabled = true;
-    const change = this.change;
-    this.restApiService
-      .setChangeTopic(this.change._number)
-      .then(() => {
-        target.disabled = false;
-        if (this.change === change) {
-          this.change.topic = '' as TopicName;
-          this.requestUpdate();
-          fireEvent(this, 'topic-changed');
-        }
-      })
-      .catch(() => {
-        target.disabled = false;
-      });
+    try {
+      fireAlert(this, 'Removing topic and reloading ...');
+      await this.restApiService.setChangeTopic(this.change._number);
+    } finally {
+      target.disabled = false;
+    }
+    fireEvent(this, 'hide-alert');
+    fireReload(this);
   }
 
   // private but used in test
-  handleHashtagRemoved(e: CustomEvent) {
+  async handleHashtagRemoved(e: CustomEvent) {
     e.preventDefault();
-    if (!this.change) {
-      throw new Error('change must be set');
-    }
+    assertIsDefined(this.change, 'change');
     const target = e.target as GrLinkedChip;
     target.disabled = true;
-    const change = this.change;
-    this.restApiService
-      .setChangeHashtag(change._number, {remove: [target.text as Hashtag]})
-      .then(newHashtags => {
-        target.disabled = false;
-        if (this.change === change) {
-          this.change.hashtags = newHashtags;
-          this.requestUpdate();
-        }
-      })
-      .catch(() => {
-        target.disabled = false;
+    try {
+      fireAlert(this, 'Removing hashtag and reloading ...');
+      await this.restApiService.setChangeHashtag(this.change._number, {
+        remove: [target.text as Hashtag],
       });
+    } finally {
+      target.disabled = false;
+    }
+    fireEvent(this, 'hide-alert');
+    fireReload(this);
   }
 
   private computeDisplayState(section: Metadata, account?: AccountDetailInfo) {
@@ -1191,11 +1108,12 @@
     if (this.topicReadOnly || !this.change || this.change.topic) {
       return;
     }
-    // Cannot use `this.$.ID` syntax because the element exists inside of a
-    // dom-if.
-    (
-      this.shadowRoot!.querySelector('.topicEditableLabel') as GrEditableLabel
-    ).open();
+    const topicEditableLabel = this.shadowRoot!.querySelector<GrEditableLabel>(
+      '.topicEditableLabel'
+    );
+    if (topicEditableLabel) {
+      topicEditableLabel.open();
+    }
   }
 
   private getTopicSuggestions(
@@ -1214,8 +1132,20 @@
       );
   }
 
-  private showNewSubmitRequirements() {
-    return showNewSubmitRequirements(this.flagsService, this.change);
+  private getHashtagSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
+    return this.restApiService
+      .getChangesWithSimilarHashtag(input)
+      .then(response =>
+        (response ?? [])
+          .flatMap(change => change.hashtags ?? [])
+          .filter(notUndefined)
+          .filter(unique)
+          .map(hashtag => {
+            return {name: hashtag, value: hashtag};
+          })
+      );
   }
 
   private computeVoteForRole(role: ChangeRole) {
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 a000445..038c34a 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
@@ -1,23 +1,10 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-change-metadata';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {ChangeRole, GrChangeMetadata} from './gr-change-metadata';
 import {
@@ -48,13 +35,12 @@
   RevisionInfo,
   ParentCommitInfo,
   TopicName,
-  PatchSetNum,
+  RevisionPatchSetNum,
   NumericChangeId,
   LabelValueToDescriptionMap,
   Hashtag,
   CommitInfo,
 } from '../../../types/common';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
 import {PluginApi} from '../../../api/plugin';
 import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
@@ -62,14 +48,14 @@
   queryAndAssert,
   resetPlugins,
   stubRestApi,
+  waitUntilCalled,
 } 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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
 
 suite('gr-change-metadata tests', () => {
   let element: GrChangeMetadata;
@@ -85,14 +71,16 @@
         },
       })
     );
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-change-metadata></gr-change-metadata>`);
     element.change = createParsedChange();
     await element.updateComplete;
   });
 
   test('renders', async () => {
     await element.updateComplete;
-    expect(element).shadowDom.to.equal(/* HTML */ `<div>
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div>
       <div class="metadata-header">
         <h3 class="heading-3 metadata-title">Change Info</h3>
         <gr-button
@@ -102,8 +90,8 @@
           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>
+          Show all <gr-icon icon="expand_more"></gr-icon>
+          <gr-icon hidden="" icon="expand_less"></gr-icon>
         </gr-button>
       </div>
       <section class="hideDisplay">
@@ -179,11 +167,11 @@
             Repo | Branch
           </span>
           <span class="value">
-            <a href="">
+            <a href="/q/project:test-project">
               test-project
             </a>
             |
-            <a href="">
+            <a href="/q/project:test-project+branch:test-branch+status:open">
               test-branch
             </a>
           </span>
@@ -201,15 +189,16 @@
         <span class="title"> Hashtags </span>
         <span class="value"> </span>
       </section>
-      <div class="oldSeparatedSection">
-      <gr-change-requirements></gr-change-requirements>
+      <div class="separatedSection">
+      <gr-submit-requirements></gr-submit-requirements>
       </div>
       <gr-endpoint-decorator name="change-metadata-item">
         <gr-endpoint-param name="labels"> </gr-endpoint-param>
         <gr-endpoint-param name="change"> </gr-endpoint-param>
         <gr-endpoint-param name="revision"> </gr-endpoint-param>
       </gr-endpoint-decorator>
-    </div>`);
+    </div>`
+    );
   });
 
   test('computeMergedCommitInfo', () => {
@@ -256,19 +245,6 @@
     assert.isNull(element.shadowRoot?.querySelector('.strategy'));
   });
 
-  test('weblinks use GerritNav interface', async () => {
-    const weblinksStub = sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .returns([{name: 'stubb', url: '#s'}]);
-    element.commitInfo = createCommitInfoWithRequiredCommit();
-    element.serverConfig = createServerInfo();
-    await element.updateComplete;
-    const webLinks = element.webLinks!;
-    assert.isTrue(weblinksStub.called);
-    assert.isNotNull(webLinks);
-    assert.equal(element.computeWebLinks().length, 1);
-  });
-
   test('weblinks hidden when no weblinks', async () => {
     element.commitInfo = createCommitInfoWithRequiredCommit();
     element.serverConfig = createServerInfo();
@@ -305,11 +281,6 @@
   });
 
   test('weblinks are visible when other weblinks', async () => {
-    const router = new GrRouter();
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router.generateWeblinks.bind(router));
-
     element.commitInfo = {
       ...createCommitInfoWithRequiredCommit(),
       web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
@@ -318,23 +289,9 @@
     const webLinks = element.webLinks!;
     assert.isFalse(webLinks.hasAttribute('hidden'));
     assert.equal(element.computeWebLinks().length, 1);
-    // With two non-gitiles weblinks, there are two returned.
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [
-        {...createWebLinkInfo(), name: 'test', url: '#'},
-        {...createWebLinkInfo(), name: 'test2', url: '#'},
-      ],
-    };
-    assert.equal(element.computeWebLinks().length, 2);
   });
 
   test('weblinks are visible when gitiles and other weblinks', async () => {
-    const router = new GrRouter();
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router.generateWeblinks.bind(router));
-
     element.commitInfo = {
       ...createCommitInfoWithRequiredCommit(),
       web_links: [
@@ -521,7 +478,7 @@
         'Push certificate is invalid:\n' +
           'No public keys found for key ID E5E20E52'
       );
-      assert.equal(result?.icon, 'gr-icons:close');
+      assert.equal(result?.icon, 'close');
       assert.equal(result?.class, 'invalid');
     });
 
@@ -539,7 +496,7 @@
         result?.message,
         'Push certificate is valid and key is trusted'
       );
-      assert.equal(result?.icon, 'gr-icons:check');
+      assert.equal(result?.icon, 'check');
       assert.equal(result?.class, 'trusted');
     });
 
@@ -552,8 +509,8 @@
         result?.message,
         'This patch set was created without a push certificate'
       );
-      assert.equal(result?.icon, 'gr-icons:help');
-      assert.equal(result?.class, 'help');
+      assert.equal(result?.icon, 'help');
+      assert.equal(result?.class, 'help filled');
     });
 
     test('computePushCertificateValidation returns undefined', () => {
@@ -748,7 +705,7 @@
     await element.updateComplete;
     assert.isFalse(element.showCherryPickOf());
     change.cherry_pick_of_change = 123 as NumericChangeId;
-    change.cherry_pick_of_patch_set = 1 as PatchSetNum;
+    change.cherry_pick_of_patch_set = 1 as RevisionPatchSetNum;
     element.change = change;
     await element.updateComplete;
     assert.isTrue(element.showCherryPickOf());
@@ -784,7 +741,7 @@
       mutable = true;
       element.mutable = mutable;
       assert.isTrue(element.computeTopicReadOnly());
-      change!.actions!.topic!.enabled = true;
+      change.actions!.topic!.enabled = true;
       element.mutable = mutable;
       element.change = change;
       assert.isFalse(element.computeTopicReadOnly());
@@ -796,7 +753,6 @@
     test('topic read only hides delete button', async () => {
       element.account = createAccountDetailWithId();
       element.change = change;
-      sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:test');
       await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const button = queryAndAssert<GrButton>(chip, 'gr-button');
@@ -807,7 +763,6 @@
       element.account = createAccountDetailWithId();
       change.actions!.topic!.enabled = true;
       element.change = change;
-      sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:test');
       await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const button = queryAndAssert<GrButton>(chip, 'gr-button');
@@ -847,7 +802,7 @@
       element.mutable = mutable;
       await element.updateComplete;
       assert.isTrue(element.computeHashtagReadOnly());
-      change!.actions!.hashtags!.enabled = true;
+      change.actions!.hashtags!.enabled = true;
       element.change = change;
       element.mutable = mutable;
       await element.updateComplete;
@@ -862,9 +817,6 @@
     test('hashtag read only hides delete button', async () => {
       element.account = createAccountDetailWithId();
       element.change = change;
-      sinon
-        .stub(GerritNav, 'getUrlForHashtag')
-        .returns('/q/hashtag:test+(status:open%20OR%20status:merged)');
       await element.updateComplete;
       assert.isTrue(element.mutable, 'Mutable');
       assert.isFalse(
@@ -880,11 +832,8 @@
     test('hashtag not read only does not hide delete button', async () => {
       await element.updateComplete;
       element.account = createAccountDetailWithId();
-      change!.actions!.hashtags!.enabled = true;
+      change.actions!.hashtags!.enabled = true;
       element.change = change;
-      sinon
-        .stub(GerritNav, 'getUrlForHashtag')
-        .returns('/q/hashtag:test+(status:open%20OR%20status:merged)');
       await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const button = queryAndAssert<GrButton>(chip, 'gr-button');
@@ -910,20 +859,24 @@
       await element.updateComplete;
     });
 
-    test('changing topic', () => {
+    test('changing topic', async () => {
       const newTopic = 'the new topic' as TopicName;
       const setChangeTopicStub = stubRestApi('setChangeTopic').returns(
         Promise.resolve(newTopic)
       );
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+
       element.handleTopicChanged(new CustomEvent('test', {detail: newTopic}));
-      const topicChangedSpy = sinon.spy();
-      element.addEventListener('topic-changed', topicChangedSpy);
+
       assert.isTrue(
         setChangeTopicStub.calledWith(42 as NumericChangeId, newTopic)
       );
-      return setChangeTopicStub.lastCall.returnValue.then(() => {
-        assert.equal(element.change!.topic, newTopic);
-        assert.isTrue(topicChangedSpy.called);
+      await setChangeTopicStub.lastCall.returnValue;
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: 'Saving topic and reloading ...',
+        showDismiss: true,
       });
     });
 
@@ -932,19 +885,21 @@
       const setChangeTopicStub = stubRestApi('setChangeTopic').returns(
         Promise.resolve(newTopic)
       );
-      sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:the+new+topic');
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
       await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
-      const remove = queryAndAssert(chip, '#remove');
-      const topicChangedSpy = sinon.spy();
-      element.addEventListener('topic-changed', topicChangedSpy);
-      tap(remove);
+      const remove = queryAndAssert<GrButton>(chip, '#remove');
+
+      remove.click();
+
       assert.isTrue(chip?.disabled);
       assert.isTrue(setChangeTopicStub.calledWith(42 as NumericChangeId));
-      return setChangeTopicStub.lastCall.returnValue.then(() => {
-        assert.isFalse(chip?.disabled);
-        assert.equal(element.change!.topic, '' as TopicName);
-        assert.isTrue(topicChangedSpy.called);
+      await setChangeTopicStub.lastCall.returnValue;
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: 'Removing topic and reloading ...',
+        showDismiss: true,
       });
     });
 
@@ -954,6 +909,8 @@
       const setChangeHashtagStub = stubRestApi('setChangeHashtag').returns(
         Promise.resolve(newHashtag)
       );
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
       element.handleHashtagChanged(
         new CustomEvent('test', {detail: 'new hashtag'})
       );
@@ -962,8 +919,11 @@
           add: ['new hashtag' as Hashtag],
         })
       );
-      return setChangeHashtagStub.lastCall.returnValue.then(() => {
-        assert.equal(element.change!.hashtags, newHashtag);
+      await setChangeHashtagStub.lastCall.returnValue;
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: 'Saving hashtag and reloading ...',
+        showDismiss: true,
       });
     });
   });
@@ -990,7 +950,7 @@
   suite('plugin endpoints', () => {
     setup(async () => {
       resetPlugins();
-      element = basicFixture.instantiate();
+      element = await fixture(html`<gr-change-metadata></gr-change-metadata>`);
       element.change = createParsedChange();
       element.revision = createRevision();
       await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
deleted file mode 100644
index 821e1ce..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles';
-import '../../../styles/gr-font-styles';
-import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-label-info/gr-label-info';
-import '../../shared/gr-limited-text/gr-limited-text';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-requirements_html';
-import {customElement, property, observe} from '@polymer/decorators';
-import {
-  ChangeInfo,
-  AccountInfo,
-  QuickLabelInfo,
-  Requirement,
-  RequirementType,
-  LabelNameToInfoMap,
-  LabelInfo,
-} from '../../../types/common';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {getAppContext} from '../../../services/app-context';
-import {labelCompare} from '../../../utils/label-util';
-import {Interaction} from '../../../constants/reporting';
-
-interface ChangeRequirement extends Requirement {
-  satisfied: boolean;
-  style: string;
-}
-
-interface ChangeWIP {
-  type: RequirementType;
-  fallback_text: string;
-  tooltip: string;
-}
-
-export interface Label {
-  labelName: string;
-  labelInfo: LabelInfo;
-  icon: string;
-  style: string;
-}
-
-@customElement('gr-change-requirements')
-export class GrChangeRequirements extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
-  change?: ChangeInfo;
-
-  @property({type: Object})
-  account?: AccountInfo;
-
-  @property({type: Boolean})
-  mutable?: boolean;
-
-  @property({type: Array, computed: '_computeRequirements(change)'})
-  _requirements?: Array<ChangeRequirement | ChangeWIP>;
-
-  @property({type: Array})
-  _requiredLabels: Label[] = [];
-
-  @property({type: Array})
-  _optionalLabels: Label[] = [];
-
-  @property({type: Boolean, computed: '_computeShowWip(change)'})
-  _showWip?: boolean;
-
-  @property({type: Boolean})
-  _showOptionalLabels = true;
-
-  private readonly reporting = getAppContext().reportingService;
-
-  _computeShowWip(change: ChangeInfo) {
-    return change.work_in_progress;
-  }
-
-  _computeRequirements(change: ChangeInfo) {
-    const _requirements: Array<ChangeRequirement | ChangeWIP> = [];
-
-    if (change.requirements) {
-      for (const requirement of change.requirements) {
-        const satisfied = requirement.status === 'OK';
-        const style = this._computeRequirementClass(satisfied);
-        _requirements.push({...requirement, satisfied, style});
-      }
-    }
-    if (change.work_in_progress) {
-      _requirements.push({
-        type: 'wip' as RequirementType,
-        fallback_text: 'Work-in-progress',
-        tooltip: "Change must not be in 'Work in Progress' state.",
-      });
-    }
-
-    return _requirements;
-  }
-
-  _computeRequirementClass(requirementStatus: boolean) {
-    return requirementStatus ? 'approved' : '';
-  }
-
-  _computeRequirementIcon(requirementStatus: boolean) {
-    return requirementStatus ? 'gr-icons:check' : 'gr-icons:schedule';
-  }
-
-  @observe('change.labels.*')
-  _computeLabels(
-    labelsRecord: PolymerDeepPropertyChange<
-      LabelNameToInfoMap,
-      LabelNameToInfoMap
-    >
-  ) {
-    const labels = labelsRecord.base || {};
-    const allLabels: Label[] = [];
-
-    for (const label of Object.keys(labels).sort(labelCompare)) {
-      allLabels.push({
-        labelName: label,
-        icon: this._computeLabelIcon(labels[label]),
-        style: this._computeLabelClass(labels[label]),
-        labelInfo: labels[label],
-      });
-    }
-    this._optionalLabels = allLabels.filter(label => label.labelInfo.optional);
-    this._requiredLabels = allLabels.filter(label => !label.labelInfo.optional);
-  }
-
-  /**
-   * @return The icon name, or undefined if no icon should
-   * be used.
-   */
-  _computeLabelIcon(labelInfo: QuickLabelInfo) {
-    if (labelInfo.approved) {
-      return 'gr-icons:check';
-    }
-    if (labelInfo.rejected) {
-      return 'gr-icons:close';
-    }
-    return 'gr-icons:schedule';
-  }
-
-  _computeLabelClass(labelInfo: QuickLabelInfo) {
-    if (labelInfo.approved) {
-      return 'approved';
-    }
-    if (labelInfo.rejected) {
-      return 'rejected';
-    }
-    return '';
-  }
-
-  _computeShowOptional(
-    optionalFieldsRecord: PolymerDeepPropertyChange<Label[], Label[]>
-  ) {
-    return optionalFieldsRecord.base.length ? '' : 'hidden';
-  }
-
-  _computeLabelValue(value: number) {
-    return `${value > 0 ? '+' : ''}${value}`;
-  }
-
-  _computeSectionClass(show: boolean) {
-    return show ? '' : 'hidden';
-  }
-
-  _handleShowHide() {
-    this._showOptionalLabels = !this._showOptionalLabels;
-    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
-      sectionName: 'optional labels',
-      toState: this._showOptionalLabels ? 'Show all' : 'Show less',
-    });
-  }
-
-  _computeSubmitRequirementEndpoint(item: ChangeRequirement | ChangeWIP) {
-    return `submit-requirement-item-${item.type}`;
-  }
-
-  _computeShowAllLabelText(_showOptionalLabels: boolean) {
-    if (_showOptionalLabels) {
-      return 'Show less';
-    } else {
-      return 'Show all';
-    }
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-change-requirements': GrChangeRequirements;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
deleted file mode 100644
index 8161592..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: table;
-      width: 100%;
-    }
-    .status {
-      color: var(--warning-foreground);
-      display: inline-block;
-      text-align: center;
-      vertical-align: top;
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-    }
-    .approved.status {
-      color: var(--positive-green-text-color);
-    }
-    .rejected.status {
-      color: var(--negative-red-text-color);
-    }
-    iron-icon {
-      color: inherit;
-    }
-    .status iron-icon {
-      vertical-align: top;
-    }
-    gr-endpoint-decorator.submit-requirement-endpoints,
-    section {
-      display: table-row;
-    }
-    .show-hide {
-      float: right;
-    }
-    .title {
-      min-width: 10em;
-      padding: var(--spacing-s) var(--spacing-m) 0
-        var(--requirements-horizontal-padding);
-    }
-    .value {
-      padding: var(--spacing-s) 0 0 0;
-    }
-    .title,
-    .value {
-      display: table-cell;
-      vertical-align: top;
-    }
-    .hidden {
-      display: none;
-    }
-    .showHide {
-      cursor: pointer;
-    }
-    .showHide .title {
-      padding-bottom: var(--spacing-m);
-      padding-top: var(--spacing-l);
-    }
-    .showHide .value {
-      padding-top: 0;
-      vertical-align: middle;
-    }
-    .showHide iron-icon {
-      color: var(--deemphasized-text-color);
-      float: right;
-    }
-    .show-all-button {
-      float: right;
-    }
-    .show-all-button iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-    .spacer {
-      height: var(--spacing-m);
-    }
-    gr-endpoint-param {
-      display: none;
-    }
-    .metadata-title {
-      font-weight: var(--font-weight-bold);
-      color: var(--deemphasized-text-color);
-      padding-left: var(--metadata-horizontal-padding);
-    }
-    .title .metadata-title {
-      padding-left: 0;
-    }
-  </style>
-  <h3 class="metadata-title heading-3">Submit requirements</h3>
-  <template is="dom-repeat" items="[[_requirements]]">
-    <gr-endpoint-decorator
-      class="submit-requirement-endpoints"
-      name$="[[_computeSubmitRequirementEndpoint(item)]]"
-    >
-      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      <gr-endpoint-param name="requirement" value="[[item]]">
-      </gr-endpoint-param>
-      <div class="title requirement">
-        <span class$="status [[item.style]]">
-          <iron-icon
-            class="icon"
-            icon="[[_computeRequirementIcon(item.satisfied)]]"
-          ></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="25"
-          tooltip="[[item.tooltip]]"
-          text="[[item.fallback_text]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-endpoint-slot name="value"></gr-endpoint-slot>
-      </div>
-    </gr-endpoint-decorator>
-  </template>
-  <template is="dom-repeat" items="[[_requiredLabels]]">
-    <section>
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="25"
-          text="[[item.labelName]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.labelName]]"
-          label-info="[[item.labelInfo]]"
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section class="spacer"></section>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"
-  ></section>
-  <section class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
-    <div class="title">
-      <h3 class="metadata-title">Other labels</h3>
-    </div>
-    <div class="value">
-      <gr-button link="" class="show-all-button" on-click="_handleShowHide"
-        >[[_computeShowAllLabelText(_showOptionalLabels)]]
-        <iron-icon
-          icon="gr-icons:expand-more"
-          hidden$="[[_showOptionalLabels]]"
-        ></iron-icon
-        ><iron-icon
-          icon="gr-icons:expand-less"
-          hidden$="[[!_showOptionalLabels]]"
-        ></iron-icon>
-      </gr-button>
-    </div>
-  </section>
-  <template is="dom-repeat" items="[[_optionalLabels]]">
-    <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <template is="dom-if" if="[[item.icon]]">
-            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-          </template>
-          <template is="dom-if" if="[[!item.icon]]">
-            <span>[[_computeLabelValue(item.labelInfo.value)]]</span>
-          </template>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="25"
-          text="[[item.labelName]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.labelName]]"
-          label-info="[[item.labelInfo]]"
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"
-  ></section>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
deleted file mode 100644
index 90f9d29..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
+++ /dev/null
@@ -1,222 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-change-requirements.js';
-import {isHidden} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-change-requirements');
-
-suite('gr-change-metadata tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('requirements computed fields', () => {
-    assert.isTrue(element._computeShowWip({work_in_progress: true}));
-    assert.isFalse(element._computeShowWip({work_in_progress: false}));
-
-    assert.equal(element._computeRequirementClass(true), 'approved');
-    assert.equal(element._computeRequirementClass(false), '');
-
-    assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
-    assert.equal(element._computeRequirementIcon(false),
-        'gr-icons:schedule');
-  });
-
-  test('label computed fields', () => {
-    assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
-    assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
-    assert.equal(element._computeLabelIcon({}), 'gr-icons:schedule');
-
-    assert.equal(element._computeLabelClass({approved: []}), 'approved');
-    assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
-    assert.equal(element._computeLabelClass({}), '');
-    assert.equal(element._computeLabelClass({value: 0}), '');
-
-    assert.equal(element._computeLabelValue(1), '+1');
-    assert.equal(element._computeLabelValue(-1), '-1');
-    assert.equal(element._computeLabelValue(0), '0');
-  });
-
-  test('_computeLabels', () => {
-    assert.equal(element._optionalLabels.length, 0);
-    assert.equal(element._requiredLabels.length, 0);
-    element._computeLabels({base: {
-      test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        value: 1,
-      },
-      opt_test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        optional: true,
-      },
-    }});
-    assert.equal(element._optionalLabels.length, 1);
-    assert.equal(element._requiredLabels.length, 1);
-
-    assert.equal(element._optionalLabels[0].labelName, 'opt_test');
-    assert.equal(element._optionalLabels[0].icon, 'gr-icons:schedule');
-    assert.equal(element._optionalLabels[0].style, '');
-    assert.ok(element._optionalLabels[0].labelInfo);
-  });
-
-  test('optional show/hide', () => {
-    element._optionalLabels = [{label: 'test'}];
-    flush();
-
-    assert.ok(element.shadowRoot
-        .querySelector('section.optional'));
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.show-all-button'));
-    flush();
-
-    assert.isFalse(element._showOptionalLabels);
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('section.optional')));
-  });
-
-  test('properly converts satisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: [],
-        },
-      },
-      requirements: [],
-    };
-    flush();
-
-    assert.ok(element.shadowRoot
-        .querySelector('.approved'));
-    assert.ok(element.shadowRoot
-        .querySelector('.name'));
-    assert.equal(element.shadowRoot
-        .querySelector('.name').text, 'Verified');
-  });
-
-  test('properly converts unsatisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: false,
-        },
-      },
-    };
-    flush();
-
-    const name = element.shadowRoot
-        .querySelector('.name');
-    assert.ok(name);
-    assert.isFalse(name.hasAttribute('hidden'));
-    assert.equal(name.text, 'Verified');
-  });
-
-  test('properly displays Work In Progress', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [],
-      work_in_progress: true,
-    };
-    flush();
-
-    const changeIsWip = element.shadowRoot
-        .querySelector('.title');
-    assert.ok(changeIsWip);
-  });
-
-  test('properly displays a satisfied requirement', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.isFalse(requirement.hasAttribute('hidden'));
-    assert.ok(requirement.querySelector('.approved'));
-    assert.equal(requirement.querySelector('.name').text,
-        'Resolve all comments');
-  });
-
-  test('satisfied class is applied with OK', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.ok(requirement.querySelector('.approved'));
-  });
-
-  test('satisfied class is not applied with NOT_READY', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'NOT_READY',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-
-  test('satisfied class is not applied with RULE_ERROR', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'RULE_ERROR',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index f5893ac..73653bb 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
@@ -1,21 +1,15 @@
 /**
  * @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 {LitElement, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import './gr-checks-chip';
+import './gr-summary-chip';
+import '../../shared/gr-avatar/gr-avatar-stack';
+import '../../shared/gr-icon/gr-icon';
+import '../../checks/gr-checks-action';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {getAppContext} from '../../../services/app-context';
@@ -25,25 +19,20 @@
   ErrorMessages,
 } 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 {fireShowTab} from '../../../utils/event-util';
 import {
   compareByWorstCategory,
   getResultsOf,
   hasCompletedWithoutResults,
   hasResults,
   hasResultsOf,
-  iconFor,
   isRunningOrScheduled,
   isRunningScheduledOrCompleted,
-  isStatus,
-  labelFor,
 } from '../../../models/checks/checks-util';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
   CommentThread,
   getFirstComment,
+  getMentionedThreads,
   hasHumanReply,
   isResolved,
   isRobotThread,
@@ -52,13 +41,11 @@
 import {pluralize} from '../../../utils/string-util';
 import {AccountInfo} from '../../../types/common';
 import {notUndefined} from '../../../types/types';
-import {uniqueDefinedAvatar} from '../../../utils/account-util';
-import {PrimaryTab} from '../../../constants/constants';
+import {Tab} from '../../../constants/constants';
 import {ChecksTabState, CommentTabState} from '../../../types/events';
 import {spinnerStyles} from '../../../styles/gr-spinner-styles';
 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';
@@ -66,320 +53,19 @@
 import {Interaction} from '../../../constants/reporting';
 import {roleDetails} from '../../../utils/change-util';
 
-export enum SummaryChipStyles {
-  INFO = 'info',
-  WARNING = 'warning',
-  CHECK = 'check',
-  UNDEFINED = '',
-}
+import {SummaryChipStyles} from './gr-summary-chip';
+import {when} from 'lit/directives/when.js';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {combineLatest} from 'rxjs';
 
 function handleSpaceOrEnter(e: KeyboardEvent, handler: () => void) {
   if (modifierPressed(e)) return;
-  // Only react to `return` and `space`.
-  if (e.keyCode !== 13 && e.keyCode !== 32) return;
+  if (e.key !== 'Enter' && e.key !== ' ') return;
   e.preventDefault();
   e.stopPropagation();
   handler();
 }
 
-@customElement('gr-summary-chip')
-export class GrSummaryChip extends LitElement {
-  @property()
-  icon = '';
-
-  @property()
-  styleType = SummaryChipStyles.UNDEFINED;
-
-  @property()
-  category?: CommentTabState;
-
-  private readonly reporting = getAppContext().reportingService;
-
-  static override get styles() {
-    return [
-      sharedStyles,
-      fontStyles,
-      css`
-        .summaryChip {
-          color: var(--chip-color);
-          cursor: pointer;
-          display: inline-block;
-          padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
-            var(--spacing-s);
-          margin-right: var(--spacing-s);
-          border-radius: 12px;
-          border: 1px solid gray;
-          vertical-align: top;
-          /* centered position of 20px chips in 24px line-height inline flow */
-          vertical-align: top;
-          position: relative;
-          top: 2px;
-        }
-        iron-icon {
-          width: var(--line-height-small);
-          height: var(--line-height-small);
-          vertical-align: top;
-        }
-        .summaryChip.warning {
-          border-color: var(--warning-foreground);
-          background: var(--warning-background);
-        }
-        .summaryChip.warning:hover {
-          background: var(--warning-background-hover);
-          box-shadow: var(--elevation-level-1);
-        }
-        .summaryChip.warning:focus-within {
-          background: var(--warning-background-focus);
-        }
-        .summaryChip.warning iron-icon {
-          color: var(--warning-foreground);
-        }
-        .summaryChip.check {
-          border-color: var(--gray-foreground);
-          background: var(--gray-background);
-        }
-        .summaryChip.check:hover {
-          background: var(--gray-background-hover);
-          box-shadow: var(--elevation-level-1);
-        }
-        .summaryChip.check:focus-within {
-          background: var(--gray-background-focus);
-        }
-        .summaryChip.check iron-icon {
-          color: var(--gray-foreground);
-        }
-      `,
-    ];
-  }
-
-  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>`}
-      <slot></slot>
-    </button>`;
-  }
-
-  private handleClick(e: MouseEvent) {
-    e.stopPropagation();
-    e.preventDefault();
-    this.reporting.reportInteraction('comment chip click', {
-      category: this.category,
-    });
-    fireShowPrimaryTab(this, PrimaryTab.COMMENT_THREADS, true, {
-      commentTab: this.category,
-    });
-  }
-}
-
-@customElement('gr-checks-chip')
-export class GrChecksChip extends LitElement {
-  @property()
-  statusOrCategory?: Category | RunStatus;
-
-  @property()
-  text = '';
-
-  @property()
-  links: string[] = [];
-
-  private readonly reporting = getAppContext().reportingService;
-
-  static override get styles() {
-    return [
-      fontStyles,
-      sharedStyles,
-      css`
-        :host {
-          display: inline-block;
-          position: relative;
-          white-space: nowrap;
-        }
-        .checksChip {
-          color: var(--chip-color);
-          cursor: pointer;
-          display: inline-block;
-          margin-right: var(--spacing-s);
-          padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
-            var(--spacing-s);
-          border-radius: 12px;
-          border: 1px solid gray;
-          /* centered position of 20px chips in 24px line-height inline flow */
-          vertical-align: top;
-          position: relative;
-          top: 2px;
-        }
-        .checksChip.hoverFullLength {
-          position: absolute;
-          z-index: 1;
-          display: none;
-        }
-        .checksChip.hoverFullLength .text {
-          max-width: 500px;
-        }
-        :host(:hover) .checksChip.hoverFullLength {
-          display: inline-block;
-        }
-        .checksChip .text {
-          display: inline-block;
-          max-width: 120px;
-          white-space: nowrap;
-          overflow: hidden;
-          text-overflow: ellipsis;
-          vertical-align: top;
-        }
-        iron-icon {
-          width: var(--line-height-small);
-          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);
-          background: var(--error-background);
-        }
-        .checksChip.error:hover {
-          background: var(--error-background-hover);
-          box-shadow: var(--elevation-level-1);
-        }
-        .checksChip.error:focus-within {
-          background: var(--error-background-focus);
-        }
-        .checksChip.error iron-icon {
-          color: var(--error-foreground);
-        }
-        .checksChip.warning {
-          border-color: var(--warning-foreground);
-          background: var(--warning-background);
-        }
-        .checksChip.warning:hover {
-          background: var(--warning-background-hover);
-          box-shadow: var(--elevation-level-1);
-        }
-        .checksChip.warning:focus-within {
-          background: var(--warning-background-focus);
-        }
-        .checksChip.warning iron-icon {
-          color: var(--warning-foreground);
-        }
-        .checksChip.info-outline {
-          border-color: var(--info-foreground);
-          background: var(--info-background);
-        }
-        .checksChip.info-outline:hover {
-          background: var(--info-background-hover);
-          box-shadow: var(--elevation-level-1);
-        }
-        .checksChip.info-outline:focus-within {
-          background: var(--info-background-focus);
-        }
-        .checksChip.info-outline iron-icon {
-          color: var(--info-foreground);
-        }
-        .checksChip.check-circle-outline {
-          border-color: var(--success-foreground);
-          background: var(--success-background);
-        }
-        .checksChip.check-circle-outline:hover {
-          background: var(--success-background-hover);
-          box-shadow: var(--elevation-level-1);
-        }
-        .checksChip.check-circle-outline:focus-within {
-          background: var(--success-background-focus);
-        }
-        .checksChip.check-circle-outline iron-icon {
-          color: var(--success-foreground);
-        }
-        .checksChip.timelapse,
-        .checksChip.scheduled {
-          border-color: var(--gray-foreground);
-          background: var(--gray-background);
-        }
-        .checksChip.timelapse:hover,
-        .checksChip.scheduled:hover {
-          background: var(--gray-background-hover);
-          box-shadow: var(--elevation-level-1);
-        }
-        .checksChip.timelapse:focus-within,
-        .checksChip.scheduled:focus-within {
-          background: var(--gray-background-focus);
-        }
-        .checksChip.timelapse iron-icon,
-        .checksChip.scheduled iron-icon {
-          color: var(--gray-foreground);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    if (!this.text) return;
-    if (!this.statusOrCategory) return;
-    const icon = iconFor(this.statusOrCategory);
-    const label = labelFor(this.statusOrCategory);
-    const count = Number(this.text);
-    let ariaLabel = label;
-    if (!isNaN(count)) {
-      const type = isStatus(this.statusOrCategory) ? 'run' : 'result';
-      const plural = count > 1 ? 's' : '';
-      ariaLabel = `${this.text} ${label} ${type}${plural}`;
-    }
-    const chipClass = `checksChip font-small ${icon}`;
-    const chipClassFullLength = `${chipClass} hoverFullLength`;
-    const grIcon = `gr-icons:${icon}`;
-    // 15 is roughly the number of chars for the chip exceeding its 120px width.
-    return html`
-      ${this.text.length > 15
-        ? html` ${this.renderChip(chipClassFullLength, ariaLabel, grIcon)}`
-        : ''}
-      ${this.renderChip(chipClass, ariaLabel, grIcon)}
-    `;
-  }
-
-  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>
-        ${this.renderLinks()}
-        <div class="text">${this.text}</div>
-      </div>
-    `;
-  }
-
-  private renderLinks() {
-    return this.links.map(
-      link => html`
-        <a
-          href=${link}
-          target="_blank"
-          @click=${this.onLinkClick}
-          @keydown=${this.onLinkKeyDown}
-          aria-label="Link to check details"
-          ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
-        ></a>
-      `
-    );
-  }
-
-  private onLinkKeyDown(e: KeyboardEvent) {
-    // Prevents onChipKeyDown() from reacting to <a> link keyboard events.
-    e.stopPropagation();
-  }
-
-  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,
-    });
-  }
-}
-
 /** What is the maximum number of detailed checks chips? */
 const DETAILS_QUOTA: Map<RunStatus | Category, number> = new Map();
 DETAILS_QUOTA.set(Category.ERROR, 7);
@@ -389,10 +75,10 @@
 @customElement('gr-change-summary')
 export class GrChangeSummary extends LitElement {
   @state()
-  changeComments?: ChangeComments;
+  commentThreads?: CommentThread[];
 
   @state()
-  commentThreads?: CommentThread[];
+  mentionCount = 0;
 
   @state()
   selfAccount?: AccountInfo;
@@ -418,11 +104,16 @@
   @state()
   messages: string[] = [];
 
+  @state()
+  draftCount = 0;
+
   private readonly showAllChips = new Map<RunStatus | Category, boolean>();
 
-  private readonly getCommentsModel = resolve(this, commentsModelToken);
+  // private but used in tests
+  readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private readonly userModel = getAppContext().userModel;
+  // private but used in tests
+  readonly userModel = getAppContext().userModel;
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
@@ -430,54 +121,78 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
+  private readonly flagsService = getAppContext().flagsService;
+
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
+      () => this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
       x => (this.runs = x)
     );
     subscribe(
       this,
-      this.getChecksModel().aPluginHasRegistered$,
+      () => this.getChecksModel().aPluginHasRegistered$,
       x => (this.showChecksSummary = x)
     );
     subscribe(
       this,
-      this.getChecksModel().someProvidersAreLoadingFirstTime$,
+      () => this.getChecksModel().someProvidersAreLoadingFirstTime$,
       x => (this.someProvidersAreLoading = x)
     );
     subscribe(
       this,
-      this.getChecksModel().errorMessagesLatest$,
+      () => this.getChecksModel().errorMessagesLatest$,
       x => (this.errorMessages = x)
     );
     subscribe(
       this,
-      this.getChecksModel().loginCallbackLatest$,
+      () => this.getChecksModel().loginCallbackLatest$,
       x => (this.loginCallback = x)
     );
     subscribe(
       this,
-      this.getChecksModel().topLevelActionsLatest$,
+      () => this.getChecksModel().topLevelActionsLatest$,
       x => (this.actions = x)
     );
     subscribe(
       this,
-      this.getChecksModel().topLevelMessagesLatest$,
+      () => this.getChecksModel().topLevelMessagesLatest$,
       x => (this.messages = x)
     );
     subscribe(
       this,
-      this.getCommentsModel().changeComments$,
-      x => (this.changeComments = x)
+      () => this.getCommentsModel().draftsCount$,
+      x => (this.draftCount = x)
     );
     subscribe(
       this,
-      this.getCommentsModel().threads$,
+      () => this.getCommentsModel().threads$,
       x => (this.commentThreads = x)
     );
-    subscribe(this, this.userModel.account$, x => (this.selfAccount = x));
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.selfAccount = x)
+    );
+    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+      subscribe(
+        this,
+        () =>
+          combineLatest([
+            this.userModel.account$,
+            this.getCommentsModel().threads$,
+          ]),
+        ([selfAccount, threads]) => {
+          if (!selfAccount || !selfAccount.email) return;
+          const unresolvedThreadsMentioningSelf = getMentionedThreads(
+            threads,
+            selfAccount
+          ).filter(isUnresolved);
+          this.mentionCount = unresolvedThreadsMentioningSelf.length;
+        }
+      );
+    }
   }
 
   static override get styles() {
@@ -497,6 +212,7 @@
         .loading.zeroState {
           margin-right: var(--spacing-m);
         }
+        div.info,
         div.error,
         .login {
           display: flex;
@@ -505,20 +221,30 @@
           margin: var(--spacing-xs) 0;
           width: 490px;
         }
+        div.info {
+          background-color: var(--info-background);
+        }
         div.error {
           background-color: var(--error-background);
         }
-        div.error iron-icon {
-          color: var(--error-foreground);
-          width: 16px;
-          height: 16px;
+        div.info gr-icon,
+        div.error gr-icon {
+          font-size: 16px;
           position: relative;
           top: 4px;
           margin-right: var(--spacing-s);
         }
+        div.info gr-icon {
+          color: var(--info-foreground);
+        }
+        div.error gr-icon {
+          color: var(--error-foreground);
+        }
+        div.info .right,
         div.error .right {
           overflow: hidden;
         }
+        div.info .right .message,
         div.error .right .message {
           overflow: hidden;
           text-overflow: ellipsis;
@@ -528,7 +254,7 @@
           justify-content: space-between;
           background: var(--info-background);
         }
-        .login iron-icon {
+        .login gr-icon {
           color: var(--info-foreground);
         }
         .login gr-button {
@@ -544,17 +270,13 @@
           padding-bottom: var(--spacing-s);
           line-height: calc(var(--line-height-normal) + var(--spacing-s));
         }
-        iron-icon.launch {
-          color: var(--gray-foreground);
-          width: var(--line-height-small);
-          height: var(--line-height-small);
-          vertical-align: top;
+        gr-avatar-stack {
+          --avatar-size: var(--line-height-small, 16px);
+          --stack-border-color: var(--warning-background);
         }
-        gr-avatar {
-          height: var(--line-height-small, 16px);
-          width: var(--line-height-small, 16px);
-          vertical-align: top;
-          margin-right: var(--spacing-xs);
+        .unresolvedIcon {
+          font-size: var(--line-height-small);
+          color: var(--warning-foreground);
         }
         /* The basics of .loadingSpin are defined in shared styles. */
         .loadingSpin {
@@ -586,10 +308,6 @@
     ];
   }
 
-  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);
@@ -638,20 +356,34 @@
         .items=${items}
         .disabledIds=${disabledIds}
       >
-        <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
-        </iron-icon>
+        <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
         <span id="moreMessage">More</span>
       </gr-dropdown>
     `;
   }
 
+  private renderSummaryMessage() {
+    return this.messages.map(
+      m => html`
+        <div class="info">
+          <div class="left">
+            <gr-icon icon="info" filled></gr-icon>
+          </div>
+          <div class="right">
+            <div class="message" title=${m}>${m}</div>
+          </div>
+        </div>
+      `
+    );
+  }
+
   renderErrorMessages() {
     return Object.entries(this.errorMessages).map(
       ([plugin, message]) =>
         html`
           <div class="error zeroState">
             <div class="left">
-              <iron-icon icon="gr-icons:error"></iron-icon>
+              <gr-icon icon="error" filled></gr-icon>
             </div>
             <div class="right">
               <div class="message" title=${message}>
@@ -668,10 +400,7 @@
     return html`
       <div class="login">
         <div class="left">
-          <iron-icon
-            class="info-outline"
-            icon="gr-icons:info-outline"
-          ></iron-icon>
+          <gr-icon icon="info"></gr-icon>
           Not logged in
         </div>
         <div class="right">
@@ -796,7 +525,7 @@
       checkName: state.checkName,
       ...roleDetails(this.getChangeModel().getChange(), this.selfAccount),
     });
-    fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
+    fireShowTab(this, Tab.CHECKS, false, {
       checksTab: state,
     });
   }
@@ -809,101 +538,144 @@
     const unresolvedThreads = commentThreads.filter(isUnresolved);
     const countUnresolvedComments = unresolvedThreads.length;
     const unresolvedAuthors = this.getAccounts(unresolvedThreads);
-    const draftCount = this.changeComments?.computeDraftCount() ?? 0;
-    const hasNonRunningChip = this.runs.some(
-      run => hasCompletedWithoutResults(run) || hasResults(run)
-    );
-    const hasRunningChip = this.runs.some(isRunningOrScheduled);
     return html`
       <div>
         <table>
-          <tr ?hidden=${!this.showChecksSummary}>
-            <td class="key">Checks</td>
-            <td class="value">
-              <div class="checksSummary">
-                ${this.renderChecksZeroState()}${this.renderChecksChipForCategory(
-                  Category.ERROR
-                )}${this.renderChecksChipForCategory(
-                  Category.WARNING
-                )}${this.renderChecksChipForCategory(
-                  Category.INFO
-                )}${this.renderChecksChipForCategory(
-                  Category.SUCCESS
-                )}${hasNonRunningChip && hasRunningChip
-                  ? html`<br />`
-                  : ''}${this.renderChecksChipRunning()}
-                <span
-                  class="loadingSpin"
-                  ?hidden=${!this.someProvidersAreLoading}
-                ></span>
-                ${this.renderErrorMessages()} ${this.renderChecksLogin()}
-                ${this.renderSummaryMessage()} ${this.renderActions()}
-              </div>
-            </td>
-          </tr>
           <tr>
             <td class="key">Comments</td>
             <td class="value">
-              <span
-                class="zeroState"
-                ?hidden=${!!countResolvedComments ||
-                !!draftCount ||
-                !!countUnresolvedComments}
-              >
-                No comments</span
-              ><gr-summary-chip
-                styleType=${SummaryChipStyles.WARNING}
-                category=${CommentTabState.DRAFTS}
-                icon="edit"
-                ?hidden=${!draftCount}
-              >
-                ${pluralize(draftCount, 'draft')}</gr-summary-chip
-              ><gr-summary-chip
-                styleType=${SummaryChipStyles.WARNING}
-                category=${CommentTabState.UNRESOLVED}
-                ?hidden=${!countUnresolvedComments}
-              >
-                ${unresolvedAuthors.map(
-                  account =>
-                    html`<gr-avatar
-                      .account=${account}
-                      imageSize="32"
-                    ></gr-avatar>`
-                )}
-                ${countUnresolvedComments} unresolved</gr-summary-chip
-              ><gr-summary-chip
-                styleType=${SummaryChipStyles.CHECK}
-                category=${CommentTabState.SHOW_ALL}
-                icon="markChatRead"
-                ?hidden=${!countResolvedComments}
-                >${countResolvedComments} resolved</gr-summary-chip
-              >
+              ${this.renderZeroState(
+                countResolvedComments,
+                countUnresolvedComments
+              )}
+              ${this.renderDraftChip()} ${this.renderMentionChip()}
+              ${this.renderUnresolvedCommentsChip(
+                countUnresolvedComments,
+                unresolvedAuthors
+              )}
+              ${this.renderResolvedCommentsChip(countResolvedComments)}
             </td>
           </tr>
-          <tr hidden>
-            <td class="key">Findings</td>
-            <td class="value"></td>
-          </tr>
+          ${this.renderChecksSummary()}
         </table>
       </div>
     `;
   }
 
+  private renderZeroState(
+    countResolvedComments: number,
+    countUnresolvedComments: number
+  ) {
+    if (
+      !!countResolvedComments ||
+      !!this.draftCount ||
+      !!countUnresolvedComments
+    )
+      return nothing;
+    return html`<span class="zeroState"> No comments</span>`;
+  }
+
+  private renderMentionChip() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
+      return nothing;
+    if (!this.mentionCount) return nothing;
+    return html` <gr-summary-chip
+      class="mentionSummary"
+      styleType=${SummaryChipStyles.WARNING}
+      category=${CommentTabState.MENTIONS}
+      icon="alternate_email"
+    >
+      ${pluralize(this.mentionCount, 'mention')}</gr-summary-chip
+    >`;
+  }
+
+  private renderDraftChip() {
+    if (!this.draftCount) return nothing;
+    return html` <gr-summary-chip
+      styleType=${SummaryChipStyles.INFO}
+      category=${CommentTabState.DRAFTS}
+      icon="rate_review"
+      iconFilled
+    >
+      ${pluralize(this.draftCount, 'draft')}</gr-summary-chip
+    >`;
+  }
+
+  private renderUnresolvedCommentsChip(
+    countUnresolvedComments: number,
+    unresolvedAuthors: AccountInfo[]
+  ) {
+    if (!countUnresolvedComments) return nothing;
+    return html` <gr-summary-chip
+      styleType=${SummaryChipStyles.WARNING}
+      category=${CommentTabState.UNRESOLVED}
+      ?hidden=${!countUnresolvedComments}
+    >
+      <gr-avatar-stack .accounts=${unresolvedAuthors} imageSize="32">
+        <gr-icon
+          slot="fallback"
+          icon="chat_bubble"
+          filled
+          class="unresolvedIcon"
+        >
+        </gr-icon>
+      </gr-avatar-stack>
+      ${countUnresolvedComments} unresolved</gr-summary-chip
+    >`;
+  }
+
+  private renderResolvedCommentsChip(countResolvedComments: number) {
+    if (!countResolvedComments) return nothing;
+    return html` <gr-summary-chip
+      styleType=${SummaryChipStyles.CHECK}
+      category=${CommentTabState.SHOW_ALL}
+      icon="mark_chat_read"
+      >${countResolvedComments} resolved</gr-summary-chip
+    >`;
+  }
+
+  private renderChecksSummary() {
+    const hasNonRunningChip = this.runs.some(
+      run => hasCompletedWithoutResults(run) || hasResults(run)
+    );
+    const hasRunningChip = this.runs.some(isRunningOrScheduled);
+    if (!this.showChecksSummary) return nothing;
+    return html` <tr>
+      <td class="key">Checks</td>
+      <td class="value">
+        <div class="checksSummary">
+          ${this.renderChecksZeroState()}${this.renderChecksChipForCategory(
+            Category.ERROR
+          )}${this.renderChecksChipForCategory(
+            Category.WARNING
+          )}${this.renderChecksChipForCategory(
+            Category.INFO
+          )}${this.renderChecksChipForCategory(
+            Category.SUCCESS
+          )}${hasNonRunningChip && hasRunningChip
+            ? html`<br />`
+            : ''}${this.renderChecksChipRunning()}
+          ${when(
+            this.someProvidersAreLoading,
+            () => html`<span class="loadingSpin"></span>`
+          )}
+          ${this.renderErrorMessages()} ${this.renderChecksLogin()}
+          ${this.renderSummaryMessage()} ${this.renderActions()}
+        </div>
+      </td>
+    </tr>`;
+  }
+
   getAccounts(commentThreads: CommentThread[]): AccountInfo[] {
-    const uniqueAuthors = commentThreads
+    return commentThreads
       .map(getFirstComment)
       .map(comment => comment?.author ?? this.selfAccount)
-      .filter(notUndefined)
-      .filter(account => !!account?.avatars?.[0]?.url)
-      .filter(uniqueDefinedAvatar);
-    return uniqueAuthors.slice(0, 3);
+      .filter(notUndefined);
   }
 }
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-change-summary': GrChangeSummary;
-    'gr-checks-chip': GrChecksChip;
-    'gr-summary-chip': GrSummaryChip;
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
index cd297c7..9584637 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
@@ -1,25 +1,164 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrChangeSummary} from './gr-change-summary';
+import {queryAndAssert} from '../../../utils/common-util';
+import {fakeRun0} from '../../../models/checks/checks-fakes';
+import {
+  createAccountWithEmail,
+  createComment,
+  createCommentThread,
+  createDraft,
+} from '../../../test/test-data-generators';
+import {stubFlags} from '../../../test/test-utils';
+import {Timestamp} from '../../../api/rest-api';
 
 suite('gr-change-summary test', () => {
+  let element: GrChangeSummary;
+  setup(async () => {
+    element = await fixture(html`<gr-change-summary></gr-change-summary>`);
+  });
+
   test('is defined', () => {
     const el = document.createElement('gr-change-summary');
     assert.instanceOf(el, GrChangeSummary);
   });
+
+  test('renders', async () => {
+    element.getCommentsModel().setState({
+      drafts: {
+        a: [createDraft(), createDraft(), createDraft()],
+      },
+      discardedDrafts: [],
+    });
+    element.commentThreads = [
+      createCommentThread([createComment()]),
+      createCommentThread([{...createComment(), unresolved: true}]),
+    ];
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div>
+        <table>
+          <tbody>
+            <tr>
+              <td class="key">Comments</td>
+              <td class="value">
+                <gr-summary-chip
+                  category="drafts"
+                  icon="rate_review"
+                  iconFilled
+                  styletype="info"
+                >
+                  3 drafts
+                </gr-summary-chip>
+                <gr-summary-chip category="unresolved" styletype="warning">
+                  <gr-avatar-stack imageSize="32">
+                    <gr-icon
+                      class="unresolvedIcon"
+                      filled
+                      icon="chat_bubble"
+                      slot="fallback"
+                    ></gr-icon>
+                  </gr-avatar-stack>
+                  1 unresolved
+                </gr-summary-chip>
+                <gr-summary-chip
+                  category="show all"
+                  icon="mark_chat_read"
+                  styletype="check"
+                >
+                  1 resolved
+                </gr-summary-chip>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div> `
+    );
+  });
+
+  test('renders checks summary message', async () => {
+    element.runs = [fakeRun0];
+    element.messages = ['a message'];
+    element.showChecksSummary = true;
+    await element.updateComplete;
+    const checksSummary = queryAndAssert(element, '.checksSummary');
+    assert.dom.equal(
+      checksSummary,
+      /* HTML */ `
+        <div class="checksSummary">
+          <gr-checks-chip> </gr-checks-chip>
+          <div class="info">
+            <div class="left">
+              <gr-icon icon="info" filled></gr-icon>
+            </div>
+            <div class="right">
+              <div class="message" title="a message">a message</div>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('renders mentions summary', async () => {
+    stubFlags('isEnabled').returns(true);
+    // recreate element so that flag protected subscriptions are added
+    element = await fixture(html`<gr-change-summary></gr-change-summary>`);
+    await element.updateComplete;
+
+    element.getCommentsModel().setState({
+      drafts: {
+        a: [
+          {
+            ...createDraft(),
+            message: 'Hey @abc@def.com pleae take a look at this.',
+            unresolved: true,
+          },
+          // Resolved draft thread hence ignored
+          {...createDraft(), message: 'Hey @abc@def.com this is important.'},
+          createDraft(),
+        ],
+      },
+      comments: {
+        a: [
+          {
+            ...createComment(),
+            message: 'Hey @abc@def.com pleae take a look at this.',
+            unresolved: true,
+          },
+        ],
+        b: [
+          {...createComment(), message: 'Hey @abc@def.com this is important.'},
+        ],
+      },
+      discardedDrafts: [],
+    });
+    element.userModel.setAccount({
+      ...createAccountWithEmail('abc@def.com'),
+      registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+    });
+    await element.updateComplete;
+    const mentionSummary = queryAndAssert(element, '.mentionSummary');
+    // Only count occurrences in unresolved threads
+    // Resolved threads are ignored hence mention chip count is 2
+    assert.dom.equal(
+      mentionSummary,
+      /* HTML */ `
+        <gr-summary-chip
+          category="mentions"
+          class="mentionSummary"
+          icon="alternate_email"
+          styletype="warning"
+        >
+          2 mentions
+        </gr-summary-chip>
+      `
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
new file mode 100644
index 0000000..2ab8ac3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
@@ -0,0 +1,234 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {getAppContext} from '../../../services/app-context';
+import {Category, RunStatus} from '../../../api/checks';
+import {
+  ChecksIcon,
+  iconFor,
+  isStatus,
+  labelFor,
+} from '../../../models/checks/checks-util';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {Interaction} from '../../../constants/reporting';
+
+@customElement('gr-checks-chip')
+export class GrChecksChip extends LitElement {
+  @property()
+  statusOrCategory?: Category | RunStatus;
+
+  @property()
+  text = '';
+
+  @property({type: Array})
+  links: string[] = [];
+
+  private readonly reporting = getAppContext().reportingService;
+
+  static override get styles() {
+    return [
+      fontStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+          position: relative;
+          white-space: nowrap;
+        }
+        .checksChip {
+          color: var(--chip-color);
+          cursor: pointer;
+          display: inline-block;
+          margin-right: var(--spacing-s);
+          padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
+            var(--spacing-s);
+          border-radius: 12px;
+          border: 1px solid gray;
+          /* centered position of 20px chips in 24px line-height inline flow */
+          vertical-align: top;
+          position: relative;
+          top: 2px;
+        }
+        .checksChip.hoverFullLength {
+          position: absolute;
+          z-index: 1;
+          display: none;
+        }
+        .checksChip.hoverFullLength .text {
+          max-width: 500px;
+        }
+        :host(:hover) .checksChip.hoverFullLength {
+          display: inline-block;
+        }
+        .checksChip .text {
+          display: inline-block;
+          max-width: 120px;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          vertical-align: top;
+        }
+        gr-icon {
+          font-size: var(--line-height-small);
+        }
+        .checksChip a gr-icon.launch {
+          color: var(--link-color);
+        }
+        .checksChip.error {
+          color: var(--error-foreground);
+          border-color: var(--error-foreground);
+          background: var(--error-background);
+        }
+        .checksChip.error:hover {
+          background: var(--error-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.error:focus-within {
+          background: var(--error-background-focus);
+        }
+        .checksChip.error gr-icon {
+          color: var(--error-foreground);
+        }
+        .checksChip.warning {
+          border-color: var(--warning-foreground);
+          background: var(--warning-background);
+        }
+        .checksChip.warning:hover {
+          background: var(--warning-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.warning:focus-within {
+          background: var(--warning-background-focus);
+        }
+        .checksChip.warning gr-icon {
+          color: var(--warning-foreground);
+        }
+        .checksChip.info {
+          border-color: var(--info-foreground);
+          background: var(--info-background);
+        }
+        .checksChip.info:hover {
+          background: var(--info-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.info:focus-within {
+          background: var(--info-background-focus);
+        }
+        .checksChip.info gr-icon {
+          color: var(--info-foreground);
+        }
+        .checksChip.check_circle {
+          border-color: var(--success-foreground);
+          background: var(--success-background);
+        }
+        .checksChip.check_circle:hover {
+          background: var(--success-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.check_circle:focus-within {
+          background: var(--success-background-focus);
+        }
+        .checksChip.check_circle gr-icon {
+          color: var(--success-foreground);
+        }
+        .checksChip.timelapse,
+        .checksChip.scheduled {
+          border-color: var(--gray-foreground);
+          background: var(--gray-background);
+        }
+        .checksChip.timelapse:hover,
+        .checksChip.pending_actions:hover {
+          background: var(--gray-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.timelapse:focus-within,
+        .checksChip.pending_actions:focus-within {
+          background: var(--gray-background-focus);
+        }
+        .checksChip.timelapse gr-icon,
+        .checksChip.pending_actions gr-icon {
+          color: var(--gray-foreground);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.text) return;
+    if (!this.statusOrCategory) return;
+    const icon = iconFor(this.statusOrCategory);
+    const ariaLabel = this.computeAriaLabel();
+    const chipClass = `checksChip font-small ${icon.name}`;
+    const chipClassFullLength = `${chipClass} hoverFullLength`;
+    // 15 is roughly the number of chars for the chip exceeding its 120px width.
+    return html`
+      ${this.text.length > 15
+        ? html` ${this.renderChip(chipClassFullLength, ariaLabel, icon)}`
+        : ''}
+      ${this.renderChip(chipClass, ariaLabel, icon)}
+    `;
+  }
+
+  private computeAriaLabel() {
+    if (!this.statusOrCategory) return '';
+    const label = labelFor(this.statusOrCategory);
+    const type = isStatus(this.statusOrCategory) ? 'run' : 'result';
+    const count = Number(this.text);
+    const isCountChip = !isNaN(count);
+    if (isCountChip) {
+      const plural = count > 1 ? 's' : '';
+      return `${this.text} ${label} ${type}${plural}`;
+    }
+    return `${label} for check ${this.text}`;
+  }
+
+  private renderChip(clazz: string, ariaLabel: string, icon: ChecksIcon) {
+    return html`
+      <div class=${clazz} role="link" tabindex="0" aria-label=${ariaLabel}>
+        <gr-icon icon=${icon.name} ?filled=${icon.filled}></gr-icon>
+        ${this.renderLinks()}
+        <div class="text">${this.text}</div>
+      </div>
+    `;
+  }
+
+  private renderLinks() {
+    return this.links.map(
+      link => html`
+        <a
+          href=${link}
+          target="_blank"
+          @click=${this.onLinkClick}
+          @keydown=${this.onLinkKeyDown}
+          aria-label="Link to check details"
+          ><gr-icon icon="open_in_new" class="launch"></gr-icon
+        ></a>
+      `
+    );
+  }
+
+  private onLinkKeyDown(e: KeyboardEvent) {
+    // Prevents onChipKeyDown() from reacting to <a> link keyboard events.
+    e.stopPropagation();
+  }
+
+  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,
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-chip': GrChecksChip;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts
new file mode 100644
index 0000000..7cd019a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrChecksChip} from './gr-checks-chip';
+import {Category} from '../../../api/checks';
+
+suite('gr-checks-chip test', () => {
+  let element: GrChecksChip;
+  setup(async () => {
+    element = await fixture(html`<gr-checks-chip
+      .statusOrCategory=${Category.SUCCESS}
+      .text=${'0'}
+    ></gr-checks-chip>`);
+  });
+
+  test('is defined', () => {
+    const el = document.createElement('gr-checks-chip');
+    assert.instanceOf(el, GrChecksChip);
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div
+        aria-label="0 success result"
+        class="check_circle checksChip font-small"
+        role="link"
+        tabindex="0"
+      >
+        <gr-icon icon="check_circle"></gr-icon>
+        <div class="text">0</div>
+      </div>`
+    );
+  });
+
+  test('renders specific check', async () => {
+    element.text = 'Super Check';
+    element.statusOrCategory = Category.ERROR;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div
+          aria-label="error for check Super Check"
+          class="checksChip error font-small"
+          role="link"
+          tabindex="0"
+        >
+          <gr-icon icon="error" filled></gr-icon>
+          <div class="text">Super Check</div>
+        </div>
+      `
+    );
+  });
+
+  test('renders check with link', async () => {
+    element.text = 'LinkProducer';
+    element.statusOrCategory = Category.WARNING;
+    element.links = ['http://www.google.com'];
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div
+          aria-label="warning for check LinkProducer"
+          class="checksChip warning font-small"
+          role="link"
+          tabindex="0"
+        >
+          <gr-icon icon="warning" filled></gr-icon>
+          <a
+            aria-label="Link to check details"
+            href="http://www.google.com"
+            target="_blank"
+          >
+            <gr-icon class="launch" icon="open_in_new"> </gr-icon>
+          </a>
+          <div class="text">LinkProducer</div>
+        </div>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts
new file mode 100644
index 0000000..34423b7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts
@@ -0,0 +1,133 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../shared/gr-icon/gr-icon';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {getAppContext} from '../../../services/app-context';
+import {fireShowTab} from '../../../utils/event-util';
+import {Tab} from '../../../constants/constants';
+import {CommentTabState} from '../../../types/events';
+import {fontStyles} from '../../../styles/gr-font-styles';
+
+export enum SummaryChipStyles {
+  INFO = 'info',
+  WARNING = 'warning',
+  CHECK = 'check',
+  UNDEFINED = '',
+}
+
+@customElement('gr-summary-chip')
+export class GrSummaryChip extends LitElement {
+  @property()
+  icon = '';
+
+  @property({type: Boolean})
+  iconFilled = false;
+
+  @property()
+  styleType = SummaryChipStyles.UNDEFINED;
+
+  @property()
+  category?: CommentTabState;
+
+  private readonly reporting = getAppContext().reportingService;
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      css`
+        .summaryChip {
+          color: var(--chip-color);
+          cursor: pointer;
+          display: inline-block;
+          padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
+            var(--spacing-s);
+          margin-right: var(--spacing-s);
+          border-radius: 12px;
+          border: 1px solid gray;
+          vertical-align: top;
+          /* centered position of 20px chips in 24px line-height inline flow */
+          vertical-align: top;
+          position: relative;
+          top: 2px;
+        }
+        gr-icon {
+          font-size: var(--line-height-small);
+        }
+        .summaryChip.info {
+          border-color: var(--info-foreground);
+          background: var(--info-background);
+        }
+        .summaryChip.info:hover {
+          background: var(--info-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .summaryChip.info:focus-within {
+          background: var(--info-background-focus);
+        }
+        .summaryChip.info gr-icon {
+          color: var(--info-foreground);
+        }
+        .summaryChip.warning {
+          border-color: var(--warning-foreground);
+          background: var(--warning-background);
+        }
+        .summaryChip.warning:hover {
+          background: var(--warning-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .summaryChip.warning:focus-within {
+          background: var(--warning-background-focus);
+        }
+        .summaryChip.warning gr-icon {
+          color: var(--warning-foreground);
+        }
+        .summaryChip.check {
+          border-color: var(--gray-foreground);
+          background: var(--gray-background);
+        }
+        .summaryChip.check:hover {
+          background: var(--gray-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .summaryChip.check:focus-within {
+          background: var(--gray-background-focus);
+        }
+        .summaryChip.check gr-icon {
+          color: var(--gray-foreground);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const chipClass = `summaryChip font-small ${this.styleType}`;
+    return html`<button class=${chipClass} @click=${this.handleClick}>
+      ${this.icon &&
+      html`<gr-icon ?filled=${this.iconFilled} icon=${this.icon}></gr-icon>`}
+      <slot></slot>
+    </button>`;
+  }
+
+  private handleClick(e: MouseEvent) {
+    e.stopPropagation();
+    e.preventDefault();
+    this.reporting.reportInteraction('comment chip click', {
+      category: this.category,
+    });
+    fireShowTab(this, Tab.COMMENT_THREADS, true, {
+      commentTab: this.category,
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-summary-chip': GrSummaryChip;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts
new file mode 100644
index 0000000..9b25591
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrSummaryChip, SummaryChipStyles} from './gr-summary-chip';
+import {CommentTabState} from '../../../types/events';
+
+suite('gr-summary-chip test', () => {
+  let element: GrSummaryChip;
+  setup(async () => {
+    element = await fixture(html`<gr-summary-chip
+      styleType=${SummaryChipStyles.WARNING}
+      category=${CommentTabState.DRAFTS}
+    ></gr-summary-chip>`);
+  });
+  test('is defined', () => {
+    const el = document.createElement('gr-summary-chip');
+    assert.instanceOf(el, GrSummaryChip);
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<button class="font-small summaryChip warning">
+        <slot> </slot>
+      </button>`
+    );
+  });
+});
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 80451a2..c62e4af 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
@@ -1,20 +1,10 @@
 /**
  * @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 {BehaviorSubject, Subscription} from 'rxjs';
+import {BehaviorSubject} from 'rxjs';
+import '../gr-copy-links/gr-copy-links';
 import '@polymer/paper-tabs/paper-tabs';
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/gr-paper-styles';
@@ -25,16 +15,16 @@
 import '../../shared/gr-change-star/gr-change-star';
 import '../../shared/gr-change-status/gr-change-status';
 import '../../shared/gr-editable-content/gr-editable-content';
-import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-formatted-text/gr-formatted-text';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-change-actions/gr-change-actions';
 import '../gr-change-summary/gr-change-summary';
 import '../gr-change-metadata/gr-change-metadata';
-import '../../shared/gr-icons/gr-icons';
 import '../gr-commit-info/gr-commit-info';
 import '../gr-download-dialog/gr-download-dialog';
 import '../gr-file-list-header/gr-file-list-header';
+import '../gr-file-list/gr-file-list';
 import '../gr-included-in-dialog/gr-included-in-dialog';
 import '../gr-messages-list/gr-messages-list';
 import '../gr-related-changes-list/gr-related-changes-list';
@@ -44,32 +34,23 @@
 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 {htmlTemplate} from './gr-change-view_html';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-  ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
-import {querySelectorAll, windowLocationReload} from '../../../utils/dom-util';
 import {
-  GeneratedWebLink,
-  GerritNav,
-} from '../../core/gr-navigation/gr-navigation';
+  querySelectorAll,
+  whenVisible,
+  windowLocationReload,
+} from '../../../utils/dom-util';
+import {navigationToken} 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 {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {
   ChangeStatus,
   DefaultBase,
-  PrimaryTab,
-  SecondaryTab,
+  Tab,
   DiffViewMode,
 } from '../../../constants/constants';
-
-import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
 import {getAppContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
@@ -89,7 +70,7 @@
   roleDetails,
 } from '../../../utils/change-util';
 import {EventType as PluginEventType} from '../../../api/plugin';
-import {customElement, observe, property} from '@polymer/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
 import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
@@ -100,73 +81,51 @@
 import {
   AccountDetailInfo,
   ActionNameToActionInfoMap,
-  ApprovalInfo,
   BasePatchSetNum,
   ChangeId,
   ChangeInfo,
   CommitId,
   CommitInfo,
   ConfigInfo,
-  EditPatchSetNum,
+  DetailedLabelInfo,
+  EDIT,
   LabelNameToInfoMap,
   NumericChangeId,
-  ParentPatchSetNum,
+  PARENT,
   PatchRange,
   PatchSetNum,
+  PatchSetNumber,
   PreferencesInfo,
   QuickLabelInfo,
   RelatedChangeAndCommitInfo,
   RelatedChangesInfo,
   RevisionInfo,
+  RevisionPatchSetNum,
   ServerInfo,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {DiffPreferencesInfo} from '../../../types/diff';
 import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
 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 {
-  assertIsDefined,
-  hasOwnProperty,
-  query,
-} from '../../../utils/common-util';
+import {assertIsDefined, assert, queryAll} from '../../../utils/common-util';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {
   CommentThread,
-  isDraftThread,
   isRobot,
   isUnresolved,
   DraftInfo,
 } from '../../../utils/comment-util';
-import {
-  PolymerDeepPropertyChange,
-  PolymerSplice,
-  PolymerSpliceChange,
-} from '@polymer/polymer/interfaces';
-import {AppElementChangeViewParams} from '../../gr-app-types';
-import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {GrFileList} from '../gr-file-list/gr-file-list';
+import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
 import {
-  DEFAULT_NUM_FILES_SHOWN,
-  GrFileList,
-} from '../gr-file-list/gr-file-list';
-import {
-  ChangeViewState,
-  EditRevisionInfo,
-  isPolymerSpliceChange,
-  ParsedChangeInfo,
-} from '../../../types/types';
-import {
-  ChecksTabState,
   CloseFixPreviewEvent,
   EditableContentSaveEvent,
   EventType,
   OpenFixPreviewEvent,
   ShowAlertEventDetail,
   SwitchTabEvent,
-  SwitchTabEventDetail,
   TabState,
   ValueChangedEvent,
 } from '../../../types/events';
@@ -174,7 +133,6 @@
 import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 import {
-  fire,
   fireAlert,
   fireDialogChange,
   fireEvent,
@@ -188,7 +146,7 @@
   throttleWrap,
   until,
 } from '../../../utils/async-util';
-import {Interaction, Timing} from '../../../constants/reporting';
+import {Interaction, Timing, Execution} from '../../../constants/reporting';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {
@@ -196,12 +154,37 @@
   getRemovedByReason,
   hasAttention,
 } from '../../../utils/attention-set-util';
-import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {
+  Shortcut,
+  ShortcutSection,
+  shortcutsServiceToken,
+} 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 {resolve} from '../../../models/dependency';
 import {checksModelToken} from '../../../models/checks/checks-model';
 import {changeModelToken} from '../../../models/change/change-model';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {filesModelToken} from '../../../models/change/files-model';
+import {getBaseUrl, prependOrigin} from '../../../utils/url-util';
+import {CopyLink, GrCopyLinks} from '../gr-copy-links/gr-copy-links';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {
+  changeViewModelToken,
+  ChangeViewState,
+  createChangeUrl,
+} from '../../../models/views/change';
+import {rootUrl} from '../../../utils/url-util';
+import {createEditUrl} from '../../../models/views/edit';
 
 const CHANGE_ID_ERROR = {
   MISMATCH: 'mismatch',
@@ -234,39 +217,10 @@
 // Making the tab names more unique in case a plugin adds one with same name
 const ROBOT_COMMENTS_LIMIT = 10;
 
-export interface GrChangeView {
-  $: {
-    applyFixDialog: GrApplyFixDialog;
-    fileList: GrFileList & Element;
-    fileListHeader: GrFileListHeader;
-    commitMessageEditor: GrEditableContent;
-    includedInOverlay: GrOverlay;
-    includedInDialog: GrIncludedInDialog;
-    downloadOverlay: GrOverlay;
-    downloadDialog: GrDownloadDialog;
-    replyOverlay: GrOverlay;
-    mainContent: HTMLDivElement;
-    changeStar: GrChangeStar;
-    actions: GrChangeActions;
-    commitMessage: HTMLDivElement;
-    commitAndRelated: HTMLDivElement;
-    metadata: GrChangeMetadata;
-    mainChangeInfo: HTMLDivElement;
-    replyBtn: GrButton;
-  };
-}
-
 export type ChangeViewPatchRange = Partial<PatchRange>;
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(DIPolymerElement);
-
 @customElement('gr-change-view')
-export class GrChangeView extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrChangeView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
@@ -285,327 +239,309 @@
    * @event show-auth-required
    */
 
-  /**
-   * URL params passed from the router.
-   */
-  @property({type: Object, observer: '_paramsChanged'})
-  params?: AppElementChangeViewParams;
+  @query('#applyFixDialog') applyFixDialog?: GrApplyFixDialog;
 
-  @property({type: Object, observer: '_viewStateChanged'})
-  viewState: Partial<ChangeViewState> = {};
+  @query('#fileList') fileList?: GrFileList;
+
+  @query('#fileListHeader') fileListHeader?: GrFileListHeader;
+
+  @query('#commitMessageEditor') commitMessageEditor?: GrEditableContent;
+
+  @query('#includedInOverlay') includedInOverlay?: GrOverlay;
+
+  @query('#includedInDialog') includedInDialog?: GrIncludedInDialog;
+
+  @query('#downloadOverlay') downloadOverlay?: GrOverlay;
+
+  @query('#downloadDialog') downloadDialog?: GrDownloadDialog;
+
+  @query('#replyOverlay') replyOverlay?: GrOverlay;
+
+  @query('#replyDialog') replyDialog?: GrReplyDialog;
+
+  @query('#mainContent') mainContent?: HTMLDivElement;
+
+  @query('#changeStar') changeStar?: GrChangeStar;
+
+  @query('#actions') actions?: GrChangeActions;
+
+  @query('#commitMessage') commitMessage?: HTMLDivElement;
+
+  @query('#commitAndRelated') commitAndRelated?: HTMLDivElement;
+
+  @query('#metadata') metadata?: GrChangeMetadata;
+
+  @query('#mainChangeInfo') mainChangeInfo?: HTMLDivElement;
+
+  @query('#replyBtn') replyBtn?: GrButton;
+
+  @query('#tabs') tabs?: PaperTabsElement;
+
+  @query('gr-messages-list') messagesList?: GrMessagesList;
+
+  @query('gr-thread-list') threadList?: GrThreadList;
+
+  @query('gr-copy-links') private copyLinksDropdown?: GrCopyLinks;
+
+  private _viewState?: ChangeViewState;
+
+  @property({type: Object})
+  get viewState() {
+    return this._viewState;
+  }
+
+  set viewState(viewState: ChangeViewState | undefined) {
+    if (this._viewState === viewState) return;
+    const oldViewState = this._viewState;
+    this._viewState = viewState;
+    this.viewStateChanged();
+    this.requestUpdate('viewState', oldViewState);
+  }
 
   @property({type: String})
   backPage?: string;
 
-  @property({type: Boolean})
-  hasParent?: boolean;
+  @state()
+  private hasParent?: boolean;
 
-  @property({type: Boolean})
-  disableEdit = false;
+  // Private but used in tests.
+  @state()
+  commentThreads?: CommentThread[];
 
-  @property({type: Array})
-  _commentThreads?: CommentThread[];
+  // Don't use, use serverConfig instead.
+  private _serverConfig?: ServerInfo;
 
-  // TODO(taoalpha): Consider replacing diffDrafts
-  // with _draftCommentThreads everywhere, currently only
-  // replaced in reply-dialog
-  @property({type: Array})
-  _draftCommentThreads?: CommentThread[];
+  // Private but used in tests.
+  @state()
+  get serverConfig() {
+    return this._serverConfig;
+  }
 
-  @property({
-    type: Array,
-    computed:
-      '_computeRobotCommentThreads(_commentThreads,' +
-      ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
-  })
-  _robotCommentThreads?: CommentThread[];
+  set serverConfig(serverConfig: ServerInfo | undefined) {
+    if (this._serverConfig === serverConfig) return;
+    const oldServerConfig = this._serverConfig;
+    this._serverConfig = serverConfig;
+    this.startUpdateCheckTimer();
+    this.requestUpdate('serverConfig', oldServerConfig);
+  }
 
-  @property({type: Object, observer: '_startUpdateCheckTimer'})
-  _serverConfig?: ServerInfo;
+  @state()
+  private account?: AccountDetailInfo;
 
-  @property({type: Object})
-  _diffPrefs?: DiffPreferencesInfo;
+  // Private but used in tests.
+  @state()
+  prefs?: PreferencesInfo;
 
-  @property({type: Number, observer: '_numFilesShownChanged'})
-  _numFilesShown = DEFAULT_NUM_FILES_SHOWN;
+  canStartReview() {
+    return !!(
+      this.change &&
+      this.change.actions &&
+      this.change.actions.ready &&
+      this.change.actions.ready.enabled
+    );
+  }
 
-  @property({type: Object})
-  _account?: AccountDetailInfo;
+  // Use change getter/setter instead.
+  private _change?: ParsedChangeInfo;
 
-  @property({type: Object})
-  _prefs?: PreferencesInfo;
+  @state()
+  get change() {
+    return this._change;
+  }
 
-  @property({type: Object})
-  _changeComments?: ChangeComments;
+  set change(change: ParsedChangeInfo | undefined) {
+    if (this._change === change) return;
+    const oldChange = this._change;
+    this._change = change;
+    this.changeChanged(oldChange);
+    this.requestUpdate('change', oldChange);
+  }
 
-  @property({type: Boolean, computed: '_computeCanStartReview(_change)'})
-  _canStartReview?: boolean;
+  // Private but used in tests.
+  @state()
+  commitInfo?: CommitInfo;
 
-  @property({type: Object, observer: '_changeChanged'})
-  _change?: ParsedChangeInfo;
+  // Private but used in tests.
+  @state()
+  changeNum?: NumericChangeId;
 
-  @property({type: Object, computed: '_getRevisionInfo(_change)'})
-  _revisionInfo?: RevisionInfoClass;
+  // Private but used in tests.
+  @state()
+  diffDrafts?: {[path: string]: DraftInfo[]} = {};
 
-  @property({type: Object})
-  _commitInfo?: CommitInfo;
+  @state()
+  private editingCommitMessage = false;
 
-  @property({
-    type: Object,
-    computed:
-      '_computeCurrentRevision(_change.current_revision, ' +
-      '_change.revisions)',
-    observer: '_handleCurrentRevisionUpdate',
-  })
-  _currentRevision?: RevisionInfo;
+  @state()
+  private latestCommitMessage: string | null = '';
 
-  @property({type: String})
-  _changeNum?: NumericChangeId;
+  // Use patchRange getter/setter.
+  private _patchRange?: ChangeViewPatchRange;
 
-  @property({type: Object})
-  _diffDrafts?: {[path: string]: DraftInfo[]} = {};
+  // Private but used in tests.
+  @state()
+  get patchRange() {
+    return this._patchRange;
+  }
 
-  @property({type: Boolean})
-  _editingCommitMessage = false;
+  set patchRange(patchRange: ChangeViewPatchRange | undefined) {
+    if (this._patchRange === patchRange) return;
+    const oldPatchRange = this._patchRange;
+    this._patchRange = patchRange;
+    this.patchNumChanged();
+    this.requestUpdate('patchRange', oldPatchRange);
+  }
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeHideEditCommitMessage(_loggedIn, ' +
-      '_editingCommitMessage, _change, _editMode)',
-  })
-  _hideEditCommitMessage?: boolean;
+  // Private but used in tests.
+  @state()
+  selectedRevision?: RevisionInfo | EditRevisionInfo;
 
-  @property({type: String})
-  _diffAgainst?: string;
-
-  @property({type: String})
-  _latestCommitMessage: string | null = '';
-
-  @property({type: Object})
-  _constants = {
-    SecondaryTab,
-    PrimaryTab,
-  };
-
-  @property({type: Object})
-  _messages = NO_ROBOT_COMMENTS_THREADS_MSG;
-
-  @property({
-    type: String,
-    computed:
-      '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
-  })
-  _changeIdCommitMessageError?: string;
-
-  @property({type: Object})
-  _patchRange?: ChangeViewPatchRange;
-
-  @property({type: String})
-  _filesExpanded?: string;
-
-  @property({type: String})
-  _basePatchNum?: string;
-
-  @property({type: Object})
-  _selectedRevision?: RevisionInfo | EditRevisionInfo;
+  @state()
+  get changeIdCommitMessageError() {
+    return this.computeChangeIdCommitMessageError(
+      this.latestCommitMessage,
+      this.change
+    );
+  }
 
   /**
    * <gr-change-actions> populates this via two-way data binding.
+   * Private but used in tests.
    */
-  @property({type: Object})
-  _currentRevisionActions?: ActionNameToActionInfoMap;
+  @state()
+  currentRevisionActions?: ActionNameToActionInfoMap = {};
 
-  @property({
-    type: Array,
-    computed: '_computeAllPatchSets(_change, _change.revisions.*)',
-  })
-  _allPatchSets?: PatchSet[];
+  @state()
+  private allPatchSets?: PatchSet[];
 
-  @property({type: Boolean})
-  _loggedIn = false;
+  // Private but used in tests.
+  @state()
+  loggedIn = false;
 
-  @property({type: Boolean})
-  _loading?: boolean;
+  // Private but used in tests.
+  @state()
+  loading?: boolean;
 
-  @property({type: Object})
-  _projectConfig?: ConfigInfo;
+  @state()
+  private projectConfig?: ConfigInfo;
 
-  @property({
-    type: String,
-    computed: '_computeReplyButtonLabel(_diffDrafts, _canStartReview)',
-  })
-  _replyButtonLabel = 'Reply';
+  @state()
+  private shownFileCount?: number;
 
-  @property({type: String})
-  _selectedPatchSet?: string;
+  // Private but used in tests.
+  @state()
+  initialLoadComplete = false;
 
-  @property({type: Number})
-  _shownFileCount?: number;
+  // Private but used in tests.
+  @state()
+  replyDisabled = true;
 
-  @property({type: Boolean})
-  _initialLoadComplete = false;
+  // Private but used in tests.
+  @state()
+  changeStatuses: ChangeStates[] = [];
 
-  @property({type: Boolean})
-  _replyDisabled = true;
+  @state()
+  private updateCheckTimerHandle?: number | null;
 
-  @property({
-    type: String,
-    computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
-  })
-  _changeStatuses?: ChangeStates[];
+  // Private but used in tests.
+  getEditMode() {
+    if (!this.patchRange || !this.viewState) {
+      return false;
+    }
 
-  /** Is the "Show more/less" button visible? */
-  @property({
-    type: Boolean,
-    computed: '_computeCommitCollapsible(_latestCommitMessage)',
-  })
-  _commitCollapsible?: boolean;
+    if (this.viewState.edit) {
+      return true;
+    }
 
-  @property({type: Number})
-  _updateCheckTimerHandle?: number | null;
+    return this.patchRange.patchNum === EDIT;
+  }
 
-  @property({
-    type: Boolean,
-    computed: '_computeEditMode(_patchRange.*, params.*)',
-  })
-  _editMode?: boolean;
+  isSubmitEnabled(): boolean {
+    return !!(
+      this.currentRevisionActions &&
+      this.currentRevisionActions.submit &&
+      this.currentRevisionActions.submit.enabled
+    );
+  }
 
-  @property({
-    type: Boolean,
-    computed: '_isParentCurrent(_currentRevisionActions)',
-  })
-  _parentIsCurrent?: boolean;
+  // Private but used in tests.
+  @state()
+  mergeable: boolean | null = null;
 
-  @property({
-    type: Boolean,
-    computed: '_isSubmitEnabled(_currentRevisionActions)',
-  })
-  _submitEnabled?: boolean;
+  /**
+   * Plugins can provide (multiple) tabs. For each plugin tab we render an
+   * endpoint for the header. If the plugin tab is active, then we also render
+   * an endpoint for the content.
+   *
+   * This is the list of endpoint names for the headers. The header name that
+   * the user sees is an implementation detail of the plugin that we don't know.
+   */
+  // Private but used in tests.
+  @state()
+  pluginTabsHeaderEndpoints: string[] = [];
 
-  @property({type: Boolean})
-  _mergeable: boolean | null = null;
+  /**
+   * Plugins can provide (multiple) tabs. For each plugin tab we render an
+   * endpoint for the header. If the plugin tab is active, then we also render
+   * an endpoint for the content.
+   *
+   * This is the list of endpoint names for the content.
+   */
+  @state()
+  private pluginTabsContentEndpoints: string[] = [];
 
-  @property({type: Boolean})
-  _showFileTabContent = true;
-
-  @property({type: Array})
-  _dynamicTabHeaderEndpoints: string[] = [];
-
-  @property({type: Array})
-  _dynamicTabContentEndpoints: string[] = [];
-
-  @property({type: String})
-  // The dynamic content of the plugin added tab
-  _selectedTabPluginEndpoint?: string;
-
-  @property({type: String})
-  // The dynamic heading of the plugin added tab
-  _selectedTabPluginHeader?: string;
-
-  @property({
-    type: Array,
-    computed:
-      '_computeRobotCommentsPatchSetDropdownItems(_change, _commentThreads)',
-  })
-  _robotCommentsPatchSetDropdownItems: DropdownLink[] = [];
-
-  @property({type: Number})
-  _currentRobotCommentsPatchSet?: PatchSetNum;
+  @state()
+  private currentRobotCommentsPatchSet?: PatchSetNum;
 
   // TODO(milutin) - remove once new gr-dialog will do it out of the box
   // This removes rest of page from a11y tree, when reply dialog is open
-  @property({type: Boolean})
-  _changeViewAriaHidden = false;
+  @state()
+  private changeViewAriaHidden = false;
 
   /**
-   * this is a two-element tuple to always
-   * hold the current active tab for both primary and secondary tabs
+   * This can be a string only for plugin provided tabs.
    */
-  @property({type: Array})
-  _activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
+  // visible for testing
+  @state()
+  activeTab: Tab | string = Tab.FILES;
 
   @property({type: Boolean})
   unresolvedOnly = true;
 
-  @property({type: Boolean})
-  _showAllRobotComments = false;
+  @state()
+  private showAllRobotComments = false;
 
-  @property({type: Boolean})
-  _showRobotCommentsButton = false;
+  @state()
+  private showRobotCommentsButton = false;
 
-  _throttledToggleChangeStar?: (e: KeyboardEvent) => void;
+  @state()
+  private draftCount = 0;
 
-  @property({type: Boolean})
-  _showChecksTab = false;
+  private throttledToggleChangeStar?: (e: KeyboardEvent) => void;
 
-  @property({type: Boolean})
+  @state()
+  private showChecksTab = false;
+
+  // visible for testing
+  @state()
+  showFindingsTab = false;
+
+  @state()
   private isViewCurrent = false;
 
-  @property({type: String})
-  _tabState?: TabState;
+  @state()
+  private tabState?: TabState;
 
-  @property({type: Object})
-  revertedChange?: ChangeInfo;
+  @state()
+  private revertedChange?: ChangeInfo;
 
-  @property({type: String})
+  // Private but used in tests.
+  @state()
   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[];
-
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [
-      listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
-      listen(Shortcut.EMOJI_DROPDOWN, _ => {}), // docOnly
-      listen(Shortcut.REFRESH_CHANGE, _ => fireReload(this, true)),
-      listen(Shortcut.OPEN_REPLY_DIALOG, _ => this._handleOpenReplyDialog()),
-      listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ =>
-        this._handleOpenDownloadDialog()
-      ),
-      listen(Shortcut.TOGGLE_DIFF_MODE, _ => this._handleToggleDiffMode()),
-      listen(Shortcut.TOGGLE_CHANGE_STAR, e => {
-        if (this._throttledToggleChangeStar) {
-          this._throttledToggleChangeStar(e);
-        }
-      }),
-      listen(Shortcut.UP_TO_DASHBOARD, _ => this._determinePageBack()),
-      listen(Shortcut.EXPAND_ALL_MESSAGES, _ =>
-        this._handleExpandAllMessages()
-      ),
-      listen(Shortcut.COLLAPSE_ALL_MESSAGES, _ =>
-        this._handleCollapseAllMessages()
-      ),
-      listen(Shortcut.OPEN_DIFF_PREFS, _ =>
-        this._handleOpenDiffPrefsShortcut()
-      ),
-      listen(Shortcut.EDIT_TOPIC, _ => this.$.metadata.editTopic()),
-      listen(Shortcut.DIFF_AGAINST_BASE, _ => this._handleDiffAgainstBase()),
-      listen(Shortcut.DIFF_AGAINST_LATEST, _ =>
-        this._handleDiffAgainstLatest()
-      ),
-      listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ =>
-        this._handleDiffBaseAgainstLeft()
-      ),
-      listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ =>
-        this._handleDiffRightAgainstLatest()
-      ),
-      listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ =>
-        this._handleDiffBaseAgainstLatest()
-      ),
-      listen(Shortcut.OPEN_SUBMIT_DIALOG, _ => this._handleOpenSubmitDialog()),
-      listen(Shortcut.TOGGLE_ATTENTION_SET, _ =>
-        this._handleToggleAttentionSet()
-      ),
-    ];
-  }
+  @state()
+  private replyOverlayOpened = false;
 
   // Accessed in tests.
   readonly reporting = getAppContext().reportingService;
@@ -616,6 +552,8 @@
 
   readonly restApiService = getAppContext().restApiService;
 
+  private readonly flagsService = getAppContext().flagsService;
+
   // Private but used in tests.
   readonly userModel = getAppContext().userModel;
 
@@ -627,9 +565,13 @@
   // Private but used in tests.
   readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private readonly shortcuts = getAppContext().shortcutsService;
+  private readonly getConfigModel = resolve(this, configModelToken);
 
-  private subscriptions: Subscription[] = [];
+  private readonly getFilesModel = resolve(this, filesModelToken);
+
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   private replyRefitTask?: DelayedTask;
 
@@ -657,9 +599,18 @@
   // visible for testing
   routerPatchNum?: PatchSetNum;
 
+  private readonly shortcutsController = new ShortcutController(this);
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
-    this.addEventListener('topic-changed', () => this._handleTopicChanged());
+    this.setupListeners();
+    this.setupShortcuts();
+    this.setupSubscriptions();
+  }
+
+  private setupListeners() {
     this.addEventListener(
       // When an overlay is opened in a mobile viewport, the overlay has a full
       // screen view. When it has a full screen view, we do not want the
@@ -667,25 +618,23 @@
       // hiding most of the contents on the screen upon opening, and showing
       // again upon closing.
       'fullscreen-overlay-opened',
-      () => this._handleHideBackgroundContent()
+      () => this.handleHideBackgroundContent()
     );
     this.addEventListener('fullscreen-overlay-closed', () =>
-      this._handleShowBackgroundContent()
+      this.handleShowBackgroundContent()
     );
-    this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
+    this.addEventListener('open-reply-dialog', () => this.openReplyDialog());
     this.addEventListener('change-message-deleted', () => fireReload(this));
     this.addEventListener('editable-content-save', e =>
-      this._handleCommitMessageSave(e)
+      this.handleCommitMessageSave(e)
     );
     this.addEventListener('editable-content-cancel', () =>
-      this._handleCommitMessageCancel()
+      this.handleCommitMessageCancel()
     );
-    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
-    this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
+    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(EventType.SHOW_TAB, e => this.setActiveTab(e));
     this.addEventListener('reload', e => {
       this.loadData(
         /* isLocationChange= */ false,
@@ -694,43 +643,176 @@
     });
   }
 
+  private setupShortcuts() {
+    // TODO: Do we still need docOnly bindings?
+    this.shortcutsController.addAbstract(Shortcut.EMOJI_DROPDOWN, () => {}); // docOnly
+    this.shortcutsController.addAbstract(Shortcut.MENTIONS_DROPDOWN, () => {}); // docOnly
+    this.shortcutsController.addAbstract(Shortcut.REFRESH_CHANGE, () =>
+      fireReload(this, true)
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_REPLY_DIALOG, () =>
+      this.handleOpenReplyDialog()
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_DOWNLOAD_DIALOG, () =>
+      this.handleOpenDownloadDialog()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_DIFF_MODE, () =>
+      this.handleToggleDiffMode()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_CHANGE_STAR, e => {
+      if (this.throttledToggleChangeStar) {
+        this.throttledToggleChangeStar(e);
+      }
+    });
+    this.shortcutsController.addAbstract(Shortcut.UP_TO_DASHBOARD, () =>
+      this.determinePageBack()
+    );
+    this.shortcutsController.addAbstract(Shortcut.EXPAND_ALL_MESSAGES, () =>
+      this.handleExpandAllMessages()
+    );
+    this.shortcutsController.addAbstract(Shortcut.COLLAPSE_ALL_MESSAGES, () =>
+      this.handleCollapseAllMessages()
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_DIFF_PREFS, () =>
+      this.handleOpenDiffPrefsShortcut()
+    );
+    this.shortcutsController.addAbstract(Shortcut.EDIT_TOPIC, () => {
+      assertIsDefined(this.metadata);
+      this.metadata.editTopic();
+    });
+    this.shortcutsController.addAbstract(Shortcut.DIFF_AGAINST_BASE, () =>
+      this.handleDiffAgainstBase()
+    );
+    this.shortcutsController.addAbstract(Shortcut.DIFF_AGAINST_LATEST, () =>
+      this.handleDiffAgainstLatest()
+    );
+    this.shortcutsController.addAbstract(Shortcut.DIFF_BASE_AGAINST_LEFT, () =>
+      this.handleDiffBaseAgainstLeft()
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+      () => this.handleDiffRightAgainstLatest()
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.DIFF_BASE_AGAINST_LATEST,
+      () => this.handleDiffBaseAgainstLatest()
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_SUBMIT_DIALOG, () =>
+      this.handleOpenSubmitDialog()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_ATTENTION_SET, () =>
+      this.handleToggleAttentionSet()
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.OPEN_COPY_LINKS_DROPDOWN,
+      () => this.copyLinksDropdown?.openDropdown()
+    );
+  }
+
   private setupSubscriptions() {
-    this.subscriptions.push(
-      this.getChecksModel().aPluginHasRegistered$.subscribe(b => {
-        this._showChecksTab = b;
-      })
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      s => (this.viewState = s)
     );
-    this.subscriptions.push(
-      this.routerModel.routerView$.subscribe(view => {
+    subscribe(
+      this,
+      () => this.getViewModel().tab$,
+      t => (this.activeTab = t ?? Tab.FILES)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().aPluginHasRegistered$,
+      b => {
+        this.showChecksTab = b;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().robotCommentCount$,
+      count => {
+        this.showFindingsTab = count > 0;
+      }
+    );
+    subscribe(
+      this,
+      () => this.routerModel.routerView$,
+      view => {
         this.isViewCurrent = view === GerritView.CHANGE;
-      })
+      }
     );
-    this.subscriptions.push(
-      this.routerModel.routerPatchNum$.subscribe(patchNum => {
+    subscribe(
+      this,
+      () => this.routerModel.routerPatchNum$,
+      patchNum => {
         this.routerPatchNum = patchNum;
-      })
+      }
     );
-    this.subscriptions.push(
-      this.getCommentsModel().drafts$.subscribe(drafts => {
-        this._diffDrafts = {...drafts};
-      })
+    subscribe(
+      this,
+      () => this.getCommentsModel().drafts$,
+      drafts => {
+        this.diffDrafts = {...drafts};
+      }
     );
-    this.subscriptions.push(
-      this.userModel.preferenceDiffViewMode$.subscribe(diffViewMode => {
+    subscribe(
+      this,
+      () => this.userModel.preferenceDiffViewMode$,
+      diffViewMode => {
         this.diffViewMode = diffViewMode;
-      })
+      }
     );
-    this.subscriptions.push(
-      this.getCommentsModel().changeComments$.subscribe(changeComments => {
-        this._changeComments = changeComments;
-      })
+    subscribe(
+      this,
+      () => this.getCommentsModel().draftsCount$,
+      draftCount => {
+        this.draftCount = draftCount;
+      }
     );
-    this.subscriptions.push(
-      this.getChangeModel().change$.subscribe(change => {
+    subscribe(
+      this,
+      () => this.getCommentsModel().threads$,
+      threads => {
+        this.commentThreads = threads;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      change => {
         // The change view is tied to a specific change number, so don't update
-        // _change to undefined.
-        if (change) this._change = change;
-      })
+        // change to undefined.
+        if (change) this.change = change;
+      }
+    );
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      account => {
+        this.account = account;
+      }
+    );
+    subscribe(
+      this,
+      () => this.userModel.loggedIn$,
+      loggedIn => {
+        this.loggedIn = loggedIn;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+        this.replyDisabled = false;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().repoConfig$,
+      config => {
+        this.projectConfig = config;
+      }
     );
   }
 
@@ -741,11 +823,21 @@
 
     // 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);
   }
 
+  override firstUpdated() {
+    // _onTabSizingChanged is called when iron-items-changed event is fired
+    // from iron-selectable but that is called before the element is present
+    // in view which whereas the method requires paper tabs already be visible
+    // as it relies on dom rect calculation.
+    // _onTabSizingChanged ensures the primary tab(Files/Comments/Checks) is
+    // underlined.
+    assertIsDefined(this.tabs, 'tabs');
+    whenVisible(this.tabs, () => this.tabs!._onTabSizingChanged());
+  }
+
   /**
    * For initialization that should only happen once, not again when
    * re-connecting to the DOM later.
@@ -757,42 +849,28 @@
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._dynamicTabHeaderEndpoints =
+        this.pluginTabsHeaderEndpoints =
           getPluginEndpoints().getDynamicEndpoints('change-view-tab-header');
-        this._dynamicTabContentEndpoints =
+        this.pluginTabsContentEndpoints =
           getPluginEndpoints().getDynamicEndpoints('change-view-tab-content');
         if (
-          this._dynamicTabContentEndpoints.length !==
-          this._dynamicTabHeaderEndpoints.length
+          this.pluginTabsContentEndpoints.length !==
+          this.pluginTabsHeaderEndpoints.length
         ) {
-          this.reporting.error(new Error('Mismatch of headers and content.'));
+          this.reporting.error(
+            'Plugin change-view-tab',
+            new Error('Mismatch of headers and content.')
+          );
         }
       })
-      .then(() => this._initActiveTabs(this.params));
+      .then(() => this.initActiveTab());
 
-    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
-      this._handleToggleChangeStar()
+    this.throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
+      this.handleToggleChangeStar()
     );
-    this._getServerConfig().then(config => {
-      this._serverConfig = config;
-      this._replyDisabled = false;
-    });
-
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this.restApiService.getAccount().then(acct => {
-          this._account = acct;
-        });
-      }
-    });
   }
 
   override disconnectedCallback() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
     document.removeEventListener(
       'visibilitychange',
       this.handleVisibilityChange
@@ -801,19 +879,878 @@
     this.replyRefitTask?.cancel();
     this.scrollTask?.cancel();
 
-    if (this._updateCheckTimerHandle) {
-      this._cancelUpdateCheckTimer();
+    if (this.updateCheckTimerHandle) {
+      this.cancelUpdateCheckTimer();
     }
     this.connected$.next(false);
     super.disconnectedCallback();
   }
 
-  get messagesList(): GrMessagesList | null {
-    return this.shadowRoot!.querySelector<GrMessagesList>('gr-messages-list');
+  protected override willUpdate(changedProperties: PropertyValues): void {
+    if (
+      changedProperties.has('change') ||
+      changedProperties.has('mergeable') ||
+      changedProperties.has('currentRevisionActions')
+    ) {
+      this.changeStatuses = this.computeChangeStatusChips();
+    }
   }
 
-  get threadList(): GrThreadList | null {
-    return this.shadowRoot!.querySelector<GrThreadList>('gr-thread-list');
+  static override get styles() {
+    return [
+      a11yStyles,
+      paperStyles,
+      sharedStyles,
+      css`
+        .container:not(.loading) {
+          background-color: var(--background-color-tertiary);
+        }
+        .container.loading {
+          color: var(--deemphasized-text-color);
+          padding: var(--spacing-l);
+        }
+        .header {
+          align-items: center;
+          background-color: var(--background-color-primary);
+          border-bottom: 1px solid var(--border-color);
+          display: flex;
+          padding: var(--spacing-s) var(--spacing-l);
+          z-index: 99; /* Less than gr-overlay's backdrop */
+        }
+        .header.editMode {
+          background-color: var(--edit-mode-background-color);
+        }
+        .header .download {
+          margin-right: var(--spacing-l);
+        }
+        gr-change-status {
+          margin-left: var(--spacing-s);
+        }
+        gr-change-status:first-child {
+          margin-left: 0;
+        }
+        .headerTitle {
+          align-items: center;
+          display: flex;
+          flex: 1;
+        }
+        .headerSubject {
+          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-left: var(--spacing-l);
+        }
+        .changeNumberColon {
+          color: transparent;
+        }
+        .changeCopyClipboard {
+          margin-left: var(--spacing-s);
+        }
+        .showCopyLinkDialogButton {
+          --gr-button-padding: 0 0 0 var(--spacing-s);
+          margin-left: var(--spacing-s);
+        }
+        #replyBtn {
+          margin-bottom: var(--spacing-m);
+        }
+        gr-change-star {
+          margin-left: var(--spacing-s);
+        }
+        .showCopyLinkDialogButton gr-change-star {
+          margin-left: 0;
+        }
+        a.changeNumber {
+          margin-left: var(--spacing-xs);
+        }
+        gr-reply-dialog {
+          width: 60em;
+        }
+        .changeStatus {
+          text-transform: capitalize;
+        }
+        /* Strong specificity here is needed due to
+            https://github.com/Polymer/polymer/issues/2531 */
+        .container .changeInfo {
+          display: flex;
+          background-color: var(--background-color-secondary);
+          padding-right: var(--spacing-m);
+        }
+        .changeId {
+          color: var(--deemphasized-text-color);
+          font-family: var(--font-family);
+          margin-top: var(--spacing-l);
+        }
+        section {
+          background-color: var(--view-background-color);
+          box-shadow: var(--elevation-level-1);
+        }
+        .changeMetadata {
+          /* Limit meta section to half of the screen at max */
+          max-width: 50%;
+        }
+        .commitMessage {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          margin-right: var(--spacing-l);
+          margin-bottom: var(--spacing-l);
+          /* Account for border and padding and rounding errors. */
+          max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
+        }
+        .commitMessage gr-formatted-text {
+          word-break: break-word;
+        }
+        #commitMessageEditor {
+          /* Account for border and padding and rounding errors. */
+          min-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
+          --collapsed-max-height: 300px;
+        }
+        .changeStatuses,
+        .commitActions {
+          align-items: center;
+          display: flex;
+        }
+        .changeStatuses {
+          flex-wrap: wrap;
+        }
+        .mainChangeInfo {
+          display: flex;
+          flex: 1;
+          flex-direction: column;
+          min-width: 0;
+        }
+        #commitAndRelated {
+          align-content: flex-start;
+          display: flex;
+          flex: 1;
+          overflow-x: hidden;
+        }
+        .relatedChanges {
+          flex: 0 1 auto;
+          overflow: hidden;
+          padding: var(--spacing-l) 0;
+        }
+        .mobile {
+          display: none;
+        }
+        hr {
+          border: 0;
+          border-top: 1px solid var(--border-color);
+          height: 0;
+          margin-bottom: var(--spacing-l);
+        }
+        .emptySpace {
+          flex-grow: 1;
+        }
+        .commitContainer {
+          display: flex;
+          flex-direction: column;
+          flex-shrink: 0;
+          margin: var(--spacing-l) 0;
+          padding: 0 var(--spacing-l);
+        }
+        .showOnEdit {
+          display: none;
+        }
+        .scrollable {
+          overflow: auto;
+        }
+        .text {
+          white-space: pre;
+        }
+        gr-commit-info {
+          display: inline-block;
+        }
+        paper-tabs {
+          background-color: var(--background-color-tertiary);
+          margin-top: var(--spacing-m);
+          height: calc(var(--line-height-h3) + var(--spacing-m));
+          --paper-tabs-selection-bar-color: var(--link-color);
+        }
+        paper-tab {
+          box-sizing: border-box;
+          max-width: 12em;
+          --paper-tab-ink: var(--link-color);
+          --paper-font-common-base_-_font-family: var(--header-font-family);
+          --paper-font-common-base_-_-webkit-font-smoothing: initial;
+          --paper-tab-content_-_margin-bottom: var(--spacing-s);
+          /* paper-tabs uses 700 here, which can look awkward */
+          --paper-tab-content-focused_-_font-weight: var(--font-weight-h3);
+          --paper-tab-content-focused_-_background: var(
+            --gray-background-focus
+          );
+          --paper-tab-content-unselected_-_opacity: 1;
+          --paper-tab-content-unselected_-_color: var(
+            --deemphasized-text-color
+          );
+        }
+        gr-thread-list,
+        gr-messages-list {
+          display: block;
+        }
+        gr-thread-list {
+          min-height: 250px;
+        }
+        #includedInOverlay {
+          width: 65em;
+        }
+        #uploadHelpOverlay {
+          width: 50em;
+        }
+        #metadata {
+          --metadata-horizontal-padding: var(--spacing-l);
+          padding-top: var(--spacing-l);
+          width: 100%;
+        }
+        gr-change-summary {
+          margin-left: var(--spacing-m);
+        }
+        @media screen and (max-width: 75em) {
+          .relatedChanges {
+            padding: 0;
+          }
+          #relatedChanges {
+            padding-top: var(--spacing-l);
+          }
+          #commitAndRelated {
+            flex-direction: column;
+            flex-wrap: nowrap;
+          }
+          #commitMessageEditor {
+            min-width: 0;
+          }
+          .commitMessage {
+            margin-right: 0;
+          }
+          .mainChangeInfo {
+            padding-right: 0;
+          }
+        }
+        @media screen and (max-width: 50em) {
+          .mobile {
+            display: block;
+          }
+          .header {
+            align-items: flex-start;
+            flex-direction: column;
+            flex: 1;
+            padding: var(--spacing-s) var(--spacing-l);
+          }
+          .headerTitle {
+            flex-wrap: wrap;
+            font-family: var(--header-font-family);
+            font-size: var(--font-size-h3);
+            font-weight: var(--font-weight-h3);
+            line-height: var(--line-height-h3);
+          }
+          .desktop {
+            display: none;
+          }
+          .reply {
+            display: block;
+            margin-right: 0;
+            /* px because don't have the same font size */
+            margin-bottom: 6px;
+          }
+          .changeInfo-column:not(:last-of-type) {
+            margin-right: 0;
+            padding-right: 0;
+          }
+          .changeInfo,
+          #commitAndRelated {
+            flex-direction: column;
+            flex-wrap: nowrap;
+          }
+          .commitContainer {
+            margin: 0;
+            padding: var(--spacing-l);
+          }
+          .changeMetadata {
+            margin-top: var(--spacing-xs);
+            max-width: none;
+          }
+          #metadata,
+          .mainChangeInfo {
+            padding: 0;
+          }
+          .commitActions {
+            display: block;
+            margin-top: var(--spacing-l);
+            width: 100%;
+          }
+          .commitMessage {
+            flex: initial;
+            margin: 0;
+          }
+          /* Change actions are the only thing thant need to remain visible due
+            to the fact that they may have the currently visible overlay open. */
+          #mainContent.overlayOpen .hideOnMobileOverlay {
+            display: none;
+          }
+          gr-reply-dialog {
+            height: 100vh;
+            min-width: initial;
+            width: 100vw;
+          }
+          #replyOverlay {
+            z-index: var(--reply-overlay-z-index);
+          }
+        }
+        .patch-set-dropdown {
+          margin: var(--spacing-m) 0 0 var(--spacing-m);
+        }
+        .show-robot-comments {
+          margin: var(--spacing-m);
+        }
+        .tabContent gr-thread-list::part(threads) {
+          padding: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`${this.renderLoading()}${this.renderMainContent()}`;
+  }
+
+  private renderLoading() {
+    if (!this.loading) return nothing;
+    return html`
+      <div class="container loading" ?hidden=${!this.loading}>Loading...</div>
+    `;
+  }
+
+  private renderMainContent() {
+    return html`
+      <div
+        id="mainContent"
+        class="container"
+        ?hidden=${this.loading}
+        aria-hidden=${this.changeViewAriaHidden ? 'true' : 'false'}
+      >
+        ${this.renderChangeInfoSection()}
+        <h2 class="assistive-tech-only">Files and Comments tabs</h2>
+        ${this.renderTabHeaders()} ${this.renderTabContent()}
+        ${this.renderChangeLog()}
+      </div>
+      <gr-apply-fix-dialog
+        id="applyFixDialog"
+        .change=${this.change}
+        .changeNum=${this.changeNum}
+      ></gr-apply-fix-dialog>
+      <gr-overlay id="downloadOverlay" with-backdrop="">
+        <gr-download-dialog
+          id="downloadDialog"
+          .change=${this.change}
+          .patchNum=${this.patchRange?.patchNum}
+          .config=${this.serverConfig?.download}
+          @close=${this.handleDownloadDialogClose}
+        ></gr-download-dialog>
+      </gr-overlay>
+      <gr-overlay id="includedInOverlay" with-backdrop="">
+        <gr-included-in-dialog
+          id="includedInDialog"
+          .changeNum=${this.changeNum}
+          @close=${this.handleIncludedInDialogClose}
+        ></gr-included-in-dialog>
+      </gr-overlay>
+      <gr-overlay
+        id="replyOverlay"
+        class="scrollable"
+        no-cancel-on-outside-click=""
+        no-cancel-on-esc-key=""
+        scroll-action="lock"
+        with-backdrop=""
+        @iron-overlay-canceled=${this.onReplyOverlayCanceled}
+        @opened-changed=${this.onReplyOverlayOpenedChanged}
+      >
+        ${when(
+          this.replyOverlayOpened && this.loggedIn,
+          () => html`
+            <gr-reply-dialog
+              id="replyDialog"
+              .patchNum=${computeLatestPatchNum(this.allPatchSets)}
+              .permittedLabels=${this.change?.permitted_labels}
+              .projectConfig=${this.projectConfig}
+              .canBeStarted=${this.canStartReview()}
+              @send=${this.handleReplySent}
+              @cancel=${this.handleReplyCancel}
+              @autogrow=${this.handleReplyAutogrow}
+              @send-disabled-changed=${this.resetReplyOverlayFocusStops}
+            >
+            </gr-reply-dialog>
+          `
+        )}
+      </gr-overlay>
+    `;
+  }
+
+  private renderChangeInfoSection() {
+    return html`<section class="changeInfoSection">
+      <div class=${this.computeHeaderClass()}>
+        <h1 class="assistive-tech-only">
+          Change ${this.change?._number}: ${this.change?.subject}
+        </h1>
+        ${this.renderHeaderTitle()} ${this.renderCommitActions()}
+      </div>
+      <h2 class="assistive-tech-only">Change metadata</h2>
+      ${this.renderChangeInfo()}
+    </section>`;
+  }
+
+  private renderHeaderTitle() {
+    const resolveWeblinks = this.commitInfo?.resolve_conflicts_web_links ?? [];
+    return html` <div class="headerTitle">
+      <div class="changeStatuses">
+        ${this.changeStatuses.map(
+          status => html` <gr-change-status
+            .change=${this.change}
+            .revertedChange=${this.revertedChange}
+            .status=${status}
+            .resolveWeblinks=${resolveWeblinks}
+          ></gr-change-status>`
+        )}
+      </div>
+
+      ${when(
+        this.flagsService.isEnabled(KnownExperimentId.COPY_LINK_DIALOG),
+        () => html`
+          ${this.renderCopyLinksDropdown()}
+          <gr-button
+            flatten
+            down-arrow
+            class="showCopyLinkDialogButton"
+            @click=${() => this.copyLinksDropdown?.toggleDropdown()}
+            ><gr-change-star
+              id="changeStar"
+              .change=${this.change}
+              @toggle-star=${this.handleToggleStar}
+              ?hidden=${!this.loggedIn}
+            ></gr-change-star>
+            <a
+              class="changeNumber"
+              aria-label=${`Change ${this.change?._number}`}
+              href=${ifDefined(this.computeChangeUrl(true))}
+              @click=${(e: MouseEvent) => e.stopPropagation()}
+              >${this.change?._number}</a
+            ></gr-button
+          >
+        `,
+        () => html`
+          <gr-change-star
+            id="changeStar"
+            .change=${this.change}
+            @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) =>
+              this.handleToggleStar(e)}
+            ?hidden=${!this.loggedIn}
+          ></gr-change-star
+          ><a
+            class="changeNumber"
+            aria-label=${`Change ${this.change?._number}`}
+            href=${ifDefined(this.computeChangeUrl(true))}
+            >${this.change?._number}</a
+          >
+          <span class="changeNumberColon">:&nbsp;</span>
+        `
+      )}
+      <span class="headerSubject">${this.change?.subject}</span>
+      <gr-copy-clipboard
+        class="changeCopyClipboard"
+        hideInput=""
+        text=${this.computeCopyTextForTitle()}
+      >
+      </gr-copy-clipboard>
+    </div>`;
+  }
+
+  private renderCopyLinksDropdown() {
+    const url = this.computeChangeUrl();
+    if (!url) return;
+    const changeURL = prependOrigin(getBaseUrl() + url);
+    const links: CopyLink[] = [
+      {
+        label: 'Change Number',
+        shortcut: 'n',
+        value: `${this.change?._number}`,
+      },
+      {
+        label: 'Change URL',
+        shortcut: 'u',
+        value: changeURL,
+      },
+      {
+        label: 'Title and URL',
+        shortcut: 't',
+        value: `${this.change?._number}: ${this.change?.subject} | ${changeURL}`,
+      },
+      {
+        label: 'URL and title',
+        shortcut: 'r',
+        value: `${changeURL}: ${this.change?.subject}`,
+      },
+      {
+        label: 'Markdown',
+        shortcut: 'm',
+        value: `[${this.change?.subject}](${changeURL})`,
+      },
+      {
+        label: 'Change-Id',
+        shortcut: 'd',
+        value: `${this.change?.id.split('~').pop()}`,
+      },
+    ];
+    if (
+      this.change?.status === ChangeStatus.MERGED &&
+      this.change?.current_revision
+    ) {
+      links.push({
+        label: 'SHA',
+        shortcut: 's',
+        value: this.change.current_revision,
+      });
+    }
+    return html`<gr-copy-links .copyLinks=${links}> </gr-copy-links>`;
+  }
+
+  private renderCommitActions() {
+    return html` <div class="commitActions">
+      <!-- always show gr-change-actions regardless if logged in or not -->
+      <gr-change-actions
+        id="actions"
+        .change=${this.change}
+        .disableEdit=${false}
+        .hasParent=${this.hasParent}
+        .account=${this.account}
+        .changeNum=${this.changeNum}
+        .changeStatus=${this.change?.status}
+        .commitNum=${this.commitInfo?.commit}
+        .latestPatchNum=${computeLatestPatchNum(this.allPatchSets)}
+        .commitMessage=${this.latestCommitMessage}
+        .editPatchsetLoaded=${this.patchRange
+          ? hasEditPatchsetLoaded(this.patchRange)
+          : false}
+        .editMode=${this.getEditMode()}
+        .editBasedOnCurrentPatchSet=${hasEditBasedOnCurrentPatchSet(
+          this.allPatchSets ?? []
+        )}
+        .privateByDefault=${this.projectConfig?.private_by_default}
+        .loggedIn=${this.loggedIn}
+        @edit-tap=${() => this.handleEditTap()}
+        @stop-edit-tap=${() => this.handleStopEditTap()}
+        @download-tap=${() => this.handleOpenDownloadDialog()}
+        @included-tap=${() => this.handleOpenIncludedInDialog()}
+        @revision-actions-changed=${this.handleRevisionActionsChanged}
+      ></gr-change-actions>
+    </div>`;
+  }
+
+  private renderChangeInfo() {
+    const hideEditCommitMessage = this.computeHideEditCommitMessage(
+      this.loggedIn,
+      this.editingCommitMessage,
+      this.change,
+      this.getEditMode()
+    );
+    return html` <div class="changeInfo">
+      <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
+        <gr-change-metadata
+          id="metadata"
+          .change=${this.change}
+          .revertedChange=${this.revertedChange}
+          .account=${this.account}
+          .revision=${this.selectedRevision}
+          .commitInfo=${this.commitInfo}
+          .serverConfig=${this.serverConfig}
+          .parentIsCurrent=${this.isParentCurrent()}
+          .repoConfig=${this.projectConfig}
+          @show-reply-dialog=${this.handleShowReplyDialog}
+        >
+        </gr-change-metadata>
+      </div>
+      <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
+        <div id="commitAndRelated" class="hideOnMobileOverlay">
+          <div class="commitContainer">
+            <h3 class="assistive-tech-only">Commit Message</h3>
+            <div>
+              <gr-button
+                id="replyBtn"
+                class="reply"
+                title=${this.createTitle(
+                  Shortcut.OPEN_REPLY_DIALOG,
+                  ShortcutSection.ACTIONS
+                )}
+                ?hidden=${!this.loggedIn}
+                primary=""
+                .disabled=${this.replyDisabled}
+                @click=${this.handleReplyTap}
+                >${this.computeReplyButtonLabel()}</gr-button
+              >
+            </div>
+            <div id="commitMessage" class="commitMessage">
+              <gr-editable-content
+                id="commitMessageEditor"
+                .editing=${this.editingCommitMessage}
+                .content=${this.latestCommitMessage}
+                @editing-changed=${this.handleEditingChanged}
+                @content-changed=${this.handleContentChanged}
+                .storageKey=${`c${this.change?._number}_rev${this.change?.current_revision}`}
+                .hideEditCommitMessage=${hideEditCommitMessage}
+                .commitCollapsible=${this.computeCommitCollapsible()}
+                remove-zero-width-space=""
+              >
+                <gr-formatted-text
+                  .content=${this.latestCommitMessage ?? ''}
+                  .markdown=${false}
+                ></gr-formatted-text>
+              </gr-editable-content>
+              <div class="changeId" ?hidden=${!this.changeIdCommitMessageError}>
+                <hr />
+                Change-Id:
+                <span
+                  class=${this.computeChangeIdClass(
+                    this.changeIdCommitMessageError
+                  )}
+                  title=${this.computeTitleAttributeWarning(
+                    this.changeIdCommitMessageError
+                  )}
+                  >${this.change?.change_id}</span
+                >
+              </div>
+            </div>
+            <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
+            <gr-change-summary></gr-change-summary>
+            <gr-endpoint-decorator name="commit-container">
+              <gr-endpoint-param name="change" .value=${this.change}>
+              </gr-endpoint-param>
+              <gr-endpoint-param
+                name="revision"
+                .value=${this.selectedRevision}
+              >
+              </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+          <div class="relatedChanges">
+            <gr-related-changes-list
+              id="relatedChanges"
+              .change=${this.change}
+              .mergeable=${this.mergeable}
+              .patchNum=${computeLatestPatchNum(this.allPatchSets)}
+            ></gr-related-changes-list>
+          </div>
+          <div class="emptySpace"></div>
+        </div>
+      </div>
+    </div>`;
+  }
+
+  private renderTabHeaders() {
+    return html`
+      <paper-tabs
+        id="tabs"
+        @selected-changed=${this.onPaperTabSelectionChanged}
+      >
+        <paper-tab @click=${this.onPaperTabClick} data-name=${Tab.FILES}
+          ><span>Files</span></paper-tab
+        >
+        <paper-tab
+          @click=${this.onPaperTabClick}
+          data-name=${Tab.COMMENT_THREADS}
+          class="commentThreads"
+        >
+          <gr-tooltip-content
+            has-tooltip
+            title=${ifDefined(this.computeTotalCommentCounts())}
+          >
+            <span>Comments</span></gr-tooltip-content
+          >
+        </paper-tab>
+        ${when(
+          this.showChecksTab,
+          () => html`
+            <paper-tab data-name=${Tab.CHECKS} @click=${this.onPaperTabClick}
+              ><span>Checks</span></paper-tab
+            >
+          `
+        )}
+        ${this.pluginTabsHeaderEndpoints.map(
+          tabHeader => html`
+            <paper-tab data-name=${tabHeader}>
+              <gr-endpoint-decorator name=${tabHeader}>
+                <gr-endpoint-param name="change" .value=${this.change}>
+                </gr-endpoint-param>
+                <gr-endpoint-param
+                  name="revision"
+                  .value=${this.selectedRevision}
+                >
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </paper-tab>
+          `
+        )}
+        ${when(
+          this.showFindingsTab,
+          () => html`
+            <paper-tab data-name=${Tab.FINDINGS} @click=${this.onPaperTabClick}>
+              <span>Findings</span>
+            </paper-tab>
+          `
+        )}
+      </paper-tabs>
+    `;
+  }
+
+  private renderTabContent() {
+    return html`
+      <section class="tabContent">
+        ${this.renderFilesTab()} ${this.renderCommentsTab()}
+        ${this.renderChecksTab()} ${this.renderFindingsTab()}
+        ${this.renderPluginTab()}
+      </section>
+    `;
+  }
+
+  private renderFilesTab() {
+    return html`
+      <div ?hidden=${this.activeTab !== Tab.FILES}>
+        <gr-file-list-header
+          id="fileListHeader"
+          .account=${this.account}
+          .allPatchSets=${this.allPatchSets}
+          .change=${this.change}
+          .changeNum=${this.changeNum}
+          .revisionInfo=${this.getRevisionInfo()}
+          .commitInfo=${this.commitInfo}
+          .changeUrl=${this.computeChangeUrl()}
+          .editMode=${this.getEditMode()}
+          .loggedIn=${this.loggedIn}
+          .shownFileCount=${this.shownFileCount}
+          .patchNum=${this.patchRange?.patchNum}
+          .basePatchNum=${this.patchRange?.basePatchNum}
+          .filesExpanded=${this.fileList?.filesExpanded}
+          @open-diff-prefs=${this.handleOpenDiffPrefs}
+          @open-download-dialog=${this.handleOpenDownloadDialog}
+          @expand-diffs=${this.expandAllDiffs}
+          @collapse-diffs=${this.collapseAllDiffs}
+        >
+        </gr-file-list-header>
+        <gr-file-list
+          id="fileList"
+          class="hideOnMobileOverlay"
+          .change=${this.change}
+          .changeNum=${this.changeNum}
+          .patchRange=${this.patchRange}
+          .editMode=${this.getEditMode()}
+          @files-shown-changed=${(e: CustomEvent<{length: number}>) => {
+            this.shownFileCount = e.detail.length;
+          }}
+          @files-expanded-changed=${(
+            _e: ValueChangedEvent<FilesExpandedState>
+          ) => {
+            this.requestUpdate();
+          }}
+          @file-action-tap=${this.handleFileActionTap}
+        >
+        </gr-file-list>
+      </div>
+    `;
+  }
+
+  private renderCommentsTab() {
+    if (this.activeTab !== Tab.COMMENT_THREADS) return nothing;
+    return html`
+      <h3 class="assistive-tech-only">Comments</h3>
+      <gr-thread-list
+        .threads=${this.commentThreads}
+        .commentTabState=${this.tabState}
+        only-show-robot-comments-with-human-reply
+        .unresolvedOnly=${this.unresolvedOnly}
+        .scrollCommentId=${this.scrollCommentId}
+        show-comment-context
+      ></gr-thread-list>
+    `;
+  }
+
+  private renderChecksTab() {
+    if (this.activeTab !== Tab.CHECKS) return nothing;
+    return html`
+      <h3 class="assistive-tech-only">Checks</h3>
+      <gr-checks-tab id="checksTab" .tabState=${this.tabState}></gr-checks-tab>
+    `;
+  }
+
+  private renderFindingsTab() {
+    if (this.activeTab !== Tab.FINDINGS) return nothing;
+    if (!this.showFindingsTab) return nothing;
+    const robotCommentThreads = this.computeRobotCommentThreads();
+    const robotCommentsPatchSetDropdownItems =
+      this.computeRobotCommentsPatchSetDropdownItems();
+    return html`
+      <gr-dropdown-list
+        class="patch-set-dropdown"
+        .items=${robotCommentsPatchSetDropdownItems}
+        .value=${this.currentRobotCommentsPatchSet}
+        @value-change=${this.handleRobotCommentPatchSetChanged}
+      >
+      </gr-dropdown-list>
+      <gr-thread-list .threads=${robotCommentThreads} hide-dropdown>
+      </gr-thread-list>
+      ${when(
+        this.showRobotCommentsButton,
+        () => html`
+          <gr-button
+            class="show-robot-comments"
+            @click=${this.toggleShowRobotComments}
+          >
+            ${this.showAllRobotComments ? 'Show Less' : 'Show more'}
+          </gr-button>
+        `
+      )}
+    `;
+  }
+
+  private renderPluginTab() {
+    const i = this.pluginTabsHeaderEndpoints.findIndex(
+      t => this.activeTab === t
+    );
+    if (i === -1) return nothing;
+    const pluginTabContentEndpoint = this.pluginTabsContentEndpoints[i];
+    return html`
+      <gr-endpoint-decorator .name=${pluginTabContentEndpoint}>
+        <gr-endpoint-param name="change" .value=${this.change}>
+        </gr-endpoint-param>
+        <gr-endpoint-param name="revision" .value=${this.selectedRevision}></gr-endpoint-param>
+        </gr-endpoint-param>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private renderChangeLog() {
+    return html`
+      <gr-endpoint-decorator name="change-view-integration">
+        <gr-endpoint-param name="change" .value=${this.change}>
+        </gr-endpoint-param>
+        <gr-endpoint-param name="revision" .value=${this.selectedRevision}>
+        </gr-endpoint-param>
+      </gr-endpoint-decorator>
+
+      <paper-tabs>
+        <paper-tab data-name="_changeLog" class="changeLog">
+          Change Log
+        </paper-tab>
+      </paper-tabs>
+      <section class="changeLog">
+        <h2 class="assistive-tech-only">Change Log</h2>
+        <gr-messages-list
+          class="hideOnMobileOverlay"
+          .labels=${this.change?.labels}
+          .messages=${this.change?.messages}
+          .reviewerUpdates=${this.change?.reviewer_updates}
+          @message-anchor-tap=${this.handleMessageAnchorTap}
+          @reply=${this.handleMessageReply}
+        ></gr-messages-list>
+      </section>
+    `;
   }
 
   private readonly handleScroll = () => {
@@ -825,15 +1762,17 @@
     );
   };
 
-  _onOpenFixPreview(e: OpenFixPreviewEvent) {
-    this.$.applyFixDialog.open(e);
+  private onOpenFixPreview(e: OpenFixPreviewEvent) {
+    assertIsDefined(this.applyFixDialog);
+    this.applyFixDialog.open(e);
   }
 
-  _onCloseFixPreview(e: CloseFixPreviewEvent) {
+  private onCloseFixPreview(e: CloseFixPreviewEvent) {
     if (e.detail.fixApplied) fireReload(this);
   }
 
-  _handleToggleDiffMode() {
+  // Private but used in tests.
+  handleToggleDiffMode() {
     if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) {
       this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
@@ -843,102 +1782,48 @@
     }
   }
 
-  _isTabActive(tab: string, activeTabs: string[]) {
-    return activeTabs.includes(tab);
-  }
+  onPaperTabSelectionChanged(e: ValueChangedEvent) {
+    if (!this.tabs) return;
+    const tabs = [...queryAll<HTMLElement>(this.tabs, 'paper-tab')];
+    if (!tabs) return;
 
-  /**
-   * Actual implementation of switching a tab
-   *
-   * @param paperTabs - the parent tabs container
-   */
-  _setActiveTab(
-    paperTabs: PaperTabsElement | null,
-    activeDetails: {
-      activeTabName?: string;
-      activeTabIndex?: number;
-      scrollIntoView?: boolean;
-    },
-    src?: string
-  ) {
-    if (!paperTabs) return;
-    const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
-    const tabs = paperTabs.querySelectorAll(
-      'paper-tab'
-    ) as NodeListOf<HTMLElement>;
-    let activeIndex = -1;
-    if (activeTabIndex !== undefined) {
-      activeIndex = activeTabIndex;
-    } else {
-      for (let i = 0; i <= tabs.length; i++) {
-        const tab = tabs[i];
-        if (tab.dataset['name'] === activeTabName) {
-          activeIndex = i;
-          break;
-        }
-      }
-    }
-    if (activeIndex === -1) {
-      this.reporting.error(new Error(`tab not found for ${activeDetails}`));
-      return;
-    }
-    const tabName = tabs[activeIndex].dataset['name'];
-    if (scrollIntoView) {
-      paperTabs.scrollIntoView({block: 'center'});
-    }
-    if (paperTabs.selected !== activeIndex) {
-      // paperTabs.selected is undefined during rendering
-      if (paperTabs.selected !== undefined) {
-        this.reporting.reportInteraction(Interaction.SHOW_TAB, {tabName, src});
-      }
-      paperTabs.selected = activeIndex;
-    }
-    return tabName;
-  }
-
-  /**
-   * Changes active primary tab.
-   */
-  _setActivePrimaryTab(e: SwitchTabEvent) {
-    const primaryTabs =
-      this.shadowRoot!.querySelector<PaperTabsElement>('#primaryTabs');
-    const activeTabName = this._setActiveTab(
-      primaryTabs,
-      {
-        activeTabName: e.detail.tab,
-        activeTabIndex: e.detail.value,
-        scrollIntoView: e.detail.scrollIntoView,
-      },
-      (e.composedPath()?.[0] as Element | undefined)?.tagName
+    const tabIndex = Number(e.detail.value);
+    assert(
+      Number.isInteger(tabIndex) && 0 <= tabIndex && tabIndex < tabs.length,
+      `${tabIndex} must be integer`
     );
-    if (activeTabName) {
-      this._activeTabs = [activeTabName, this._activeTabs[1]];
+    const tab = tabs[tabIndex].dataset['name'];
 
-      // update plugin endpoint if its a plugin tab
-      const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
-        activeTabName
-      );
-      if (pluginIndex !== -1) {
-        this._selectedTabPluginEndpoint =
-          this._dynamicTabContentEndpoints[pluginIndex];
-        this._selectedTabPluginHeader =
-          this._dynamicTabHeaderEndpoints[pluginIndex];
-      } else {
-        this._selectedTabPluginEndpoint = '';
-        this._selectedTabPluginHeader = '';
-      }
+    this.getViewModel().updateState({tab});
+  }
+
+  setActiveTab(e: SwitchTabEvent) {
+    if (!this.tabs) return;
+    const tabs = [...queryAll<HTMLElement>(this.tabs, 'paper-tab')];
+    if (!tabs) return;
+
+    const tab = e.detail.tab;
+    const tabIndex = tabs.findIndex(t => t.dataset['name'] === tab);
+    assert(tabIndex !== -1, `tab ${tab} not found`);
+
+    if (this.tabs.selected !== tabIndex) {
+      this.tabs.selected = tabIndex;
     }
-    if (e.detail.tabState) this._tabState = e.detail.tabState;
+
+    this.getViewModel().updateState({tab});
+
+    if (e.detail.tabState) this.tabState = e.detail.tabState;
+    if (e.detail.scrollIntoView) this.tabs.scrollIntoView({block: 'center'});
   }
 
   /**
    * Currently there is a bug in this code where this.unresolvedOnly is only
-   * assigned the correct value when _onPaperTabClick is triggered which is
+   * 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) {
+  private onPaperTabClick(e: MouseEvent) {
     let target = e.target as HTMLElement | null;
     let tabName: string | undefined;
     // target can be slot child of papertab, so we search for tabName in parents
@@ -948,11 +1833,11 @@
       target = target?.parentElement as HTMLElement | null;
     } while (target);
 
-    if (tabName === PrimaryTab.COMMENT_THREADS) {
+    if (tabName === Tab.COMMENT_THREADS) {
       // Show unresolved threads by default
       // Show resolved threads only if no unresolved threads exist
       const hasUnresolvedThreads =
-        (this._commentThreads ?? []).filter(thread => isUnresolved(thread))
+        (this.commentThreads ?? []).filter(thread => isUnresolved(thread))
           .length > 0;
       if (!hasUnresolvedThreads) this.unresolvedOnly = false;
     }
@@ -963,77 +1848,76 @@
     });
   }
 
-  handleEditingChanged(e: ValueChangedEvent<boolean>) {
-    this._editingCommitMessage = e.detail.value;
+  private handleEditingChanged(e: ValueChangedEvent<boolean>) {
+    this.editingCommitMessage = e.detail.value;
   }
 
-  handleContentChanged(e: ValueChangedEvent) {
-    this._latestCommitMessage = e.detail.value;
+  private 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');
+  // Private but used in tests.
+  handleCommitMessageSave(e: EditableContentSaveEvent) {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.changeNum, 'changeNum');
     // to prevent 2 requests at the same time
-    if (this.$.commitMessageEditor.disabled) return;
+    if (!this.commitMessageEditor || this.commitMessageEditor.disabled) return;
     // Trim trailing whitespace from each line.
     const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
 
-    this.jsAPI.handleCommitMessage(this._change, message);
+    this.jsAPI.handleCommitMessage(this.change, message);
 
-    this.$.commitMessageEditor.disabled = true;
+    this.commitMessageEditor.disabled = true;
     this.restApiService
-      .putChangeCommitMessage(this._changeNum, message)
+      .putChangeCommitMessage(this.changeNum, message)
       .then(resp => {
-        this.$.commitMessageEditor.disabled = false;
+        assertIsDefined(this.commitMessageEditor);
+        this.commitMessageEditor.disabled = false;
         if (!resp.ok) {
           return;
         }
 
-        this._latestCommitMessage = this._prepareCommitMsgForLinkify(message);
-        this._editingCommitMessage = false;
-        this._reloadWindow();
+        this.latestCommitMessage = this.prepareCommitMsgForLinkify(message);
+        this.editingCommitMessage = false;
+        this.reloadWindow();
       })
       .catch(() => {
-        this.$.commitMessageEditor.disabled = false;
+        assertIsDefined(this.commitMessageEditor);
+        this.commitMessageEditor.disabled = false;
       });
   }
 
-  _reloadWindow() {
+  private reloadWindow() {
     windowLocationReload();
   }
 
-  _handleCommitMessageCancel() {
-    this._editingCommitMessage = false;
+  private handleCommitMessageCancel() {
+    this.editingCommitMessage = false;
   }
 
-  _computeChangeStatusChips(
-    change: ChangeInfo | undefined,
-    mergeable: boolean | null,
-    submitEnabled?: boolean
-  ) {
-    if (!change) {
-      return undefined;
+  private computeChangeStatusChips() {
+    if (!this.change) {
+      return [];
     }
 
     // Show no chips until mergeability is loaded.
-    if (mergeable === null) {
+    if (this.mergeable === null) {
       return [];
     }
 
     const options = {
       includeDerived: true,
-      mergeable: !!mergeable,
-      submitEnabled: !!submitEnabled,
+      mergeable: !!this.mergeable,
+      submitEnabled: !!this.isSubmitEnabled(),
     };
-    return changeStatuses(change, options);
+    return changeStatuses(this.change as ChangeInfo, options);
   }
 
-  _computeHideEditCommitMessage(
+  // Private but used in tests.
+  computeHideEditCommitMessage(
     loggedIn: boolean,
     editing: boolean,
-    change: ChangeInfo,
+    change?: ParsedChangeInfo,
     editMode?: boolean
   ) {
     if (
@@ -1048,7 +1932,8 @@
     return false;
   }
 
-  _robotCommentCountPerPatchSet(threads: CommentThread[]) {
+  // Private but used in tests.
+  robotCommentCountPerPatchSet(threads: CommentThread[]) {
     return threads.reduce((robotCommentCountMap, thread) => {
       const comments = thread.comments;
       const robotCommentsCount = comments.reduce(
@@ -1063,83 +1948,63 @@
     }, {} as {[patchset: string]: number});
   }
 
-  /**
-   * Returns `this` as the visibility observer target for the keyboard shortcut
-   * mixin to decide whether shortcuts should be enabled or not.
-   */
-  _computeObserverTarget() {
-    return this;
-  }
-
-  _computeText(patch: RevisionInfo, commentThreads: CommentThread[]) {
-    const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
+  // Private but used in tests.
+  computeText(
+    patch: RevisionInfo | EditRevisionInfo,
+    commentThreads: CommentThread[]
+  ) {
+    const commentCount = this.robotCommentCountPerPatchSet(commentThreads);
     const commentCnt = commentCount[patch._number] || 0;
     if (commentCnt === 0) return `Patchset ${patch._number}`;
     return `Patchset ${patch._number} (${pluralize(commentCnt, 'finding')})`;
   }
 
-  _computeRobotCommentsPatchSetDropdownItems(
-    change: ChangeInfo,
-    commentThreads: CommentThread[]
-  ) {
-    if (!change || !commentThreads || !change.revisions) return [];
+  private computeRobotCommentsPatchSetDropdownItems() {
+    if (!this.change || !this.commentThreads || !this.change.revisions)
+      return [];
 
-    return Object.values(change.revisions)
-      .filter(patch => patch._number !== 'edit')
+    return Object.values(this.change.revisions)
+      .filter(patch => patch._number !== EDIT)
       .map(patch => {
         return {
-          text: this._computeText(patch, commentThreads),
+          text: this.computeText(patch, this.commentThreads!),
           value: patch._number,
         };
       })
       .sort((a, b) => (b.value as number) - (a.value as number));
   }
 
-  _handleCurrentRevisionUpdate(currentRevision?: RevisionInfo) {
-    this._currentRobotCommentsPatchSet = currentRevision?._number;
-  }
-
-  _handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
+  private handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
     const patchSet = Number(e.detail.value) as PatchSetNum;
-    if (patchSet === this._currentRobotCommentsPatchSet) return;
-    this._currentRobotCommentsPatchSet = patchSet;
+    if (patchSet === this.currentRobotCommentsPatchSet) return;
+    this.currentRobotCommentsPatchSet = patchSet;
   }
 
-  _computeShowText(showAllRobotComments: boolean) {
-    return showAllRobotComments ? 'Show Less' : 'Show more';
+  private toggleShowRobotComments() {
+    this.showAllRobotComments = !this.showAllRobotComments;
   }
 
-  _toggleShowRobotComments() {
-    this._showAllRobotComments = !this._showAllRobotComments;
-  }
-
-  _computeRobotCommentThreads(
-    commentThreads: CommentThread[],
-    currentRobotCommentsPatchSet: PatchSetNum,
-    showAllRobotComments: boolean
-  ) {
-    if (!commentThreads || !currentRobotCommentsPatchSet) return [];
-    const threads = commentThreads.filter(thread => {
+  // Private but used in tests.
+  computeRobotCommentThreads() {
+    if (!this.commentThreads || !this.currentRobotCommentsPatchSet) return [];
+    const threads = this.commentThreads.filter(thread => {
       const comments = thread.comments || [];
       return (
         comments.length &&
         isRobot(comments[0]) &&
-        comments[0].patch_set === currentRobotCommentsPatchSet
+        comments[0].patch_set === this.currentRobotCommentsPatchSet
       );
     });
-    this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
+    this.showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
     return threads.slice(
       0,
-      showAllRobotComments ? undefined : ROBOT_COMMENTS_LIMIT
+      this.showAllRobotComments ? undefined : ROBOT_COMMENTS_LIMIT
     );
   }
 
-  _computeTotalCommentCounts(
-    unresolvedCount: number,
-    changeComments: ChangeComments
-  ) {
-    if (!changeComments) return undefined;
-    const draftCount = changeComments.computeDraftCount();
+  private computeTotalCommentCounts() {
+    const unresolvedCount = this.change?.unresolved_comment_count ?? 0;
+    const draftCount = this.draftCount;
     const unresolvedString =
       unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
     const draftString = pluralize(draftCount, 'draft');
@@ -1152,64 +2017,82 @@
     );
   }
 
-  _handleReplyTap(e: MouseEvent) {
+  private handleReplyTap(e: MouseEvent) {
     e.preventDefault();
-    this._openReplyDialog(FocusTarget.ANY);
+    this.openReplyDialog(FocusTarget.ANY);
   }
 
-  onReplyOverlayCanceled() {
+  private onReplyOverlayCanceled() {
     fireDialogChange(this, {canceled: true});
-    this._changeViewAriaHidden = false;
+    this.changeViewAriaHidden = false;
   }
 
-  _handleOpenDiffPrefs() {
-    this.$.fileList.openDiffPrefs();
+  private onReplyOverlayOpenedChanged(e: ValueChangedEvent<boolean>) {
+    this.replyOverlayOpened = e.detail.value;
   }
 
-  _handleOpenIncludedInDialog() {
-    this.$.includedInDialog.loadData().then(() => {
+  private handleOpenDiffPrefs() {
+    assertIsDefined(this.fileList);
+    this.fileList.openDiffPrefs();
+  }
+
+  private handleOpenIncludedInDialog() {
+    assertIsDefined(this.includedInDialog);
+    assertIsDefined(this.includedInOverlay);
+    this.includedInDialog.loadData().then(() => {
+      assertIsDefined(this.includedInOverlay);
       flush();
-      this.$.includedInOverlay.refit();
+      this.includedInOverlay.refit();
     });
-    this.$.includedInOverlay.open();
+    this.includedInOverlay.open();
   }
 
-  _handleIncludedInDialogClose() {
-    this.$.includedInOverlay.close();
+  private handleIncludedInDialogClose() {
+    assertIsDefined(this.includedInOverlay);
+    this.includedInOverlay.close();
   }
 
-  _handleOpenDownloadDialog() {
-    this.$.downloadOverlay.open().then(() => {
-      this.$.downloadOverlay.setFocusStops(
-        this.$.downloadDialog.getFocusStops()
-      );
-      this.$.downloadDialog.focus();
+  // Private but used in tests
+  handleOpenDownloadDialog() {
+    assertIsDefined(this.downloadOverlay);
+    this.downloadOverlay.open().then(() => {
+      assertIsDefined(this.downloadOverlay);
+      assertIsDefined(this.downloadDialog);
+      this.downloadOverlay.setFocusStops(this.downloadDialog.getFocusStops());
+      this.downloadDialog.focus();
     });
   }
 
-  _handleDownloadDialogClose() {
-    this.$.downloadOverlay.close();
+  private handleDownloadDialogClose() {
+    assertIsDefined(this.downloadOverlay);
+    this.downloadOverlay.close();
   }
 
-  _handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
+  // Private but used in tests.
+  handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
     const msg: string = e.detail.message.message;
     const quoteStr =
       msg
         .split('\n')
         .map(line => '> ' + line)
         .join('\n') + '\n\n';
-    this._openReplyDialog(FocusTarget.BODY, quoteStr);
+    this.openReplyDialog(FocusTarget.BODY, quoteStr);
   }
 
-  _handleHideBackgroundContent() {
-    this.$.mainContent.classList.add('overlayOpen');
+  // Private but used in tests.
+  handleHideBackgroundContent() {
+    assertIsDefined(this.mainContent);
+    this.mainContent.classList.add('overlayOpen');
   }
 
-  _handleShowBackgroundContent() {
-    this.$.mainContent.classList.remove('overlayOpen');
+  // Private but used in tests.
+  handleShowBackgroundContent() {
+    assertIsDefined(this.mainContent);
+    this.mainContent.classList.remove('overlayOpen');
   }
 
-  _handleReplySent() {
+  // Private but used in tests.
+  handleReplySent() {
     this.addEventListener(
       'change-details-loaded',
       () => {
@@ -1217,41 +2100,45 @@
       },
       {once: true}
     );
-    this.$.replyOverlay.cancel();
+    assertIsDefined(this.replyOverlay);
+    this.replyOverlay.cancel();
     fireReload(this);
   }
 
-  _handleReplyCancel() {
-    this.$.replyOverlay.cancel();
+  private handleReplyCancel() {
+    assertIsDefined(this.replyOverlay);
+    this.replyOverlay.cancel();
   }
 
-  _handleReplyAutogrow() {
+  private handleReplyAutogrow() {
     // If the textarea resizes, we need to re-fit the overlay.
     this.replyRefitTask = debounce(
       this.replyRefitTask,
-      () => this.$.replyOverlay.refit(),
+      () => {
+        assertIsDefined(this.replyOverlay);
+        this.replyOverlay.refit();
+      },
       REPLY_REFIT_DEBOUNCE_INTERVAL_MS
     );
   }
 
-  _handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
+  // Private but used in tests.
+  handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
     let target = FocusTarget.REVIEWERS;
     if (e.detail.value && e.detail.value.ccsOnly) {
       target = FocusTarget.CCS;
     }
-    this._openReplyDialog(target);
+    this.openReplyDialog(target);
   }
 
-  _setShownFiles(e: CustomEvent<{length: number}>) {
-    this._shownFileCount = e.detail.length;
+  private expandAllDiffs() {
+    assertIsDefined(this.fileList);
+    this.fileList.expandAllDiffs();
   }
 
-  _expandAllDiffs() {
-    this.$.fileList.expandAllDiffs();
-  }
-
-  _collapseAllDiffs() {
-    this.$.fileList.collapseAllDiffs();
+  private collapseAllDiffs() {
+    assertIsDefined(this.fileList);
+    this.fileList.collapseAllDiffs();
   }
 
   /**
@@ -1264,39 +2151,42 @@
    * anymore. The app element makes sure that an obsolete change view is not
    * shown anymore, so if the change view is still and doing some update to
    * itself, then that is not dangerous. But for example it should not call
-   * navigateToChange() anymore. That would very likely cause erroneous
-   * behavior.
+   * the navigation service's set/replaceUrl() methods anymore. That would very
+   * likely cause erroneous behavior.
    */
   private isChangeObsolete() {
-    // While this._changeNum is undefined the change view is fresh and has just
-    // not updated it to params.changeNum yet. Not obsolete in that case.
-    if (this._changeNum === undefined) return false;
-    // this.params reflects the current state of the URL. If this._changeNum
+    // While this.changeNum is undefined the change view is fresh and has just
+    // not updated it to viewState.changeNum yet. Not obsolete in that case.
+    if (this.changeNum === undefined) return false;
+    // this.viewState reflects the current state of the URL. If this.changeNum
     // does not match it anymore, then this view must be considered obsolete.
-    return this._changeNum !== this.params?.changeNum;
+    return this.changeNum !== this.viewState?.changeNum;
   }
 
-  hasPatchRangeChanged(value: AppElementChangeViewParams) {
-    if (!this._patchRange) return false;
-    if (this._patchRange.basePatchNum !== value.basePatchNum) return true;
-    return this.hasPatchNumChanged(value);
+  // Private but used in tests.
+  hasPatchRangeChanged(viewState: ChangeViewState) {
+    if (!this.patchRange) return false;
+    if (this.patchRange.basePatchNum !== viewState.basePatchNum) return true;
+    return this.hasPatchNumChanged(viewState);
   }
 
-  hasPatchNumChanged(value: AppElementChangeViewParams) {
-    if (!this._patchRange) return false;
-    if (value.patchNum !== undefined) {
-      return this._patchRange.patchNum !== value.patchNum;
+  // Private but used in tests.
+  hasPatchNumChanged(viewState: ChangeViewState) {
+    if (!this.patchRange) return false;
+    if (viewState.patchNum !== undefined) {
+      return this.patchRange.patchNum !== viewState.patchNum;
     } else {
       // value.patchNum === undefined specifies the latest patchset
       return (
-        this._patchRange.patchNum !== computeLatestPatchNum(this._allPatchSets)
+        this.patchRange.patchNum !== computeLatestPatchNum(this.allPatchSets)
       );
     }
   }
 
-  _paramsChanged(value: AppElementChangeViewParams) {
-    if (value.view !== GerritView.CHANGE) {
-      this._initialLoadComplete = false;
+  // Private but used in tests.
+  viewStateChanged() {
+    if (this.viewState === undefined) {
+      this.initialLoadComplete = false;
       querySelectorAll(this, 'gr-overlay').forEach(overlay =>
         (overlay as GrOverlay).close()
       );
@@ -1310,46 +2200,50 @@
       return;
     }
 
-    if (value.changeNum && value.project) {
-      this.restApiService.setInProjectLookup(value.changeNum, value.project);
+    if (this.viewState.changeNum && this.viewState.project) {
+      this.restApiService.setInProjectLookup(
+        this.viewState.changeNum,
+        this.viewState.project
+      );
     }
 
-    if (value.basePatchNum === undefined)
-      value.basePatchNum = ParentPatchSetNum;
+    if (this.viewState.basePatchNum === undefined)
+      this.viewState.basePatchNum = PARENT;
 
-    const patchChanged = this.hasPatchRangeChanged(value);
-    let patchNumChanged = this.hasPatchNumChanged(value);
+    const patchChanged = this.hasPatchRangeChanged(this.viewState);
+    let patchNumChanged = this.hasPatchNumChanged(this.viewState);
 
-    this._patchRange = {
-      patchNum: value.patchNum,
-      basePatchNum: value.basePatchNum,
+    this.patchRange = {
+      patchNum: this.viewState.patchNum,
+      basePatchNum: this.viewState.basePatchNum,
     };
-    this.scrollCommentId = value.commentId;
+    this.scrollCommentId = this.viewState.commentId;
 
     const patchKnown =
-      !this._patchRange.patchNum ||
-      (this._allPatchSets ?? []).some(
-        ps => ps.num === this._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;
+    const forceReload = this.viewState.forceReload || !patchKnown;
 
     // 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),
+    if (this.changeNum !== undefined && !forceReload) {
+      if (!this.patchRange.patchNum) {
+        this.patchRange = {
+          ...this.patchRange,
+          patchNum: computeLatestPatchNum(this.allPatchSets),
         };
         patchNumChanged = true;
       }
       if (patchChanged) {
-        // We need to collapse all diffs when params change so that a non
+        // We need to collapse all diffs when viewState changes so that a non
         // existing diff is not requested. See Issue 125270 for more details.
-        this.$.fileList.collapseAllDiffs();
-        this._reloadPatchNumDependentResources(patchNumChanged).then(() => {
-          this._sendShowChangeEvent();
+        this.fileList?.resetFileState();
+        this.fileList?.collapseAllDiffs();
+        this.reloadPatchNumDependentResources(patchNumChanged).then(() => {
+          this.sendShowChangeEvent();
         });
       }
 
@@ -1358,150 +2252,108 @@
       // 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);
+      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();
+      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();
+    // We need to collapse all diffs when viewState changes so that a non
+    // existing diff is not requested. See Issue 125270 for more details.
+    this.updateComplete.then(() => {
+      assertIsDefined(this.fileList);
+      this.fileList?.collapseAllDiffs();
+      this.fileList?.resetFileState();
+    });
 
     // 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
+    // 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) {
+    //    this.change is not updated.
+    if (this.changeNum) {
       fireReload(this);
       return;
     }
 
-    this._initialLoadComplete = false;
-    this._changeNum = value.changeNum;
+    this.initialLoadComplete = false;
+    this.changeNum = this.viewState.changeNum;
     this.loadData(true).then(() => {
-      this._performPostLoadTasks();
+      this.performPostLoadTasks();
     });
 
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._initActiveTabs(value);
+        this.initActiveTab();
       });
   }
 
-  _initActiveTabs(params?: AppElementChangeViewParams) {
-    let primaryTab = PrimaryTab.FILES;
-    if (params?.tab) {
-      primaryTab = params?.tab as PrimaryTab;
-    } else if (params?.commentId) {
-      primaryTab = PrimaryTab.COMMENT_THREADS;
+  private initActiveTab() {
+    let tab = Tab.FILES;
+    if (this.viewState?.tab) {
+      tab = this.viewState?.tab as Tab;
+    } else if (this.viewState?.commentId) {
+      tab = Tab.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(EventType.SHOW_PRIMARY_TAB, {
-        detail,
-      })
-    );
+    this.setActiveTab(new CustomEvent(EventType.SHOW_TAB, {detail: {tab}}));
   }
 
-  _sendShowChangeEvent() {
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
+  // Private but used in tests.
+  sendShowChangeEvent() {
+    assertIsDefined(this.patchRange, 'patchRange');
     this.jsAPI.handleEvent(PluginEventType.SHOW_CHANGE, {
-      change: this._change,
-      patchNum: this._patchRange.patchNum,
-      info: {mergeable: this._mergeable},
+      change: this.change,
+      patchNum: this.patchRange.patchNum,
+      info: {mergeable: this.mergeable},
     });
   }
 
-  _performPostLoadTasks() {
-    this._maybeShowReplyDialog();
-    this._maybeShowRevertDialog();
+  private performPostLoadTasks() {
+    this.maybeShowReplyDialog();
+    this.maybeShowRevertDialog();
 
-    this._sendShowChangeEvent();
+    this.sendShowChangeEvent();
 
-    setTimeout(() => {
-      this._maybeScrollToMessage(window.location.hash);
-      this._initialLoadComplete = true;
+    this.updateComplete.then(() => {
+      this.maybeScrollToMessage(window.location.hash);
+      this.initialLoadComplete = true;
     });
   }
 
-  @observe('params', '_change')
-  _paramsAndChangeChanged(
-    value?: AppElementChangeViewParams,
-    change?: ChangeInfo
-  ) {
-    // Polymer 2: check for undefined
-    if (!value || !change) {
-      return;
-    }
-
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    // If the change number or patch range is different, then reset the
-    // selected file index.
-    const patchRangeState = this.viewState.patchRange;
-    if (
-      this.viewState.changeNum !== this._changeNum ||
-      !patchRangeState ||
-      patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
-      patchRangeState.patchNum !== this._patchRange.patchNum
-    ) {
-      this._resetFileListViewState();
-    }
-  }
-
-  _viewStateChanged(viewState: ChangeViewState) {
-    this._numFilesShown = viewState.numFilesShown
-      ? viewState.numFilesShown
-      : DEFAULT_NUM_FILES_SHOWN;
-  }
-
-  _numFilesShownChanged(numFilesShown: number) {
-    this.viewState.numFilesShown = numFilesShown;
-  }
-
-  _handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
+  // Private but used in tests.
+  handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
     const hash = PREFIX + e.detail.id;
-    const url = GerritNav.getUrlForChange(this._change, {
-      patchNum: this._patchRange.patchNum,
-      basePatchNum: this._patchRange.basePatchNum,
-      isEdit: this._editMode,
+    const url = createChangeUrl({
+      change: this.change,
+      patchNum: this.patchRange.patchNum,
+      basePatchNum: this.patchRange.basePatchNum,
+      edit: this.getEditMode(),
       messageHash: hash,
     });
     history.replaceState(null, '', url);
   }
 
-  _maybeScrollToMessage(hash: string) {
+  // Private but used in tests.
+  maybeScrollToMessage(hash: string) {
     if (hash.startsWith(PREFIX) && this.messagesList) {
       this.messagesList.scrollToMessage(hash.substr(PREFIX.length));
     }
   }
 
-  _getLocationSearch() {
+  // Private but used in tests.
+  getLocationSearch() {
     // Not inlining to make it easier to test.
     return window.location.search;
   }
 
   _getUrlParameter(param: string) {
-    const pageURL = this._getLocationSearch().substring(1);
+    const pageURL = this.getLocationSearch().substring(1);
     const vars = pageURL.split('&');
     for (let i = 0; i < vars.length; i++) {
       const name = vars[i].split('=');
@@ -1512,55 +2364,32 @@
     return null;
   }
 
-  _maybeShowRevertDialog() {
+  // Private but used in tests.
+  maybeShowRevertDialog() {
     getPluginLoader()
       .awaitPluginsLoaded()
-      .then(() => this._getLoggedIn())
-      .then(loggedIn => {
+      .then(() => {
         if (
-          !loggedIn ||
-          !this._change ||
-          this._change.status !== ChangeStatus.MERGED
+          !this.loggedIn ||
+          !this.change ||
+          this.change.status !== ChangeStatus.MERGED
         ) {
           // Do not display dialog if not logged-in or the change is not
           // merged.
           return;
         }
         if (this._getUrlParameter('revert')) {
-          this.$.actions.showRevertDialog();
+          assertIsDefined(this.actions);
+          this.actions.showRevertDialog();
         }
       });
   }
 
-  _maybeShowReplyDialog() {
-    this._getLoggedIn().then(loggedIn => {
-      if (!loggedIn) {
-        return;
-      }
-
-      if (this.viewState.showReplyDialog) {
-        this._openReplyDialog(FocusTarget.ANY);
-        this.set('viewState.showReplyDialog', false);
-        fire(this, 'view-state-change-view-changed', {
-          value: this.viewState as ChangeViewState,
-        });
-      }
-    });
-  }
-
-  _resetFileListViewState() {
-    this.set('viewState.selectedFileIndex', 0);
-    if (
-      !!this.viewState.changeNum &&
-      this.viewState.changeNum !== this._changeNum
-    ) {
-      this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
+  private maybeShowReplyDialog() {
+    if (!this.loggedIn) return;
+    if (this.viewState?.openReplyDialog) {
+      this.openReplyDialog(FocusTarget.ANY);
     }
-    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) {
@@ -1569,68 +2398,93 @@
     fireTitleChange(this, title);
   }
 
-  _changeChanged(change?: ChangeInfo | ParsedChangeInfo) {
-    if (!change || !this._patchRange || !this._allPatchSets) {
+  // Private but used in tests.
+  changeChanged(oldChange: ParsedChangeInfo | undefined) {
+    this.allPatchSets = computeAllPatchSets(this.change);
+    if (!this.change) return;
+    this.labelsChanged(oldChange?.labels, this.change.labels);
+    if (
+      this.change.current_revision &&
+      this.change.revisions &&
+      this.change.revisions[this.change.current_revision]
+    ) {
+      this.currentRobotCommentsPatchSet =
+        this.change.revisions[this.change.current_revision]._number;
+    }
+    if (!this.change || !this.patchRange || !this.allPatchSets) {
       return;
     }
 
     // We get the parent first so we keep the original value for basePatchNum
     // and not the updated value.
-    const parent = this._getBasePatchNum(change, this._patchRange);
+    const parent = this.getBasePatchNum();
 
-    this.set(
-      '_patchRange.patchNum',
-      this._patchRange.patchNum || computeLatestPatchNum(this._allPatchSets)
-    );
-
-    this.set('_patchRange.basePatchNum', parent);
-    this.updateTitle(change);
+    this.patchRange = {
+      ...this.patchRange,
+      basePatchNum: parent,
+      patchNum:
+        this.patchRange.patchNum || computeLatestPatchNum(this.allPatchSets),
+    };
+    this.updateTitle(this.change);
   }
 
   /**
    * Gets base patch number, if it is a parent try and decide from
    * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
+   * Private but used in tests.
    */
-  _getBasePatchNum(
-    change: ChangeInfo | ParsedChangeInfo,
-    patchRange: ChangeViewPatchRange
-  ) {
-    if (patchRange.basePatchNum && patchRange.basePatchNum !== 'PARENT') {
-      return patchRange.basePatchNum;
+  getBasePatchNum() {
+    if (
+      this.patchRange &&
+      this.patchRange.basePatchNum &&
+      this.patchRange.basePatchNum !== PARENT
+    ) {
+      return this.patchRange.basePatchNum;
     }
 
-    const revisionInfo = this._getRevisionInfo(change);
-    if (!revisionInfo) return 'PARENT';
+    const revisionInfo = this.getRevisionInfo();
+    if (!revisionInfo) return PARENT;
 
-    const parentCounts = revisionInfo.getParentCountMap();
-    // check that there is at least 2 parents otherwise fall back to 1,
-    // which means there is only one parent.
-    const parentCount = hasOwnProperty(parentCounts, 1) ? parentCounts[1] : 1;
-
+    // TODO: It is a bit unclear why `1` is used here instead of
+    // `patchRange.patchNum`. Maybe that is a bug? Maybe if one patchset
+    // is a merge commit, then all patchsets are merge commits??
+    const isMerge = revisionInfo.isMergeCommit(1 as PatchSetNumber);
     const preferFirst =
-      this._prefs &&
-      this._prefs.default_base_for_merges === DefaultBase.FIRST_PARENT;
+      this.prefs &&
+      this.prefs.default_base_for_merges === DefaultBase.FIRST_PARENT;
 
-    if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
-      return -1;
+    // TODO: I think checking `!patchRange.patchNum` here is a bug and means
+    // that the feature is actually broken at the moment. Looking at the
+    // `changeChanged` method, `patchRange.patchNum` is set before
+    // `getBasePatchNum` is called, so it is unlikely that this method will
+    // ever return -1.
+    if (isMerge && preferFirst && !this.patchRange?.patchNum) {
+      this.reporting.reportExecution(Execution.PREFER_MERGE_FIRST_PARENT);
+      return -1 as BasePatchSetNum;
     }
-
-    return 'PARENT';
+    return PARENT;
   }
 
-  // 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, {
+  private computeChangeUrl(forceReload?: boolean) {
+    if (!this.change) return undefined;
+    return createChangeUrl({
+      change: this.change,
       forceReload: !!forceReload,
     });
   }
 
-  _computeChangeIdClass(displayChangeId: string) {
-    return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
+  // private but used in test
+  computeChangeIdClass(displayChangeId?: string | null) {
+    if (displayChangeId) {
+      return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
+    }
+    return '';
   }
 
-  _computeTitleAttributeWarning(displayChangeId: string) {
+  computeTitleAttributeWarning(displayChangeId?: string | null) {
+    if (!displayChangeId) {
+      return undefined;
+    }
     if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
       return 'Change-Id mismatch';
     } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
@@ -1639,9 +2493,9 @@
     return undefined;
   }
 
-  _computeChangeIdCommitMessageError(
-    commitMessage?: string,
-    change?: ChangeInfo
+  computeChangeIdCommitMessageError(
+    commitMessage: string | null,
+    change?: ParsedChangeInfo
   ) {
     if (change === undefined) {
       return undefined;
@@ -1673,190 +2527,208 @@
     return CHANGE_ID_ERROR.MISSING;
   }
 
-  _computeReplyButtonLabel(
-    drafts?: {[path: string]: DraftInfo[]},
-    canStartReview?: boolean
-  ) {
-    if (drafts === undefined || canStartReview === undefined) {
+  // Private but used in tests.
+  computeReplyButtonLabel() {
+    if (this.diffDrafts === undefined) {
       return 'Reply';
     }
 
-    const draftCount = Object.keys(drafts).reduce(
-      (count, file) => count + drafts[file].length,
+    const draftCount = Object.keys(this.diffDrafts).reduce(
+      (count, file) => count + this.diffDrafts![file].length,
       0
     );
 
-    let label = canStartReview ? 'Start Review' : 'Reply';
+    let label = this.canStartReview() ? 'Start Review' : 'Reply';
     if (draftCount > 0) {
       label += ` (${draftCount})`;
     }
     return label;
   }
 
-  _handleOpenReplyDialog() {
-    this._getLoggedIn().then(isLoggedIn => {
-      if (!isLoggedIn) {
-        fireEvent(this, 'show-auth-required');
-        return;
-      }
-      this._openReplyDialog(FocusTarget.ANY);
-    });
+  private handleOpenReplyDialog() {
+    if (!this.loggedIn) {
+      fireEvent(this, 'show-auth-required');
+      return;
+    }
+    this.openReplyDialog(FocusTarget.ANY);
   }
 
-  _handleOpenSubmitDialog() {
-    if (!this._submitEnabled) return;
-    this.$.actions.showSubmitDialog();
+  private handleOpenSubmitDialog() {
+    if (!this.isSubmitEnabled()) return;
+    assertIsDefined(this.actions);
+    this.actions.showSubmitDialog();
   }
 
-  _handleToggleAttentionSet() {
-    if (!this._change || !this._account?._account_id) return;
-    if (!this._loggedIn || !isInvolved(this._change, this._account)) return;
-    if (!this._change.attention_set) this._change.attention_set = {};
-    if (hasAttention(this._account, this._change)) {
-      const reason = getRemovedByReason(this._account, this._serverConfig);
-      if (this._change.attention_set)
-        delete this._change.attention_set[this._account._account_id];
+  // Private but used in tests.
+  handleToggleAttentionSet() {
+    if (!this.change || !this.account?._account_id) return;
+    if (!this.loggedIn || !isInvolved(this.change, this.account)) return;
+    const newChange = {...this.change};
+    if (!newChange.attention_set) newChange.attention_set = {};
+    if (hasAttention(this.account, this.change)) {
+      const reason = getRemovedByReason(this.account, this.serverConfig);
+      if (newChange.attention_set)
+        delete newChange.attention_set[this.account._account_id];
       fireAlert(this, 'Removing you from the attention set ...');
       this.restApiService
         .removeFromAttentionSet(
-          this._change._number,
-          this._account._account_id,
+          this.change._number,
+          this.account._account_id,
           reason
         )
         .then(() => {
           fireEvent(this, 'hide-alert');
         });
     } else {
-      const reason = getAddedByReason(this._account, this._serverConfig);
+      const reason = getAddedByReason(this.account, this.serverConfig);
       fireAlert(this, 'Adding you to the attention set ...');
-      this._change.attention_set[this._account._account_id] = {
-        account: this._account,
+      newChange.attention_set[this.account._account_id] = {
+        account: this.account,
         reason,
-        reason_account: this._account,
+        reason_account: this.account,
       };
       this.restApiService
         .addToAttentionSet(
-          this._change._number,
-          this._account._account_id,
+          this.change._number,
+          this.account._account_id,
           reason
         )
         .then(() => {
           fireEvent(this, 'hide-alert');
         });
     }
-    this._change = {...this._change};
+    this.change = newChange;
   }
 
-  _handleDiffAgainstBase() {
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+  // Private but used in tests.
+  handleDiffAgainstBase() {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+    if (this.patchRange.basePatchNum === PARENT) {
       fireAlert(this, 'Base is already selected.');
       return;
     }
-    GerritNav.navigateToChange(this._change, {
-      patchNum: this._patchRange.patchNum,
-    });
+    this.getNavigation().setUrl(
+      createChangeUrl({change: this.change, patchNum: this.patchRange.patchNum})
+    );
   }
 
-  _handleDiffBaseAgainstLeft() {
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+  // Private but used in tests.
+  handleDiffBaseAgainstLeft() {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+
+    if (this.patchRange.basePatchNum === PARENT) {
       fireAlert(this, 'Left is already base.');
       return;
     }
-    GerritNav.navigateToChange(this._change, {
-      patchNum: this._patchRange.basePatchNum,
-    });
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change: this.change,
+        patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
+      })
+    );
   }
 
-  _handleDiffAgainstLatest() {
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (this._patchRange.patchNum === latestPatchNum) {
+  // Private but used in tests.
+  handleDiffAgainstLatest() {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
+    if (this.patchRange.patchNum === latestPatchNum) {
       fireAlert(this, 'Latest is already selected.');
       return;
     }
-    GerritNav.navigateToChange(this._change, {
-      patchNum: latestPatchNum,
-      basePatchNum: this._patchRange.basePatchNum,
-    });
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change: this.change,
+        patchNum: latestPatchNum,
+        basePatchNum: this.patchRange.basePatchNum,
+      })
+    );
   }
 
-  _handleDiffRightAgainstLatest() {
-    assertIsDefined(this._change, '_change');
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    if (this._patchRange.patchNum === latestPatchNum) {
+  // Private but used in tests.
+  handleDiffRightAgainstLatest() {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
+    if (this.patchRange.patchNum === latestPatchNum) {
       fireAlert(this, 'Right is already latest.');
       return;
     }
-    GerritNav.navigateToChange(this._change, {
-      patchNum: latestPatchNum,
-      basePatchNum: this._patchRange.patchNum as BasePatchSetNum,
-    });
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change: this.change,
+        patchNum: latestPatchNum,
+        basePatchNum: this.patchRange.patchNum as BasePatchSetNum,
+      })
+    );
   }
 
-  _handleDiffBaseAgainstLatest() {
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+  // Private but used in tests.
+  handleDiffBaseAgainstLatest() {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
     if (
-      this._patchRange.patchNum === latestPatchNum &&
-      this._patchRange.basePatchNum === ParentPatchSetNum
+      this.patchRange.patchNum === latestPatchNum &&
+      this.patchRange.basePatchNum === PARENT
     ) {
       fireAlert(this, 'Already diffing base against latest.');
       return;
     }
-    GerritNav.navigateToChange(this._change, {patchNum: latestPatchNum});
+    this.getNavigation().setUrl(
+      createChangeUrl({change: this.change, patchNum: latestPatchNum})
+    );
   }
 
-  _handleToggleChangeStar() {
-    this.$.changeStar.toggleStar();
+  private handleToggleChangeStar() {
+    assertIsDefined(this.changeStar);
+    this.changeStar.toggleStar();
   }
 
-  _handleExpandAllMessages() {
+  private handleExpandAllMessages() {
     if (this.messagesList) {
       this.messagesList.handleExpandCollapse(true);
     }
   }
 
-  _handleCollapseAllMessages() {
+  private handleCollapseAllMessages() {
     if (this.messagesList) {
       this.messagesList.handleExpandCollapse(false);
     }
   }
 
-  _handleOpenDiffPrefsShortcut() {
-    if (!this._loggedIn) return;
-    this.$.fileList.openDiffPrefs();
+  private handleOpenDiffPrefsShortcut() {
+    if (!this.loggedIn) return;
+    assertIsDefined(this.fileList);
+    this.fileList.openDiffPrefs();
   }
 
-  _determinePageBack() {
+  private determinePageBack() {
     // Default backPage to root if user came to change view page
     // via an email link, etc.
-    GerritNav.navigateToRelativeUrl(this.backPage || GerritNav.getUrlForRoot());
+    this.getNavigation().setUrl(this.backPage || rootUrl());
   }
 
-  _handleLabelRemoved(
-    splices: Array<PolymerSplice<ApprovalInfo[]>>,
-    path: string
+  private handleLabelRemoved(
+    oldLabels: LabelNameToInfoMap,
+    newLabels: LabelNameToInfoMap
   ) {
-    for (const splice of splices) {
-      for (const removed of splice.removed) {
-        const changePath = path.split('.');
-        const labelPath = changePath.splice(0, changePath.length - 2);
-        const labelDict = this.get(labelPath) as QuickLabelInfo;
+    for (const key in oldLabels) {
+      if (!Object.prototype.hasOwnProperty.call(oldLabels, key)) continue;
+      const oldLabelInfo: QuickLabelInfo & DetailedLabelInfo = oldLabels[key];
+      const newLabelInfo: (QuickLabelInfo & DetailedLabelInfo) | undefined =
+        newLabels[key];
+      if (!newLabelInfo) continue;
+      if (!oldLabelInfo.all || !newLabelInfo.all) continue;
+      const oldAccounts = oldLabelInfo.all.map(x => x._account_id);
+      const newAccounts = newLabelInfo.all.map(x => x._account_id);
+      for (const account of oldAccounts) {
         if (
-          labelDict.approved &&
-          labelDict.approved._account_id === removed._account_id
+          !newAccounts.includes(account) &&
+          newLabelInfo.approved?._account_id === account
         ) {
           fireReload(this);
           return;
@@ -1865,65 +2737,38 @@
     }
   }
 
-  @observe('_change.labels.*')
-  _labelsChanged(
-    changeRecord: PolymerDeepPropertyChange<
-      LabelNameToInfoMap,
-      PolymerSpliceChange<ApprovalInfo[]>
-    >
+  private labelsChanged(
+    oldLabels: LabelNameToInfoMap | undefined,
+    newLabels: LabelNameToInfoMap | undefined
   ) {
-    if (!changeRecord) {
+    if (!oldLabels || !newLabels) {
       return;
     }
-    if (changeRecord.value && isPolymerSpliceChange(changeRecord.value)) {
-      this._handleLabelRemoved(
-        changeRecord.value.indexSplices,
-        changeRecord.path
-      );
-    }
+    this.handleLabelRemoved(oldLabels, newLabels);
     this.jsAPI.handleEvent(PluginEventType.LABEL_CHANGE, {
-      change: this._change,
+      change: this.change,
     });
   }
 
-  _openReplyDialog(focusTarget?: FocusTarget, quote?: string) {
-    if (!this._change) return;
-    const overlay = this.$.replyOverlay;
-    overlay.open().finally(async () => {
+  openReplyDialog(focusTarget?: FocusTarget, quote?: string) {
+    if (!this.change) return;
+    assertIsDefined(this.replyOverlay);
+    const overlay = this.replyOverlay;
+    overlay.open().finally(() => {
       // the following code should be executed no matter open succeed or not
-      const dialog = query<GrReplyDialog>(this, '#replyDialog');
+      const dialog = this.replyDialog;
       assertIsDefined(dialog, 'reply dialog');
-      this._resetReplyOverlayFocusStops();
+      this.resetReplyOverlayFocusStops();
       dialog.open(focusTarget, quote);
       const observer = new ResizeObserver(() => overlay.center());
       observer.observe(dialog);
     });
     fireDialogChange(this, {opened: true});
-    this._changeViewAriaHidden = true;
+    this.changeViewAriaHidden = true;
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  _getServerConfig() {
-    return this.restApiService.getConfig();
-  }
-
-  _getProjectConfig() {
-    assertIsDefined(this._change, '_change');
-    return this.restApiService
-      .getProjectConfig(this._change.project)
-      .then(config => {
-        this._projectConfig = config;
-      });
-  }
-
-  _getPreferences() {
-    return this.restApiService.getPreferences();
-  }
-
-  _prepareCommitMsgForLinkify(msg: string) {
+  // Private but used in tests.
+  prepareCommitMsgForLinkify(msg: string) {
     // TODO(wyatta) switch linkify sequence, see issue 5526.
     // This is a zero-with space. It is added to prevent the linkify library
     // from including R= or CC= as part of the email address.
@@ -1933,14 +2778,15 @@
   /**
    * Utility function to make the necessary modifications to a change in the
    * case an edit exists.
+   * Private but used in tests.
    */
-  _processEdit(change: ParsedChangeInfo) {
+  processEdit(change: ParsedChangeInfo) {
     const revisions = Object.values(change.revisions || {});
     const editRev = findEdit(revisions);
     const editParentRev = findEditParentRevision(revisions);
     if (
       !editRev &&
-      this._patchRange?.patchNum === EditPatchSetNum &&
+      this.patchRange?.patchNum === EDIT &&
       changeIsOpen(change)
     ) {
       fireAlert(this, 'Change edit not found. Please create a change edit.');
@@ -1951,7 +2797,7 @@
     if (
       !editRev &&
       (changeIsMerged(change) || changeIsAbandoned(change)) &&
-      this._editMode
+      this.getEditMode()
     ) {
       fireAlert(
         this,
@@ -1962,7 +2808,7 @@
     }
 
     if (!editRev) return;
-    assertIsDefined(this._patchRange, '_patchRange');
+    assertIsDefined(this.patchRange, 'patchRange');
     assertIsDefined(editRev.commit.commit, 'editRev.commit.commit');
     assertIsDefined(editParentRev, 'editParentRev');
 
@@ -1972,14 +2818,14 @@
     // 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
+    // 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);
+      this.patchRange = {...this.patchRange, patchNum: EDIT};
       // The file list is not reactive (yet) with regards to patch range
       // changes, so we have to actively trigger it.
-      this._reloadPatchNumDependentResources();
+      this.reloadPatchNumDependentResources();
     }
   }
 
@@ -1998,20 +2844,24 @@
       const submittedRevert = changes.find(
         change => change?.status === ChangeStatus.MERGED
       );
-      if (!this._changeStatuses) return;
+      if (!this.changeStatuses) return;
       if (submittedRevert) {
         this.revertedChange = submittedRevert;
-        this.push('_changeStatuses', ChangeStates.REVERT_SUBMITTED);
+        this.changeStatuses = this.changeStatuses.concat([
+          ChangeStates.REVERT_SUBMITTED,
+        ]);
       } else {
         if (changes[0]) this.revertedChange = changes[0];
-        this.push('_changeStatuses', ChangeStates.REVERT_CREATED);
+        this.changeStatuses = this.changeStatuses.concat([
+          ChangeStates.REVERT_CREATED,
+        ]);
       }
     });
   }
 
   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
+    // model is loaded.  This can happen when viewState changes when setting up
     // this view. It's unclear whether this issue is related to Polymer
     // specifically.
     if (!this.isConnected) {
@@ -2030,57 +2880,57 @@
    */
   // private but used in tests
   async performPostChangeLoadTasks() {
-    assertIsDefined(this._changeNum, '_changeNum');
+    assertIsDefined(this.changeNum, 'changeNum');
 
-    const prefCompletes = this._getPreferences();
+    const prefCompletes = this.restApiService.getPreferences();
     await this.untilModelLoaded();
 
-    this._prefs = await prefCompletes;
+    this.prefs = await prefCompletes;
 
-    if (!this._change) return false;
+    if (!this.change) return false;
 
-    this._processEdit(this._change);
+    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.topic) {
+      this.change.topic = null as unknown as undefined;
     }
-    if (!this._change.reviewer_updates) {
-      this._change.reviewer_updates = 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);
+    const latestRevisionSha = this.getLatestRevisionSHA(this.change);
     if (!latestRevisionSha)
       throw new Error('Could not find latest Revision Sha');
-    const currentRevision = this._change.revisions[latestRevisionSha];
+    const currentRevision = this.change.revisions[latestRevisionSha];
     if (currentRevision.commit && currentRevision.commit.message) {
-      this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+      this.latestCommitMessage = this.prepareCommitMsgForLinkify(
         currentRevision.commit.message
       );
     } else {
-      this._latestCommitMessage = null;
+      this.latestCommitMessage = null;
     }
 
-    this.computeRevertSubmitted(this._change);
+    this.computeRevertSubmitted(this.change);
     if (
-      !this._patchRange ||
-      !this._patchRange.patchNum ||
-      this._patchRange.patchNum === currentRevision._number
+      !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;
+      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(
+      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') {
+          const thePatchNum = this.patchRange!.patchNum;
+          if (thePatchNum === EDIT) {
             return revision._number === thePatchNum;
           }
           return revision._number === Number(`${thePatchNum}`);
@@ -2090,15 +2940,8 @@
     return true;
   }
 
-  _isSubmitEnabled(revisionActions: ActionNameToActionInfoMap) {
-    return !!(
-      revisionActions &&
-      revisionActions.submit &&
-      revisionActions.submit.enabled
-    );
-  }
-
-  _isParentCurrent(revisionActions: ActionNameToActionInfoMap) {
+  private isParentCurrent() {
+    const revisionActions = this.currentRevisionActions;
     if (revisionActions && revisionActions.rebase) {
       return !revisionActions.rebase.enabled;
     } else {
@@ -2106,23 +2949,24 @@
     }
   }
 
-  _getLatestCommitMessage() {
-    if (!this._changeNum)
-      throw new Error('missing required changeNum property');
-    const lastpatchNum = computeLatestPatchNum(this._allPatchSets);
+  // Private but used in tests.
+  getLatestCommitMessage() {
+    assertIsDefined(this.changeNum, 'changeNum');
+    const lastpatchNum = computeLatestPatchNum(this.allPatchSets);
     if (lastpatchNum === undefined)
       throw new Error('missing lastPatchNum property');
     return this.restApiService
-      .getChangeCommitInfo(this._changeNum, lastpatchNum)
+      .getChangeCommitInfo(this.changeNum, lastpatchNum)
       .then(commitInfo => {
         if (!commitInfo) return;
-        this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+        this.latestCommitMessage = this.prepareCommitMsgForLinkify(
           commitInfo.message
         );
       });
   }
 
-  _getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
+  // Private but used in tests.
+  getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
     if (change.current_revision) return change.current_revision;
     // current_revision may not be present in the case where the latest rev is
     // a draft and the user doesn’t have permission to view that rev.
@@ -2139,32 +2983,12 @@
 
   // visible for testing
   loadAndSetCommitInfo() {
-    assertIsDefined(this._changeNum, '_changeNum');
-    assertIsDefined(this._patchRange?.patchNum, '_patchRange.patchNum');
+    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;
-      });
-  }
-
-  @observe('_changeComments')
-  changeCommentsChanged(comments?: ChangeComments) {
-    if (!comments) return;
-    this._changeComments = comments;
-    this._commentThreads = this._changeComments.getAllThreadsForChange();
-    this._draftCommentThreads = this._commentThreads
-      .filter(isDraftThread)
-      .map(thread => {
-        const copiedThread = {...thread};
-        // Make a hardcopy of all comments and collapse all but last one
-        const commentsInThread = (copiedThread.comments = thread.comments.map(
-          comment => {
-            return {...comment, collapsed: true as boolean};
-          }
-        ));
-        commentsInThread[commentsInThread.length - 1].collapsed = false;
-        return copiedThread;
+        this.commitInfo = commitInfo;
       });
   }
 
@@ -2181,13 +3005,13 @@
    */
   loadData(isLocationChange?: boolean, clearPatchset?: boolean) {
     if (this.isChangeObsolete()) return Promise.resolve();
-    if (clearPatchset && this._change) {
-      GerritNav.navigateToChange(this._change, {
-        forceReload: true,
-      });
+    if (clearPatchset && this.change) {
+      this.getNavigation().setUrl(
+        createChangeUrl({change: this.change, forceReload: true})
+      );
       return Promise.resolve();
     }
-    this._loading = true;
+    this.loading = true;
     this.reporting.time(Timing.CHANGE_RELOAD);
     this.reporting.time(Timing.CHANGE_DATA);
 
@@ -2202,49 +3026,36 @@
     // Resolves when the loading flag is set to false, meaning that some
     // change content may start appearing.
     const loadingFlagSet = detailCompletes.then(() => {
-      this._loading = false;
+      this.loading = false;
       this.performPostChangeLoadTasks();
     });
 
-    // Resolves when the project config has successfully loaded.
-    const projectConfigLoaded = detailCompletes.then(() => {
-      if (!this._change) return Promise.resolve();
-      return this._getProjectConfig();
-    });
-    allDataPromises.push(projectConfigLoaded);
-
     let coreDataPromise;
 
     // If the patch number is specified
-    if (this._patchRange && this._patchRange.patchNum) {
+    if (this.patchRange && this.patchRange.patchNum) {
       // Because a specific patchset is specified, reload the resources that
       // are keyed by patch number or patch range.
-      const patchResourcesLoaded = this._reloadPatchNumDependentResources();
+      const patchResourcesLoaded = this.reloadPatchNumDependentResources();
       allDataPromises.push(patchResourcesLoaded);
 
       // Promise resolves when the change detail and patch dependent resources
       // have loaded.
       coreDataPromise = Promise.all([patchResourcesLoaded, loadingFlagSet]);
     } else {
-      // Resolves when the file list has loaded.
-      const fileListReload = loadingFlagSet.then(() =>
-        this.$.fileList.reload()
-      );
-      allDataPromises.push(fileListReload);
-
       const latestCommitMessageLoaded = loadingFlagSet.then(() => {
         // If the latest commit message is known, there is nothing to do.
-        if (this._latestCommitMessage) {
+        if (this.latestCommitMessage) {
           return Promise.resolve();
         }
-        return this._getLatestCommitMessage();
+        return this.getLatestCommitMessage();
       });
       allDataPromises.push(latestCommitMessageLoaded);
 
       coreDataPromise = loadingFlagSet;
     }
     const mergeabilityLoaded = coreDataPromise.then(() =>
-      this._getMergeability()
+      this.getMergeability()
     );
     allDataPromises.push(mergeabilityLoaded);
 
@@ -2252,27 +3063,25 @@
       fireEvent(this, 'change-details-loaded');
       this.reporting.timeEnd(Timing.CHANGE_RELOAD);
       if (isLocationChange) {
-        this.reporting.changeDisplayed(
-          roleDetails(this._change, this._account)
-        );
+        this.reporting.changeDisplayed(roleDetails(this.change, this.account));
       }
     });
 
     if (isLocationChange) {
-      this._editingCommitMessage = false;
+      this.editingCommitMessage = false;
     }
     const relatedChangesLoaded = coreDataPromise.then(() => {
       let relatedChangesPromise:
         | Promise<RelatedChangesInfo | undefined>
         | undefined;
-      const patchNum = this._computeLatestPatchNum(this._allPatchSets);
-      if (this._change && patchNum) {
+      const patchNum = computeLatestPatchNum(this.allPatchSets);
+      if (this.change && patchNum) {
         relatedChangesPromise = this.restApiService
-          .getRelatedChanges(this._change._number, patchNum)
+          .getRelatedChanges(this.change._number, patchNum)
           .then(response => {
-            if (this._change && response) {
-              this.hasParent = this._calculateHasParent(
-                this._change.change_id,
+            if (this.change && response) {
+              this.hasParent = this.calculateHasParent(
+                this.change.change_id,
                 response.changes
               );
             }
@@ -2282,6 +3091,7 @@
       return this.getRelatedChangesList()?.reload(relatedChangesPromise);
     });
     allDataPromises.push(relatedChangesLoaded);
+    allDataPromises.push(this.filesLoaded());
 
     Promise.all(allDataPromises).then(() => {
       // Loading of commments data is no longer part of this reporting
@@ -2294,12 +3104,19 @@
     return coreDataPromise;
   }
 
+  private async filesLoaded() {
+    if (!this.isConnected) await until(this.connected$, connected => connected);
+    await until(this.getFilesModel().files$, f => f.length > 0);
+  }
+
   /**
    * Determines whether or not the given change has a parent change. If there
    * is a relation chain, and the change id is not the last item of the
    * relation chain, there is a parent.
+   *
+   * Private but used in tests.
    */
-  _calculateHasParent(
+  calculateHasParent(
     currentChangeId: ChangeId,
     relatedChanges: RelatedChangeAndCommitInfo[]
   ) {
@@ -2311,130 +3128,105 @@
 
   /**
    * Kicks off requests for resources that rely on the patch range
-   * (`this._patchRange`) being defined.
+   * (`this.patchRange`) being defined.
    */
-  _reloadPatchNumDependentResources(patchNumChanged?: boolean) {
-    assertIsDefined(this._changeNum, '_changeNum');
-    if (!this._patchRange?.patchNum) throw new Error('missing patchNum');
-    const promises = [this.loadAndSetCommitInfo(), this.$.fileList.reload()];
+  reloadPatchNumDependentResources(patchNumChanged?: boolean) {
+    assertIsDefined(this.changeNum, 'changeNum');
+    if (!this.patchRange?.patchNum) throw new Error('missing patchNum');
+    const promises = [this.loadAndSetCommitInfo()];
     if (patchNumChanged) {
       promises.push(
         this.getCommentsModel().reloadPortedComments(
-          this._changeNum,
-          this._patchRange?.patchNum
+          this.changeNum,
+          this.patchRange?.patchNum
         )
       );
       promises.push(
         this.getCommentsModel().reloadPortedDrafts(
-          this._changeNum,
-          this._patchRange?.patchNum
+          this.changeNum,
+          this.patchRange?.patchNum
         )
       );
     }
     return Promise.all(promises);
   }
 
-  _getMergeability(): Promise<void> {
-    if (!this._change) {
-      this._mergeable = null;
+  // Private but used in tests
+  getMergeability(): Promise<void> {
+    if (!this.change) {
+      this.mergeable = null;
       return Promise.resolve();
     }
     // If the change is closed, it is not mergeable. Note: already merged
     // changes are obviously not mergeable, but the mergeability API will not
     // answer for abandoned changes.
     if (
-      this._change.status === ChangeStatus.MERGED ||
-      this._change.status === ChangeStatus.ABANDONED
+      this.change.status === ChangeStatus.MERGED ||
+      this.change.status === ChangeStatus.ABANDONED
     ) {
-      this._mergeable = false;
+      this.mergeable = false;
       return Promise.resolve();
     }
 
-    if (!this._changeNum) {
+    if (!this.changeNum) {
       return Promise.reject(new Error('missing required changeNum property'));
     }
 
     // If mergeable bit was already returned in detail REST endpoint, use it.
-    if (this._change.mergeable !== undefined) {
-      this._mergeable = this._change.mergeable;
+    if (this.change.mergeable !== undefined) {
+      this.mergeable = this.change.mergeable;
       return Promise.resolve();
     }
 
-    this._mergeable = null;
+    this.mergeable = null;
     return this.restApiService
-      .getMergeable(this._changeNum)
+      .getMergeable(this.changeNum)
       .then(mergableInfo => {
         if (mergableInfo) {
-          this._mergeable = mergableInfo.mergeable;
+          this.mergeable = mergableInfo.mergeable;
         }
       });
   }
 
-  _computeResolveWeblinks(
-    change?: ChangeInfo,
-    commitInfo?: CommitInfo,
-    config?: ServerInfo
-  ) {
-    if (!change || !commitInfo || !config) {
-      return [];
-    }
-    return GerritNav.getResolveConflictsWeblinks(
-      change.project,
-      commitInfo.commit,
-      {
-        weblinks: commitInfo.resolve_conflicts_web_links,
-        config,
-      }
-    );
-  }
-
-  _computeCanStartReview(change: ChangeInfo): boolean {
-    return !!(
-      change.actions &&
-      change.actions.ready &&
-      change.actions.ready.enabled
-    );
-  }
-
-  _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
-    return `Change ${changeNum}`;
-  }
-
   /**
    * Returns the text to be copied when
    * click the copy icon next to change subject
+   * Private but used in tests.
    */
-  _computeCopyTextForTitle(change: ChangeInfo): string {
+  computeCopyTextForTitle(): string {
     return (
-      `${change._number}: ${change.subject} | ` +
+      `${this.change?._number}: ${this.change?.subject} | ` +
       `${location.protocol}//${location.host}` +
-      `${this._computeChangeUrl(change)}`
+      `${this.computeChangeUrl()}`
     );
   }
 
-  _computeCommitCollapsible(commitMessage?: string) {
-    if (!commitMessage) {
+  private computeCommitCollapsible() {
+    if (!this.latestCommitMessage) {
       return false;
     }
-    return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
+    return (
+      this.latestCommitMessage.split('\n').length >=
+      MIN_LINES_FOR_COMMIT_COLLAPSE
+    );
   }
 
-  _startUpdateCheckTimer() {
+  private startUpdateCheckTimer() {
     if (
-      !this._serverConfig ||
-      !this._serverConfig.change ||
-      this._serverConfig.change.update_delay === undefined ||
-      this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS
+      !this.serverConfig ||
+      !this.serverConfig.change ||
+      this.serverConfig.change.update_delay === undefined ||
+      this.serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS
     ) {
       return;
     }
 
-    this._updateCheckTimerHandle = window.setTimeout(() => {
-      if (!this.isViewCurrent || !this._change) {
-        this._startUpdateCheckTimer();
+    this.updateCheckTimerHandle = window.setTimeout(() => {
+      if (!this.isViewCurrent || !this.change) {
+        this.startUpdateCheckTimer();
         return;
       }
-      const change = this._change;
+      const change = this.change;
       this.getChangeModel()
         .fetchChangeUpdates(change)
         .then(result => {
@@ -2458,20 +3250,20 @@
           // 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;
+          const changeWasReloaded = change !== this.change;
           if (
             !toastMessage ||
-            this._loading ||
+            this.loading ||
             changeWasReloaded ||
             !this.isViewCurrent
           ) {
-            this._startUpdateCheckTimer();
+            this.startUpdateCheckTimer();
             return;
           }
 
-          this._cancelUpdateCheckTimer();
+          this.cancelUpdateCheckTimer();
           this.dispatchEvent(
-            new CustomEvent<ShowAlertEventDetail>('show-alert', {
+            new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
               detail: {
                 message: toastMessage,
                 // Persist this alert.
@@ -2485,80 +3277,58 @@
             })
           );
         });
-    }, this._serverConfig.change.update_delay * 1000);
+    }, this.serverConfig.change.update_delay * 1000);
   }
 
-  _cancelUpdateCheckTimer() {
-    if (this._updateCheckTimerHandle) {
-      window.clearTimeout(this._updateCheckTimerHandle);
+  private cancelUpdateCheckTimer() {
+    if (this.updateCheckTimerHandle) {
+      window.clearTimeout(this.updateCheckTimerHandle);
     }
-    this._updateCheckTimerHandle = null;
+    this.updateCheckTimerHandle = null;
   }
 
   private readonly handleVisibilityChange = () => {
-    if (document.hidden && this._updateCheckTimerHandle) {
-      this._cancelUpdateCheckTimer();
-    } else if (!this._updateCheckTimerHandle) {
-      this._startUpdateCheckTimer();
+    if (document.hidden && this.updateCheckTimerHandle) {
+      this.cancelUpdateCheckTimer();
+    } else if (!this.updateCheckTimerHandle) {
+      this.startUpdateCheckTimer();
     }
   };
 
-  _handleTopicChanged() {
-    this.getRelatedChangesList()?.reload();
-  }
-
-  _computeHeaderClass(editMode?: boolean) {
+  // Private but used in tests.
+  computeHeaderClass() {
     const classes = ['header'];
-    if (editMode) {
+    if (this.getEditMode()) {
       classes.push('editMode');
     }
     return classes.join(' ');
   }
 
-  _computeEditMode(
-    patchRangeRecord: PolymerDeepPropertyChange<
-      ChangeViewPatchRange,
-      ChangeViewPatchRange
-    >,
-    paramsRecord: PolymerDeepPropertyChange<
-      AppElementChangeViewParams,
-      AppElementChangeViewParams
-    >
-  ) {
-    if (!patchRangeRecord || !paramsRecord) {
-      return undefined;
-    }
-
-    if (paramsRecord.base && paramsRecord.base.edit) {
-      return true;
-    }
-
-    const patchRange = patchRangeRecord.base || {};
-    return patchRange.patchNum === EditPatchSetNum;
-  }
-
-  _handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
+  private handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
     e.preventDefault();
+    assertIsDefined(this.fileListHeader);
     const controls =
-      this.$.fileListHeader.shadowRoot!.querySelector<GrEditControls>(
+      this.fileListHeader.shadowRoot!.querySelector<GrEditControls>(
         '#editControls'
       );
     if (!controls) throw new Error('Missing edit controls');
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+
     const path = e.detail.path;
     switch (e.detail.action) {
       case GrEditConstants.Actions.DELETE.id:
         controls.openDeleteDialog(path);
         break;
       case GrEditConstants.Actions.OPEN.id:
-        GerritNav.navigateToRelativeUrl(
-          GerritNav.getEditUrlForDiff(
-            this._change,
+        assertIsDefined(this.patchRange.patchNum, 'patchset number');
+        this.getNavigation().setUrl(
+          createEditUrl({
+            changeNum: this.change._number,
+            project: this.change.project,
             path,
-            this._patchRange.patchNum
-          )
+            patchNum: this.patchRange.patchNum,
+          })
         );
         break;
       case GrEditConstants.Actions.RENAME.id:
@@ -2570,84 +3340,79 @@
     }
   }
 
-  _computeCommitMessageKey(number: NumericChangeId, revision: CommitId) {
-    return `c${number}_rev${revision}`;
-  }
-
-  @observe('_patchRange.patchNum')
-  _patchNumChanged(patchNumStr?: PatchSetNum) {
-    if (!this._selectedRevision || !patchNumStr) {
+  private patchNumChanged() {
+    if (!this.selectedRevision || !this.patchRange?.patchNum) {
       return;
     }
-    assertIsDefined(this._change, '_change');
+    assertIsDefined(this.change, 'change');
 
-    let patchNum: PatchSetNum;
-    if (patchNumStr === 'edit') {
-      patchNum = EditPatchSetNum;
-    } else {
-      patchNum = Number(`${patchNumStr}`) as PatchSetNum;
-    }
-
-    if (patchNum === this._selectedRevision._number) {
+    if (this.patchRange.patchNum === this.selectedRevision._number) {
       return;
     }
-    if (this._change.revisions)
-      this._selectedRevision = Object.values(this._change.revisions).find(
-        revision => revision._number === patchNum
-      );
+    if (!this.change.revisions) return;
+    this.selectedRevision = Object.values(this.change.revisions).find(
+      revision => revision._number === this.patchRange!.patchNum
+    );
   }
 
   /**
    * If an edit exists already, load it. Otherwise, toggle edit mode via the
    * navigation API.
    */
-  _handleEditTap() {
-    if (!this._change || !this._change.revisions)
+  private handleEditTap() {
+    if (!this.change || !this.change.revisions)
       throw new Error('missing required change property');
-    const editInfo = Object.values(this._change.revisions).find(
-      info => info._number === EditPatchSetNum
+    const editInfo = Object.values(this.change.revisions).find(
+      info => info._number === EDIT
     );
 
     if (editInfo) {
-      GerritNav.navigateToChange(this._change, {patchNum: EditPatchSetNum});
+      const url = createChangeUrl({change: this.change, patchNum: EDIT});
+      this.getNavigation().setUrl(url);
       return;
     }
 
     // Avoid putting patch set in the URL unless a non-latest patch set is
     // selected.
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
+    assertIsDefined(this.patchRange, 'patchRange');
     let patchNum;
     if (
-      !(this._patchRange.patchNum === computeLatestPatchNum(this._allPatchSets))
+      !(this.patchRange.patchNum === computeLatestPatchNum(this.allPatchSets))
     ) {
-      patchNum = this._patchRange.patchNum;
+      patchNum = this.patchRange.patchNum;
     }
-    GerritNav.navigateToChange(this._change, {
-      patchNum,
-      isEdit: true,
-      forceReload: true,
-    });
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change: this.change,
+        patchNum,
+        edit: true,
+        forceReload: true,
+      })
+    );
   }
 
-  _handleStopEditTap() {
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    GerritNav.navigateToChange(this._change, {
-      patchNum: this._patchRange.patchNum,
-      forceReload: true,
-    });
+  private handleStopEditTap() {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change: this.change,
+        patchNum: this.patchRange.patchNum,
+        forceReload: true,
+      })
+    );
   }
 
-  _resetReplyOverlayFocusStops() {
-    const dialog = query<GrReplyDialog>(this, '#replyDialog');
+  private resetReplyOverlayFocusStops() {
+    const dialog = this.replyDialog;
     const focusStops = dialog?.getFocusStops();
     if (!focusStops) return;
-    this.$.replyOverlay.setFocusStops(focusStops);
+    assertIsDefined(this.replyOverlay);
+    this.replyOverlay.setFocusStops(focusStops);
   }
 
-  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+  // Private but used in tests.
+  async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-change-view');
       this.lastStarredTimestamp = Date.now();
@@ -2659,58 +3424,20 @@
         this.reporting.reportInteraction('change-accidentally-starred');
       }
     }
-    this.restApiService.saveChangeStarred(
+    const msg = e.detail.starred
+      ? 'Starring change...'
+      : 'Unstarring change...';
+    fireAlert(this, msg);
+    await this.restApiService.saveChangeStarred(
       e.detail.change._number,
       e.detail.starred
     );
+    fireEvent(this, 'hide-alert');
   }
 
-  _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo): RevisionInfoClass {
-    return new RevisionInfoClass(change);
-  }
-
-  _computeCurrentRevision(
-    currentRevision: CommitId,
-    revisions: {[revisionId: string]: RevisionInfo}
-  ) {
-    return currentRevision && revisions && revisions[currentRevision];
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeLatestPatchNum(allPatchSets?: PatchSet[]) {
-    return computeLatestPatchNum(allPatchSets);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]): boolean {
-    return hasEditBasedOnCurrentPatchSet(allPatchSets);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _hasEditPatchsetLoaded(
-    patchRangeRecord: PolymerDeepPropertyChange<
-      ChangeViewPatchRange,
-      ChangeViewPatchRange
-    >
-  ): boolean {
-    const patchRange = patchRangeRecord.base;
-    if (!patchRange) {
-      return false;
-    }
-    return hasEditPatchsetLoaded(patchRange);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeAllPatchSets(change: ChangeInfo) {
-    return computeAllPatchSets(change);
+  private getRevisionInfo(): RevisionInfoClass | undefined {
+    if (this.change === undefined) return undefined;
+    return new RevisionInfoClass(this.change);
   }
 
   getRelatedChangesList() {
@@ -2720,20 +3447,19 @@
   }
 
   createTitle(shortcutName: Shortcut, section: ShortcutSection) {
-    return this.shortcuts.createTitle(shortcutName, section);
+    return this.getShortcutsService().createTitle(shortcutName, section);
   }
 
-  _handleRevisionActionsChanged(
+  private handleRevisionActionsChanged(
     e: CustomEvent<{value: ActionNameToActionInfoMap}>
   ) {
-    this._currentRevisionActions = e.detail.value;
+    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
deleted file mode 100644
index b4d961d..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ /dev/null
@@ -1,715 +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="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);
-    }
-    .container.loading {
-      color: var(--deemphasized-text-color);
-      padding: var(--spacing-l);
-    }
-    .header {
-      align-items: center;
-      background-color: var(--background-color-primary);
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      padding: var(--spacing-s) var(--spacing-l);
-      z-index: 99; /* Less than gr-overlay's backdrop */
-    }
-    .header.editMode {
-      background-color: var(--edit-mode-background-color);
-    }
-    .header .download {
-      margin-right: var(--spacing-l);
-    }
-    gr-change-status {
-      margin-left: var(--spacing-s);
-    }
-    gr-change-status:first-child {
-      margin-left: 0;
-    }
-    .headerTitle {
-      align-items: center;
-      display: flex;
-      flex: 1;
-    }
-    .headerSubject {
-      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-left: var(--spacing-l);
-    }
-    .changeNumberColon {
-      color: transparent;
-    }
-    .changeCopyClipboard {
-      margin-left: var(--spacing-s);
-    }
-    #replyBtn {
-      margin-bottom: var(--spacing-m);
-    }
-    gr-change-star {
-      margin-left: var(--spacing-s);
-      --gr-change-star-size: var(--line-height-normal);
-    }
-    a.changeNumber {
-      margin-left: var(--spacing-xs);
-    }
-    gr-reply-dialog {
-      width: 60em;
-    }
-    .changeStatus {
-      text-transform: capitalize;
-    }
-    /* Strong specificity here is needed due to
-         https://github.com/Polymer/polymer/issues/2531 */
-    .container .changeInfo {
-      display: flex;
-      background-color: var(--background-color-secondary);
-      padding-right: var(--spacing-m);
-    }
-    section {
-      background-color: var(--view-background-color);
-      box-shadow: var(--elevation-level-1);
-    }
-    .changeId {
-      color: var(--deemphasized-text-color);
-      font-family: var(--font-family);
-      margin-top: var(--spacing-l);
-    }
-    .changeMetadata {
-      /* Limit meta section to half of the screen at max */
-      max-width: 50%;
-    }
-    .commitMessage {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      margin-right: var(--spacing-l);
-      margin-bottom: var(--spacing-l);
-      /* Account for border and padding and rounding errors. */
-      max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
-    }
-    .commitMessage gr-linked-text {
-      word-break: break-word;
-    }
-    #commitMessageEditor {
-      /* Account for border and padding and rounding errors. */
-      min-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
-      --collapsed-max-height: 300px;
-    }
-    .changeStatuses,
-    .commitActions {
-      align-items: center;
-      display: flex;
-    }
-    .changeStatuses {
-      flex-wrap: wrap;
-    }
-    .mainChangeInfo {
-      display: flex;
-      flex: 1;
-      flex-direction: column;
-      min-width: 0;
-    }
-    #commitAndRelated {
-      align-content: flex-start;
-      display: flex;
-      flex: 1;
-      overflow-x: hidden;
-    }
-    .relatedChanges {
-      flex: 0 1 auto;
-      overflow: hidden;
-      padding: var(--spacing-l) 0;
-    }
-    .mobile {
-      display: none;
-    }
-    hr {
-      border: 0;
-      border-top: 1px solid var(--border-color);
-      height: 0;
-      margin-bottom: var(--spacing-l);
-    }
-    .emptySpace {
-      flex-grow: 1;
-    }
-    .commitContainer {
-      display: flex;
-      flex-direction: column;
-      flex-shrink: 0;
-      margin: var(--spacing-l) 0;
-      padding: 0 var(--spacing-l);
-    }
-    .showOnEdit {
-      display: none;
-    }
-    .scrollable {
-      overflow: auto;
-    }
-    .text {
-      white-space: pre;
-    }
-    gr-commit-info {
-      display: inline-block;
-    }
-    paper-tabs {
-      background-color: var(--background-color-tertiary);
-      margin-top: var(--spacing-m);
-      height: calc(var(--line-height-h3) + var(--spacing-m));
-      --paper-tabs-selection-bar-color: var(--link-color);
-    }
-    paper-tab {
-      box-sizing: border-box;
-      max-width: 12em;
-      --paper-tab-ink: var(--link-color);
-    }
-    gr-thread-list,
-    gr-messages-list {
-      display: block;
-    }
-    gr-thread-list {
-      min-height: 250px;
-    }
-    #includedInOverlay {
-      width: 65em;
-    }
-    #uploadHelpOverlay {
-      width: 50em;
-    }
-    #metadata {
-      --metadata-horizontal-padding: var(--spacing-l);
-      padding-top: var(--spacing-l);
-      width: 100%;
-    }
-    gr-change-summary {
-      margin-left: var(--spacing-m);
-    }
-    @media screen and (max-width: 75em) {
-      .relatedChanges {
-        padding: 0;
-      }
-      #relatedChanges {
-        padding-top: var(--spacing-l);
-      }
-      #commitAndRelated {
-        flex-direction: column;
-        flex-wrap: nowrap;
-      }
-      #commitMessageEditor {
-        min-width: 0;
-      }
-      .commitMessage {
-        margin-right: 0;
-      }
-      .mainChangeInfo {
-        padding-right: 0;
-      }
-    }
-    @media screen and (max-width: 50em) {
-      .mobile {
-        display: block;
-      }
-      .header {
-        align-items: flex-start;
-        flex-direction: column;
-        flex: 1;
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      gr-change-star {
-        vertical-align: middle;
-      }
-      .headerTitle {
-        flex-wrap: wrap;
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      .desktop {
-        display: none;
-      }
-      .reply {
-        display: block;
-        margin-right: 0;
-        /* px because don't have the same font size */
-        margin-bottom: 6px;
-      }
-      .changeInfo-column:not(:last-of-type) {
-        margin-right: 0;
-        padding-right: 0;
-      }
-      .changeInfo,
-      #commitAndRelated {
-        flex-direction: column;
-        flex-wrap: nowrap;
-      }
-      .commitContainer {
-        margin: 0;
-        padding: var(--spacing-l);
-      }
-      .changeMetadata {
-        margin-top: var(--spacing-xs);
-        max-width: none;
-      }
-      #metadata,
-      .mainChangeInfo {
-        padding: 0;
-      }
-      .commitActions {
-        display: block;
-        margin-top: var(--spacing-l);
-        width: 100%;
-      }
-      .commitMessage {
-        flex: initial;
-        margin: 0;
-      }
-      /* Change actions are the only thing thant need to remain visible due
-        to the fact that they may have the currently visible overlay open. */
-      #mainContent.overlayOpen .hideOnMobileOverlay {
-        display: none;
-      }
-      gr-reply-dialog {
-        height: 100vh;
-        min-width: initial;
-        width: 100vw;
-      }
-      #replyOverlay {
-        z-index: var(--reply-overlay-z-index);
-      }
-    }
-    .patch-set-dropdown {
-      margin: var(--spacing-m) 0 0 var(--spacing-m);
-    }
-    .show-robot-comments {
-      margin: var(--spacing-m);
-    }
-    .patchInfo gr-thread-list::part(threads) {
-      padding: var(--spacing-l);
-    }
-  </style>
-  <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
-  <div
-    id="mainContent"
-    class="container"
-    hidden$="{{_loading}}"
-    aria-hidden="[[_changeViewAriaHidden]]"
-  >
-    <section class="changeInfoSection">
-      <div class$="[[_computeHeaderClass(_editMode)]]">
-        <h1 class="assistive-tech-only">
-          Change [[_change._number]]: [[_change.subject]]
-        </h1>
-        <div class="headerTitle">
-          <div class="changeStatuses">
-            <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
-              <gr-change-status
-                change="[[_change]]"
-                reverted-change="[[revertedChange]]"
-                status="[[status]]"
-                resolve-weblinks="[[resolveWeblinks]]"
-              ></gr-change-status>
-            </template>
-          </div>
-          <gr-change-star
-            id="changeStar"
-            change="[[_change]]"
-            on-toggle-star="_handleToggleStar"
-            hidden$="[[!_loggedIn]]"
-          ></gr-change-star>
-
-          <a
-            class="changeNumber"
-            aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
-            href$="[[_computeChangeUrl(_change, 'forceReload')]]"
-            >[[_change._number]]</a
-          >
-          <span class="changeNumberColon">:&nbsp;</span>
-          <span class="headerSubject">[[_change.subject]]</span>
-          <gr-copy-clipboard
-            class="changeCopyClipboard"
-            hideInput=""
-            text="[[_computeCopyTextForTitle(_change)]]"
-          >
-          </gr-copy-clipboard>
-        </div>
-        <!-- end headerTitle -->
-        <!-- always show gr-change-actions regardless if logged in or not -->
-        <div class="commitActions">
-          <gr-change-actions
-            id="actions"
-            change="[[_change]]"
-            disable-edit="[[disableEdit]]"
-            has-parent="[[hasParent]]"
-            revision-actions="[[_currentRevisionActions]]"
-            account="[[_account]]"
-            change-num="[[_changeNum]]"
-            change-status="[[_change.status]]"
-            commit-num="[[_commitInfo.commit]]"
-            latest-patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
-            commit-message="[[_latestCommitMessage]]"
-            edit-patchset-loaded="[[_hasEditPatchsetLoaded(_patchRange.*)]]"
-            edit-mode="[[_editMode]]"
-            edit-based-on-current-patch-set="[[_hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
-            private-by-default="[[_projectConfig.private_by_default]]"
-            logged-in="[[_loggedIn]]"
-            on-edit-tap="_handleEditTap"
-            on-stop-edit-tap="_handleStopEditTap"
-            on-download-tap="_handleOpenDownloadDialog"
-            on-included-tap="_handleOpenIncludedInDialog"
-            on-revision-actions-changed="_handleRevisionActionsChanged"
-          ></gr-change-actions>
-        </div>
-        <!-- end commit actions -->
-      </div>
-      <!-- end header -->
-      <h2 class="assistive-tech-only">Change metadata</h2>
-      <div class="changeInfo">
-        <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
-          <gr-change-metadata
-            id="metadata"
-            change="{{_change}}"
-            reverted-change="[[revertedChange]]"
-            account="[[_account]]"
-            revision="[[_selectedRevision]]"
-            commit-info="[[_commitInfo]]"
-            server-config="[[_serverConfig]]"
-            parent-is-current="[[_parentIsCurrent]]"
-            repo-config="[[_projectConfig]]"
-            on-show-reply-dialog="_handleShowReplyDialog"
-          >
-          </gr-change-metadata>
-        </div>
-        <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
-          <div id="commitAndRelated" class="hideOnMobileOverlay">
-            <div class="commitContainer">
-              <h3 class="assistive-tech-only">Commit Message</h3>
-              <div>
-                <gr-button
-                  id="replyBtn"
-                  class="reply"
-                  title="[[createTitle(Shortcut.OPEN_REPLY_DIALOG,
-                        ShortcutSection.ACTIONS)]]"
-                  hidden$="[[!_loggedIn]]"
-                  primary=""
-                  disabled="[[_replyDisabled]]"
-                  on-click="_handleReplyTap"
-                  >[[_replyButtonLabel]]</gr-button
-                >
-              </div>
-              <div id="commitMessage" class="commitMessage">
-                <gr-editable-content
-                  id="commitMessageEditor"
-                  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]]"
-                  remove-zero-width-space=""
-                >
-                  <gr-linked-text
-                    pre=""
-                    content="[[_latestCommitMessage]]"
-                    config="[[_projectConfig.commentlinks]]"
-                    remove-zero-width-space=""
-                  ></gr-linked-text>
-                </gr-editable-content>
-                <div
-                  class="changeId"
-                  hidden$="[[!_changeIdCommitMessageError]]"
-                >
-                  <hr />
-                  Change-Id:
-                  <span
-                    class$="[[_computeChangeIdClass(_changeIdCommitMessageError)]]"
-                    title$="[[_computeTitleAttributeWarning(_changeIdCommitMessageError)]]"
-                  >
-                    [[_change.change_id]]
-                  </span>
-                </div>
-              </div>
-              <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
-              <gr-change-summary></gr-change-summary>
-              <gr-endpoint-decorator name="commit-container">
-                <gr-endpoint-param name="change" value="[[_change]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param
-                  name="revision"
-                  value="[[_selectedRevision]]"
-                >
-                </gr-endpoint-param>
-              </gr-endpoint-decorator>
-            </div>
-            <div class="relatedChanges">
-              <gr-related-changes-list
-                change="[[_change]]"
-                id="relatedChanges"
-                mergeable="[[_mergeable]]"
-                patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
-              ></gr-related-changes-list>
-            </div>
-            <div class="emptySpace"></div>
-          </div>
-        </div>
-      </div>
-    </section>
-
-    <h2 class="assistive-tech-only">Files and Comments tabs</h2>
-    <paper-tabs id="primaryTabs" on-selected-changed="_setActivePrimaryTab">
-      <paper-tab
-        on-click="_onPaperTabClick"
-        data-name$="[[_constants.PrimaryTab.FILES]]"
-        ><span>Files</span></paper-tab
-      >
-      <paper-tab
-        on-click="_onPaperTabClick"
-        data-name$="[[_constants.PrimaryTab.COMMENT_THREADS]]"
-        class="commentThreads"
-      >
-        <gr-tooltip-content
-          has-tooltip
-          title$="[[_computeTotalCommentCounts(_change.unresolved_comment_count, _changeComments)]]"
-        >
-          <span>Comments</span></gr-tooltip-content
-        >
-      </paper-tab>
-      <template is="dom-if" if="[[_showChecksTab]]">
-        <paper-tab
-          data-name$="[[_constants.PrimaryTab.CHECKS]]"
-          on-click="_onPaperTabClick"
-          ><span>Checks</span></paper-tab
-        >
-      </template>
-      <template
-        is="dom-repeat"
-        items="[[_dynamicTabHeaderEndpoints]]"
-        as="tabHeader"
-      >
-        <paper-tab data-name$="[[tabHeader]]">
-          <gr-endpoint-decorator name$="[[tabHeader]]">
-            <gr-endpoint-param name="change" value="[[_change]]">
-            </gr-endpoint-param>
-            <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-            </gr-endpoint-param>
-          </gr-endpoint-decorator>
-        </paper-tab>
-      </template>
-      <paper-tab
-        data-name$="[[_constants.PrimaryTab.FINDINGS]]"
-        on-click="_onPaperTabClick"
-      >
-        <span>Findings</span>
-      </paper-tab>
-    </paper-tabs>
-
-    <section class="patchInfo">
-      <div
-        hidden$="[[!_isTabActive(_constants.PrimaryTab.FILES, _activeTabs)]]"
-      >
-        <gr-file-list-header
-          id="fileListHeader"
-          account="[[_account]]"
-          all-patch-sets="[[_allPatchSets]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          revision-info="[[_revisionInfo]]"
-          commit-info="[[_commitInfo]]"
-          change-url="[[_computeChangeUrl(_change)]]"
-          edit-mode="[[_editMode]]"
-          logged-in="[[_loggedIn]]"
-          server-config="[[_serverConfig]]"
-          shown-file-count="[[_shownFileCount]]"
-          diff-prefs="[[_diffPrefs]]"
-          patch-num="{{_patchRange.patchNum}}"
-          base-patch-num="{{_patchRange.basePatchNum}}"
-          files-expanded="[[_filesExpanded]]"
-          diff-prefs-disabled="[[!_loggedIn]]"
-          on-open-diff-prefs="_handleOpenDiffPrefs"
-          on-open-download-dialog="_handleOpenDownloadDialog"
-          on-expand-diffs="_expandAllDiffs"
-          on-collapse-diffs="_collapseAllDiffs"
-        >
-        </gr-file-list-header>
-        <gr-file-list
-          id="fileList"
-          class="hideOnMobileOverlay"
-          diff-prefs="{{_diffPrefs}}"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          patch-range="{{_patchRange}}"
-          selected-index="{{viewState.selectedFileIndex}}"
-          diff-view-mode="[[viewState.diffMode]]"
-          edit-mode="[[_editMode]]"
-          num-files-shown="{{_numFilesShown}}"
-          files-expanded="{{_filesExpanded}}"
-          file-list-increment="{{_numFilesShown}}"
-          on-files-shown-changed="_setShownFiles"
-          on-file-action-tap="_handleFileActionTap"
-          observer-target="[[_computeObserverTarget()]]"
-        >
-        </gr-file-list>
-      </div>
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_constants.PrimaryTab.COMMENT_THREADS, _activeTabs)]]"
-      >
-        <h3 class="assistive-tech-only">Comments</h3>
-        <gr-thread-list
-          threads="[[_commentThreads]]"
-          comment-tab-state="[[_tabState]]"
-          only-show-robot-comments-with-human-reply=""
-          unresolved-only="[[unresolvedOnly]]"
-          scroll-comment-id="[[scrollCommentId]]"
-          show-comment-context
-        ></gr-thread-list>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
-      >
-        <h3 class="assistive-tech-only">Checks</h3>
-        <gr-checks-tab id="checksTab" tab-state="[[_tabState]]"></gr-checks-tab>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_constants.PrimaryTab.FINDINGS, _activeTabs)]]"
-      >
-        <gr-dropdown-list
-          class="patch-set-dropdown"
-          items="[[_robotCommentsPatchSetDropdownItems]]"
-          on-value-change="_handleRobotCommentPatchSetChanged"
-          value="[[_currentRobotCommentsPatchSet]]"
-        >
-        </gr-dropdown-list>
-        <gr-thread-list threads="[[_robotCommentThreads]]" hide-dropdown>
-        </gr-thread-list>
-        <template is="dom-if" if="[[_showRobotCommentsButton]]">
-          <gr-button
-            class="show-robot-comments"
-            on-click="_toggleShowRobotComments"
-          >
-            [[_computeShowText(_showAllRobotComments)]]
-          </gr-button>
-        </template>
-      </template>
-
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_selectedTabPluginHeader, _activeTabs)]]"
-      >
-        <gr-endpoint-decorator name$="[[_selectedTabPluginEndpoint]]">
-          <gr-endpoint-param name="change" value="[[_change]]">
-          </gr-endpoint-param>
-          <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-          </gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </template>
-    </section>
-
-    <gr-endpoint-decorator name="change-view-integration">
-      <gr-endpoint-param name="change" value="[[_change]]"> </gr-endpoint-param>
-      <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-      </gr-endpoint-param>
-    </gr-endpoint-decorator>
-
-    <paper-tabs id="secondaryTabs">
-      <paper-tab
-        data-name$="[[_constants.SecondaryTab.CHANGE_LOG]]"
-        class="changeLog"
-      >
-        Change Log
-      </paper-tab>
-    </paper-tabs>
-    <section class="changeLog">
-      <h2 class="assistive-tech-only">Change Log</h2>
-      <gr-messages-list
-        class="hideOnMobileOverlay"
-        labels="[[_change.labels]]"
-        messages="[[_change.messages]]"
-        reviewer-updates="[[_change.reviewer_updates]]"
-        on-message-anchor-tap="_handleMessageAnchorTap"
-        on-reply="_handleMessageReply"
-      ></gr-messages-list>
-    </section>
-  </div>
-
-  <gr-apply-fix-dialog
-    id="applyFixDialog"
-    prefs="[[_diffPrefs]]"
-    change="[[_change]]"
-    change-num="[[_changeNum]]"
-  ></gr-apply-fix-dialog>
-  <gr-overlay id="downloadOverlay" with-backdrop="">
-    <gr-download-dialog
-      id="downloadDialog"
-      change="[[_change]]"
-      patch-num="[[_patchRange.patchNum]]"
-      config="[[_serverConfig.download]]"
-      on-close="_handleDownloadDialogClose"
-    ></gr-download-dialog>
-  </gr-overlay>
-  <gr-overlay id="includedInOverlay" with-backdrop="">
-    <gr-included-in-dialog
-      id="includedInDialog"
-      change-num="[[_changeNum]]"
-      on-close="_handleIncludedInDialogClose"
-    ></gr-included-in-dialog>
-  </gr-overlay>
-  <gr-overlay
-    id="replyOverlay"
-    class="scrollable"
-    no-cancel-on-outside-click=""
-    no-cancel-on-esc-key=""
-    scroll-action="lock"
-    with-backdrop=""
-    opened="{{replyOverlayOpened}}"
-    on-iron-overlay-canceled="onReplyOverlayCanceled"
-  >
-    <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>
-`;
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 3ec70c1..d86007a 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import '../../edit/gr-edit-constants';
 import '../gr-thread-list/gr-thread-list';
 import './gr-change-view';
@@ -26,24 +14,27 @@
   DiffViewMode,
   HttpMethod,
   MessageTag,
-  PrimaryTab,
   createDefaultPreferences,
+  Tab,
 } 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 {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {EventType, PluginApi} from '../../../api/plugin';
 import {
   mockPromise,
+  pressKey,
   queryAndAssert,
+  stubFlags,
   stubRestApi,
   stubUsers,
+  waitEventLoop,
   waitQueryAndAssert,
   waitUntil,
 } from '../../../test/test-utils';
 import {
-  createAppElementChangeViewParams,
+  createChangeViewState,
   createApproval,
   createChange,
   createChangeMessages,
@@ -62,8 +53,9 @@
   createRelatedChangeAndCommitInfo,
   createAccountDetailWithId,
   createParsedChange,
+  createDraft,
 } from '../../../test/test-data-generators';
-import {ChangeViewPatchRange, GrChangeView} from './gr-change-view';
+import {GrChangeView} from './gr-change-view';
 import {
   AccountId,
   ApprovalInfo,
@@ -71,11 +63,9 @@
   ChangeId,
   ChangeInfo,
   CommitId,
-  EditPatchSetNum,
+  EDIT,
   NumericChangeId,
-  ParentPatchSetNum,
-  PatchRange,
-  PatchSetNum,
+  PARENT,
   RelatedChangeAndCommitInfo,
   ReviewInputTag,
   RevisionInfo,
@@ -84,13 +74,11 @@
   RobotCommentInfo,
   Timestamp,
   UrlEncodedCommentId,
+  DetailedLabelInfo,
+  RepoName,
+  QuickLabelInfo,
 } from '../../../types/common';
-import {
-  pressAndReleaseKeyOn,
-  tap,
-} from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
-import {AppElementChangeViewParams} from '../../gr-app-types';
 import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {CommentThread} from '../../../utils/comment-util';
@@ -103,15 +91,20 @@
 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 fixture = fixtureFromElement('gr-change-view');
+import {assertIsDefined} from '../../../utils/common-util';
+import {DEFAULT_NUM_FILES_SHOWN} from '../gr-file-list/gr-file-list';
+import {fixture, html, assert} from '@open-wc/testing';
+import {deepClone} from '../../../utils/deep-util';
+import {Modifier} from '../../../utils/dom-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrCopyLinks} from '../gr-copy-links/gr-copy-links';
+import {ChangeViewState} from '../../../models/views/change';
+import {rootUrl} from '../../../utils/url-util';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-change-view tests', () => {
   let element: GrChangeView;
-
-  let navigateToChangeStub: SinonStubbedMember<
-    typeof GerritNav.navigateToChange
-  >;
+  let setUrlStub: sinon.SinonStub;
 
   const ROBOT_COMMENTS_LIMIT = 10;
 
@@ -126,7 +119,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 2 as PatchSetNum,
+          patch_set: 2 as RevisionPatchSetNum,
           robot_id: 'rb1' as RobotId,
           id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
           line: 5,
@@ -141,7 +134,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 4 as PatchSetNum,
+          patch_set: 4 as RevisionPatchSetNum,
           id: 'ecf0b9fa_fe1a5f62_1' as UrlEncodedCommentId,
           line: 5,
           updated: '2018-02-08 18:49:18.000000000' as Timestamp,
@@ -157,7 +150,7 @@
           message: 'draft',
           unresolved: false,
           __draft: true,
-          patch_set: 2 as PatchSetNum,
+          patch_set: 2 as RevisionPatchSetNum,
         },
       ],
       patchNum: 4 as RevisionPatchSetNum,
@@ -175,7 +168,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 3 as PatchSetNum,
+          patch_set: 3 as RevisionPatchSetNum,
           id: 'ecf0b9fa_fe5f62' as UrlEncodedCommentId,
           robot_id: 'rb2' as RobotId,
           line: 5,
@@ -190,7 +183,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 3 as PatchSetNum,
+          patch_set: 3 as RevisionPatchSetNum,
           id: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
           side: CommentSide.PARENT,
           updated: '2018-02-13 22:47:19.000000000' as Timestamp,
@@ -198,7 +191,7 @@
           unresolved: false,
         },
       ],
-      patchNum: 3 as PatchSetNum,
+      patchNum: 3 as RevisionPatchSetNum,
       path: 'test.txt',
       rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
       commentSide: CommentSide.PARENT,
@@ -212,7 +205,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 2 as PatchSetNum,
+          patch_set: 2 as RevisionPatchSetNum,
           id: '8caddf38_44770ec1' as UrlEncodedCommentId,
           line: 4,
           updated: '2018-02-13 22:48:40.000000000' as Timestamp,
@@ -236,7 +229,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 2 as PatchSetNum,
+          patch_set: 2 as RevisionPatchSetNum,
           id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
           line: 4,
           updated: '2018-02-14 22:48:40.000000000' as Timestamp,
@@ -260,7 +253,7 @@
           message: 'resolved draft',
           unresolved: false,
           __draft: true,
-          patch_set: 2 as PatchSetNum,
+          patch_set: 2 as RevisionPatchSetNum,
         },
       ],
       patchNum: 4 as RevisionPatchSetNum,
@@ -278,7 +271,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 4 as PatchSetNum,
+          patch_set: 4 as RevisionPatchSetNum,
           id: 'rc1' as UrlEncodedCommentId,
           line: 5,
           updated: '2019-02-08 18:49:18.000000000' as Timestamp,
@@ -302,7 +295,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 4 as PatchSetNum,
+          patch_set: 4 as RevisionPatchSetNum,
           id: 'rc2' as UrlEncodedCommentId,
           line: 5,
           updated: '2019-03-08 18:49:18.000000000' as Timestamp,
@@ -317,7 +310,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 4 as PatchSetNum,
+          patch_set: 4 as RevisionPatchSetNum,
           id: 'c2_1' as UrlEncodedCommentId,
           line: 5,
           updated: '2019-03-08 18:49:18.000000000' as Timestamp,
@@ -333,10 +326,10 @@
     },
   ];
 
-  setup(() => {
+  setup(async () => {
     // Since pluginEndpoints are global, must reset state.
     _testOnly_resetEndpoints();
-    navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+    setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
 
     stubRestApi('getConfig').returns(
       Promise.resolve({
@@ -353,9 +346,7 @@
     stubRestApi('getDiffComments').returns(Promise.resolve({}));
     stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
     stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-    element = fixture.instantiate();
-    element._changeNum = TEST_NUMERIC_CHANGE_ID;
-    sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
+
     getPluginLoader().loadPlugins([]);
     window.Gerrit.install(
       plugin => {
@@ -371,112 +362,292 @@
       '0.1',
       'http://some/plugins/url.js'
     );
+    element = await fixture<GrChangeView>(
+      html`<gr-change-view></gr-change-view>`
+    );
+    element.viewState = {
+      view: GerritView.CHANGE,
+      changeNum: TEST_NUMERIC_CHANGE_ID,
+      project: 'gerrit' as RepoName,
+    };
+    await element.updateComplete.then(() => {
+      assertIsDefined(element.actions);
+      sinon.stub(element.actions, 'reload').returns(Promise.resolve());
+    });
   });
 
   teardown(async () => {
-    await flush();
+    await element.updateComplete;
   });
 
-  test('_handleMessageAnchorTap', () => {
-    element._changeNum = 1 as NumericChangeId;
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container loading">Loading...</div>
+        <div aria-hidden="false" class="container" hidden="" id="mainContent">
+          <section class="changeInfoSection">
+            <div class="header">
+              <h1 class="assistive-tech-only">Change :</h1>
+              <div class="headerTitle">
+                <div class="changeStatuses"></div>
+                <gr-change-star id="changeStar"> </gr-change-star>
+                <a aria-label="Change undefined" class="changeNumber"> </a>
+                <span class="changeNumberColon"> : </span>
+                <span class="headerSubject"> </span>
+                <gr-copy-clipboard
+                  class="changeCopyClipboard"
+                  hideinput=""
+                  text="undefined: undefined | http://localhost:9876undefined"
+                >
+                </gr-copy-clipboard>
+              </div>
+              <div class="commitActions">
+                <gr-change-actions hidden="" id="actions"> </gr-change-actions>
+              </div>
+            </div>
+            <h2 class="assistive-tech-only">Change metadata</h2>
+            <div class="changeInfo">
+              <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
+                <gr-change-metadata id="metadata"> </gr-change-metadata>
+              </div>
+              <div class="changeInfo-column mainChangeInfo" id="mainChangeInfo">
+                <div class="hideOnMobileOverlay" id="commitAndRelated">
+                  <div class="commitContainer">
+                    <h3 class="assistive-tech-only">Commit Message</h3>
+                    <div>
+                      <gr-button
+                        aria-disabled="false"
+                        class="reply"
+                        id="replyBtn"
+                        primary=""
+                        role="button"
+                        tabindex="0"
+                        title="Open reply dialog to publish comments and add reviewers (shortcut: a)"
+                      >
+                        Reply
+                      </gr-button>
+                    </div>
+                    <div class="commitMessage" id="commitMessage">
+                      <gr-editable-content
+                        id="commitMessageEditor"
+                        remove-zero-width-space=""
+                      >
+                        <gr-formatted-text></gr-formatted-text>
+                      </gr-editable-content>
+                      <div class="changeId" hidden="">
+                        <hr />
+                        Change-Id:
+                        <span class="" title=""></span>
+                      </div>
+                    </div>
+                    <h3 class="assistive-tech-only">
+                      Comments and Checks Summary
+                    </h3>
+                    <gr-change-summary> </gr-change-summary>
+                    <gr-endpoint-decorator name="commit-container">
+                      <gr-endpoint-param name="change"> </gr-endpoint-param>
+                      <gr-endpoint-param name="revision"> </gr-endpoint-param>
+                    </gr-endpoint-decorator>
+                  </div>
+                  <div class="relatedChanges">
+                    <gr-related-changes-list id="relatedChanges">
+                    </gr-related-changes-list>
+                  </div>
+                  <div class="emptySpace"></div>
+                </div>
+              </div>
+            </div>
+          </section>
+          <h2 class="assistive-tech-only">Files and Comments tabs</h2>
+          <paper-tabs dir="null" id="tabs" role="tablist" tabindex="0">
+            <paper-tab
+              aria-disabled="false"
+              aria-selected="true"
+              class="iron-selected"
+              data-name="files"
+              role="tab"
+              tabindex="0"
+            >
+              <span> Files </span>
+            </paper-tab>
+            <paper-tab
+              aria-disabled="false"
+              aria-selected="false"
+              class="commentThreads"
+              data-name="comments"
+              role="tab"
+              tabindex="-1"
+            >
+              <gr-tooltip-content has-tooltip="" title="">
+                <span> Comments </span>
+              </gr-tooltip-content>
+            </paper-tab>
+            <paper-tab
+              aria-disabled="false"
+              aria-selected="false"
+              data-name="change-view-tab-header-url"
+              role="tab"
+              tabindex="-1"
+            >
+              <gr-endpoint-decorator name="change-view-tab-header-url">
+                <gr-endpoint-param name="change"> </gr-endpoint-param>
+                <gr-endpoint-param name="revision"> </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </paper-tab>
+          </paper-tabs>
+          <section class="tabContent">
+            <div>
+              <gr-file-list-header id="fileListHeader"> </gr-file-list-header>
+              <gr-file-list class="hideOnMobileOverlay" id="fileList">
+              </gr-file-list>
+            </div>
+          </section>
+          <gr-endpoint-decorator name="change-view-integration">
+            <gr-endpoint-param name="change"> </gr-endpoint-param>
+            <gr-endpoint-param name="revision"> </gr-endpoint-param>
+          </gr-endpoint-decorator>
+          <paper-tabs dir="null" role="tablist" tabindex="0">
+            <paper-tab
+              aria-disabled="false"
+              aria-selected="false"
+              class="changeLog"
+              data-name="_changeLog"
+              role="tab"
+              tabindex="-1"
+            >
+              Change Log
+            </paper-tab>
+          </paper-tabs>
+          <section class="changeLog">
+            <h2 class="assistive-tech-only">Change Log</h2>
+            <gr-messages-list class="hideOnMobileOverlay"> </gr-messages-list>
+          </section>
+        </div>
+        <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
+        <gr-overlay
+          aria-hidden="true"
+          id="downloadOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-download-dialog id="downloadDialog" role="dialog">
+          </gr-download-dialog>
+        </gr-overlay>
+        <gr-overlay
+          aria-hidden="true"
+          id="includedInOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-included-in-dialog id="includedInDialog"> </gr-included-in-dialog>
+        </gr-overlay>
+        <gr-overlay
+          aria-hidden="true"
+          class="scrollable"
+          id="replyOverlay"
+          no-cancel-on-esc-key=""
+          no-cancel-on-outside-click=""
+          scroll-action="lock"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+        </gr-overlay>
+      `
+    );
+  });
+
+  test('handleMessageAnchorTap', async () => {
+    element.changeNum = 1 as NumericChangeId;
+    element.patchRange = {
+      basePatchNum: PARENT,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element._change = createChangeViewChange();
-    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
+    element.change = createChangeViewChange();
+    await element.updateComplete;
     const replaceStateStub = sinon.stub(history, 'replaceState');
-    element._handleMessageAnchorTap(
+    element.handleMessageAnchorTap(
       new CustomEvent('message-anchor-tap', {detail: {id: 'a12345'}})
     );
 
-    assert.equal(getUrlStub.lastCall.args[1]!.messageHash, '#message-a12345');
     assert.isTrue(replaceStateStub.called);
   });
 
-  test('_handleDiffAgainstBase', () => {
-    element._change = {
+  test('handleDiffAgainstBase', () => {
+    element.change = {
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element._patchRange = {
+    element.patchRange = {
       patchNum: 3 as RevisionPatchSetNum,
       basePatchNum: 1 as BasePatchSetNum,
     };
-    element._handleDiffAgainstBase();
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[0], element._change);
-    assert.equal(args[1]!.patchNum, 3 as PatchSetNum);
+    element.handleDiffAgainstBase();
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3');
   });
 
-  test('_handleDiffAgainstLatest', () => {
-    element._change = {
+  test('handleDiffAgainstLatest', () => {
+    element.change = {
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element._patchRange = {
+    element.patchRange = {
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-    element._handleDiffAgainstLatest();
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[0], element._change);
-    assert.equal(args[1]!.patchNum, 10 as PatchSetNum);
-    assert.equal(args[1]!.basePatchNum, 1 as BasePatchSetNum);
+    element.handleDiffAgainstLatest();
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1..10');
   });
 
-  test('_handleDiffBaseAgainstLeft', () => {
-    element._change = {
+  test('handleDiffBaseAgainstLeft', () => {
+    element.change = {
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element._patchRange = {
+    element.patchRange = {
       patchNum: 3 as RevisionPatchSetNum,
       basePatchNum: 1 as BasePatchSetNum,
     };
-    element._handleDiffBaseAgainstLeft();
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[0], element._change);
-    assert.equal(args[1]!.patchNum, 1 as PatchSetNum);
+    element.handleDiffBaseAgainstLeft();
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
   });
 
-  test('_handleDiffRightAgainstLatest', () => {
-    element._change = {
+  test('handleDiffRightAgainstLatest', () => {
+    element.change = {
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element._patchRange = {
+    element.patchRange = {
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-    element._handleDiffRightAgainstLatest();
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[1]!.patchNum, 10 as PatchSetNum);
-    assert.equal(args[1]!.basePatchNum, 3 as BasePatchSetNum);
+    element.handleDiffRightAgainstLatest();
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3..10');
   });
 
-  test('_handleDiffBaseAgainstLatest', () => {
-    element._change = {
+  test('handleDiffBaseAgainstLatest', () => {
+    element.change = {
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element._patchRange = {
+    element.patchRange = {
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-    element._handleDiffBaseAgainstLatest();
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[1]!.patchNum, 10 as PatchSetNum);
-    assert.isNotOk(args[1]!.basePatchNum);
+    element.handleDiffBaseAgainstLatest();
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/10');
   });
 
   test('toggle attention set status', async () => {
-    element._change = {
+    element.change = {
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
@@ -487,56 +658,53 @@
     const removeFromAttentionSetStub = stubRestApi(
       'removeFromAttentionSet'
     ).returns(Promise.resolve(new Response()));
-    element._patchRange = {
+    element.patchRange = {
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-
-    assert.isNotOk(element._change.attention_set);
-    await element._getLoggedIn();
-    await element.restApiService.getAccount();
-    element._handleToggleAttentionSet();
+    await element.updateComplete;
+    assert.isNotOk(element.change.attention_set);
+    element.handleToggleAttentionSet();
     assert.isTrue(addToAttentionSetStub.called);
     assert.isFalse(removeFromAttentionSetStub.called);
 
-    element._handleToggleAttentionSet();
+    element.handleToggleAttentionSet();
     assert.isTrue(removeFromAttentionSetStub.called);
   });
 
   suite('plugins adding to file tab', () => {
     setup(async () => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      // Resolving it here instead of during setup() as other tests depend
-      // on flush() not being called during setup.
-      await flush();
+      element.changeNum = TEST_NUMERIC_CHANGE_ID;
+      await element.updateComplete;
+      await waitUntil(() => element.pluginTabsHeaderEndpoints.length > 0);
     });
 
-    test('plugin added tab shows up as a dynamic endpoint', () => {
+    test('plugin added tab shows up as a dynamic endpoint', async () => {
       assert(
-        element._dynamicTabHeaderEndpoints.includes(
-          'change-view-tab-header-url'
-        )
+        element.pluginTabsHeaderEndpoints.includes('change-view-tab-header-url')
       );
-      const primaryTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
-      const paperTabs = primaryTabs.querySelectorAll<HTMLElement>('paper-tab');
-      // 4 Tabs are : Files, Comment Threads, Plugin, Findings
-      assert.equal(primaryTabs.querySelectorAll('paper-tab').length, 4);
+      const tabs = element.shadowRoot!.querySelector('#tabs')!;
+      const paperTabs = tabs.querySelectorAll<HTMLElement>('paper-tab');
+      // 4 Tabs are : Files, Comment Threads, Plugin
+      assert.equal(tabs.querySelectorAll('paper-tab').length, 3);
+      assert.equal(paperTabs[0].dataset.name, 'files');
+      assert.equal(paperTabs[1].dataset.name, 'comments');
       assert.equal(paperTabs[2].dataset.name, 'change-view-tab-header-url');
     });
 
-    test('_setActivePrimaryTab switched tab correctly', async () => {
-      element._setActivePrimaryTab(
+    test('setActiveTab switched tab correctly', async () => {
+      element.setActiveTab(
         new CustomEvent('', {
           detail: {tab: 'change-view-tab-header-url'},
         })
       );
-      await flush();
-      assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+      await element.updateComplete;
+      assert.equal(element.activeTab, 'change-view-tab-header-url');
     });
 
-    test('show-primary-tab switched primary tab correctly', async () => {
+    test('show-tab switched primary tab correctly', async () => {
       element.dispatchEvent(
-        new CustomEvent('show-primary-tab', {
+        new CustomEvent('show-tab', {
           composed: true,
           bubbles: true,
           detail: {
@@ -544,42 +712,49 @@
           },
         })
       );
-      await flush();
-      assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+      await element.updateComplete;
+      assert.equal(element.activeTab, 'change-view-tab-header-url');
     });
 
     test('param change should switch primary tab correctly', async () => {
-      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      assert.equal(element.activeTab, Tab.FILES);
       // view is required
-      element._changeNum = undefined;
-      element.params = {
-        ...createAppElementChangeViewParams(),
-        ...element.params,
-        tab: PrimaryTab.FINDINGS,
+      element.changeNum = undefined;
+      element.viewState = {
+        ...createChangeViewState(),
+        ...element.viewState,
+        tab: Tab.COMMENT_THREADS,
       };
-      await flush();
-      assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
+      await element.updateComplete;
+      assert.equal(element.activeTab, Tab.COMMENT_THREADS);
     });
 
     test('invalid param change should not switch primary tab', async () => {
-      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      assert.equal(element.activeTab, Tab.FILES);
       // view is required
-      element.params = {
-        ...createAppElementChangeViewParams(),
-        ...element.params,
+      element.viewState = {
+        ...createChangeViewState(),
+        ...element.viewState,
         tab: 'random',
       };
-      await flush();
-      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      await element.updateComplete;
+      assert.equal(element.activeTab, Tab.FILES);
     });
 
-    test('switching tab sets _selectedTabPluginEndpoint', async () => {
-      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
-      tap(paperTabs.querySelectorAll('paper-tab')[2]);
-      await flush();
-      assert.equal(
-        element._selectedTabPluginEndpoint,
-        'change-view-tab-content-url'
+    test('switching to plugin tab renders the plugin tab content', async () => {
+      const paperTabs = element.shadowRoot!.querySelector('#tabs')!;
+      paperTabs.querySelectorAll('paper-tab')[2].click();
+      await element.updateComplete;
+      const tabContent = queryAndAssert(element, '.tabContent');
+      const endpoint = queryAndAssert(tabContent, 'gr-endpoint-decorator');
+      assert.dom.equal(
+        endpoint,
+        /* HTML */ `
+          <gr-endpoint-decorator>
+            <gr-endpoint-param name="change"></gr-endpoint-param>
+            <gr-endpoint-param name="revision"></gr-endpoint-param>
+          </gr-endpoint-decorator>
+        `
       );
     });
   });
@@ -596,91 +771,90 @@
     });
 
     test('t to add topic', () => {
-      const editStub = sinon.stub(element.$.metadata, 'editTopic');
-      pressAndReleaseKeyOn(element, 83, null, 't');
+      assertIsDefined(element.metadata);
+      const editStub = sinon.stub(element.metadata, 'editTopic');
+      pressKey(element, 't');
       assert(editStub.called);
     });
 
     test('S should toggle the CL star', () => {
-      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
-      pressAndReleaseKeyOn(element, 83, null, 's');
+      assertIsDefined(element.changeStar);
+      const starStub = sinon.stub(element.changeStar, 'toggleStar');
+      pressKey(element, 's');
       assert(starStub.called);
     });
 
     test('toggle star is throttled', () => {
-      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
-      pressAndReleaseKeyOn(element, 83, null, 's');
+      assertIsDefined(element.changeStar);
+      const starStub = sinon.stub(element.changeStar, 'toggleStar');
+      pressKey(element, 's');
       assert(starStub.called);
-      pressAndReleaseKeyOn(element, 83, null, 's');
+      pressKey(element, 's');
       assert.equal(starStub.callCount, 1);
       clock.tick(1000);
-      pressAndReleaseKeyOn(element, 83, null, 's');
+      pressKey(element, 's');
       assert.equal(starStub.callCount, 2);
     });
 
     test('U should navigate to root if no backPage set', () => {
-      const relativeNavStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
-      pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert.isTrue(relativeNavStub.called);
-      assert.isTrue(
-        relativeNavStub.lastCall.calledWithExactly(GerritNav.getUrlForRoot())
-      );
+      pressKey(element, 'u');
+      assert.isTrue(setUrlStub.called);
+      assert.isTrue(setUrlStub.lastCall.calledWithExactly(rootUrl()));
     });
 
     test('U should navigate to backPage if set', () => {
-      const relativeNavStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
       element.backPage = '/dashboard/self';
-      pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert.isTrue(relativeNavStub.called);
-      assert.isTrue(
-        relativeNavStub.lastCall.calledWithExactly('/dashboard/self')
-      );
+      pressKey(element, 'u');
+      assert.isTrue(setUrlStub.called);
+      assert.isTrue(setUrlStub.lastCall.calledWithExactly('/dashboard/self'));
     });
 
     test('A fires an error event when not logged in', async () => {
-      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+      element.userModel.setAccount(undefined);
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      pressAndReleaseKeyOn(element, 65, null, 'a');
-      await flush();
-      assert.isFalse(element.$.replyOverlay.opened);
+      pressKey(element, 'a');
+      await element.updateComplete;
+      assertIsDefined(element.replyOverlay);
+      assert.isFalse(element.replyOverlay.opened);
       assert.isTrue(loggedInErrorSpy.called);
     });
 
     test('shift A does not open reply overlay', async () => {
-      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-      pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-      await flush();
-      assert.isFalse(element.$.replyOverlay.opened);
+      pressKey(element, 'a', Modifier.SHIFT_KEY);
+      await element.updateComplete;
+      assertIsDefined(element.replyOverlay);
+      assert.isFalse(element.replyOverlay.opened);
     });
 
     test('A toggles overlay when logged in', async () => {
-      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-      element._change = {
+      element.change = {
         ...createChangeViewChange(),
         revisions: createRevisions(1),
         messages: createChangeMessages(1),
       };
-      element._change.labels = {};
+      element.change.labels = {};
+      await element.updateComplete;
 
-      const openSpy = sinon.spy(element, '_openReplyDialog');
+      const openSpy = sinon.spy(element, 'openReplyDialog');
 
-      pressAndReleaseKeyOn(element, 65, null, 'a');
-      await flush();
-      assert.isTrue(element.$.replyOverlay.opened);
-      element.$.replyOverlay.close();
-      assert.isFalse(element.$.replyOverlay.opened);
+      pressKey(element, 'a');
+      await element.updateComplete;
+      assertIsDefined(element.replyOverlay);
+      assert.isTrue(element.replyOverlay.opened);
+      element.replyOverlay.close();
+      assert.isFalse(element.replyOverlay.opened);
       assert(
         openSpy.lastCall.calledWithExactly(FocusTarget.ANY),
-        '_openReplyDialog should have been passed ANY'
+        'openReplyDialog should have been passed ANY'
       );
       assert.equal(openSpy.callCount, 1);
     });
 
-    test('fullscreen-overlay-opened hides content', () => {
-      element._loggedIn = true;
-      element._loading = false;
-      element._change = {
+    test('fullscreen-overlay-opened hides content', async () => {
+      element.loggedIn = true;
+      element.loading = false;
+      element.change = {
         ...createChangeViewChange(),
         labels: {},
         actions: {
@@ -692,7 +866,8 @@
           },
         },
       };
-      const handlerSpy = sinon.spy(element, '_handleHideBackgroundContent');
+      await element.updateComplete;
+      const handlerSpy = sinon.spy(element, 'handleHideBackgroundContent');
       const overlay = queryAndAssert<GrOverlay>(element, '#replyOverlay');
       overlay.dispatchEvent(
         new CustomEvent('fullscreen-overlay-opened', {
@@ -700,15 +875,18 @@
           bubbles: true,
         })
       );
+      await element.updateComplete;
       assert.isTrue(handlerSpy.called);
-      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-      assert.equal(getComputedStyle(element.$.actions).display, 'flex');
+      assertIsDefined(element.mainContent);
+      assertIsDefined(element.actions);
+      assert.isTrue(element.mainContent.classList.contains('overlayOpen'));
+      assert.equal(getComputedStyle(element.actions).display, 'flex');
     });
 
-    test('fullscreen-overlay-closed shows content', () => {
-      element._loggedIn = true;
-      element._loading = false;
-      element._change = {
+    test('fullscreen-overlay-closed shows content', async () => {
+      element.loggedIn = true;
+      element.loading = false;
+      element.change = {
         ...createChangeViewChange(),
         labels: {},
         actions: {
@@ -720,7 +898,8 @@
           },
         },
       };
-      const handlerSpy = sinon.spy(element, '_handleShowBackgroundContent');
+      await element.updateComplete;
+      const handlerSpy = sinon.spy(element, 'handleShowBackgroundContent');
       const overlay = queryAndAssert<GrOverlay>(element, '#replyOverlay');
       overlay.dispatchEvent(
         new CustomEvent('fullscreen-overlay-closed', {
@@ -728,13 +907,17 @@
           bubbles: true,
         })
       );
+      await element.updateComplete;
       assert.isTrue(handlerSpy.called);
-      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+      assertIsDefined(element.mainContent);
+      assert.isFalse(element.mainContent.classList.contains('overlayOpen'));
     });
 
     test('expand all messages when expand-diffs fired', () => {
-      const handleExpand = sinon.stub(element.$.fileList, 'expandAllDiffs');
-      element.$.fileListHeader.dispatchEvent(
+      assertIsDefined(element.fileList);
+      assertIsDefined(element.fileListHeader);
+      const handleExpand = sinon.stub(element.fileList, 'expandAllDiffs');
+      element.fileListHeader.dispatchEvent(
         new CustomEvent('expand-diffs', {
           composed: true,
           bubbles: true,
@@ -744,8 +927,10 @@
     });
 
     test('collapse all messages when collapse-diffs fired', () => {
-      const handleCollapse = sinon.stub(element.$.fileList, 'collapseAllDiffs');
-      element.$.fileListHeader.dispatchEvent(
+      assertIsDefined(element.fileList);
+      assertIsDefined(element.fileListHeader);
+      const handleCollapse = sinon.stub(element.fileList, 'collapseAllDiffs');
+      element.fileListHeader.dispatchEvent(
         new CustomEvent('collapse-diffs', {
           composed: true,
           bubbles: true,
@@ -755,57 +940,58 @@
     });
 
     test('X should expand all messages', async () => {
-      await flush();
+      await element.updateComplete;
       const handleExpand = sinon.stub(
         element.messagesList!,
         'handleExpandCollapse'
       );
-      pressAndReleaseKeyOn(element, 88, null, 'x');
+      pressKey(element, 'x');
       assert(handleExpand.calledWith(true));
     });
 
     test('Z should collapse all messages', async () => {
-      await flush();
+      await element.updateComplete;
       const handleExpand = sinon.stub(
         element.messagesList!,
         'handleExpandCollapse'
       );
-      pressAndReleaseKeyOn(element, 90, null, 'z');
+      pressKey(element, 'z');
       assert(handleExpand.calledWith(false));
     });
 
     test('d should open download overlay', () => {
+      assertIsDefined(element.downloadOverlay);
       const stub = sinon
-        .stub(element.$.downloadOverlay, 'open')
+        .stub(element.downloadOverlay, 'open')
         .returns(Promise.resolve());
-      pressAndReleaseKeyOn(element, 68, null, 'd');
+      pressKey(element, 'd');
       assert.isTrue(stub.called);
     });
 
-    test(', should open diff preferences', () => {
-      const stub = sinon.stub(
-        element.$.fileList.$.diffPreferencesDialog,
-        'open'
-      );
-      element._loggedIn = false;
-      pressAndReleaseKeyOn(element, 188, null, ',');
+    test(', should open diff preferences', async () => {
+      assertIsDefined(element.fileList);
+      await element.fileList.updateComplete;
+      assertIsDefined(element.fileList.diffPreferencesDialog);
+      const stub = sinon.stub(element.fileList.diffPreferencesDialog, 'open');
+      element.loggedIn = false;
+      pressKey(element, ',');
       assert.isFalse(stub.called);
 
-      element._loggedIn = true;
-      pressAndReleaseKeyOn(element, 188, null, ',');
+      element.loggedIn = true;
+      pressKey(element, ',');
       assert.isTrue(stub.called);
     });
 
     test('m should toggle diff mode', async () => {
       const updatePreferencesStub = stubUsers('updatePreferences');
-      await flush();
+      await element.updateComplete;
 
       const prefs = {
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       };
       element.userModel.setPreferences(prefs);
-      element._handleToggleDiffMode();
+      element.handleToggleDiffMode();
       assert.isTrue(
         updatePreferencesStub.calledWith({diff_view: DiffViewMode.UNIFIED})
       );
@@ -815,8 +1001,8 @@
         diff_view: DiffViewMode.UNIFIED,
       };
       element.userModel.setPreferences(newPrefs);
-      await flush();
-      element._handleToggleDiffMode();
+      await element.updateComplete;
+      element.handleToggleDiffMode();
       assert.isTrue(
         updatePreferencesStub.calledWith({diff_view: DiffViewMode.SIDE_BY_SIDE})
       );
@@ -825,12 +1011,12 @@
 
   suite('thread list and change log tabs', () => {
     setup(() => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._patchRange = {
-        basePatchNum: ParentPatchSetNum,
+      element.changeNum = TEST_NUMERIC_CHANGE_ID;
+      element.patchRange = {
+        basePatchNum: PARENT,
         patchNum: 1 as RevisionPatchSetNum,
       };
-      element._change = {
+      element.change = {
         ...createChangeViewChange(),
         revisions: {
           rev2: createRevision(2),
@@ -854,15 +1040,15 @@
       ) as GrRelatedChangesList;
       sinon.stub(relatedChanges, 'reload');
       sinon.stub(element, 'loadData').returns(Promise.resolve());
-      sinon.spy(element, '_paramsChanged');
-      element.params = createAppElementChangeViewParams();
+      sinon.spy(element, 'viewStateChanged');
+      element.viewState = createChangeViewState();
     });
   });
 
   suite('Comments tab', () => {
     setup(async () => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._change = {
+      element.changeNum = TEST_NUMERIC_CHANGE_ID;
+      element.change = {
         ...createChangeViewChange(),
         revisions: {
           rev2: createRevision(2),
@@ -873,11 +1059,14 @@
         },
         current_revision: 'rev4' as CommitId,
       };
-      element._commentThreads = THREADS;
-      await flush();
-      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
-      tap(paperTabs.querySelectorAll('paper-tab')[1]);
-      await flush();
+      element.commentThreads = THREADS;
+      await element.updateComplete;
+      const paperTabs = element.shadowRoot!.querySelector('#tabs')!;
+      const tabs = paperTabs.querySelectorAll('paper-tab');
+      assert.isTrue(tabs.length > 1);
+      assert.equal(tabs[1].dataset.name, 'comments');
+      tabs[1].click();
+      await element.updateComplete;
     });
 
     test('commentId overrides unresolveOnly default', async () => {
@@ -890,15 +1079,15 @@
       assert.isTrue(threadList.unresolvedOnly);
 
       element.scrollCommentId = 'abcd' as UrlEncodedCommentId;
-      await flush();
+      await element.updateComplete;
       assert.isFalse(threadList.unresolvedOnly);
     });
   });
 
   suite('Findings robot-comment tab', () => {
     setup(async () => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._change = {
+      element.changeNum = TEST_NUMERIC_CHANGE_ID;
+      element.change = {
         ...createChangeViewChange(),
         revisions: {
           rev2: createRevision(2),
@@ -909,15 +1098,19 @@
         },
         current_revision: 'rev4' as CommitId,
       };
-      element._commentThreads = THREADS;
-      await flush();
-      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
-      tap(paperTabs.querySelectorAll('paper-tab')[3]);
-      await flush();
+      element.commentThreads = THREADS;
+      element.showFindingsTab = true;
+      await element.updateComplete;
+      const paperTabs = element.shadowRoot!.querySelector('#tabs')!;
+      const tabs = paperTabs.querySelectorAll('paper-tab');
+      assert.isTrue(tabs.length > 3);
+      assert.equal(tabs[3].dataset.name, 'findings');
+      tabs[3].click();
+      await element.updateComplete;
     });
 
     test('robot comments count per patchset', () => {
-      const count = element._robotCommentCountPerPatchSet(THREADS);
+      const count = element.robotCommentCountPerPatchSet(THREADS);
       const expectedCount = {
         2: 1,
         3: 1,
@@ -925,37 +1118,44 @@
       };
       assert.deepEqual(count, expectedCount);
       assert.equal(
-        element._computeText(createRevision(2), THREADS),
+        element.computeText(createRevision(2), THREADS),
         'Patchset 2 (1 finding)'
       );
       assert.equal(
-        element._computeText(createRevision(4), THREADS),
+        element.computeText(createRevision(4), THREADS),
         'Patchset 4 (2 findings)'
       );
       assert.equal(
-        element._computeText(createRevision(5), THREADS),
+        element.computeText(createRevision(5), THREADS),
         'Patchset 5'
       );
     });
 
     test('only robot comments are rendered', () => {
-      assert.equal(element._robotCommentThreads!.length, 2);
+      assert.equal(element.computeRobotCommentThreads().length, 2);
       assert.equal(
-        (element._robotCommentThreads![0].comments[0] as RobotCommentInfo)
-          .robot_id,
+        (
+          element.computeRobotCommentThreads()[0]
+            .comments[0] as RobotCommentInfo
+        ).robot_id,
         'rc1'
       );
       assert.equal(
-        (element._robotCommentThreads![1].comments[0] as RobotCommentInfo)
-          .robot_id,
+        (
+          element.computeRobotCommentThreads()[1]
+            .comments[0] as RobotCommentInfo
+        ).robot_id,
         'rc2'
       );
     });
 
     test('changing patchsets resets robot comments', async () => {
-      element.set('_change.current_revision', 'rev3');
-      await flush();
-      assert.equal(element._robotCommentThreads!.length, 1);
+      assertIsDefined(element.change);
+      const newChange = {...element.change};
+      newChange.current_revision = 'rev3' as CommitId;
+      element.change = newChange;
+      await element.updateComplete;
+      assert.equal(element.computeRobotCommentThreads().length, 1);
     });
 
     test('Show more button is hidden', () => {
@@ -968,35 +1168,42 @@
         for (let i = 0; i <= 30; i++) {
           arr.push(...THREADS);
         }
-        element._commentThreads = arr;
-        await flush();
+        element.commentThreads = arr;
+        await element.updateComplete;
       });
 
       test('Show more button is rendered', () => {
         assert.isOk(element.shadowRoot!.querySelector('.show-robot-comments'));
         assert.equal(
-          element._robotCommentThreads!.length,
+          element.computeRobotCommentThreads().length,
           ROBOT_COMMENTS_LIMIT
         );
       });
 
       test('Clicking show more button renders all comments', async () => {
-        tap(element.shadowRoot!.querySelector('.show-robot-comments')!);
-        await flush();
-        assert.equal(element._robotCommentThreads!.length, 62);
+        element
+          .shadowRoot!.querySelector<GrButton>('.show-robot-comments')!
+          .click();
+        await element.updateComplete;
+        assert.equal(element.computeRobotCommentThreads().length, 62);
       });
     });
   });
 
-  test('reply button is not visible when logged out', () => {
-    assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
-    element._loggedIn = true;
-    assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
+  test('reply button is not visible when logged out', async () => {
+    assertIsDefined(element.replyBtn);
+    element.loggedIn = false;
+    await element.updateComplete;
+    assert.equal(getComputedStyle(element.replyBtn).display, 'none');
+    element.loggedIn = true;
+    await element.updateComplete;
+    assert.notEqual(getComputedStyle(element.replyBtn).display, 'none');
   });
 
-  test('download tap calls _handleOpenDownloadDialog', () => {
-    const openDialogStub = sinon.stub(element, '_handleOpenDownloadDialog');
-    element.$.actions.dispatchEvent(
+  test('download tap calls handleOpenDownloadDialog', () => {
+    assertIsDefined(element.actions);
+    const openDialogStub = sinon.stub(element, 'handleOpenDownloadDialog');
+    element.actions.dispatchEvent(
       new CustomEvent('download-tap', {
         composed: true,
         bubbles: true,
@@ -1006,16 +1213,16 @@
   });
 
   test('fetches the server config on attached', async () => {
-    await flush();
+    await element.updateComplete;
     assert.equal(
-      element._serverConfig!.user.anonymous_coward_name,
+      element.serverConfig!.user.anonymous_coward_name,
       'test coward name'
     );
   });
 
-  test('_changeStatuses', () => {
-    element._loading = false;
-    element._change = {
+  test('changeStatuses', async () => {
+    element.loading = false;
+    element.change = {
       ...createChangeViewChange(),
       revisions: {
         rev2: createRevision(2),
@@ -1025,7 +1232,6 @@
       },
       current_revision: 'rev3' as CommitId,
       status: ChangeStatus.MERGED,
-      work_in_progress: true,
       labels: {
         test: {
           all: [],
@@ -1035,13 +1241,13 @@
         },
       },
     };
-    element._mergeable = true;
-    const expectedStatuses = [ChangeStates.MERGED, ChangeStates.WIP];
-    assert.deepEqual(element._changeStatuses, expectedStatuses);
-    flush();
+    element.mergeable = true;
+    await element.updateComplete;
+    const expectedStatuses = [ChangeStates.MERGED];
+    assert.deepEqual(element.changeStatuses, expectedStatuses);
     const statusChips =
       element.shadowRoot!.querySelectorAll('gr-change-status');
-    assert.equal(statusChips.length, 2);
+    assert.equal(statusChips.length, 1);
   });
 
   suite('ChangeStatus revert', () => {
@@ -1061,17 +1267,18 @@
           ...createChange(),
         })
       );
-      element._change = change;
-      element._mergeable = true;
-      element._submitEnabled = true;
-      await flush();
-      element.computeRevertSubmitted(element._change);
-      await flush();
+      element.change = change;
+      element.mergeable = true;
+      element.currentRevisionActions = {submit: {enabled: true}};
+      assert.isTrue(element.isSubmitEnabled());
+      await element.updateComplete;
+      element.computeRevertSubmitted(element.change);
+      await element.updateComplete;
       assert.isFalse(
-        element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
       );
       assert.isFalse(
-        element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
       );
     });
 
@@ -1099,17 +1306,18 @@
           status: ChangeStatus.ABANDONED,
         })
       );
-      element._change = change;
-      element._mergeable = true;
-      element._submitEnabled = true;
-      await flush();
-      element.computeRevertSubmitted(element._change);
-      await flush();
+      element.change = change;
+      element.mergeable = true;
+      element.currentRevisionActions = {submit: {enabled: true}};
+      assert.isTrue(element.isSubmitEnabled());
+      await element.updateComplete;
+      element.computeRevertSubmitted(element.change);
+      await element.updateComplete;
       assert.isFalse(
-        element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
       );
       assert.isFalse(
-        element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
       );
     });
 
@@ -1135,17 +1343,20 @@
           ...createChange(),
         })
       );
-      element._change = change;
-      element._mergeable = true;
-      element._submitEnabled = true;
-      await flush();
-      element.computeRevertSubmitted(element._change);
-      await flush();
+      element.change = change;
+      element.mergeable = true;
+      element.currentRevisionActions = {submit: {enabled: true}};
+      assert.isTrue(element.isSubmitEnabled());
+      await element.updateComplete;
+      element.computeRevertSubmitted(element.change);
+      // Wait for promises to settle.
+      await waitEventLoop();
+      await element.updateComplete;
       assert.isFalse(
-        element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
       );
       assert.isTrue(
-        element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
       );
     });
 
@@ -1168,24 +1379,31 @@
           ...createChange(),
         })
       );
-      element._change = change;
-      element._mergeable = true;
-      element._submitEnabled = true;
-      await flush();
-      element.computeRevertSubmitted(element._change);
-      await flush();
+      element.change = change;
+      element.mergeable = true;
+      element.currentRevisionActions = {submit: {enabled: true}};
+      assert.isTrue(element.isSubmitEnabled());
+      await element.updateComplete;
+      element.computeRevertSubmitted(element.change);
+      // Wait for promises to settle.
+      await waitEventLoop();
+      await element.updateComplete;
       assert.isFalse(
-        element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
       );
       assert.isTrue(
-        element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
       );
     });
   });
 
-  test('diff preferences open when open-diff-prefs is fired', () => {
-    const overlayOpenStub = sinon.stub(element.$.fileList, 'openDiffPrefs');
-    element.$.fileListHeader.dispatchEvent(
+  test('diff preferences open when open-diff-prefs is fired', async () => {
+    await element.updateComplete;
+    assertIsDefined(element.fileList);
+    assertIsDefined(element.fileListHeader);
+    await element.fileList.updateComplete;
+    const overlayOpenStub = sinon.stub(element.fileList, 'openDiffPrefs');
+    element.fileListHeader.dispatchEvent(
       new CustomEvent('open-diff-prefs', {
         composed: true,
         bubbles: true,
@@ -1194,40 +1412,42 @@
     assert.isTrue(overlayOpenStub.called);
   });
 
-  test('_prepareCommitMsgForLinkify', () => {
+  test('prepareCommitMsgForLinkify', () => {
     let commitMessage = 'R=test@google.com';
-    let result = element._prepareCommitMsgForLinkify(commitMessage);
+    let result = element.prepareCommitMsgForLinkify(commitMessage);
     assert.equal(result, 'R=\u200Btest@google.com');
 
     commitMessage = 'R=test@google.com\nR=test@google.com';
-    result = element._prepareCommitMsgForLinkify(commitMessage);
+    result = element.prepareCommitMsgForLinkify(commitMessage);
     assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
 
     commitMessage = 'CC=test@google.com';
-    result = element._prepareCommitMsgForLinkify(commitMessage);
+    result = element.prepareCommitMsgForLinkify(commitMessage);
     assert.equal(result, 'CC=\u200Btest@google.com');
   });
 
   test('_isSubmitEnabled', () => {
-    assert.isFalse(element._isSubmitEnabled({}));
-    assert.isFalse(element._isSubmitEnabled({submit: {}}));
-    assert.isTrue(element._isSubmitEnabled({submit: {enabled: true}}));
+    assert.isFalse(element.isSubmitEnabled());
+    element.currentRevisionActions = {submit: {}};
+    assert.isFalse(element.isSubmitEnabled());
+    element.currentRevisionActions = {submit: {enabled: true}};
+    assert.isTrue(element.isSubmitEnabled());
   });
 
-  test('_reload is called when an approved label is removed', () => {
+  test('reload is called when an approved label is removed', async () => {
     const vote: ApprovalInfo = {
       ...createApproval(),
       _account_id: 1 as AccountId,
       name: 'bojack',
       value: 1,
     };
-    element._changeNum = TEST_NUMERIC_CHANGE_ID;
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
+    element.changeNum = TEST_NUMERIC_CHANGE_ID;
+    element.patchRange = {
+      basePatchNum: PARENT,
       patchNum: 1 as RevisionPatchSetNum,
     };
     const change = {
-      ...createChangeViewChange(),
+      ...createParsedChange(),
       owner: createAccountWithIdNameAndEmail(),
       revisions: {
         rev2: createRevision(2),
@@ -1246,76 +1466,82 @@
         },
       },
     };
-    element._change = change;
-    flush();
+    element.change = change;
+    await element.updateComplete;
     const reloadStub = sinon.stub(element, 'loadData');
-    element.splice('_change.labels.test.all', 0, 1);
+    const newChange = {...element.change};
+    (newChange.labels!.test! as DetailedLabelInfo).all = [];
+    element.change = deepClone(newChange);
+    await element.updateComplete;
     assert.isFalse(reloadStub.called);
-    change.labels.test.all.push(vote);
-    change.labels.test.all.push(vote);
-    change.labels.test.approved = vote;
-    flush();
-    element.splice('_change.labels.test.all', 0, 2);
+
+    assert.isDefined(element.change);
+    const testLabels: DetailedLabelInfo & QuickLabelInfo =
+      newChange.labels!.test;
+    assertIsDefined(testLabels);
+    testLabels.all!.push(vote);
+    testLabels.all!.push(vote);
+    testLabels.approved = vote;
+    element.change = deepClone(newChange);
+    await element.updateComplete;
+    assert.isFalse(reloadStub.called);
+
+    assert.isDefined(element.change);
+    (newChange.labels!.test! as DetailedLabelInfo).all = [];
+    element.change = deepClone(newChange);
+    await element.updateComplete;
     assert.isTrue(reloadStub.called);
     assert.isTrue(reloadStub.calledOnce);
   });
 
   test('reply button has updated count when there are drafts', () => {
-    const getLabel = element._computeReplyButtonLabel;
-
-    assert.equal(getLabel(undefined, false), 'Reply');
-    assert.equal(getLabel(undefined, true), 'Reply');
-
-    let drafts = {};
-    assert.equal(getLabel(drafts, false), 'Reply');
-
-    drafts = {
-      'file1.txt': [{}],
-      'file2.txt': [{}, {}],
+    const getLabel = (canReview: boolean) => {
+      element.change!.actions!.ready = {enabled: canReview};
+      return element.computeReplyButtonLabel();
     };
-    assert.equal(getLabel(drafts, false), 'Reply (3)');
-    assert.equal(getLabel(drafts, true), 'Start Review (3)');
+    element.change = createParsedChange();
+    element.change.actions = {};
+    element.diffDrafts = undefined;
+    assert.equal(getLabel(false), 'Reply');
+    assert.equal(getLabel(true), 'Reply');
+
+    element.diffDrafts = {};
+    assert.equal(getLabel(false), 'Reply');
+    assert.equal(getLabel(true), 'Start Review');
+
+    element.diffDrafts = {
+      'file1.txt': [createDraft()],
+      'file2.txt': [createDraft(), createDraft()],
+    };
+    assert.equal(getLabel(false), 'Reply (3)');
+    assert.equal(getLabel(true), 'Start Review (3)');
   });
 
-  test('change num change', () => {
+  test('change num change', async () => {
     const change = {
       ...createChangeViewChange(),
       labels: {},
     } as ParsedChangeInfo;
-    element._changeNum = undefined;
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
+    element.changeNum = undefined;
+    element.patchRange = {
+      basePatchNum: PARENT,
       patchNum: 2 as RevisionPatchSetNum,
     };
-    element._change = change;
-    element.viewState.changeNum = null;
-    element.viewState.diffMode = DiffViewMode.UNIFIED;
-    assert.equal(element.viewState.numFilesShown, 200);
-    assert.equal(element._numFilesShown, 200);
-    element._numFilesShown = 150;
-    flush();
-    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
-    assert.equal(element.viewState.numFilesShown, 150);
+    element.change = change;
+    assertIsDefined(element.fileList);
+    assert.equal(element.fileList.numFilesShown, DEFAULT_NUM_FILES_SHOWN);
+    element.fileList.numFilesShown = 150;
+    element.fileList.selectedIndex = 15;
+    await element.updateComplete;
 
-    element._changeNum = 1 as NumericChangeId;
-    element.params = {
-      ...createAppElementChangeViewParams(),
-      changeNum: 1 as NumericChangeId,
-    };
-    flush();
-    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
-    assert.equal(element.viewState.changeNum, 1);
-
-    element._changeNum = 2 as NumericChangeId;
-    element.params = {
-      ...createAppElementChangeViewParams(),
+    element.changeNum = 2 as NumericChangeId;
+    element.viewState = {
+      ...createChangeViewState(),
       changeNum: 2 as NumericChangeId,
     };
-    flush();
-    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
-    assert.equal(element.viewState.changeNum, 2);
-    assert.equal(element.viewState.numFilesShown, 200);
-    assert.equal(element._numFilesShown, 200);
+    await element.updateComplete;
+    assert.equal(element.fileList.numFilesShown, DEFAULT_NUM_FILES_SHOWN);
+    assert.equal(element.fileList.selectedIndex, 0);
   });
 
   test('don’t reload entire page when patchRange changes', async () => {
@@ -1323,22 +1549,24 @@
       .stub(element, 'loadData')
       .callsFake(() => Promise.resolve());
     const reloadPatchDependentStub = sinon
-      .stub(element, '_reloadPatchNumDependentResources')
+      .stub(element, 'reloadPatchNumDependentResources')
       .callsFake(() => Promise.resolve([undefined, undefined, undefined]));
-    flush();
-    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
-    const value: AppElementChangeViewParams = {
-      ...createAppElementChangeViewParams(),
+    assertIsDefined(element.fileList);
+    await element.fileList.updateComplete;
+    const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
+    const value: ChangeViewState = {
+      ...createChangeViewState(),
       view: GerritView.CHANGE,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element._changeNum = undefined;
-    element.params = value;
-    await flush();
+    element.changeNum = undefined;
+    element.viewState = value;
+    await element.updateComplete;
     assert.isTrue(reloadStub.calledOnce);
 
-    element._initialLoadComplete = true;
-    element._change = {
+    element.initialLoadComplete = true;
+    element.fileList.selectedIndex = 15;
+    element.change = {
       ...createChangeViewChange(),
       revisions: {
         rev1: createRevision(1),
@@ -1348,18 +1576,20 @@
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
-    element.params = {...value};
-    await flush();
+    element.viewState = {...value};
+    await element.updateComplete;
+    await waitEventLoop();
+    assert.equal(element.fileList.selectedIndex, 0);
     assert.isFalse(reloadStub.calledTwice);
     assert.isTrue(reloadPatchDependentStub.calledOnce);
     assert.isTrue(collapseStub.calledTwice);
   });
 
   test('reload ported comments when patchNum changes', async () => {
+    assertIsDefined(element.fileList);
     sinon.stub(element, 'loadData').callsFake(() => Promise.resolve());
     sinon.stub(element, 'loadAndSetCommitInfo');
-    sinon.stub(element.$.fileList, 'reload');
-    flush();
+    await element.updateComplete;
     const reloadPortedCommentsStub = sinon.stub(
       element.getCommentsModel(),
       'reloadPortedComments'
@@ -1368,18 +1598,18 @@
       element.getCommentsModel(),
       'reloadPortedDrafts'
     );
-    sinon.stub(element.$.fileList, 'collapseAllDiffs');
+    sinon.stub(element.fileList, 'collapseAllDiffs');
 
-    const value: AppElementChangeViewParams = {
-      ...createAppElementChangeViewParams(),
+    const value: ChangeViewState = {
+      ...createChangeViewState(),
       view: GerritView.CHANGE,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element.params = value;
-    await flush();
+    element.viewState = value;
+    await element.updateComplete;
 
-    element._initialLoadComplete = true;
-    element._change = {
+    element.initialLoadComplete = true;
+    element.change = {
       ...createChangeViewChange(),
       revisions: {
         rev1: createRevision(1),
@@ -1389,66 +1619,66 @@
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
-    element.params = {...value};
-    await flush();
+    element.viewState = {...value};
+    await element.updateComplete;
     assert.isTrue(reloadPortedCommentsStub.calledOnce);
     assert.isTrue(reloadPortedDraftsStub.calledOnce);
   });
 
   test('do not reload entire page when patchRange doesnt change', async () => {
+    assertIsDefined(element.fileList);
     const reloadStub = sinon
       .stub(element, 'loadData')
       .callsFake(() => Promise.resolve());
-    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
-    const value: AppElementChangeViewParams =
-      createAppElementChangeViewParams();
-    element.params = value;
+    const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
+    const value: ChangeViewState = createChangeViewState();
+    element.viewState = value;
     // change already loaded
-    assert.isOk(element._changeNum);
-    await flush();
+    assert.isOk(element.changeNum);
+    await element.updateComplete;
     assert.isFalse(reloadStub.calledOnce);
-    element._initialLoadComplete = true;
-    element.params = {...value};
-    await flush();
+    element.initialLoadComplete = true;
+    element.viewState = {...value};
+    await element.updateComplete;
     assert.isFalse(reloadStub.calledTwice);
     assert.isFalse(collapseStub.calledTwice);
   });
 
   test('forceReload updates the change', async () => {
+    assertIsDefined(element.fileList);
     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();
+    const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
+    element.viewState = {...createChangeViewState(), forceReload: true};
+    await element.updateComplete;
     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);
+    // 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 () => {
     const recreateSpy = sinon.spy();
     element.addEventListener('recreate-change-view', recreateSpy);
 
-    const value: AppElementChangeViewParams =
-      createAppElementChangeViewParams();
-    element.params = value;
-    await flush();
+    const value: ChangeViewState = createChangeViewState();
+    element.viewState = value;
+    await element.updateComplete;
     assert.isFalse(recreateSpy.calledOnce);
 
     value.changeNum = 555111333 as NumericChangeId;
-    element.params = {...value};
-    await flush();
+    element.viewState = {...value};
+    await element.updateComplete;
     assert.isTrue(recreateSpy.calledOnce);
   });
 
   test('related changes are updated when loadData is called', async () => {
-    await flush();
+    await element.updateComplete;
     const relatedChanges = element.shadowRoot!.querySelector(
       '#relatedChanges'
     ) as GrRelatedChangesList;
@@ -1457,7 +1687,7 @@
       Promise.resolve({...createMergeable(), mergeable: true})
     );
 
-    element.params = createAppElementChangeViewParams();
+    element.viewState = createChangeViewState();
     element.getChangeModel().setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
@@ -1466,12 +1696,12 @@
     });
 
     await element.loadData(true);
-    assert.isFalse(navigateToChangeStub.called);
+    assert.isFalse(setUrlStub.called);
     assert.isTrue(reloadStub.called);
   });
 
-  test('_computeCopyTextForTitle', () => {
-    const change: ChangeInfo = {
+  test('computeCopyTextForTitle', () => {
+    element.change = {
       ...createChangeViewChange(),
       _number: 123 as NumericChangeId,
       subject: 'test subject',
@@ -1481,10 +1711,9 @@
       },
       current_revision: 'rev3' as CommitId,
     };
-    sinon.stub(GerritNav, 'getUrlForChange').returns('/change/123');
     assert.equal(
-      element._computeCopyTextForTitle(change),
-      `123: test subject | http://${location.host}/change/123`
+      element.computeCopyTextForTitle(),
+      `123: test subject | http://${location.host}/c/test-project/+/123`
     );
   });
 
@@ -1497,7 +1726,7 @@
       },
       current_revision: 'rev3' as CommitId,
     };
-    assert.equal(element._getLatestRevisionSHA(change), 'rev3');
+    assert.equal(element.getLatestRevisionSHA(change), 'rev3');
     change = {
       ...createChange(),
       revisions: {
@@ -1505,57 +1734,58 @@
       },
       current_revision: undefined,
     };
-    assert.equal(element._getLatestRevisionSHA(change), 'rev1');
+    assert.equal(element.getLatestRevisionSHA(change), 'rev1');
   });
 
   test('show commit message edit button', () => {
-    const change = createChange();
-    const mergedChanged: ChangeInfo = {
-      ...createChangeViewChange(),
+    const change = createParsedChange();
+    const mergedChanged: ParsedChangeInfo = {
+      ...createParsedChange(),
       status: ChangeStatus.MERGED,
     };
-    assert.isTrue(element._computeHideEditCommitMessage(false, false, change));
-    assert.isTrue(element._computeHideEditCommitMessage(true, true, change));
-    assert.isTrue(element._computeHideEditCommitMessage(false, true, change));
-    assert.isFalse(element._computeHideEditCommitMessage(true, false, change));
+    assert.isTrue(element.computeHideEditCommitMessage(false, false, change));
+    assert.isTrue(element.computeHideEditCommitMessage(true, true, change));
+    assert.isTrue(element.computeHideEditCommitMessage(false, true, change));
+    assert.isFalse(element.computeHideEditCommitMessage(true, false, change));
     assert.isTrue(
-      element._computeHideEditCommitMessage(true, false, mergedChanged)
+      element.computeHideEditCommitMessage(true, false, mergedChanged)
     );
     assert.isTrue(
-      element._computeHideEditCommitMessage(true, false, change, true)
+      element.computeHideEditCommitMessage(true, false, change, true)
     );
     assert.isFalse(
-      element._computeHideEditCommitMessage(true, false, change, false)
+      element.computeHideEditCommitMessage(true, false, change, false)
     );
   });
 
-  test('_handleCommitMessageSave trims trailing whitespace', async () => {
-    element._change = createChangeViewChange();
+  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(
       Promise.resolve(new Response(null, {status: 500}))
     );
-
+    await element.updateComplete;
     const mockEvent = (content: string) =>
       new CustomEvent('', {detail: {content}});
 
-    element._handleCommitMessageSave(mockEvent('test \n  test '));
+    assertIsDefined(element.commitMessageEditor);
+    element.handleCommitMessageSave(mockEvent('test \n  test '));
     assert.equal(putStub.lastCall.args[1], 'test\n  test');
-    element.$.commitMessageEditor.disabled = false;
-    element._handleCommitMessageSave(mockEvent('  test\ntest'));
+    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'));
+    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');
   });
-  test('_computeChangeIdCommitMessageError', () => {
+  test('computeChangeIdCommitMessageError', () => {
     let commitMessage = 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
-    let change: ChangeInfo = {
+    let change: ParsedChangeInfo = {
       ...createChangeViewChange(),
       change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
     };
     assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
+      element.computeChangeIdCommitMessageError(commitMessage, change),
       null
     );
 
@@ -1564,13 +1794,13 @@
       change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
     };
     assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
+      element.computeChangeIdCommitMessageError(commitMessage, change),
       'mismatch'
     );
 
     commitMessage = 'This is the greatest change.';
     assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
+      element.computeChangeIdCommitMessageError(commitMessage, change),
       'missing'
     );
   });
@@ -1580,12 +1810,12 @@
       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
     ].join('\n');
-    let change: ChangeInfo = {
+    let change: ParsedChangeInfo = {
       ...createChangeViewChange(),
       change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
     };
     assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
+      element.computeChangeIdCommitMessageError(commitMessage, change),
       null
     );
     change = {
@@ -1593,7 +1823,7 @@
       change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
     };
     assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
+      element.computeChangeIdCommitMessageError(commitMessage, change),
       'mismatch'
     );
   });
@@ -1603,12 +1833,12 @@
       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
     ].join(' and ');
-    let change: ChangeInfo = {
+    let change: ParsedChangeInfo = {
       ...createChangeViewChange(),
       change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
     };
     assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
+      element.computeChangeIdCommitMessageError(commitMessage, change),
       null
     );
     change = {
@@ -1616,38 +1846,38 @@
       change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
     };
     assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
+      element.computeChangeIdCommitMessageError(commitMessage, change),
       'mismatch'
     );
   });
 
-  test('_computeTitleAttributeWarning', () => {
+  test('computeTitleAttributeWarning', () => {
     let changeIdCommitMessageError = 'missing';
     assert.equal(
-      element._computeTitleAttributeWarning(changeIdCommitMessageError),
+      element.computeTitleAttributeWarning(changeIdCommitMessageError),
       'No Change-Id in commit message'
     );
 
     changeIdCommitMessageError = 'mismatch';
     assert.equal(
-      element._computeTitleAttributeWarning(changeIdCommitMessageError),
+      element.computeTitleAttributeWarning(changeIdCommitMessageError),
       'Change-Id mismatch'
     );
   });
 
-  test('_computeChangeIdClass', () => {
+  test('computeChangeIdClass', () => {
     let changeIdCommitMessageError = 'missing';
-    assert.equal(element._computeChangeIdClass(changeIdCommitMessageError), '');
+    assert.equal(element.computeChangeIdClass(changeIdCommitMessageError), '');
 
     changeIdCommitMessageError = 'mismatch';
     assert.equal(
-      element._computeChangeIdClass(changeIdCommitMessageError),
+      element.computeChangeIdClass(changeIdCommitMessageError),
       'warning'
     );
   });
 
   test('topic is coalesced to null', async () => {
-    sinon.stub(element, '_changeChanged');
+    sinon.stub(element, 'changeChanged');
     element.getChangeModel().setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
@@ -1659,11 +1889,10 @@
     });
 
     await element.performPostChangeLoadTasks();
-    assert.isNull(element._change!.topic);
+    assert.isNull(element.change!.topic);
   });
 
   test('commit sha is populated from getChangeDetail', async () => {
-    sinon.stub(element, '_changeChanged');
     element.getChangeModel().setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
@@ -1675,27 +1904,28 @@
     });
 
     await element.performPostChangeLoadTasks();
-    assert.equal('foo', element._commitInfo!.commit);
+    assert.equal('foo', element.commitInfo!.commit);
   });
 
-  test('_getBasePatchNum', () => {
-    const _change: ChangeInfo = {
+  test('getBasePatchNum', async () => {
+    element.change = {
       ...createChangeViewChange(),
       revisions: {
         '98da160735fb81604b4c40e93c368f380539dd0e': createRevision(),
       },
     };
-    const _patchRange: ChangeViewPatchRange = {
-      basePatchNum: ParentPatchSetNum,
+    element.patchRange = {
+      basePatchNum: PARENT,
     };
-    assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
+    await element.updateComplete;
+    assert.equal(element.getBasePatchNum(), PARENT);
 
-    element._prefs = {
+    element.prefs = {
       ...createPreferences(),
       default_base_for_merges: DefaultBase.FIRST_PARENT,
     };
 
-    const _change2: ChangeInfo = {
+    element.change = {
       ...createChangeViewChange(),
       revisions: {
         '98da160735fb81604b4c40e93c368f380539dd0e': {
@@ -1716,29 +1946,33 @@
         },
       },
     };
-    assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
+    await element.updateComplete;
+    assert.equal(element.getBasePatchNum(), -1 as BasePatchSetNum);
 
-    _patchRange.patchNum = 1 as RevisionPatchSetNum;
-    assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
+    element.patchRange.basePatchNum = PARENT;
+    element.patchRange.patchNum = 1 as RevisionPatchSetNum;
+    await element.updateComplete;
+    assert.equal(element.getBasePatchNum(), PARENT);
   });
 
-  test('_openReplyDialog called with `ANY` when coming from tap event', async () => {
-    await flush();
-    const openStub = sinon.stub(element, '_openReplyDialog');
-    tap(element.$.replyBtn);
+  test('openReplyDialog called with `ANY` when coming from tap event', async () => {
+    await element.updateComplete;
+    assertIsDefined(element.replyBtn);
+    const openStub = sinon.stub(element, 'openReplyDialog');
+    element.replyBtn.click();
     assert(
       openStub.lastCall.calledWithExactly(FocusTarget.ANY),
-      '_openReplyDialog should have been passed ANY'
+      'openReplyDialog should have been passed ANY'
     );
     assert.equal(openStub.callCount, 1);
   });
 
   test(
-    '_openReplyDialog called with `BODY` when coming from message reply' +
+    'openReplyDialog called with `BODY` when coming from message reply' +
       'event',
     async () => {
-      await flush();
-      const openStub = sinon.stub(element, '_openReplyDialog');
+      await element.updateComplete;
+      const openStub = sinon.stub(element, 'openReplyDialog');
       element.messagesList!.dispatchEvent(
         new CustomEvent('reply', {
           detail: {message: {message: 'text'}},
@@ -1752,30 +1986,29 @@
   );
 
   test('reply dialog focus can be controlled', () => {
-    const openStub = sinon.stub(element, '_openReplyDialog');
+    const openStub = sinon.stub(element, 'openReplyDialog');
 
     const e = new CustomEvent('show-reply-dialog', {
       detail: {value: {ccsOnly: false}},
     });
-    element._handleShowReplyDialog(e);
+    element.handleShowReplyDialog(e);
     assert(
       openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
-      '_openReplyDialog should have been passed REVIEWERS'
+      'openReplyDialog should have been passed REVIEWERS'
     );
     assert.equal(openStub.callCount, 1);
 
     e.detail.value = {ccsOnly: true};
-    element._handleShowReplyDialog(e);
+    element.handleShowReplyDialog(e);
     assert(
       openStub.lastCall.calledWithExactly(FocusTarget.CCS),
-      '_openReplyDialog should have been passed CCS'
+      'openReplyDialog should have been passed CCS'
     );
     assert.equal(openStub.callCount, 2);
   });
 
   test('getUrlParameter functionality', () => {
-    const locationStub = sinon.stub(element, '_getLocationSearch');
-
+    const locationStub = sinon.stub(element, 'getLocationSearch');
     locationStub.returns('?test');
     assert.equal(element._getUrlParameter('test'), 'test');
     locationStub.returns('?test2=12&test=3');
@@ -1793,11 +2026,11 @@
       .stub(getPluginLoader(), 'awaitPluginsLoaded')
       .callsFake(() => Promise.resolve());
 
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
+    element.patchRange = {
+      basePatchNum: PARENT,
       patchNum: 2 as RevisionPatchSetNum,
     };
-    element._change = {
+    element.change = {
       ...createChangeViewChange(),
       revisions: {
         rev1: createRevision(1),
@@ -1815,19 +2048,19 @@
     });
 
     const promise = mockPromise();
-
+    assertIsDefined(element.actions);
     sinon
-      .stub(element.$.actions, 'showRevertDialog')
+      .stub(element.actions, 'showRevertDialog')
       .callsFake(() => promise.resolve());
 
-    element._maybeShowRevertDialog();
+    element.maybeShowRevertDialog();
     assert.isTrue(awaitPluginsLoadedStub.called);
     await promise;
   });
 
   suite('reply dialog tests', () => {
-    setup(() => {
-      element._change = {
+    setup(async () => {
+      element.change = {
         ...createChangeViewChange(),
         // element has latest info
         revisions: {rev1: createRevision()},
@@ -1835,10 +2068,11 @@
         current_revision: 'rev1' as CommitId,
         labels: {},
       };
+      await element.updateComplete;
     });
 
     test('show reply dialog on open-reply-dialog event', async () => {
-      const openReplyDialogStub = sinon.stub(element, '_openReplyDialog');
+      const openReplyDialogStub = sinon.stub(element, 'openReplyDialog');
       element.dispatchEvent(
         new CustomEvent('open-reply-dialog', {
           composed: true,
@@ -1846,7 +2080,7 @@
           detail: {},
         })
       );
-      await flush();
+      await element.updateComplete;
       assert.isTrue(openReplyDialogStub.calledOnce);
     });
 
@@ -1854,84 +2088,70 @@
       const e = new CustomEvent('', {
         detail: {message: {message: 'quote text'}},
       });
-      element._handleMessageReply(e);
+      element.handleMessageReply(e);
       const dialog = await waitQueryAndAssert<GrReplyDialog>(
         element,
         '#replyDialog'
       );
       const openSpy = sinon.spy(dialog, 'open');
-      await flush();
+      await element.updateComplete;
       await waitUntil(() => openSpy.called && !!openSpy.lastCall.args[1]);
       assert.equal(openSpy.lastCall.args[1], '> quote text\n\n');
     });
   });
 
-  test('reply button is disabled until server config is loaded', async () => {
-    assert.isTrue(element._replyDisabled);
-    // fetches the server config on attached
-    await flush();
-    assert.isFalse(element._replyDisabled);
-  });
-
   test('header class computation', () => {
-    assert.equal(element._computeHeaderClass(), 'header');
-    assert.equal(element._computeHeaderClass(true), 'header editMode');
+    assert.equal(element.computeHeaderClass(), 'header');
+    assertIsDefined(element.viewState);
+    element.viewState.edit = true;
+    assert.equal(element.computeHeaderClass(), 'header editMode');
   });
 
-  test('_maybeScrollToMessage', async () => {
-    await flush();
+  test('maybeScrollToMessage', async () => {
+    await element.updateComplete;
     const scrollStub = sinon.stub(element.messagesList!, 'scrollToMessage');
 
-    element._maybeScrollToMessage('');
+    element.maybeScrollToMessage('');
     assert.isFalse(scrollStub.called);
-    element._maybeScrollToMessage('message');
+    element.maybeScrollToMessage('message');
     assert.isFalse(scrollStub.called);
-    element._maybeScrollToMessage('#message-TEST');
+    element.maybeScrollToMessage('#message-TEST');
     assert.isTrue(scrollStub.called);
     assert.equal(scrollStub.lastCall.args[0], 'TEST');
   });
 
-  test('topic update reloads related changes', () => {
-    flush();
-    const relatedChanges = element.shadowRoot!.querySelector(
-      '#relatedChanges'
-    ) as GrRelatedChangesList;
-    const reloadStub = sinon.stub(relatedChanges, 'reload');
-    element.dispatchEvent(new CustomEvent('topic-changed'));
-    assert.isTrue(reloadStub.calledOnce);
-  });
-
-  test('_computeEditMode', () => {
-    const callCompute = (
-      range: PatchRange,
-      params: AppElementChangeViewParams
-    ) =>
-      element._computeEditMode(
-        {base: range, path: '', value: range},
-        {base: params, path: '', value: params}
-      );
+  test('computeEditMode', async () => {
+    const callCompute = async (viewState: ChangeViewState) => {
+      element.viewState = viewState;
+      await element.updateComplete;
+      return element.getEditMode();
+    };
     assert.isTrue(
-      callCompute(
-        {basePatchNum: ParentPatchSetNum, patchNum: 1 as RevisionPatchSetNum},
-        {...createAppElementChangeViewParams(), edit: true}
-      )
+      await callCompute({
+        ...createChangeViewState(),
+        edit: true,
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      })
     );
     assert.isFalse(
-      callCompute(
-        {basePatchNum: ParentPatchSetNum, patchNum: 1 as RevisionPatchSetNum},
-        createAppElementChangeViewParams()
-      )
+      await callCompute({
+        ...createChangeViewState(),
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      })
     );
     assert.isTrue(
-      callCompute(
-        {basePatchNum: 1 as BasePatchSetNum, patchNum: EditPatchSetNum},
-        createAppElementChangeViewParams()
-      )
+      await callCompute({
+        ...createChangeViewState(),
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: EDIT,
+      })
     );
   });
 
-  test('_processEdit', () => {
-    element._patchRange = {};
+  test('processEdit', () => {
+    element.patchRange = {};
     const change: ParsedChangeInfo = {
       ...createChangeViewChange(),
       current_revision: 'foo' as CommitId,
@@ -1941,11 +2161,11 @@
     };
 
     // With no edit, nothing happens.
-    element._processEdit(change);
-    assert.equal(element._patchRange.patchNum, undefined);
+    element.processEdit(change);
+    assert.equal(element.patchRange.patchNum, undefined);
 
     change.revisions['bar'] = {
-      _number: EditPatchSetNum,
+      _number: EDIT,
       basePatchNum: 1 as BasePatchSetNum,
       commit: {
         ...createCommit(),
@@ -1955,41 +2175,38 @@
     };
 
     // When edit is set, but not patchNum, then switch to edit ps.
-    element._processEdit(change);
-    assert.equal(element._patchRange.patchNum, EditPatchSetNum);
+    element.processEdit(change);
+    assert.equal(element.patchRange.patchNum, EDIT);
 
     // When edit is set, but patchNum as well, then keep patchNum.
-    element._patchRange.patchNum = 5 as RevisionPatchSetNum;
+    element.patchRange.patchNum = 5 as RevisionPatchSetNum;
     element.routerPatchNum = 5 as RevisionPatchSetNum;
-    element._processEdit(change);
-    assert.equal(element._patchRange.patchNum, 5 as RevisionPatchSetNum);
+    element.processEdit(change);
+    assert.equal(element.patchRange.patchNum, 5 as RevisionPatchSetNum);
   });
 
   test('file-action-tap handling', async () => {
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
+    element.patchRange = {
+      basePatchNum: PARENT,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element._change = {
+    element.change = {
       ...createChangeViewChange(),
     };
-    const fileList = element.$.fileList;
+    assertIsDefined(element.fileList);
+    assertIsDefined(element.fileListHeader);
+    const fileList = element.fileList;
     const Actions = GrEditConstants.Actions;
-    element.$.fileListHeader.editMode = true;
-    await element.$.fileListHeader.updateComplete;
-    flush();
+    element.fileListHeader.editMode = true;
+    await element.fileListHeader.updateComplete;
+    await element.updateComplete;
     const controls = queryAndAssert<GrEditControls>(
-      element.$.fileListHeader,
+      element.fileListHeader,
       '#editControls'
     );
     const openDeleteDialogStub = sinon.stub(controls, 'openDeleteDialog');
     const openRenameDialogStub = sinon.stub(controls, 'openRenameDialog');
     const openRestoreDialogStub = sinon.stub(controls, 'openRestoreDialog');
-    const getEditUrlForDiffStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
-    const navigateToRelativeUrlStub = sinon.stub(
-      GerritNav,
-      'navigateToRelativeUrl'
-    );
 
     // Delete
     fileList.dispatchEvent(
@@ -1999,7 +2216,7 @@
         composed: true,
       })
     );
-    flush();
+    await element.updateComplete;
 
     assert.isTrue(openDeleteDialogStub.called);
     assert.equal(openDeleteDialogStub.lastCall.args[0], 'foo');
@@ -2012,7 +2229,7 @@
         composed: true,
       })
     );
-    flush();
+    await element.updateComplete;
 
     assert.isTrue(openRestoreDialogStub.called);
     assert.equal(openRestoreDialogStub.lastCall.args[0], 'foo');
@@ -2025,7 +2242,7 @@
         composed: true,
       })
     );
-    flush();
+    await element.updateComplete;
 
     assert.isTrue(openRenameDialogStub.called);
     assert.equal(openRenameDialogStub.lastCall.args[0], 'foo');
@@ -2038,15 +2255,12 @@
         composed: true,
       })
     );
-    flush();
+    await element.updateComplete;
 
-    assert.isTrue(getEditUrlForDiffStub.called);
-    assert.equal(getEditUrlForDiffStub.lastCall.args[1], 'foo');
-    assert.equal(getEditUrlForDiffStub.lastCall.args[2], 1 as PatchSetNum);
-    assert.isTrue(navigateToRelativeUrlStub.called);
+    assert.isTrue(setUrlStub.called);
   });
 
-  test('_selectedRevision updates when patchNum is changed', () => {
+  test('selectedRevision updates when patchNum is changed', async () => {
     const revision1: RevisionInfo = createRevision(1);
     const revision2: RevisionInfo = createRevision(2);
     element.getChangeModel().setState({
@@ -2062,20 +2276,18 @@
         current_revision: 'bbb' as CommitId,
       },
     });
+    element.userModel.setPreferences(createPreferences());
 
-    sinon
-      .stub(element, '_getPreferences')
-      .returns(Promise.resolve(createPreferences()));
-    element._patchRange = {patchNum: 2 as RevisionPatchSetNum};
-    return element.performPostChangeLoadTasks().then(() => {
-      assert.strictEqual(element._selectedRevision, revision2);
+    element.patchRange = {patchNum: 2 as RevisionPatchSetNum};
+    await element.performPostChangeLoadTasks();
+    assert.strictEqual(element.selectedRevision, revision2);
 
-      element.set('_patchRange.patchNum', '1');
-      assert.strictEqual(element._selectedRevision, revision1);
-    });
+    element.patchRange = {patchNum: 1 as RevisionPatchSetNum};
+    await element.updateComplete;
+    assert.strictEqual(element.selectedRevision, revision1);
   });
 
-  test('_selectedRevision is assigned when patchNum is edit', async () => {
+  test('selectedRevision is assigned when patchNum is edit', async () => {
     const revision1 = createRevision(1);
     const revision2 = createRevision(2);
     const revision3 = createEditRevision();
@@ -2093,21 +2305,20 @@
         current_revision: 'ccc' as CommitId,
       },
     });
-    sinon
-      .stub(element, '_getPreferences')
-      .returns(Promise.resolve(createPreferences()));
-    element._patchRange = {patchNum: EditPatchSetNum};
+    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
+
+    element.patchRange = {patchNum: EDIT};
     await element.performPostChangeLoadTasks();
-    assert.strictEqual(element._selectedRevision, revision3);
+    assert.strictEqual(element.selectedRevision, revision3);
   });
 
-  test('_sendShowChangeEvent', () => {
+  test('sendShowChangeEvent', () => {
     const change = {...createChangeViewChange(), labels: {}};
-    element._change = {...change};
-    element._patchRange = {patchNum: 4 as RevisionPatchSetNum};
-    element._mergeable = true;
+    element.change = {...change};
+    element.patchRange = {patchNum: 4 as RevisionPatchSetNum};
+    element.mergeable = true;
     const showStub = sinon.stub(element.jsAPI, 'handleEvent');
-    element._sendShowChangeEvent();
+    element.sendShowChangeEvent();
     assert.isTrue(showStub.calledOnce);
     assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
     assert.deepEqual(showStub.lastCall.args[1], {
@@ -2118,213 +2329,205 @@
   });
 
   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};
+    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();
+    const viewState = createChangeViewState();
 
-    assert.isFalse(element.hasPatchRangeChanged(params));
-    assert.isFalse(element.hasPatchNumChanged(params));
+    assert.isFalse(element.hasPatchRangeChanged(viewState));
+    assert.isFalse(element.hasPatchNumChanged(viewState));
 
-    params.basePatchNum = ParentPatchSetNum;
+    viewState.basePatchNum = PARENT;
     // undefined means navigate to latest patchset
-    params.patchNum = undefined;
+    viewState.patchNum = undefined;
 
-    element._patchRange = {
+    element.patchRange = {
       patchNum: 2 as RevisionPatchSetNum,
-      basePatchNum: ParentPatchSetNum,
+      basePatchNum: PARENT,
     };
 
-    assert.isTrue(element.hasPatchRangeChanged(params));
-    assert.isTrue(element.hasPatchNumChanged(params));
+    assert.isTrue(element.hasPatchRangeChanged(viewState));
+    assert.isTrue(element.hasPatchNumChanged(viewState));
 
-    element._patchRange = {
+    element.patchRange = {
       patchNum: 4 as RevisionPatchSetNum,
-      basePatchNum: ParentPatchSetNum,
+      basePatchNum: PARENT,
     };
 
-    assert.isFalse(element.hasPatchRangeChanged(params));
-    assert.isFalse(element.hasPatchNumChanged(params));
+    assert.isFalse(element.hasPatchRangeChanged(viewState));
+    assert.isFalse(element.hasPatchNumChanged(viewState));
   });
 
-  suite('_handleEditTap', () => {
+  suite('handleEditTap', () => {
     let fireEdit: () => void;
 
     setup(() => {
       fireEdit = () => {
-        element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
+        assertIsDefined(element.actions);
+        element.actions.dispatchEvent(new CustomEvent('edit-tap'));
       };
-      navigateToChangeStub.restore();
 
-      element._change = {
+      element.change = {
         ...createChangeViewChange(),
         revisions: {rev1: createRevision()},
       };
     });
 
     test('edit exists in revisions', async () => {
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 2);
-        assert.equal(args[1]!.patchNum, EditPatchSetNum); // patchNum
-        promise.resolve();
-      });
-
-      element.set('_change.revisions.rev2', {
-        _number: EditPatchSetNum,
-      });
-      await flush();
+      assertIsDefined(element.change);
+      const newChange = {...element.change};
+      newChange.revisions.rev2 = createRevision(EDIT);
+      element.change = newChange;
+      await element.updateComplete;
 
       fireEdit();
-      await promise;
+      assert.isTrue(setUrlStub.called);
+      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/edit');
     });
 
     test('no edit exists in revisions, non-latest patchset', async () => {
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 2);
-        assert.equal(args[1]!.patchNum, 1 as PatchSetNum); // patchNum
-        assert.equal(args[1]!.isEdit, true); // opt_isEdit
-        promise.resolve();
-      });
-
-      element.set('_change.revisions.rev2', {_number: 2});
-      element._patchRange = {patchNum: 1 as RevisionPatchSetNum};
-      await flush();
+      assertIsDefined(element.change);
+      const newChange = {...element.change};
+      newChange.revisions.rev2 = createRevision(2);
+      element.change = newChange;
+      element.patchRange = {patchNum: 1 as RevisionPatchSetNum};
+      await element.updateComplete;
 
       fireEdit();
-      await promise;
+      assert.isTrue(setUrlStub.called);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/1,edit?forceReload=true'
+      );
     });
 
     test('no edit exists in revisions, latest patchset', async () => {
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 2);
-        // No patch should be specified when patchNum == latest.
-        assert.isNotOk(args[1]!.patchNum); // patchNum
-        assert.equal(args[1]!.isEdit, true); // opt_isEdit
-        promise.resolve();
-      });
-
-      element.set('_change.revisions.rev2', {_number: 2});
-      element._patchRange = {patchNum: 2 as RevisionPatchSetNum};
-      await flush();
+      assertIsDefined(element.change);
+      const newChange = {...element.change};
+      newChange.revisions.rev2 = createRevision(2);
+      element.change = newChange;
+      element.patchRange = {patchNum: 2 as RevisionPatchSetNum};
+      await element.updateComplete;
 
       fireEdit();
-      await promise;
+      assert.isTrue(setUrlStub.called);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42,edit?forceReload=true'
+      );
     });
   });
 
-  test('_handleStopEditTap', async () => {
-    element._change = {
+  test('handleStopEditTap', async () => {
+    element.change = {
       ...createChangeViewChange(),
     };
-    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]!.patchNum, 1 as PatchSetNum); // patchNum
-      promise.resolve();
-    });
+    await element.updateComplete;
+    assertIsDefined(element.metadata);
+    assertIsDefined(element.actions);
+    sinon.stub(element.metadata, 'computeLabelNames');
 
-    element._patchRange = {patchNum: 1 as RevisionPatchSetNum};
-    element.$.actions.dispatchEvent(
+    element.patchRange = {patchNum: 1 as RevisionPatchSetNum};
+    element.actions.dispatchEvent(
       new CustomEvent('stop-edit-tap', {bubbles: false})
     );
-    await promise;
+
+    assert.isTrue(setUrlStub.called);
+    assert.equal(
+      setUrlStub.lastCall.firstArg,
+      '/c/test-project/+/42/1?forceReload=true'
+    );
   });
 
   suite('plugin endpoints', () => {
     test('endpoint params', async () => {
-      element._change = {...createChangeViewChange(), labels: {}};
-      element._selectedRevision = createRevision();
+      element.change = {...createChangeViewChange(), labels: {}};
+      element.selectedRevision = createRevision();
       const promise = mockPromise();
       window.Gerrit.install(
         promise.resolve,
         '0.1',
         'http://some/plugins/url.js'
       );
-      await flush();
+      await element.updateComplete;
       const plugin: PluginApi = (await promise) as PluginApi;
       const hookEl = await plugin
         .hook('change-view-integration')
         .getLastAttached();
       assert.strictEqual((hookEl as any).plugin, plugin);
-      assert.strictEqual((hookEl as any).change, element._change);
-      assert.strictEqual((hookEl as any).revision, element._selectedRevision);
+      assert.strictEqual((hookEl as any).change, element.change);
+      assert.strictEqual((hookEl as any).revision, element.selectedRevision);
     });
   });
 
-  suite('_getMergeability', () => {
+  suite('getMergeability', () => {
     let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
     setup(() => {
-      element._change = {...createChangeViewChange(), labels: {}};
+      element.change = {...createChangeViewChange(), labels: {}};
       getMergeableStub = stubRestApi('getMergeable').returns(
         Promise.resolve({...createMergeable(), mergeable: true})
       );
     });
 
     test('merged change', () => {
-      element._mergeable = null;
-      element._change!.status = ChangeStatus.MERGED;
-      return element._getMergeability().then(() => {
-        assert.isFalse(element._mergeable);
+      element.mergeable = null;
+      element.change!.status = ChangeStatus.MERGED;
+      return element.getMergeability().then(() => {
+        assert.isFalse(element.mergeable);
         assert.isFalse(getMergeableStub.called);
       });
     });
 
     test('abandoned change', () => {
-      element._mergeable = null;
-      element._change!.status = ChangeStatus.ABANDONED;
-      return element._getMergeability().then(() => {
-        assert.isFalse(element._mergeable);
+      element.mergeable = null;
+      element.change!.status = ChangeStatus.ABANDONED;
+      return element.getMergeability().then(() => {
+        assert.isFalse(element.mergeable);
         assert.isFalse(getMergeableStub.called);
       });
     });
 
     test('open change', () => {
-      element._mergeable = null;
-      return element._getMergeability().then(() => {
-        assert.isTrue(element._mergeable);
+      element.mergeable = null;
+      return element.getMergeability().then(() => {
+        assert.isTrue(element.mergeable);
         assert.isTrue(getMergeableStub.called);
       });
     });
   });
 
-  test('_handleToggleStar called when star is tapped', async () => {
-    element._change = {
+  test('handleToggleStar called when star is tapped', async () => {
+    element.change = {
       ...createChangeViewChange(),
       owner: {_account_id: 1 as AccountId},
       starred: false,
     };
-    element._loggedIn = true;
-    await flush();
+    element.loggedIn = true;
+    await element.updateComplete;
 
-    const stub = sinon.stub(element, '_handleToggleStar');
+    const stub = sinon.stub(element, 'handleToggleStar');
 
     const changeStar = queryAndAssert<GrChangeStar>(element, '#changeStar');
-    tap(queryAndAssert<HTMLButtonElement>(changeStar, 'button')!);
+    queryAndAssert<HTMLButtonElement>(changeStar, 'button')!.click();
     assert.isTrue(stub.called);
   });
 
   suite('gr-reporting tests', () => {
     setup(() => {
-      element._patchRange = {
-        basePatchNum: ParentPatchSetNum,
+      element.patchRange = {
+        basePatchNum: PARENT,
         patchNum: 1 as RevisionPatchSetNum,
       };
       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());
+      sinon.stub(element, 'getMergeability').returns(Promise.resolve());
+      sinon.stub(element, 'getLatestCommitMessage').returns(Promise.resolve());
       sinon
-        .stub(element, '_reloadPatchNumDependentResources')
+        .stub(element, 'reloadPatchNumDependentResources')
         .returns(Promise.resolve([undefined, undefined, undefined]));
     });
 
@@ -2337,13 +2540,16 @@
         element.reporting,
         'changeFullyLoaded'
       );
-      element._handleReplySent();
-      await flush();
+      element.handleReplySent();
+      await element.updateComplete;
       assert.isFalse(changeDisplayStub.called);
       assert.isFalse(changeFullyLoadedStub.called);
     });
 
-    test('report changeDisplayed on _paramsChanged', async () => {
+    test('report changeDisplayed on viewStateChanged', async () => {
+      stubRestApi('getChangeOrEditFiles').resolves({
+        'a-file.js': {},
+      });
       const changeDisplayStub = sinon.stub(
         element.reporting,
         'changeDisplayed'
@@ -2353,9 +2559,9 @@
         'changeFullyLoaded'
       );
       // reset so reload is triggered
-      element._changeNum = undefined;
-      element.params = {
-        ...createAppElementChangeViewParams(),
+      element.changeNum = undefined;
+      element.viewState = {
+        ...createChangeViewState(),
         changeNum: TEST_NUMERIC_CHANGE_ID,
         project: TEST_PROJECT_NAME,
       };
@@ -2368,28 +2574,48 @@
           revisions: {foo: createRevision()},
         },
       });
-      await flush();
+      await element.updateComplete;
+      await waitEventLoop();
       assert.isTrue(changeDisplayStub.called);
       assert.isTrue(changeFullyLoadedStub.called);
     });
   });
 
-  test('_calculateHasParent', () => {
+  test('calculateHasParent', () => {
     const changeId = '123' as ChangeId;
     const relatedChanges: RelatedChangeAndCommitInfo[] = [];
 
-    assert.equal(element._calculateHasParent(changeId, relatedChanges), false);
+    assert.equal(element.calculateHasParent(changeId, relatedChanges), false);
 
     relatedChanges.push({
       ...createRelatedChangeAndCommitInfo(),
       change_id: '123' as ChangeId,
     });
-    assert.equal(element._calculateHasParent(changeId, relatedChanges), false);
+    assert.equal(element.calculateHasParent(changeId, relatedChanges), false);
 
     relatedChanges.push({
       ...createRelatedChangeAndCommitInfo(),
       change_id: '234' as ChangeId,
     });
-    assert.equal(element._calculateHasParent(changeId, relatedChanges), true);
+    assert.equal(element.calculateHasParent(changeId, relatedChanges), true);
+  });
+
+  test('renders sha in copy links', async () => {
+    stubFlags('isEnabled').returns(true);
+    const sha = '123' as CommitId;
+    element.change = {
+      ...createChangeViewChange(),
+      status: ChangeStatus.MERGED,
+      current_revision: sha,
+    };
+    await element.updateComplete;
+
+    const copyLinksDialog = queryAndAssert<GrCopyLinks>(
+      element,
+      'gr-copy-links'
+    );
+    assert.isTrue(
+      copyLinksDialog.copyLinks.some(copyLink => copyLink.value === sha)
+    );
   });
 });
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 100b88e..d6a5327 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
@@ -1,26 +1,18 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {ChangeInfo, CommitInfo, ServerInfo} from '../../../types/common';
+import {CommitInfo, ServerInfo} from '../../../types/common';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {createSearchUrl} from '../../../models/views/search';
+import {getPatchSetWeblink} from '../../../utils/weblink-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -30,18 +22,13 @@
 
 @customElement('gr-commit-info')
 export class GrCommitInfo extends LitElement {
-  // TODO(TS): can not use `?` here as @computed require dependencies as
-  // not optional
+  // TODO(TS): Maybe limit to StandaloneCommitInfo.
   @property({type: Object})
-  change: ChangeInfo | undefined;
+  commitInfo?: CommitInfo;
 
-  // TODO(TS): maybe limit to StandaloneCommitInfo if never pass in
-  // with commit inside RevisionInfo
-  @property({type: Object})
-  commitInfo: CommitInfo | undefined;
+  @state() serverConfig?: ServerInfo;
 
-  @property({type: Object})
-  serverConfig: ServerInfo | undefined;
+  private readonly getConfigModel = resolve(this, configModelToken);
 
   static override get styles() {
     return [
@@ -55,90 +42,45 @@
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => (this.serverConfig = config)
+    );
+  }
+
   override render() {
+    const commit = this.commitInfo?.commit;
+    if (!commit) return nothing;
     return html` <div class="container">
-      <a
-        target="_blank"
-        rel="noopener"
-        href=${this.computeCommitLink(
-          this._webLink,
-          this.change,
-          this.commitInfo,
-          this.serverConfig
-        )}
-        >${this._computeShortHash(
-          this.change,
-          this.commitInfo,
-          this.serverConfig
-        )}</a
+      <a target="_blank" rel="noopener" href=${this.computeCommitLink()}
+        >${this.getWeblink()?.name ?? ''}</a
       >
       <gr-copy-clipboard
         hastooltip
         .buttonTitle=${'Copy full SHA to clipboard'}
         hideinput
-        .text=${this.commitInfo?.commit}
+        .text=${commit}
       >
       </gr-copy-clipboard>
     </div>`;
   }
 
-  /**
-   * Used only within the tests.
-   */
-  get _showWebLink(): boolean {
-    if (!this.change || !this.commitInfo || !this.serverConfig) {
-      return false;
-    }
-
-    const weblink = this._getWeblink(
-      this.change,
-      this.commitInfo,
+  getWeblink() {
+    return getPatchSetWeblink(
+      this.commitInfo?.commit,
+      this.commitInfo?.web_links,
       this.serverConfig
     );
-    return !!weblink && !!weblink.url;
   }
 
-  get _webLink(): string | undefined {
-    if (!this.change || !this.commitInfo || !this.serverConfig) {
-      return '';
-    }
+  computeCommitLink() {
+    const weblink = this.getWeblink();
+    if (weblink?.url) return weblink.url;
 
-    // TODO(TS): if getPatchSetWeblink always return a valid WebLink,
-    // can remove the fallback here
-    const {url} =
-      this._getWeblink(this.change, this.commitInfo, this.serverConfig) || {};
-    return url;
-  }
-
-  _getWeblink(change: ChangeInfo, commitInfo: CommitInfo, config: ServerInfo) {
-    return GerritNav.getPatchSetWeblink(change.project, commitInfo.commit, {
-      weblinks: commitInfo.web_links,
-      config,
-    });
-  }
-
-  computeCommitLink(
-    webLink?: string,
-    change?: ChangeInfo,
-    commitInfo?: CommitInfo,
-    serverConfig?: ServerInfo
-  ) {
-    if (webLink) return webLink;
-    const hash = this._computeShortHash(change, commitInfo, serverConfig);
-    if (hash === undefined) return '';
-    return GerritNav.getUrlForSearchQuery(hash);
-  }
-
-  _computeShortHash(
-    change?: ChangeInfo,
-    commitInfo?: CommitInfo,
-    serverConfig?: ServerInfo
-  ) {
-    if (!change || !commitInfo || !serverConfig) {
-      return '';
-    }
-
-    const weblink = this._getWeblink(change, commitInfo, serverConfig);
-    return weblink?.name ?? '';
+    const hash = weblink?.name;
+    return hash ? createSearchUrl({query: hash}) : '';
   }
 }
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 c240a17..d992ffe 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
@@ -1,134 +1,67 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-commit-info';
 import {GrCommitInfo} from './gr-commit-info';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
-  createChange,
   createCommit,
   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');
+import {CommitId} from '../../../types/common';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-commit-info tests', () => {
   let element: GrCommitInfo;
 
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('weblinks use GerritNav interface', async () => {
-    const weblinksStub = sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .returns([{name: 'stubb', url: '#s'}]);
-    element.change = createChange();
-    element.commitInfo = createCommit();
+  setup(async () => {
+    element = await fixture(html`<gr-commit-info></gr-commit-info>`);
     element.serverConfig = createServerInfo();
-    await flush();
-    assert.isTrue(weblinksStub.called);
   });
 
-  test('no web link when unavailable', () => {
+  test('render nothing', async () => {
     element.commitInfo = createCommit();
-    element.serverConfig = createServerInfo();
-    element.change = {...createChange(), labels: {}, project: '' as RepoName};
+    await element.updateComplete;
 
-    assert.isNotOk(element._showWebLink);
+    assert.shadowDom.equal(element, '');
   });
 
-  test('use web link when available', () => {
-    const router = new GrRouter();
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router.generateWeblinks.bind(router));
-
-    element.change = {...createChange(), labels: {}, project: '' as RepoName};
+  test('web link from commit info', async () => {
     element.commitInfo = {
       ...createCommit(),
-      commit: 'commitsha' as CommitId,
+      commit: 'sha45678901234567890' as CommitId,
       web_links: [{name: 'gitweb', url: 'link-url'}],
     };
-    element.serverConfig = createServerInfo();
+    await element.updateComplete;
 
-    assert.isOk(element._showWebLink);
-    assert.equal(element._webLink, 'link-url');
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <a href="link-url" rel="noopener" target="_blank">sha4567</a>
+          <gr-copy-clipboard hastooltip="" hideinput=""> </gr-copy-clipboard>
+        </div>
+      `
+    );
   });
 
-  test('does not relativize web links that begin with scheme', () => {
-    const router = new GrRouter();
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router.generateWeblinks.bind(router));
-
-    element.change = {...createChange(), labels: {}, project: '' as RepoName};
+  test('web link fall back to search query', async () => {
     element.commitInfo = {
       ...createCommit(),
-      commit: 'commitsha' as CommitId,
-      web_links: [{name: 'gitweb', url: 'https://link-url'}],
+      commit: 'sha45678901234567890' as CommitId,
     };
-    element.serverConfig = createServerInfo();
+    await element.updateComplete;
 
-    assert.isOk(element._showWebLink);
-    assert.equal(element._webLink, 'https://link-url');
-  });
-
-  test('ignore web links that are neither gitweb nor gitiles', () => {
-    const router = new GrRouter();
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router.generateWeblinks.bind(router));
-
-    element.change = {...createChange(), project: 'project-name' as RepoName};
-    element.commitInfo = {
-      ...createCommit(),
-      commit: 'commit-sha' as CommitId,
-      web_links: [
-        {
-          name: 'ignore',
-          url: 'ignore',
-        },
-        {
-          name: 'gitiles',
-          url: 'https://link-url',
-        },
-      ],
-    };
-    element.serverConfig = createServerInfo();
-
-    assert.isOk(element._showWebLink);
-    assert.equal(element._webLink, 'https://link-url');
-
-    // Remove gitiles link.
-    element.commitInfo = {
-      ...createCommit(),
-      commit: 'commit-sha' as CommitId,
-      web_links: [
-        {
-          name: 'ignore',
-          url: 'ignore',
-        },
-      ],
-    };
-    assert.isNotOk(element._showWebLink);
-    assert.isNotOk(element._webLink);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <a href="/q/sha4567" rel="noopener" target="_blank">sha4567</a>
+          <gr-copy-clipboard hastooltip="" hideinput=""> </gr-copy-clipboard>
+        </div>
+      `
+    );
   });
 });
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 323c439..ab15ae6 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
@@ -1,28 +1,18 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-dialog/gr-dialog';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
+import {Key, Modifier} from '../../../utils/dom-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html, css} from 'lit';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
+import {ShortcutController} from '../../lit/shortcut-controller';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -49,26 +39,18 @@
   @property({type: String})
   message = '';
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  private readonly shortcuts = new ShortcutController(this);
 
-  override disconnectedCallback() {
-    super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
-        this.confirm()
-      )
+  constructor() {
+    super();
+    this.shortcuts.addLocal(
+      {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
+      () => this.confirm()
     );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
-        this.confirm()
-      )
+
+    this.shortcuts.addLocal(
+      {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
+      _ => this.confirm()
     );
   }
 
@@ -168,6 +150,6 @@
   }
 
   private handleBindValueChanged(e: BindValueChangeEvent) {
-    this.message = e.detail.value;
+    this.message = e.detail.value ?? '';
   }
 }
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 a737b08..3602ebc 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
@@ -1,34 +1,44 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-confirm-abandon-dialog';
 import {GrConfirmAbandonDialog} from './gr-confirm-abandon-dialog';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-
-const basicFixture = fixtureFromElement('gr-confirm-abandon-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-confirm-abandon-dialog tests', () => {
   let element: GrConfirmAbandonDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-confirm-abandon-dialog></gr-confirm-abandon-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog confirm-label="Abandon" role="dialog">
+          <div class="header" slot="header">Abandon Change</div>
+          <div class="main" slot="main">
+            <label for="messageInput"> Abandon Message </label>
+            <iron-autogrow-textarea
+              aria-disabled="false"
+              autocomplete="on"
+              class="message"
+              id="messageInput"
+              placeholder="<Insert reasoning here>"
+            >
+            </iron-autogrow-textarea>
+          </div>
+        </gr-dialog>
+      `
+    );
   });
 
   test('handleConfirmTap', () => {
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 5dd0ce7..15f3e21 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
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css, html, LitElement} from 'lit';
-import {customElement} from 'lit/decorators';
+import {customElement} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import '../../shared/gr-dialog/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 ad89521..891175f 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
@@ -1,22 +1,10 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {fixture, html} from '@open-wc/testing-helpers';
-import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import {queryAndAssert} from '../../../utils/common-util';
 import './gr-confirm-cherrypick-conflict-dialog';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
@@ -33,18 +21,21 @@
   });
 
   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
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* 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>
-    `);
+            >
+          </div>
+        </gr-dialog>
+      `
+    );
   });
 
   test('confirm', async () => {
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 f8103fd..259154f 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
@@ -1,25 +1,14 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '@polymer/iron-input/iron-input';
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
 import {
   ChangeInfo,
@@ -28,7 +17,7 @@
   CommitId,
   ChangeInfoId,
 } from '../../../types/common';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {
   AutocompleteQuery,
   AutocompleteSuggestion,
@@ -42,9 +31,11 @@
 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 {choose} from 'lit/directives/choose.js';
+import {when} from 'lit/directives/when.js';
 import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {createSearchUrl} from '../../../models/views/search';
 
 const SUGGESTIONS_LIMIT = 15;
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -135,6 +126,8 @@
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.statuses = {};
@@ -242,34 +235,40 @@
           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()],
-          ])}
+          <gr-endpoint-decorator name="cherrypick-main">
+            <gr-endpoint-param name="changes" .value=${this.changes}>
+            </gr-endpoint-param>
+            <gr-endpoint-slot name="top"></gr-endpoint-slot>
+            ${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()],
+            ])}
+            <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+          </gr-endpoint-decorator>
         </div>
       </gr-dialog>
     `;
@@ -327,7 +326,7 @@
         .maxRows=${15}
         .bindValue=${this.message}
         @bind-value-changed=${(e: BindValueChangeEvent) =>
-          (this.message = e.detail.value)}
+          (this.message = e.detail.value ?? '')}
       ></iron-autogrow-textarea>
     `;
   }
@@ -507,7 +506,6 @@
   }
 
   private computeMessage() {
-    // Polymer 2: check for undefined
     if (
       this.changeStatus === undefined ||
       this.commitNum === undefined ||
@@ -582,9 +580,10 @@
             v => v.status !== ProgressStatus.SUCCESSFUL
           );
           if (!failedOrPending) {
-            /* This needs some more work, as the new topic may not always be
-          created, instead we may end up creating a new patchset */
-            GerritNav.navigateToSearchQuery(`topic: "${topic}"`);
+            // This needs some more work, as the new topic may not always be
+            // created, instead we may end up creating a new patchset */
+            const query = `topic: "${topic}"`;
+            this.getNavigation().setUrl(createSearchUrl({query}));
           }
         });
     });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
index 45e13b2..dc8dba9 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-confirm-cherrypick-dialog';
 import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {GrConfirmCherrypickDialog} from './gr-confirm-cherrypick-dialog';
@@ -33,10 +21,9 @@
   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 {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {ProgressStatus} from '../../../constants/constants';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 
 const CHERRY_PICK_TYPES = {
   SINGLE_CHANGE: 1,
@@ -65,6 +52,52 @@
     element.project = 'test-project' as RepoName;
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog confirm-label="Cherry Pick" disabled="" role="dialog">
+          <div class="header title" slot="header">
+            Cherry Pick Change to Another Branch
+          </div>
+          <div class="main" slot="main">
+            <gr-endpoint-decorator name="cherrypick-main">
+              <gr-endpoint-param name="changes"> </gr-endpoint-param>
+              <gr-endpoint-slot name="top"> </gr-endpoint-slot>
+              <label for="branchInput"> Cherry Pick to branch </label>
+              <gr-autocomplete
+                id="branchInput"
+                placeholder="Destination branch"
+              >
+              </gr-autocomplete>
+              <label for="baseInput">
+                Provide base commit sha1 for cherry-pick
+              </label>
+              <iron-input>
+                <input
+                  id="baseCommitInput"
+                  is="iron-input"
+                  maxlength="40"
+                  placeholder="(optional)"
+                />
+              </iron-input>
+              <label for="messageInput"> Cherry Pick Commit Message </label>
+              <iron-autogrow-textarea
+                aria-disabled="false"
+                autocomplete="on"
+                class="message"
+                id="messageInput"
+                rows="4"
+              >
+              </iron-autogrow-textarea>
+              <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+            </gr-endpoint-decorator>
+          </div>
+        </gr-dialog>
+      `
+    );
+  });
+
   test('with message missing newline', async () => {
     element.changeStatus = ChangeStatus.MERGED;
     element.commitMessage = 'message';
@@ -183,7 +216,7 @@
       );
       assert.equal(checkboxes.length, 2);
       assert.isTrue(checkboxes[0].checked);
-      MockInteractions.tap(checkboxes[0]);
+      checkboxes[0].click();
       queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!.click();
       await element.updateComplete;
       assert.equal(executeChangeActionStub.callCount, 1);
@@ -201,11 +234,9 @@
         'input[type="checkbox"]'
       );
       assert.equal(checkboxes.length, 2);
-      MockInteractions.tap(checkboxes[0]);
-      MockInteractions.tap(checkboxes[1]);
-      MockInteractions.tap(
-        queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!
-      );
+      checkboxes[0].click();
+      checkboxes[1].click();
+      queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!.click();
       await element.updateComplete;
       assert.equal(executeChangeActionStub.callCount, 0);
       assert.equal(
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 9e2b2f6..7adc2ca 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
@@ -1,29 +1,19 @@
 /**
  * @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 {css, html, LitElement} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
+import {Key, Modifier} from '../../../utils/dom-util';
 import {ValueChangedEvent} from '../../../types/events';
+import {ShortcutController} from '../../lit/shortcut-controller';
 
 const SUGGESTIONS_LIMIT = 15;
 
@@ -50,27 +40,26 @@
   @property({type: String})
   project?: RepoName;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  private readonly shortcuts = new ShortcutController(this);
+
+  constructor() {
+    super();
+    this.shortcuts.addLocal(
+      {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
+      e => this.handleConfirmTap(e)
+    );
+    this.shortcuts.addLocal(
+      {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
+      e => this.handleConfirmTap(e)
+    );
+  }
 
   override disconnectedCallback() {
     super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, e =>
-        this.handleConfirmTap(e)
-      )
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, e =>
-        this.handleConfirmTap(e)
-      )
-    );
   }
 
   private readonly restApiService = getAppContext().restApiService;
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 af75b48..aca7e0ddc 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
@@ -1,26 +1,14 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-confirm-move-dialog';
 import {GrConfirmMoveDialog} from './gr-confirm-move-dialog';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {BranchName, GitRef, RepoName} from '../../../types/common';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 
 suite('gr-confirm-move-dialog tests', () => {
@@ -48,26 +36,29 @@
   });
 
   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>
-    `);
+    assert.shadowDom.equal(
+      element,
+      /* 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 () => {
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 25c403f..14cd5e8 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
@@ -1,21 +1,10 @@
 /**
  * @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 {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {NumericChangeId, BranchName} from '../../../types/common';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-autocomplete/gr-autocomplete';
@@ -35,6 +24,7 @@
 
 export interface ConfirmRebaseEventDetail {
   base: string | null;
+  allowConflicts: boolean;
 }
 
 @customElement('gr-confirm-rebase-dialog')
@@ -81,6 +71,9 @@
   @query('#rebaseOnOtherInput')
   rebaseOnOtherInput!: HTMLInputElement;
 
+  @query('#rebaseAllowConflicts')
+  private rebaseAllowConflicts!: HTMLInputElement;
+
   @query('#parentInput')
   parentInput!: GrAutocomplete;
 
@@ -122,8 +115,8 @@
         display: block;
         width: 100%;
       }
-      .parentRevisionContainer label {
-        margin-bottom: var(--spacing-xs);
+      .rebaseAllowConflicts {
+        margin-top: var(--spacing-m);
       }
       .rebaseOption {
         margin: var(--spacing-m) 0;
@@ -210,6 +203,12 @@
             >
             </gr-autocomplete>
           </div>
+          <div class="rebaseAllowConflicts">
+            <input id="rebaseAllowConflicts" type="checkbox" />
+            <label for="rebaseAllowConflicts"
+              >Allow rebase with conflicts</label
+            >
+          </div>
         </div>
       </gr-dialog>
     `;
@@ -308,6 +307,7 @@
     e.stopPropagation();
     const detail: ConfirmRebaseEventDetail = {
       base: this.getSelectedBase(),
+      allowConflicts: this.rebaseAllowConflicts.checked,
     };
     this.dispatchEvent(new CustomEvent('confirm', {detail}));
     this.text = '';
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 bc000af..eba4bfe 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
@@ -1,28 +1,21 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-confirm-rebase-dialog';
 import {GrConfirmRebaseDialog, RebaseChange} from './gr-confirm-rebase-dialog';
-import {queryAndAssert, stubRestApi, waitUntil} from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  pressKey,
+  queryAndAssert,
+  stubRestApi,
+  waitUntil,
+} from '../../../test/test-utils';
 import {NumericChangeId, BranchName} from '../../../types/common';
 import {createChangeViewChange} from '../../../test/test-data-generators';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
+import {Key} from '../../../utils/dom-util';
 
 suite('gr-confirm-rebase-dialog tests', () => {
   let element: GrConfirmRebaseDialog;
@@ -36,55 +29,64 @@
   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>
+    assert.shadowDom.equal(
+      element,
+      /* 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 class="rebaseAllowConflicts">
+            <input id="rebaseAllowConflicts" type="checkbox" />
+            <label for="rebaseAllowConflicts">
+              Allow rebase with conflicts
+            </label>
+          </div>
         </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> `);
+      </gr-dialog> `
+    );
   });
 
   test('controls with parent and rebase on current available', async () => {
@@ -290,11 +292,9 @@
     test('input text change triggers function', async () => {
       const recentChangesSpy = sinon.spy(element, 'getRecentChanges');
       element.parentInput.noDebounce = true;
-      MockInteractions.pressAndReleaseKeyOn(
+      pressKey(
         queryAndAssert(queryAndAssert(element, '#parentInput'), '#input'),
-        13,
-        null,
-        'enter'
+        Key.ENTER
       );
       await element.updateComplete;
       element.text = '1';
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 8ea2bf5..5f4835a 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
@@ -1,26 +1,15 @@
 /**
  * @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 '../../shared/gr-dialog/gr-dialog';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {LitElement, html, css, nothing} from 'lit';
-import {customElement, state} from 'lit/decorators';
+import {customElement, state} from 'lit/decorators.js';
 import {ChangeInfo, CommitId} from '../../../types/common';
-import {fireAlert} from '../../../utils/event-util';
+import {fire, fireAlert} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {BindValueChangeEvent} from '../../../types/events';
@@ -40,20 +29,23 @@
   message?: string;
 }
 
+export interface CancelRevertEventDetail {
+  revertType: RevertType;
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    /** Fired when the confirm button is pressed. */
+    // prettier-ignore
+    'confirm': CustomEvent<ConfirmRevertEventDetail>;
+    /** Fired when the cancel button is pressed. */
+    // prettier-ignore
+    'cancel': CustomEvent<CancelRevertEventDetail>;
+  }
+}
+
 @customElement('gr-confirm-revert-dialog')
 export class GrConfirmRevertDialog extends LitElement {
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
   /* The revert message updated by the user
       The default value is set by the dialog */
   @state()
@@ -122,7 +114,7 @@
   override render() {
     return html`
       <gr-dialog
-        .confirmLabel=${'Revert'}
+        .confirmLabel=${'Create Revert Change'}
         @confirm=${(e: Event) => this.handleConfirmTap(e)}
         @cancel=${(e: Event) => this.handleCancelTap(e)}
       >
@@ -278,7 +270,7 @@
   }
 
   private handleBindValueChanged(e: BindValueChangeEvent) {
-    this.message = e.detail.value;
+    this.message = e.detail.value ?? '';
   }
 
   private handleRevertSingleChangeClicked() {
@@ -311,25 +303,16 @@
       revertType: this.revertType,
       message: this.message,
     };
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        detail,
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fire(this, 'confirm', detail);
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        detail: {revertType: this.revertType},
-        composed: true,
-        bubbles: false,
-      })
-    );
+    const detail: ConfirmRevertEventDetail = {
+      revertType: this.revertType,
+    };
+    fire(this, 'cancel', detail);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index 0fdcb97..59416da 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
@@ -1,24 +1,13 @@
 /**
  * @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 {fixture, html} from '@open-wc/testing-helpers';
-import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import {createChange} from '../../../test/test-data-generators';
 import {CommitId} from '../../../types/common';
+import {EventType} from '../../../types/events';
 import './gr-confirm-revert-dialog';
 import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
 
@@ -32,30 +21,33 @@
   });
 
   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>
+    assert.shadowDom.equal(
+      element,
+      /* 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-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>
-    `);
+        </gr-dialog>
+      `
+    );
   });
 
   test('no match', () => {
     assert.isNotOk(element.message);
     const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
+    element.addEventListener(EventType.SHOW_ALERT, alertStub);
     element.populateRevertSingleChangeMessage(
       createChange(),
       'not a commitHash in sight',
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 de9395f..cf66ecf 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
@@ -1,32 +1,20 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-icon/iron-icon';
-import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-icon/gr-icon';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../gr-thread-list/gr-thread-list';
-import {ActionInfo} from '../../../types/common';
+import {ActionInfo, EDIT} 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, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {subscribe} from '../../lit/subscription-controller';
 import {ParsedChangeInfo} from '../../../types/types';
@@ -93,12 +81,16 @@
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.getChangeModel().change$, x => (this.change = x));
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getCommentsModel().threads$,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().threads$,
       x => (this.unresolvedThreads = x.filter(isUnresolved))
     );
   }
@@ -107,10 +99,7 @@
     if (!this.change?.is_private) return '';
     return html`
       <p>
-        <iron-icon
-          icon="gr-icons:warning"
-          class="warningBeforeSubmit"
-        ></iron-icon>
+        <gr-icon icon="warning" filled class="warningBeforeSubmit"></gr-icon>
         <strong>Heads Up!</strong>
         Submitting this private change will also make it public.
       </p>
@@ -121,10 +110,7 @@
     if (!this.unresolvedThreads?.length) return '';
     return html`
       <p>
-        <iron-icon
-          icon="gr-icons:warning"
-          class="warningBeforeSubmit"
-        ></iron-icon>
+        <gr-icon icon="warning" filled class="warningBeforeSubmit"></gr-icon>
         ${this.computeUnresolvedCommentsWarning()}
       </p>
       <gr-thread-list
@@ -139,10 +125,7 @@
   private renderChangeEdit() {
     if (!this.computeHasChangeEdit()) return '';
     return html`
-      <iron-icon
-        icon="gr-icons:warning"
-        class="warningBeforeSubmit"
-      ></iron-icon>
+      <gr-icon icon="warning" filled class="warningBeforeSubmit"></gr-icon>
       Your unpublished edit will not be submitted. Did you forget to click
       <b>PUBLISH</b>
     `;
@@ -193,7 +176,7 @@
   // Private method, but visible for testing.
   computeHasChangeEdit() {
     return Object.values(this.change?.revisions ?? {}).some(
-      rev => rev._number === 'edit'
+      rev => rev._number === EDIT
     );
   }
 
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 9962351..82de7b0 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
@@ -1,42 +1,30 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {
   createParsedChange,
   createRevision,
   createThread,
 } from '../../../test/test-data-generators';
-import {queryAndAssert} from '../../../test/test-utils';
-import {PatchSetNum} from '../../../types/common';
+import {EDIT} from '../../../types/common';
 import {GrConfirmSubmitDialog} from './gr-confirm-submit-dialog';
 import './gr-confirm-submit-dialog';
-
-const basicFixture = fixtureFromElement('gr-confirm-submit-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-confirm-submit-dialog tests', () => {
   let element: GrConfirmSubmitDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-submit-dialog></gr-confirm-submit-dialog>`
+    );
     element.initialised = true;
   });
 
-  test('display', async () => {
+  test('render', async () => {
     element.action = {label: 'my-label'};
     element.change = {
       ...createParsedChange(),
@@ -44,12 +32,31 @@
       revisions: {},
     };
     await element.updateComplete;
-    const header = queryAndAssert(element, '.header');
-    assert.equal(header.textContent!.trim(), 'my-label');
 
-    const message = queryAndAssert(element, '.main p');
-    assert.isNotEmpty(message.textContent);
-    assert.notEqual(message.textContent!.indexOf('my-subject'), -1);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog
+          confirm-label="Continue"
+          confirm-on-enter=""
+          id="dialog"
+          role="dialog"
+        >
+          <div class="header" slot="header">my-label</div>
+          <div class="main" slot="main">
+            <gr-endpoint-decorator name="confirm-submit-change">
+              <p>
+                Ready to submit “
+                <strong> my-subject </strong>
+                ”?
+              </p>
+              <gr-endpoint-param name="change"> </gr-endpoint-param>
+              <gr-endpoint-param name="action"> </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+        </gr-dialog>
+      `
+    );
   });
 
   test('computeUnresolvedCommentsWarning', () => {
@@ -71,10 +78,7 @@
     element.change = {
       ...createParsedChange(),
       revisions: {
-        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
-          ...createRevision(),
-          _number: 'edit' as PatchSetNum,
-        },
+        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: createRevision(EDIT),
       },
       unresolved_comment_count: 0,
     };
@@ -84,10 +88,7 @@
     element.change = {
       ...createParsedChange(),
       revisions: {
-        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
-          ...createRevision(),
-          _number: 2 as PatchSetNum,
-        },
+        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: createRevision(2),
       },
     };
     assert.isFalse(element.computeHasChangeEdit());
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
new file mode 100644
index 0000000..4858a31
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
@@ -0,0 +1,164 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '@polymer/iron-dropdown/iron-dropdown';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {strToClassName} from '../../../utils/dom-util';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {copyToClipbard, queryAndAssert} from '../../../utils/common-util';
+import {ValueChangedEvent} from '../../../types/events';
+
+export interface CopyLink {
+  label: string;
+  shortcut: string;
+  value: string;
+}
+
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
+
+@customElement('gr-copy-links')
+export class GrCopyLinks extends LitElement {
+  @property({type: Array})
+  copyLinks: CopyLink[] = [];
+
+  @state() isDropdownOpen = false;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  static override get styles() {
+    return [
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: min(90vw, 640px);
+          background-color: var(--dialog-background-color);
+          border-radius: var(--border-radius);
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-m) var(--spacing-l) var(--spacing-m);
+        }
+        .copy-link-row {
+          margin-bottom: var(--spacing-m);
+          display: flex;
+          align-items: center;
+        }
+        .copy-link-row label {
+          flex: 0 0 120px;
+          color: var(--deemphasized-text-color);
+        }
+        .copy-link-row input {
+          flex: 1 1 420px;
+        }
+        .copy-link-row .shortcut {
+          width: 27px;
+          margin: 0 var(--spacing-m);
+          color: var(--deemphasized-text-color);
+        }
+        .copy-link-row gr-copy-clipboard {
+          flex: 0 0 20px;
+        }
+        /* TODO(milutin): It's from shared styles, move it to input styles */
+        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: 0;
+          padding: var(--spacing-s);
+          font: inherit;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.copyLinks) return nothing;
+    return html`<iron-dropdown
+      .horizontalAlign=${'left'}
+      .verticalAlign=${'top'}
+      .verticalOffset=${20}
+      @keydown=${this.handleKeydown}
+      @opened-changed=${(e: ValueChangedEvent<boolean>) =>
+        (this.isDropdownOpen = e.detail.value)}
+    >
+      ${this.renderCopyLinks()}
+    </iron-dropdown>`;
+  }
+
+  private renderCopyLinks() {
+    return html`<div slot="dropdown-content">
+      ${this.copyLinks?.map(link => this.renderCopyLinkRow(link))}
+    </div>`;
+  }
+
+  private renderCopyLinkRow(copyLink: CopyLink) {
+    const {label, shortcut, value} = copyLink;
+    const id = `${strToClassName(label, '')}-field`;
+    // TODO(milutin): Use input in gr-copy-clipboard instead of creating new
+    // one. Move shorcut to gr-copy-clipboard.
+    return html`<div class="copy-link-row">
+      <label for=${id}>${label}</label
+      ><input type="text" readonly="" id=${id} class="input" .value=${value} />
+      <span class="shortcut">${`l - ${shortcut}`}</span>
+      <gr-copy-clipboard
+        hideInput=""
+        text=${value}
+        id=${`${id}-copy-clipboard`}
+      ></gr-copy-clipboard>
+    </div>`;
+  }
+
+  private async handleKeydown(e: KeyboardEvent) {
+    const copyLink = this.copyLinks?.find(link => link.shortcut === e.key);
+    if (!copyLink) return;
+    await copyToClipbard(copyLink.value, copyLink.label);
+    this.closeDropdown();
+  }
+
+  toggleDropdown() {
+    this.isDropdownOpen ? this.closeDropdown() : this.openDropdown();
+  }
+
+  private closeDropdown() {
+    this.dropdown?.close();
+  }
+
+  openDropdown() {
+    this.dropdown?.open();
+    this.awaitOpen(() => {
+      queryAndAssert<HTMLInputElement>(this.dropdown, 'input')?.select();
+    });
+  }
+
+  /**
+   * NOTE: (milutin) Slightly hacky way to listen to the overlay actually
+   * opening. It's from gr-editable-label. It will be removed when we
+   * migrate out of iron-* components.
+   */
+  private awaitOpen(fn: () => void) {
+    let iters = 0;
+    const step = () => {
+      setTimeout(() => {
+        if (this.dropdown?.style.display !== 'none') {
+          fn.call(this);
+        } else if (iters++ < AWAIT_MAX_ITERS) {
+          step.call(this);
+        }
+      }, AWAIT_STEP);
+    };
+    step.call(this);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-copy-links': GrCopyLinks;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
new file mode 100644
index 0000000..4a0742b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import './gr-copy-links';
+import {GrCopyLinks} from './gr-copy-links';
+import {pressKey, queryAndAssert, waitUntil} from '../../../test/test-utils';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrCopyClipboard} from '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+
+suite('gr-copy-links tests', () => {
+  let element: GrCopyLinks;
+  setup(async () => {
+    const links = [
+      {
+        label: 'Change ID',
+        shortcut: 'd',
+        value: '123456',
+      },
+    ];
+    element = await fixture<GrCopyLinks>(
+      html`<gr-copy-links .copyLinks=${links}></gr-copy-links>`
+    );
+    await element.updateComplete;
+    element.openDropdown();
+    await waitUntil(() => element.isDropdownOpen);
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<iron-dropdown
+        aria-disabled="false"
+        horizontal-align="left"
+        vertical-align="top"
+      >
+      <div slot="dropdown-content">
+          <div class="copy-link-row">
+            <label for="Change_ID-field">Change ID</label>
+            <input
+              class="input"
+              id="Change_ID-field"
+              readonly=""
+              type="text"
+            >
+            <span class="shortcut">l - d</span>
+            <gr-copy-clipboard hideinput="" text="123456" id="Change_ID-field-copy-clipboard">
+            </gr-copy-clipboard>
+          </div>
+      </iron-dropdown>`,
+      {
+        // iron-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+      }
+    );
+  });
+
+  test('click writes to clipboard', () => {
+    const clipboardStub = sinon.stub(navigator.clipboard, 'writeText');
+    const copyClipboard = queryAndAssert<GrCopyClipboard>(
+      element,
+      'gr-copy-clipboard'
+    );
+    const copyBtn = queryAndAssert<GrButton>(copyClipboard, '.copyToClipboard');
+    copyBtn.click();
+    assert.isTrue(clipboardStub.called);
+    assert.isTrue(clipboardStub.calledWith('123456'));
+  });
+
+  test('shorcuts writes to clipboard', () => {
+    const clipboardStub = sinon.stub(window.navigator.clipboard, 'writeText');
+    const ironDropdown = queryAndAssert<IronDropdownElement>(
+      element,
+      'iron-dropdown'
+    );
+    pressKey(ironDropdown, 'd');
+    assert.isTrue(clipboardStub.called);
+    assert.isTrue(clipboardStub.calledWith('123456'));
+  });
+});
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 c1fa7d5..6603149 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
@@ -1,35 +1,28 @@
 /**
  * @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 '../../shared/gr-download-commands/gr-download-commands';
 import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
 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, queryAndAssert} from '../../../utils/common-util';
+import {
+  copyToClipbard,
+  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';
+import {fireEvent} from '../../../utils/event-util';
 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 {customElement, property, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {BindValueChangeEvent} from '../../../types/events';
+import {ShortcutController} from '../../lit/shortcut-controller';
 
 @customElement('gr-download-dialog')
 export class GrDownloadDialog extends LitElement {
@@ -56,21 +49,12 @@
 
   @state() private selectedScheme?: string;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  private readonly shortcuts = new ShortcutController(this);
 
-  override disconnectedCallback() {
-    super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     for (const key of ['1', '2', '3', '4', '5']) {
-      this.cleanups.push(
-        addShortcut(this, {key}, e => this.handleNumberKey(e))
-      );
+      this.shortcuts.addLocal({key}, e => this.handleNumberKey(e));
     }
   }
 
@@ -238,14 +222,15 @@
     return [];
   }
 
-  private handleNumberKey(e: KeyboardEvent) {
+  private async handleNumberKey(e: KeyboardEvent) {
     const index = Number(e.key) - 1;
     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`);
-      fireEvent(this, 'close');
-    });
+    await copyToClipbard(
+      commands[index].command,
+      `${commands[index].title} command`
+    );
+    fireEvent(this, 'close');
   }
 
   override focus() {
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 142b999..e5f40a6 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
@@ -1,22 +1,9 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import '../../../test/common-test-setup';
 import {
   createChange,
   createCommit,
@@ -33,8 +20,8 @@
 import {GrDownloadDialog} from './gr-download-dialog';
 import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
-
-const basicFixture = fixtureFromElement('gr-download-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 function getChangeObject() {
   return {
@@ -108,12 +95,63 @@
   let element: GrDownloadDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-download-dialog></gr-download-dialog>`);
     element.patchNum = 1 as PatchSetNum;
     element.config = createDownloadInfo();
     await element.updateComplete;
   });
 
+  test('render', () => {
+    // prettier and shadowDom string don't agree on the long text in the h3
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <section>
+        <h3 class="heading-3">
+          Patch set 1 of
+          0
+        </h3>
+      </section>
+      <section class="hidden">
+        <gr-download-commands
+          id="downloadCommands"
+          show-keyboard-shortcut-tooltips=""
+        >
+        </gr-download-commands>
+      </section>
+      <section class="flexContainer">
+        <div class="patchFiles">
+          <label> Patch file </label>
+          <div>
+            <a download="" href="" id="download"> </a>
+            <a download="" href=""> </a>
+          </div>
+        </div>
+        <div class="archivesContainer">
+          <label> Archive </label>
+          <div class="archives" id="archives">
+            <a download="" href=""> tgz </a>
+            <a download="" href=""> tar </a>
+          </div>
+        </div>
+      </section>
+      <section class="footer">
+        <span class="closeButtonContainer">
+          <gr-button
+            aria-disabled="false"
+            id="closeButton"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Close
+          </gr-button>
+        </span>
+      </section>
+    `
+    );
+  });
+
   test('anchors use download attribute', () => {
     const anchors = Array.from(queryAll(element, 'a'));
     assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
@@ -182,11 +220,11 @@
       element.addEventListener('close', () => {
         closeCalled.resolve();
       });
-      const closeButton = queryAndAssert(
+      const closeButton = queryAndAssert<GrButton>(
         element,
         '.closeButtonContainer gr-button'
       );
-      tap(closeButton);
+      closeButton.click();
       await closeCalled;
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.ts b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
index 0e55494..b498e6a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-constants.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
  */
 
 export enum FilesExpandedState {
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 a711680..832738b 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
@@ -1,30 +1,19 @@
 /**
  * @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 '../../../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 '../../shared/gr-icon/gr-icon';
 import '../gr-commit-info/gr-commit-info';
 import {FilesExpandedState} from '../gr-file-list-constants';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {computeLatestPatchNum, PatchSet} from '../../../utils/patch-set-util';
-import {property, customElement, query} from 'lit/decorators';
+import {property, customElement, query, state} from 'lit/decorators.js';
 import {
   AccountInfo,
   ChangeInfo,
@@ -39,15 +28,20 @@
 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 {css, html, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {when} from 'lit/directives/when.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {
   Shortcut,
   ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+  shortcutsServiceToken,
+} from '../../../services/shortcuts/shortcuts-service';
+import {resolve} from '../../../models/dependency';
 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';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {createChangeUrl} from '../../../models/views/change';
 
 @customElement('gr-file-list-header')
 export class GrFileListHeader extends LitElement {
@@ -91,15 +85,9 @@
   @property({type: Boolean})
   loggedIn: boolean | undefined;
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
-
   @property({type: Number})
   shownFileCount = 0;
 
-  @property({type: Object})
-  diffPrefs?: DiffPreferencesInfo;
-
   @property({type: String})
   patchNum?: PatchSetNum;
 
@@ -112,6 +100,12 @@
   @property({type: Object})
   revisionInfo?: RevisionInfo;
 
+  @state()
+  diffPrefs?: DiffPreferencesInfo;
+
+  @state()
+  serverConfig?: ServerInfo;
+
   @query('#modeSelect')
   modeSelect?: GrDiffModeSelector;
 
@@ -121,12 +115,37 @@
   @query('#collapseBtn')
   collapseBtn?: GrButton;
 
-  private readonly shortcuts = getAppContext().shortcutsService;
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
 
   // Caps the number of files that can be shown and have the 'show diffs' /
   // 'hide diffs' buttons still be functional.
   private readonly maxFilesForBulkActions = 225;
 
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      diffPreferences => {
+        if (!diffPreferences) return;
+        this.diffPrefs = diffPreferences;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+  }
+
   static override styles = [
     sharedStyles,
     css`
@@ -269,11 +288,7 @@
             >
             </gr-patch-range-select>
             <span class="separator"></span>
-            <gr-commit-info
-              .change=${this.change}
-              .serverConfig=${this.serverConfig}
-              .commitInfo=${this.commitInfo}
-            ></gr-commit-info>
+            <gr-commit-info .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>
@@ -310,7 +325,7 @@
                   link
                   class="prefsButton desktop"
                   @click=${this.handlePrefsTap}
-                  ><iron-icon icon="gr-icons:settings"></iron-icon
+                  ><gr-icon icon="settings" filled></gr-icon
                 ></gr-button>
               </gr-tooltip-content>
             </span>
@@ -409,7 +424,9 @@
     ) {
       return;
     }
-    GerritNav.navigateToChange(this.change, {patchNum, basePatchNum});
+    this.getNavigation().setUrl(
+      createChangeUrl({change: this.change, patchNum, basePatchNum})
+    );
   }
 
   private handlePrefsTap(e: Event) {
@@ -438,7 +455,7 @@
   }
 
   private createTitle(shortcutName: Shortcut, section: ShortcutSection) {
-    return this.shortcuts.createTitle(shortcutName, section);
+    return this.getShortcutsService().createTitle(shortcutName, section);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
index ac2b4d4..7b79893 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
@@ -1,38 +1,34 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-file-list-header';
 import {FilesExpandedState} from '../gr-file-list-constants';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {createChange, createRevision} from '../../../test/test-data-generators';
-import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  isVisible,
+  query,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {GrFileListHeader} from './gr-file-list-header';
 import {
   BasePatchSetNum,
   ChangeId,
   NumericChangeId,
+  PARENT,
   PatchSetNum,
-} from '../../../types/common.js';
-import {ChangeInfo, ChangeStatus} from '../../../api/rest-api.js';
+  PatchSetNumber,
+} from '../../../types/common';
+import {ChangeInfo, ChangeStatus} from '../../../api/rest-api';
 import {PatchSet} from '../../../utils/patch-set-util';
-import {createDefaultDiffPrefs} from '../../../constants/constants.js';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../../shared/gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-file-list-header tests', () => {
   let element: GrFileListHeader;
@@ -54,10 +50,95 @@
     element = await fixture(
       html`<gr-file-list-header
         .change=${change}
-        .diffPrefs=${createDefaultDiffPrefs()}
         .shownFileCount=${3}
       ></gr-file-list-header>`
     );
+    element.diffPrefs = createDefaultDiffPrefs();
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="patchInfo-header">
+          <div class="patchInfo-left">
+            <div class="patchInfoContent">
+              <gr-patch-range-select id="rangeSelect"> </gr-patch-range-select>
+              <span class="separator"> </span>
+              <gr-commit-info> </gr-commit-info>
+              <span class="container latestPatchContainer">
+                <span class="separator"> </span>
+                <a> Go to latest patch set </a>
+              </span>
+            </div>
+          </div>
+          <div class="rightControls">
+            <div class="fileViewActions">
+              <span class="fileViewActionsLabel"> Diff view: </span>
+              <gr-diff-mode-selector id="modeSelect"> </gr-diff-mode-selector>
+              <span class="hideOnEdit" hidden="" id="diffPrefsContainer">
+                <gr-tooltip-content has-tooltip="" title="Diff preferences">
+                  <gr-button
+                    aria-disabled="false"
+                    class="desktop prefsButton"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    <gr-icon filled icon="settings"></gr-icon>
+                  </gr-button>
+                </gr-tooltip-content>
+              </span>
+              <span class="separator"> </span>
+            </div>
+            <span class="desktop downloadContainer">
+              <gr-tooltip-content
+                has-tooltip=""
+                title="Open download overlay (shortcut: d)"
+              >
+                <gr-button
+                  aria-disabled="false"
+                  class="download"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Download
+                </gr-button>
+              </gr-tooltip-content>
+            </span>
+            <gr-tooltip-content
+              has-tooltip=""
+              title="Show/hide all inline diffs (shortcut: I)"
+            >
+              <gr-button
+                aria-disabled="false"
+                id="expandBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Expand All
+              </gr-button>
+            </gr-tooltip-content>
+            <gr-tooltip-content
+              has-tooltip=""
+              title="Show/hide all inline diffs (shortcut: I)"
+            >
+              <gr-button
+                aria-disabled="false"
+                id="collapseBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Collapse All
+              </gr-button>
+            </gr-tooltip-content>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('Diff preferences hidden when no prefs', async () => {
@@ -96,7 +177,7 @@
 
   test('show/hide diffs disabled for large amounts of files', async () => {
     element.changeNum = 42 as NumericChangeId;
-    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    element.basePatchNum = PARENT;
     element.patchNum = '2' as PatchSetNum;
     element.shownFileCount = 1;
     await element.updateComplete;
@@ -157,8 +238,8 @@
     assert.equal(getComputedStyle(collapseBtn).display, 'none');
   });
 
-  test('navigateToChange called when range select changes', async () => {
-    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+  test('setUrl called when range select changes', async () => {
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     element.basePatchNum = 1 as BasePatchSetNum;
     element.patchNum = 2 as PatchSetNum;
     await element.updateComplete;
@@ -168,20 +249,15 @@
     } as CustomEvent);
     await element.updateComplete;
 
-    assert.equal(navigateToChangeStub.callCount, 1);
-    assert.isTrue(
-      navigateToChangeStub.lastCall.calledWithExactly(change, {
-        patchNum: 3 as PatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-      })
-    );
+    assert.equal(setUrlStub.callCount, 1);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1..3');
   });
 
   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: ''},
+      {num: 4 as PatchSetNumber, desc: undefined, sha: ''},
+      {num: 2 as PatchSetNumber, desc: undefined, sha: ''},
+      {num: 1 as PatchSetNumber, desc: undefined, sha: ''},
     ];
     assert.equal(
       element.computePatchInfoClass(1 as PatchSetNum, allPatchSets),
@@ -203,17 +279,12 @@
       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: ''},
+        {num: 1 as PatchSetNumber, desc: undefined, sha: ''},
+        {num: 2 as PatchSetNumber, desc: undefined, sha: ''},
+        {num: 3 as PatchSetNumber, desc: undefined, sha: ''},
       ];
       await element.updateComplete;
 
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 237e126..3e8530c 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
@@ -1,20 +1,8 @@
 /**
  * @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 {Subscription} from 'rxjs';
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
@@ -23,77 +11,75 @@
 import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
-import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-file-status-chip/gr-file-status-chip';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {htmlTemplate} from './gr-file-list_html';
-import {asyncForeach, debounce, DelayedTask} from '../../../utils/async-util';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import '../../shared/gr-file-status/gr-file-status';
+import {assertIsDefined} from '../../../utils/common-util';
+import {asyncForeach} from '../../../utils/async-util';
 import {FilesExpandedState} from '../gr-file-list-constants';
-import {pluralize} from '../../../utils/string-util';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {diffFilePaths, pluralize} from '../../../utils/string-util';
+import {navigationToken} 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 {getAppContext} from '../../../services/app-context';
 import {
   DiffViewMode,
+  FileInfoStatus,
   ScrollMode,
   SpecialFilePath,
 } from '../../../constants/constants';
+import {descendedFromClass, Key, toggleClass} from '../../../utils/dom-util';
 import {
-  addGlobalShortcut,
-  addShortcut,
-  descendedFromClass,
-  Key,
-  toggleClass,
-} from '../../../utils/dom-util';
-import {
-  addUnmodifiedFiles,
   computeDisplayPath,
   computeTruncatedPath,
   isMagicPath,
-  specialFilePathCompare,
 } from '../../../utils/path-list-util';
-import {customElement, observe, property} from '@polymer/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {
   BasePatchSetNum,
-  EditPatchSetNum,
-  ElementPropertyDeepChange,
+  EDIT,
   FileInfo,
-  FileNameToFileInfoMap,
   NumericChangeId,
+  PARENT,
   PatchRange,
-  RevisionPatchSetNum,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
 import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
-import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
-import {Timing} from '../../../constants/reporting';
+import {Interaction, Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
-import {listen} from '../../../services/shortcuts/shortcuts-service';
 import {select} from '../../../utils/observable-util';
-import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {resolve} from '../../../models/dependency';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
+import {filesModelToken} from '../../../models/change/files-model';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {Shortcut} from '../../../services/shortcuts/shortcuts-config';
+import {fire} from '../../../utils/event-util';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ValueChangedEvent} from '../../../types/events';
+import {subscribe} from '../../lit/subscription-controller';
+import {when} from 'lit/directives/when.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {incrementalRepeat} from '../../lit/incremental-repeat';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {HtmlPatched} from '../../../utils/lit-util';
+import {createDiffUrl} from '../../../models/views/diff';
+import {createEditUrl} from '../../../models/views/edit';
+import {createChangeUrl} from '../../../models/views/change';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
 const WARN_SHOW_ALL_THRESHOLD = 1000;
-const LOADING_DEBOUNCE_INTERVAL = 100;
 
 const SIZE_BAR_MAX_WIDTH = 61;
 const SIZE_BAR_GAP_WIDTH = 1;
@@ -101,17 +87,7 @@
 
 const FILE_ROW_CLASS = 'file-row';
 
-export interface GrFileList {
-  $: {
-    diffPreferencesDialog: GrDiffPreferencesDialog;
-  };
-}
-
-interface ReviewedFileInfo extends FileInfo {
-  isReviewed?: boolean;
-}
-
-export interface NormalizedFileInfo extends ReviewedFileInfo {
+export interface NormalizedFileInfo extends FileInfo {
   __path: string;
 }
 
@@ -160,8 +136,6 @@
   element: HTMLElement;
 }
 
-export type FileNameToReviewedFileInfoMap = {[name: string]: ReviewedFileInfo};
-
 /**
  * Type for FileInfo
  *
@@ -177,14 +151,25 @@
  * @property {number} lines_inserted - fallback to 0 if not present in api
  */
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(DIPolymerElement);
-
-@customElement('gr-file-list')
-export class GrFileList extends base {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementEventMap {
+    'files-shown-changed': CustomEvent<{length: number}>;
+    'files-expanded-changed': ValueChangedEvent<FilesExpandedState>;
+    'diff-prefs-changed': ValueChangedEvent<DiffPreferencesInfo>;
   }
+  interface HTMLElementTagNameMap {
+    'gr-file-list': GrFileList;
+  }
+}
+@customElement('gr-file-list')
+export class GrFileList extends LitElement {
+  /**
+   * @event files-expanded-changed
+   * @event files-shown-changed
+   * @event diff-prefs-changed
+   */
+  @query('#diffPreferencesDialog')
+  diffPreferencesDialog?: GrDiffPreferencesDialog;
 
   @property({type: Object})
   patchRange?: PatchRange;
@@ -198,121 +183,103 @@
   @property({type: Object})
   changeComments?: ChangeComments;
 
-  @property({type: Number, notify: true})
-  selectedIndex = -1;
+  @state() selectedIndex = 0;
 
   @property({type: Object})
   change?: ParsedChangeInfo;
 
-  @property({type: String, notify: true, observer: '_updateDiffPreferences'})
+  @state()
   diffViewMode?: DiffViewMode;
 
-  @property({type: Boolean, observer: '_editModeChanged'})
+  @property({type: Boolean})
   editMode?: boolean;
 
-  @property({type: String, notify: true})
-  filesExpanded = FilesExpandedState.NONE;
+  private _filesExpanded = FilesExpandedState.NONE;
 
-  @property({type: Object})
-  _filesByPath?: FileNameToFileInfoMap;
+  get filesExpanded() {
+    return this._filesExpanded;
+  }
 
-  @property({type: Array, observer: '_filesChanged'})
-  _files: NormalizedFileInfo[] = [];
+  set filesExpanded(filesExpanded: FilesExpandedState) {
+    if (this._filesExpanded === filesExpanded) return;
+    const oldFilesExpanded = this._filesExpanded;
+    this._filesExpanded = filesExpanded;
+    fire(this, 'files-expanded-changed', {value: this._filesExpanded});
+    this.requestUpdate('filesExpanded', oldFilesExpanded);
+  }
 
-  @property({type: Boolean})
-  _loggedIn = false;
+  // Private but used in tests.
+  @state()
+  files: NormalizedFileInfo[] = [];
 
-  @property({type: Array})
-  reviewed?: string[] = [];
+  // Private but used in tests.
+  @state() filesLeftBase: NormalizedFileInfo[] = [];
 
-  @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
+  @state() private filesRightBase: NormalizedFileInfo[] = [];
+
+  // Private but used in tests.
+  @state()
+  loggedIn = false;
+
+  /**
+   * List of paths of files that are marked as reviewed. Direct model
+   * subscription.
+   */
+  @state()
+  reviewed: string[] = [];
+
+  @state()
   diffPrefs?: DiffPreferencesInfo;
 
-  @property({type: Number, notify: true})
-  numFilesShown: number = DEFAULT_NUM_FILES_SHOWN;
+  @state() numFilesShown = DEFAULT_NUM_FILES_SHOWN;
 
-  @property({type: Object, computed: '_calculatePatchChange(_files)'})
-  _patchChange: PatchChange = createDefaultPatchChange();
-
-  @property({type: Number})
+  @state()
   fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN;
 
-  @property({type: Boolean, computed: '_shouldHideChangeTotals(_patchChange)'})
-  _hideChangeTotals = true;
+  // Private but used in tests.
+  shownFiles: NormalizedFileInfo[] = [];
 
-  @property({
-    type: Boolean,
-    computed: '_shouldHideBinaryChangeTotals(_patchChange)',
-  })
-  _hideBinaryChangeTotals = true;
+  @state()
+  private reportinShownFilesIncrement = 0;
 
-  @property({
-    type: Array,
-    computed: '_computeFilesShown(numFilesShown, _files)',
-  })
-  _shownFiles: NormalizedFileInfo[] = [];
+  // Private but used in tests.
+  @state()
+  expandedFiles: PatchSetFile[] = [];
 
-  @property({type: Number})
-  _reportinShownFilesIncrement = 0;
+  // Private but used in tests.
+  @state()
+  displayLine?: boolean;
 
-  @property({type: Array})
-  _expandedFiles: PatchSetFile[] = [];
-
-  @property({type: Boolean})
-  _displayLine?: boolean;
-
-  @property({type: Boolean, observer: '_loadingChanged'})
-  _loading?: boolean;
-
-  @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'})
-  _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout();
-
-  @property({type: Boolean})
-  _showSizeBars = true;
+  // Private but used in tests.
+  @state()
+  showSizeBars = true;
 
   // For merge commits vs Auto Merge, an extra file row is shown detailing the
   // files that were merged without conflict. These files are also passed to any
   // plugins.
-  @property({type: Array})
-  _cleanlyMergedPaths: string[] = [];
+  @state()
+  private cleanlyMergedPaths: string[] = [];
 
-  @property({type: Array})
-  _cleanlyMergedOldPaths: string[] = [];
+  // Private but used in tests.
+  @state()
+  cleanlyMergedOldPaths: string[] = [];
 
-  private _cancelForEachDiff?: () => void;
+  private cancelForEachDiff?: () => void;
 
-  loadingTask?: DelayedTask;
+  @state()
+  private dynamicHeaderEndpoints?: string[];
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
-      '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
-  })
-  _showDynamicColumns = false;
+  @state()
+  private dynamicContentEndpoints?: string[];
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeShowPrependedDynamicColumns(' +
-      '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
-  })
-  _showPrependedDynamicColumns = false;
+  @state()
+  private dynamicSummaryEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicHeaderEndpoints?: string[];
+  @state()
+  private dynamicPrependedHeaderEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicContentEndpoints?: string[];
-
-  @property({type: Array})
-  _dynamicSummaryEndpoints?: string[];
-
-  @property({type: Array})
-  _dynamicPrependedHeaderEndpoints?: string[];
-
-  @property({type: Array})
-  _dynamicPrependedContentEndpoints?: string[];
+  @state()
+  private dynamicPrependedContentEndpoints?: string[];
 
   private readonly reporting = getAppContext().reportingService;
 
@@ -322,52 +289,22 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getFilesModel = resolve(this, filesModelToken);
+
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly getBrowserModel = resolve(this, browserModelToken);
 
-  private subscriptions: Subscription[] = [];
+  private readonly patched = new HtmlPatched(key => {
+    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
+      component: this.tagName,
+      key: key.substring(0, 300),
+    });
+  });
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  shortcutsController = new ShortcutController(this);
 
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [
-      listen(Shortcut.LEFT_PANE, _ => this._handleLeftPane()),
-      listen(Shortcut.RIGHT_PANE, _ => this._handleRightPane()),
-      listen(Shortcut.TOGGLE_INLINE_DIFF, _ => this._handleToggleInlineDiff()),
-      listen(Shortcut.TOGGLE_ALL_INLINE_DIFFS, _ => this._toggleInlineDiffs()),
-      listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
-        toggleClass(this, 'hideComments')
-      ),
-      listen(Shortcut.CURSOR_NEXT_FILE, e => this._handleCursorNext(e)),
-      listen(Shortcut.CURSOR_PREV_FILE, e => this._handleCursorPrev(e)),
-      // This is already been taken care of by CURSOR_NEXT_FILE above. The two
-      // shortcuts share the same bindings. It depends on whether all files
-      // are expanded whether the cursor moves to the next file or line.
-      listen(Shortcut.NEXT_LINE, _ => {}), // docOnly
-      // This is already been taken care of by CURSOR_PREV_FILE above. The two
-      // shortcuts share the same bindings. It depends on whether all files
-      // are expanded whether the cursor moves to the previous file or line.
-      listen(Shortcut.PREV_LINE, _ => {}), // docOnly
-      listen(Shortcut.NEW_COMMENT, _ => this._handleNewComment()),
-      listen(Shortcut.OPEN_LAST_FILE, _ =>
-        this._openSelectedFile(this._files.length - 1)
-      ),
-      listen(Shortcut.OPEN_FIRST_FILE, _ => this._openSelectedFile(0)),
-      listen(Shortcut.OPEN_FILE, _ => this.handleOpenFile()),
-      listen(Shortcut.NEXT_CHUNK, _ => this._handleNextChunk()),
-      listen(Shortcut.PREV_CHUNK, _ => this._handlePrevChunk()),
-      listen(Shortcut.NEXT_COMMENT_THREAD, _ => this._handleNextComment()),
-      listen(Shortcut.PREV_COMMENT_THREAD, _ => this._handlePrevComment()),
-      listen(Shortcut.TOGGLE_FILE_REVIEWED, _ =>
-        this._handleToggleFileReviewed()
-      ),
-      listen(Shortcut.TOGGLE_LEFT_PANE, _ => this._handleToggleLeftPane()),
-      listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}), // docOnly
-      listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}), // docOnly
-    ];
-  }
+  private readonly getNavigation = resolve(this, navigationToken);
 
   // private but used in test
   fileCursor = new GrCursorManager();
@@ -375,135 +312,1289 @@
   // private but used in test
   diffCursor?: GrDiffCursor;
 
+  static override get styles() {
+    return [
+      a11yStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        .row {
+          align-items: center;
+          border-top: 1px solid var(--border-color);
+          display: flex;
+          min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
+          padding: var(--spacing-xs) var(--spacing-l);
+        }
+        /* The class defines a content visible only to screen readers */
+        .noCommentsScreenReaderText {
+          opacity: 0;
+          max-width: 1px;
+          overflow: hidden;
+          display: none;
+          vertical-align: top;
+        }
+        div[role='gridcell']
+          > div.comments
+          > span:empty
+          + span:empty
+          + span.noCommentsScreenReaderText {
+          /* inline-block instead of block, such that it can control width */
+          display: inline-block;
+        }
+        :host(.editMode) .hideOnEdit {
+          display: none;
+        }
+        .showOnEdit {
+          display: none;
+        }
+        :host(.editMode) .showOnEdit {
+          display: initial;
+        }
+        .invisible {
+          visibility: hidden;
+        }
+        .header-row {
+          background-color: var(--background-color-secondary);
+        }
+        .controlRow {
+          align-items: center;
+          display: flex;
+          height: 2.25em;
+          justify-content: center;
+        }
+        .controlRow.invisible,
+        .show-hide.invisible {
+          display: none;
+        }
+        .reviewed {
+          align-items: center;
+          display: inline-flex;
+        }
+        .reviewed {
+          display: inline-block;
+          text-align: left;
+          width: 1.5em;
+        }
+        .file-row {
+          cursor: pointer;
+        }
+        .file-row.expanded {
+          border-bottom: 1px solid var(--border-color);
+          position: -webkit-sticky;
+          position: sticky;
+          top: 0;
+          /* Has to visible above the diff view, and by default has a lower
+            z-index. setting to 1 places it directly above. */
+          z-index: 1;
+        }
+        .file-row:hover {
+          background-color: var(--hover-background-color);
+        }
+        .file-row.selected {
+          background-color: var(--selection-background-color);
+        }
+        .file-row.expanded,
+        .file-row.expanded:hover {
+          background-color: var(--expanded-background-color);
+        }
+        .status {
+          margin-right: var(--spacing-m);
+          display: flex;
+          width: 20px;
+          justify-content: flex-end;
+        }
+        .status.extended {
+          width: 56px;
+        }
+        .status > * {
+          display: block;
+        }
+        .header-row .status .content {
+          width: 20px;
+          text-align: center;
+        }
+        .path {
+          cursor: pointer;
+          flex: 1;
+          /* Wrap it into multiple lines if too long. */
+          white-space: normal;
+          word-break: break-word;
+        }
+        .oldPath {
+          color: var(--deemphasized-text-color);
+        }
+        .header-stats {
+          text-align: center;
+          min-width: 7.5em;
+        }
+        .stats {
+          text-align: right;
+          min-width: 7.5em;
+        }
+        .comments {
+          padding-left: var(--spacing-l);
+          min-width: 7.5em;
+          white-space: nowrap;
+        }
+        .row:not(.header-row) .stats,
+        .total-stats {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          display: flex;
+        }
+        .sizeBars {
+          margin-left: var(--spacing-m);
+          min-width: 7em;
+          text-align: center;
+        }
+        .sizeBars.hide {
+          display: none;
+        }
+        .added,
+        .removed {
+          display: inline-block;
+          min-width: 3.5em;
+        }
+        .added {
+          color: var(--positive-green-text-color);
+        }
+        .removed {
+          color: var(--negative-red-text-color);
+          text-align: left;
+          min-width: 4em;
+          padding-left: var(--spacing-s);
+        }
+        .drafts {
+          color: var(--error-foreground);
+          font-weight: var(--font-weight-bold);
+        }
+        .show-hide-icon:focus {
+          outline: none;
+        }
+        .show-hide {
+          margin-left: var(--spacing-s);
+          width: 1.9em;
+        }
+        .fileListButton {
+          margin: var(--spacing-m);
+        }
+        .totalChanges {
+          justify-content: flex-end;
+          text-align: right;
+        }
+        .warning {
+          color: var(--deemphasized-text-color);
+        }
+        input.show-hide {
+          display: none;
+        }
+        label.show-hide {
+          cursor: pointer;
+          display: block;
+          min-width: 2em;
+        }
+        gr-diff {
+          display: block;
+          overflow-x: auto;
+        }
+        .matchingFilePath {
+          color: var(--deemphasized-text-color);
+        }
+        .newFilePath {
+          color: var(--primary-text-color);
+        }
+        .fileName {
+          color: var(--link-color);
+        }
+        .truncatedFileName {
+          display: none;
+        }
+        .mobile {
+          display: none;
+        }
+        .reviewed {
+          margin-left: var(--spacing-xxl);
+          width: 15em;
+        }
+        .reviewedSwitch {
+          color: var(--link-color);
+          opacity: 0;
+          justify-content: flex-end;
+          width: 100%;
+        }
+        .reviewedSwitch:hover {
+          cursor: pointer;
+          opacity: 100;
+        }
+        .showParentButton {
+          line-height: var(--line-height-normal);
+          margin-bottom: calc(var(--spacing-s) * -1);
+          margin-left: var(--spacing-m);
+          margin-top: calc(var(--spacing-s) * -1);
+        }
+        .row:focus {
+          outline: none;
+        }
+        .row:hover .reviewedSwitch,
+        .row:focus-within .reviewedSwitch,
+        .row.expanded .reviewedSwitch {
+          opacity: 100;
+        }
+        .reviewedLabel {
+          color: var(--deemphasized-text-color);
+          margin-right: var(--spacing-l);
+          opacity: 0;
+        }
+        .reviewedLabel.isReviewed {
+          display: initial;
+          opacity: 100;
+        }
+        .editFileControls {
+          width: 7em;
+        }
+        .markReviewed:focus {
+          outline: none;
+        }
+        .markReviewed,
+        .pathLink {
+          display: inline-block;
+          margin: -2px 0;
+          padding: var(--spacing-s) 0;
+          text-decoration: none;
+        }
+        .pathLink:hover span.fullFileName,
+        .pathLink:hover span.truncatedFileName {
+          text-decoration: underline;
+        }
+
+        /** copy on file path **/
+        .pathLink gr-copy-clipboard,
+        .oldPath gr-copy-clipboard {
+          display: inline-block;
+          visibility: hidden;
+          vertical-align: bottom;
+          --gr-button-padding: 0px;
+        }
+        .row:focus-within gr-copy-clipboard,
+        .row:hover gr-copy-clipboard {
+          visibility: visible;
+        }
+
+        .file-status-arrow {
+          font-size: 16px;
+          position: relative;
+          top: 2px;
+          display: block;
+        }
+
+        @media screen and (max-width: 1200px) {
+          gr-endpoint-decorator.extra-col {
+            display: none;
+          }
+        }
+
+        @media screen and (max-width: 1000px) {
+          .reviewed {
+            display: none;
+          }
+        }
+
+        @media screen and (max-width: 800px) {
+          .desktop {
+            display: none;
+          }
+          .mobile {
+            display: block;
+          }
+          .row.selected {
+            background-color: var(--view-background-color);
+          }
+          .stats {
+            display: none;
+          }
+          .reviewed,
+          .status {
+            justify-content: flex-start;
+          }
+          .comments {
+            min-width: initial;
+          }
+          .expanded .fullFileName,
+          .truncatedFileName {
+            display: inline;
+          }
+          .expanded .truncatedFileName,
+          .fullFileName {
+            display: none;
+          }
+        }
+        :host(.hideComments) {
+          --gr-comment-thread-display: none;
+        }
+      `,
+    ];
+  }
+
   constructor() {
     super();
     this.fileCursor.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.fileCursor.cursorTargetClass = 'selected';
     this.fileCursor.focusOnMove = true;
+    this.shortcutsController.addAbstract(Shortcut.LEFT_PANE, _ =>
+      this.handleLeftPane()
+    );
+    this.shortcutsController.addAbstract(Shortcut.RIGHT_PANE, _ =>
+      this.handleRightPane()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_INLINE_DIFF, _ =>
+      this.handleToggleInlineDiff()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_ALL_INLINE_DIFFS, _ =>
+      this.toggleInlineDiffs()
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+      _ => toggleClass(this, 'hideComments')
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.CURSOR_NEXT_FILE,
+      e => this.handleCursorNext(e),
+      {preventDefault: false}
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.CURSOR_PREV_FILE,
+      e => this.handleCursorPrev(e),
+      {preventDefault: false}
+    );
+    // This is already been taken care of by CURSOR_NEXT_FILE above. The two
+    // shortcuts share the same bindings. It depends on whether all files
+    // are expanded whether the cursor moves to the next file or line.
+    this.shortcutsController.addAbstract(Shortcut.NEXT_LINE, _ => {}, {
+      preventDefault: false,
+    }); // docOnly
+    // This is already been taken care of by CURSOR_PREV_FILE above. The two
+    // shortcuts share the same bindings. It depends on whether all files
+    // are expanded whether the cursor moves to the previous file or line.
+    this.shortcutsController.addAbstract(Shortcut.PREV_LINE, _ => {}, {
+      preventDefault: false,
+    }); // docOnly
+    this.shortcutsController.addAbstract(Shortcut.NEW_COMMENT, _ =>
+      this.handleNewComment()
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_LAST_FILE, _ =>
+      this.openSelectedFile(this.files.length - 1)
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_FIRST_FILE, _ =>
+      this.openSelectedFile(0)
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_FILE, _ =>
+      this.handleOpenFile()
+    );
+    this.shortcutsController.addAbstract(Shortcut.NEXT_CHUNK, _ =>
+      this.handleNextChunk()
+    );
+    this.shortcutsController.addAbstract(Shortcut.PREV_CHUNK, _ =>
+      this.handlePrevChunk()
+    );
+    this.shortcutsController.addAbstract(Shortcut.NEXT_COMMENT_THREAD, _ =>
+      this.handleNextComment()
+    );
+    this.shortcutsController.addAbstract(Shortcut.PREV_COMMENT_THREAD, _ =>
+      this.handlePrevComment()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_FILE_REVIEWED, _ =>
+      this.handleToggleFileReviewed()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_LEFT_PANE, _ =>
+      this.handleToggleLeftPane()
+    );
+    this.shortcutsController.addGlobal({key: Key.ESC}, _ =>
+      this.handleEscKey()
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.EXPAND_ALL_COMMENT_THREADS,
+      _ => {}
+    ); // docOnly
+    this.shortcutsController.addAbstract(
+      Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+      _ => {}
+    ); // docOnly
+    this.shortcutsController.addLocal(
+      {key: Key.ENTER},
+      _ => this.handleOpenFile(),
+      {
+        shouldSuppress: true,
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().changeComments$,
+      changeComments => {
+        this.changeComments = changeComments;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getFilesModel().filesWithUnmodified$,
+      files => {
+        this.files = [...files];
+      }
+    );
+    subscribe(
+      this,
+      () => this.getFilesModel().filesLeftBase$,
+      files => {
+        this.filesLeftBase = [...files];
+      }
+    );
+    subscribe(
+      this,
+      () => this.getFilesModel().filesRightBase$,
+      files => {
+        this.filesRightBase = [...files];
+      }
+    );
+    subscribe(
+      this,
+      () => this.getBrowserModel().diffViewMode$,
+      diffView => {
+        this.diffViewMode = diffView;
+      }
+    );
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      diffPreferences => {
+        this.diffPrefs = diffPreferences;
+      }
+    );
+    subscribe(
+      this,
+      () =>
+        select(
+          this.userModel.preferences$,
+          prefs => !!prefs?.size_bar_in_change_table
+        ),
+      sizeBarInChangeTable => {
+        this.showSizeBars = sizeBarInChangeTable;
+      }
+    );
+    subscribe(
+      this,
+      () => this.userModel.loggedIn$,
+      loggedIn => {
+        this.loggedIn = loggedIn;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().reviewedFiles$,
+      reviewedFiles => {
+        this.reviewed = reviewedFiles ?? [];
+      }
+    );
+  }
+
+  override willUpdate(changedProperties: PropertyValues): void {
+    if (
+      changedProperties.has('diffPrefs') ||
+      changedProperties.has('diffViewMode')
+    ) {
+      this.updateDiffPreferences();
+    }
+    if (changedProperties.has('files')) {
+      this.filesChanged();
+    }
+    if (
+      changedProperties.has('files') ||
+      changedProperties.has('numFilesShown')
+    ) {
+      this.shownFiles = this.computeFilesShown();
+    }
+    if (changedProperties.has('expandedFiles')) {
+      this.expandedFilesChanged(changedProperties.get('expandedFiles'));
+    }
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.subscriptions = [
-      this.getCommentsModel().changeComments$.subscribe(changeComments => {
-        this.changeComments = changeComments;
-      }),
-      this.getBrowserModel().diffViewMode$.subscribe(
-        diffView => (this.diffViewMode = diffView)
-      ),
-      this.userModel.diffPreferences$.subscribe(diffPreferences => {
-        this.diffPrefs = diffPreferences;
-      }),
-      select(
-        this.userModel.preferences$,
-        prefs => !!prefs?.size_bar_in_change_table
-      ).subscribe(sizeBarInChangeTable => {
-        this._showSizeBars = sizeBarInChangeTable;
-      }),
-      this.getChangeModel().reviewedFiles$.subscribe(reviewedFiles => {
-        this.reviewed = reviewedFiles ?? [];
-      }),
-    ];
 
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+        this.dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
           'change-view-file-list-header'
         );
-        this._dynamicContentEndpoints =
-          getPluginEndpoints().getDynamicEndpoints(
-            'change-view-file-list-content'
-          );
-        this._dynamicPrependedHeaderEndpoints =
+        this.dynamicContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-content'
+        );
+        this.dynamicPrependedHeaderEndpoints =
           getPluginEndpoints().getDynamicEndpoints(
             'change-view-file-list-header-prepend'
           );
-        this._dynamicPrependedContentEndpoints =
+        this.dynamicPrependedContentEndpoints =
           getPluginEndpoints().getDynamicEndpoints(
             'change-view-file-list-content-prepend'
           );
-        this._dynamicSummaryEndpoints =
-          getPluginEndpoints().getDynamicEndpoints(
-            'change-view-file-list-summary'
-          );
+        this.dynamicSummaryEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-summary'
+        );
 
         if (
-          this._dynamicHeaderEndpoints.length !==
-          this._dynamicContentEndpoints.length
+          this.dynamicHeaderEndpoints.length !==
+          this.dynamicContentEndpoints.length
         ) {
-          this.reporting.error(new Error('dynamic header/content mismatch'));
+          this.reporting.error(
+            'Plugin change-view-file-list',
+            new Error('dynamic header/content mismatch')
+          );
         }
         if (
-          this._dynamicPrependedHeaderEndpoints.length !==
-          this._dynamicPrependedContentEndpoints.length
+          this.dynamicPrependedHeaderEndpoints.length !==
+          this.dynamicPrependedContentEndpoints.length
         ) {
-          this.reporting.error(new Error('dynamic header/content mismatch'));
+          this.reporting.error(
+            'Plugin change-view-file-list',
+            new Error('dynamic prepend header/content mismatch')
+          );
         }
         if (
-          this._dynamicHeaderEndpoints.length !==
-          this._dynamicSummaryEndpoints.length
+          this.dynamicHeaderEndpoints.length !==
+          this.dynamicSummaryEndpoints.length
         ) {
-          this.reporting.error(new Error('dynamic header/content mismatch'));
+          this.reporting.error(
+            'Plugin change-view-file-list',
+            new Error('dynamic header/summary mismatch')
+          );
         }
       });
-    this.cleanups.push(
-      addGlobalShortcut({key: Key.ESC}, _ => this._handleEscKey()),
-      addShortcut(this, {key: Key.ENTER}, _ => this.handleOpenFile(), {
-        shouldSuppress: true,
-      })
-    );
     this.diffCursor = new GrDiffCursor();
     this.diffCursor.replaceDiffs(this.diffs);
   }
 
   override disconnectedCallback() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
     this.diffCursor?.dispose();
     this.fileCursor.unsetCursor();
-    this._cancelDiffs();
-    this.loadingTask?.cancel();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
+    this.cancelDiffs();
     super.disconnectedCallback();
   }
 
-  reload() {
-    if (!this.changeNum || !this.patchRange?.patchNum) {
-      return Promise.resolve();
+  protected override async getUpdateComplete(): Promise<boolean> {
+    const result = await super.getUpdateComplete();
+    await Promise.all(this.diffs.map(d => d.updateComplete));
+    return result;
+  }
+
+  override render() {
+    this.classList.toggle('editMode', this.editMode);
+    const patchChange = this.calculatePatchChange();
+    return html`
+      <h3 class="assistive-tech-only">File list</h3>
+      ${this.renderContainer()} ${this.renderChangeTotals(patchChange)}
+      ${this.renderBinaryTotals(patchChange)} ${this.renderControlRow()}
+      <gr-diff-preferences-dialog
+        id="diffPreferencesDialog"
+        @reload-diff-preference=${this.handleReloadingDiffPreference}
+      >
+      </gr-diff-preferences-dialog>
+    `;
+  }
+
+  private renderContainer() {
+    return html`
+      <div
+        id="container"
+        @click=${(e: MouseEvent) => this.handleFileListClick(e)}
+        role="grid"
+        aria-label="Files list"
+      >
+        ${this.renderHeaderRow()} ${this.renderShownFiles()}
+        ${when(this.computeShowNumCleanlyMerged(), () =>
+          this.renderCleanlyMerged()
+        )}
+      </div>
+    `;
+  }
+
+  private renderHeaderRow() {
+    const showPrependedDynamicColumns =
+      this.computeShowPrependedDynamicColumns();
+    const showDynamicColumns = this.computeShowDynamicColumns();
+    return html` <div class="header-row row" role="row">
+      <!-- endpoint: change-view-file-list-header-prepend -->
+      ${when(showPrependedDynamicColumns, () =>
+        this.renderPrependedHeaderEndpoints()
+      )}
+      ${this.renderFileStatus()}
+      <div class="path" role="columnheader">File</div>
+      <div class="comments desktop" role="columnheader">Comments</div>
+      <div class="comments mobile" role="columnheader" title="Comments">C</div>
+      <div class="sizeBars desktop" role="columnheader">Size</div>
+      <div class="header-stats" role="columnheader">Delta</div>
+      <!-- endpoint: change-view-file-list-header -->
+      ${when(showDynamicColumns, () => this.renderDynamicHeaderEndpoints())}
+      <!-- Empty div here exists to keep spacing in sync with file rows. -->
+      <div
+        class="reviewed hideOnEdit"
+        ?hidden=${!this.loggedIn}
+        aria-hidden="true"
+      ></div>
+      <div class="editFileControls showOnEdit" aria-hidden="true"></div>
+      <div class="show-hide" aria-hidden="true"></div>
+    </div>`;
+  }
+
+  private renderPrependedHeaderEndpoints() {
+    return this.dynamicPrependedHeaderEndpoints?.map(
+      headerEndpoint => html`
+        <gr-endpoint-decorator
+          class="prepended-col"
+          .name=${headerEndpoint}
+          role="columnheader"
+        >
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="files" .value=${this.files}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `
+    );
+  }
+
+  private renderDynamicHeaderEndpoints() {
+    return this.dynamicHeaderEndpoints?.map(
+      headerEndpoint => html`
+        <gr-endpoint-decorator
+          class="extra-col"
+          .name=${headerEndpoint}
+          role="columnheader"
+        ></gr-endpoint-decorator>
+      `
+    );
+  }
+
+  // for DIFF_AUTOCLOSE logging purposes only
+  private shownFilesOld: NormalizedFileInfo[] = this.shownFiles;
+
+  private renderShownFiles() {
+    const showDynamicColumns = this.computeShowDynamicColumns();
+    const showPrependedDynamicColumns =
+      this.computeShowPrependedDynamicColumns();
+    const sizeBarLayout = this.computeSizeBarLayout();
+
+    // for DIFF_AUTOCLOSE logging purposes only
+    if (
+      this.shownFilesOld.length > 0 &&
+      this.shownFiles !== this.shownFilesOld
+    ) {
+      this.reporting.reportInteraction(
+        Interaction.DIFF_AUTOCLOSE_SHOWN_FILES_CHANGED
+      );
     }
-    const changeNum = this.changeNum;
-    const patchRange = this.patchRange;
-
-    this._loading = true;
-
-    this.collapseAllDiffs();
-    const promises: Promise<boolean | void>[] = [];
-
-    promises.push(
-      this.restApiService
-        .getChangeOrEditFiles(changeNum, patchRange)
-        .then(filesByPath => {
-          this._filesByPath = filesByPath;
-        })
-    );
-
-    promises.push(
-      this._getLoggedIn().then(loggedIn => (this._loggedIn = loggedIn))
-    );
-
-    return Promise.all(promises).then(() => {
-      this._loading = false;
-      this._detectChromiteButler();
-      this.reporting.fileListDisplayed();
+    this.shownFilesOld = this.shownFiles;
+    return incrementalRepeat({
+      values: this.shownFiles,
+      mapFn: (f, i) =>
+        this.renderFileRow(
+          f as NormalizedFileInfo,
+          i,
+          sizeBarLayout,
+          showDynamicColumns,
+          showPrependedDynamicColumns
+        ),
+      initialCount: this.fileListIncrement,
+      targetFrameRate: 1,
     });
   }
 
-  @observe('_filesByPath')
-  async _updateCleanlyMergedPaths(filesByPath?: FileNameToFileInfoMap) {
+  private renderFileRow(
+    file: NormalizedFileInfo,
+    index: number,
+    sizeBarLayout: SizeBarLayout,
+    showDynamicColumns: boolean,
+    showPrependedDynamicColumns: boolean
+  ) {
+    this.reportRenderedRow(index);
+    const previousFileName = this.shownFiles[index - 1]?.__path;
+    const patchSetFile = this.computePatchSetFile(file);
+    return html` <div class="stickyArea">
+      <div
+        class=${`file-row row ${this.computePathClass(file.__path)}`}
+        data-file=${JSON.stringify(patchSetFile)}
+        tabindex="-1"
+        role="row"
+      >
+        <!-- endpoint: change-view-file-list-content-prepend -->
+        ${when(showPrependedDynamicColumns, () =>
+          this.renderPrependedContentEndpointsForFile(file)
+        )}
+        ${this.renderFileStatus(file)}
+        ${this.renderFilePath(file, previousFileName)}
+        ${this.renderFileComments(file)}
+        ${this.renderSizeBar(file, sizeBarLayout)} ${this.renderFileStats(file)}
+        ${when(showDynamicColumns, () =>
+          this.renderDynamicContentEndpointsForFile(file)
+        )}
+        <!-- endpoint: change-view-file-list-content -->
+        ${this.renderReviewed(file)} ${this.renderFileControls(file)}
+        ${this.renderShowHide(file)}
+      </div>
+      ${when(
+        this.isFileExpanded(file.__path),
+        () => this.patched.html`
+          <gr-diff-host
+            ?noAutoRender=${true}
+            ?showLoadFailure=${true}
+            .displayLine=${this.displayLine}
+            .changeNum=${this.changeNum}
+            .change=${this.change}
+            .patchRange=${this.patchRange}
+            .file=${patchSetFile}
+            .path=${file.__path}
+            .projectName=${this.change?.project}
+            ?noRenderOnPrefsChange=${true}
+          ></gr-diff-host>
+        `
+      )}
+    </div>`;
+  }
+
+  private renderPrependedContentEndpointsForFile(file: NormalizedFileInfo) {
+    return this.dynamicPrependedContentEndpoints?.map(
+      contentEndpoint => html`
+        <gr-endpoint-decorator
+          class="prepended-col"
+          .name=${contentEndpoint}
+          role="gridcell"
+        >
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="changeNum" .value=${this.changeNum}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="path" .value=${file.__path}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="oldPath" .value=${this.getOldPath(file)}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `
+    );
+  }
+
+  private renderFileStatus(file?: NormalizedFileInfo) {
+    const hasExtendedStatus = this.filesLeftBase.length > 0;
+    const leftStatus = this.renderFileStatusLeft(file?.__path);
+    const rightStatus = this.renderFileStatusRight(file);
+    return html`<div
+      class=${classMap({status: true, extended: hasExtendedStatus})}
+      role="gridcell"
+    >
+      ${leftStatus}${rightStatus}
+    </div>`;
+  }
+
+  private renderDivWithTooltip(content: string, tooltip: string) {
+    return html`
+      <gr-tooltip-content title=${tooltip} has-tooltip>
+        <div class="content">${content}</div>
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderFileStatusRight(file?: NormalizedFileInfo) {
+    const hasExtendedStatus = this.filesLeftBase.length > 0;
+    // no file means "header row"
+    if (!file) {
+      const psNum = this.patchRange?.patchNum;
+      return hasExtendedStatus
+        ? this.renderDivWithTooltip(`${psNum}`, `Patchset ${psNum}`)
+        : nothing;
+    }
+    if (isMagicPath(file.__path)) return nothing;
+
+    const fileWasAlreadyChanged = this.filesLeftBase.some(
+      info => info.__path === file?.__path
+    );
+    const fileIsReverted =
+      fileWasAlreadyChanged &&
+      !this.filesRightBase.some(info => info.__path === file?.__path);
+    const newlyChanged = hasExtendedStatus && !fileWasAlreadyChanged;
+
+    const status = fileIsReverted
+      ? FileInfoStatus.REVERTED
+      : file?.status ?? FileInfoStatus.MODIFIED;
+    const left = `patchset ${this.patchRange?.basePatchNum}`;
+    const right = `patchset ${this.patchRange?.patchNum}`;
+    const postfix = ` between ${left} and ${right}`;
+
+    return html`<gr-file-status
+      .status=${status}
+      .labelPostfix=${postfix}
+      ?newlyChanged=${newlyChanged}
+    ></gr-file-status>`;
+  }
+
+  private renderFileStatusLeft(path?: string) {
+    if (this.filesLeftBase.length === 0) return nothing;
+    // no path means "header row"
+    const psNum = this.patchRange?.basePatchNum;
+    if (!path) {
+      return html`
+        ${this.renderDivWithTooltip(`${psNum}`, `Patchset ${psNum}`)}
+        <gr-icon icon="arrow_right_alt" class="file-status-arrow"></gr-icon>
+      `;
+    }
+    if (isMagicPath(path)) return nothing;
+    const file = this.filesLeftBase.find(info => info.__path === path);
+    if (!file) return nothing;
+
+    const status = file.status ?? FileInfoStatus.MODIFIED;
+    const left = 'base';
+    const right = `patchset ${this.patchRange?.basePatchNum}`;
+    const postfix = ` between ${left} and ${right}`;
+
+    return html`
+      <gr-file-status
+        .status=${status}
+        .labelPostfix=${postfix}
+      ></gr-file-status>
+      <gr-icon icon="arrow_right_alt" class="file-status-arrow"></gr-icon>
+    `;
+  }
+
+  private renderFilePath(file: NormalizedFileInfo, previousFilePath?: string) {
+    return html`
+      <span class="path" role="gridcell">
+        <a class="pathLink" href=${ifDefined(this.computeDiffURL(file.__path))}>
+          <span title=${computeDisplayPath(file.__path)} class="fullFileName">
+            ${this.renderStyledPath(file.__path, previousFilePath)}
+          </span>
+          <span
+            title=${computeDisplayPath(file.__path)}
+            class="truncatedFileName"
+          >
+            ${computeTruncatedPath(file.__path)}
+          </span>
+          <gr-copy-clipboard
+            ?hideInput=${true}
+            .text=${file.__path}
+          ></gr-copy-clipboard>
+        </a>
+        ${when(
+          file.old_path,
+          () => html`
+            <div class="oldPath" title=${ifDefined(file.old_path)}>
+              ${file.old_path}
+              <gr-copy-clipboard
+                ?hideInput=${true}
+                .text=${file.old_path}
+              ></gr-copy-clipboard>
+            </div>
+          `
+        )}
+      </span>
+    `;
+  }
+
+  private renderStyledPath(filePath: string, previousFilePath?: string) {
+    const {matchingFolders, newFolders, fileName} = diffFilePaths(
+      filePath,
+      previousFilePath
+    );
+    return [
+      matchingFolders.length > 0
+        ? html`<span class="matchingFilePath">${matchingFolders}</span>`
+        : nothing,
+      newFolders.length > 0
+        ? html`<span class="newFilePath">${newFolders}</span>`
+        : nothing,
+      html`<span class="fileName">${fileName}</span>`,
+    ];
+  }
+
+  private renderFileComments(file: NormalizedFileInfo) {
+    return html` <div role="gridcell">
+      <div class="comments desktop">
+        <span class="drafts">${this.computeDraftsString(file)}</span>
+        <span>${this.computeCommentsString(file)}</span>
+        <span class="noCommentsScreenReaderText">
+          <!-- Screen readers read the following content only if 2 other
+          spans in the parent div is empty. The content is not visible on
+          the page.
+          Without this span, screen readers don't navigate correctly inside
+          table, because empty div doesn't rendered. For example, VoiceOver
+          jumps back to the whole table.
+          We can use &nbsp instead, but it sounds worse.
+          -->
+          No comments
+        </span>
+      </div>
+      <div class="comments mobile">
+        <span class="drafts">${this.computeDraftsStringMobile(file)}</span>
+        <span>${this.computeCommentsStringMobile(file)}</span>
+        <span class="noCommentsScreenReaderText">
+          <!-- The same as for desktop comments -->
+          No comments
+        </span>
+      </div>
+    </div>`;
+  }
+
+  private renderSizeBar(
+    file: NormalizedFileInfo,
+    sizeBarLayout: SizeBarLayout
+  ) {
+    return html` <div class="desktop" role="gridcell">
+      <!-- The content must be in a separate div. It guarantees, that
+          gridcell always visible for screen readers.
+          For example, without a nested div screen readers pronounce the
+          "Commit message" row content with incorrect column headers.
+        -->
+      <div
+        class=${this.computeSizeBarsClass(file.__path)}
+        aria-label="A bar that represents the addition and deletion ratio for the current file"
+      >
+        <svg width="61" height="8">
+          <rect
+            x=${this.computeBarAdditionX(file, sizeBarLayout)}
+            y="0"
+            height="8"
+            fill="var(--positive-green-text-color)"
+            width=${this.computeBarAdditionWidth(file, sizeBarLayout)}
+          ></rect>
+          <rect
+            x=${this.computeBarDeletionX(sizeBarLayout)}
+            y="0"
+            height="8"
+            fill="var(--negative-red-text-color)"
+            width=${this.computeBarDeletionWidth(file, sizeBarLayout)}
+          ></rect>
+        </svg>
+      </div>
+    </div>`;
+  }
+
+  private renderFileStats(file: NormalizedFileInfo) {
+    return html` <div class="stats" role="gridcell">
+      <!-- The content must be in a separate div. It guarantees, that
+        gridcell always visible for screen readers.
+        For example, without a nested div screen readers pronounce the
+        "Commit message" row content with incorrect column headers.
+        -->
+      <div class=${this.computeClass('', file.__path)}>
+        <span
+          class="added"
+          tabindex="0"
+          aria-label=${`${file.lines_inserted} lines added`}
+          ?hidden=${file.binary}
+        >
+          +${file.lines_inserted}
+        </span>
+        <span
+          class="removed"
+          tabindex="0"
+          aria-label=${`${file.lines_deleted} lines removed`}
+          ?hidden=${file.binary}
+        >
+          -${file.lines_deleted}
+        </span>
+        <span
+          class=${ifDefined(this.computeBinaryClass(file.size_delta))}
+          ?hidden=${!file.binary}
+        >
+          ${this.formatBytes(file.size_delta)}
+          ${this.formatPercentage(file.size, file.size_delta)}
+        </span>
+      </div>
+    </div>`;
+  }
+
+  private renderDynamicContentEndpointsForFile(file: NormalizedFileInfo) {
+    return this.dynamicContentEndpoints?.map(
+      contentEndpoint => html` <div
+        class=${this.computeClass('', file.__path)}
+        role="gridcell"
+      >
+        <gr-endpoint-decorator class="extra-col" .name=${contentEndpoint}>
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="changeNum" .value=${this.changeNum}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="path" .value=${file.__path}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>`
+    );
+  }
+
+  private renderReviewed(file: NormalizedFileInfo) {
+    if (!this.loggedIn) return nothing;
+    const isReviewed = this.reviewed.includes(file.__path);
+    const reviewedTitle = `Mark as ${
+      isReviewed ? 'not ' : ''
+    }reviewed (shortcut: r)`;
+    const reviewedText = isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
+    return html` <div class="reviewed hideOnEdit" role="gridcell">
+      <span
+        class=${`reviewedLabel ${isReviewed ? 'isReviewed' : ''}`}
+        aria-hidden=${this.booleanToString(!isReviewed)}
+        >Reviewed</span
+      >
+      <!-- Do not use input type="checkbox" with hidden input and
+              visible label here. Screen readers don't read/interract
+              correctly with such input.
+          -->
+      <span
+        class="reviewedSwitch"
+        role="switch"
+        tabindex="0"
+        @click=${(e: MouseEvent) => this.reviewedClick(e)}
+        @keydown=${(e: KeyboardEvent) => this.reviewedClick(e)}
+        aria-label="Reviewed"
+        aria-checked=${this.booleanToString(isReviewed)}
+      >
+        <!-- Trick with tabindex to avoid outline on mouse focus, but
+            preserve focus outline for keyboard navigation -->
+        <span tabindex="-1" class="markReviewed" title=${reviewedTitle}
+          >${reviewedText}</span
+        >
+      </span>
+    </div>`;
+  }
+
+  private renderFileControls(file: NormalizedFileInfo) {
+    return html` <div
+      class="editFileControls showOnEdit"
+      role="gridcell"
+      aria-hidden=${this.booleanToString(!this.editMode)}
+    >
+      ${when(
+        this.editMode,
+        () => html`
+          <gr-edit-file-controls
+            class=${this.computeClass('', file.__path)}
+            .filePath=${file.__path}
+          ></gr-edit-file-controls>
+        `
+      )}
+    </div>`;
+  }
+
+  private renderShowHide(file: NormalizedFileInfo) {
+    return html` <div class="show-hide" role="gridcell">
+      <!-- Do not use input type="checkbox" with hidden input and
+            visible label here. Screen readers don't read/interract
+            correctly with such input.
+        -->
+      <span
+        class="show-hide"
+        data-path=${file.__path}
+        data-expand="true"
+        role="switch"
+        tabindex="0"
+        aria-checked=${this.isFileExpandedStr(file.__path)}
+        aria-label="Expand file"
+        @click=${this.expandedClick}
+        @keydown=${this.expandedClick}
+      >
+        <!-- Trick with tabindex to avoid outline on mouse focus, but
+          preserve focus outline for keyboard navigation -->
+        <gr-icon
+          class="show-hide-icon"
+          tabindex="-1"
+          id="icon"
+          icon=${this.computeShowHideIcon(file.__path)}
+        ></gr-icon>
+      </span>
+    </div>`;
+  }
+
+  private renderCleanlyMerged() {
+    const showPrependedDynamicColumns =
+      this.computeShowPrependedDynamicColumns();
+    return html` <div class="row">
+      <!-- endpoint: change-view-file-list-content-prepend -->
+      ${when(showPrependedDynamicColumns, () =>
+        this.renderPrependedContentEndpoints()
+      )}
+      <div role="gridcell">
+        <div>
+          <span class="cleanlyMergedText">
+            ${this.computeCleanlyMergedText()}
+          </span>
+          <gr-button
+            link
+            class="showParentButton"
+            @click=${this.handleShowParent1}
+          >
+            Show Parent 1
+          </gr-button>
+        </div>
+      </div>
+    </div>`;
+  }
+
+  private renderPrependedContentEndpoints() {
+    return this.dynamicPrependedContentEndpoints?.map(
+      contentEndpoint => html`
+        <gr-endpoint-decorator
+          class="prepended-col"
+          .name=${contentEndpoint}
+          role="gridcell"
+        >
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="changeNum" .value=${this.changeNum}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+          </gr-endpoint-param>
+          <gr-endpoint-param
+            name="cleanlyMergedPaths"
+            .value=${this.cleanlyMergedPaths}
+          >
+          </gr-endpoint-param>
+          <gr-endpoint-param
+            name="cleanlyMergedOldPaths"
+            .value=${this.cleanlyMergedOldPaths}
+          >
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `
+    );
+  }
+
+  private renderChangeTotals(patchChange: PatchChange) {
+    const showDynamicColumns = this.computeShowDynamicColumns();
+    if (this.shouldHideChangeTotals(patchChange)) return nothing;
+    return html`
+      <div class="row totalChanges">
+        <div class="total-stats">
+          <div>
+            <span
+              class="added"
+              tabindex="0"
+              aria-label="Total ${patchChange.inserted} lines added"
+            >
+              +${patchChange.inserted}
+            </span>
+            <span
+              class="removed"
+              tabindex="0"
+              aria-label="Total ${patchChange.deleted} lines removed"
+            >
+              -${patchChange.deleted}
+            </span>
+          </div>
+        </div>
+        ${when(showDynamicColumns, () =>
+          this.dynamicSummaryEndpoints?.map(
+            summaryEndpoint => html`
+              <gr-endpoint-decorator class="extra-col" name=${summaryEndpoint}>
+                <gr-endpoint-param name="change" .value=${this.change}>
+                </gr-endpoint-param>
+                <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            `
+          )
+        )}
+
+        <!-- Empty div here exists to keep spacing in sync with file rows. -->
+        <div class="reviewed hideOnEdit" ?hidden=${!this.loggedIn}></div>
+        <div class="editFileControls showOnEdit"></div>
+        <div class="show-hide"></div>
+      </div>
+    `;
+  }
+
+  private renderBinaryTotals(patchChange: PatchChange) {
+    if (this.shouldHideBinaryChangeTotals(patchChange)) return nothing;
+    const deltaInserted = this.formatBytes(patchChange.size_delta_inserted);
+    const deltaDeleted = this.formatBytes(patchChange.size_delta_deleted);
+    return html`
+      <div class="row totalChanges">
+        <div class="total-stats">
+          <span
+            class="added"
+            aria-label="Total bytes inserted: ${deltaInserted}"
+          >
+            ${deltaInserted}
+            ${this.formatPercentage(
+              patchChange.total_size,
+              patchChange.size_delta_inserted
+            )}
+          </span>
+          <span
+            class="removed"
+            aria-label="Total bytes removed: ${deltaDeleted}"
+          >
+            ${deltaDeleted}
+            ${this.formatPercentage(
+              patchChange.total_size,
+              patchChange.size_delta_deleted
+            )}
+          </span>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderControlRow() {
+    return html`<div
+      class=${`row controlRow ${this.computeFileListControlClass()}`}
+    >
+      <gr-button
+        class="fileListButton"
+        id="incrementButton"
+        link=""
+        @click=${this.incrementNumFilesShown}
+      >
+        ${this.computeIncrementText()}
+      </gr-button>
+      <gr-tooltip-content
+        ?has-tooltip=${this.computeWarnShowAll()}
+        ?show-icon=${this.computeWarnShowAll()}
+        .title=${this.computeShowAllWarning()}
+      >
+        <gr-button
+          class="fileListButton"
+          id="showAllButton"
+          link=""
+          @click=${this.showAllFiles}
+        >
+          ${this.computeShowAllText()}
+        </gr-button>
+      </gr-tooltip-content>
+    </div>`;
+  }
+
+  protected override firstUpdated(): void {
+    this.detectChromiteButler();
+    this.reporting.fileListDisplayed();
+  }
+
+  protected override updated(): void {
+    // for DIFF_AUTOCLOSE logging purposes only
+    const ids = this.diffs.map(d => d.uid);
+    if (ids.length > 0) {
+      this.reporting.reportInteraction(
+        Interaction.DIFF_AUTOCLOSE_FILE_LIST_UPDATED,
+        {l: ids.length, ids: ids.slice(0, 10)}
+      );
+    }
+  }
+
+  // TODO: Move into files-model.
+  // visible for testing
+  async updateCleanlyMergedPaths() {
     // When viewing Auto Merge base vs a patchset, add an additional row that
     // knows how many files were cleanly merged. This requires an additional RPC
     // for the diffs between target parent and the patch set. The cleanly merged
@@ -514,8 +1605,8 @@
       this.changeNum &&
       this.patchRange?.patchNum &&
       new RevisionInfo(this.change).isMergeCommit(this.patchRange.patchNum) &&
-      this.patchRange.basePatchNum === 'PARENT' &&
-      this.patchRange.patchNum !== EditPatchSetNum
+      this.patchRange.basePatchNum === PARENT &&
+      this.patchRange.patchNum !== EDIT
     ) {
       const allFilesByPath = await this.restApiService.getChangeOrEditFiles(
         this.changeNum,
@@ -524,21 +1615,21 @@
           patchNum: this.patchRange.patchNum,
         }
       );
-      if (!allFilesByPath || !filesByPath) return;
-      const conflictingPaths = Object.keys(filesByPath);
-      this._cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
+      if (!allFilesByPath) return;
+      const conflictingPaths = this.files.map(f => f.__path);
+      this.cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
         path => !conflictingPaths.includes(path)
       );
-      this._cleanlyMergedOldPaths = this._cleanlyMergedPaths
+      this.cleanlyMergedOldPaths = this.cleanlyMergedPaths
         .map(path => allFilesByPath[path].old_path)
         .filter((oldPath): oldPath is string => !!oldPath);
     } else {
-      this._cleanlyMergedPaths = [];
-      this._cleanlyMergedOldPaths = [];
+      this.cleanlyMergedPaths = [];
+      this.cleanlyMergedOldPaths = [];
     }
   }
 
-  _detectChromiteButler() {
+  private detectChromiteButler() {
     const hasButler = !!document.getElementById('butler-suggested-owners');
     if (hasButler) {
       this.reporting.reportExtension('butler');
@@ -546,7 +1637,7 @@
   }
 
   get diffs(): GrDiffHost[] {
-    const diffs = this.root!.querySelectorAll('gr-diff-host');
+    const diffs = this.shadowRoot!.querySelectorAll('gr-diff-host');
     // It is possible that a bogus diff element is hanging around invisibly
     // from earlier with a different patch set choice and associated with a
     // different entry in the files array. So filter on visible items only.
@@ -555,13 +1646,20 @@
     );
   }
 
-  openDiffPrefs() {
-    this.$.diffPreferencesDialog.open();
+  resetFileState() {
+    this.numFilesShown = DEFAULT_NUM_FILES_SHOWN;
+    this.selectedIndex = 0;
+    this.fileCursor.setCursorAtIndex(this.selectedIndex, true);
   }
 
-  _calculatePatchChange(files: NormalizedFileInfo[]): PatchChange {
-    const magicFilesExcluded = files.filter(
-      files => !isMagicPath(files.__path)
+  openDiffPrefs() {
+    this.diffPreferencesDialog?.open();
+  }
+
+  // Private but used in tests.
+  calculatePatchChange(): PatchChange {
+    const magicFilesExcluded = this.files.filter(
+      file => !isMagicPath(file.__path)
     );
 
     return magicFilesExcluded.reduce((acc, obj) => {
@@ -569,9 +1667,9 @@
       const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
       const total_size = obj.size && obj.binary ? obj.size : 0;
       const size_delta_inserted =
-        obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
+        obj.binary && obj.size_delta && obj.size_delta > 0 ? obj.size_delta : 0;
       const size_delta_deleted =
-        obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
+        obj.binary && obj.size_delta && obj.size_delta < 0 ? obj.size_delta : 0;
 
       return {
         inserted: acc.inserted + inserted,
@@ -584,40 +1682,43 @@
   }
 
   // private but used in test
-  _toggleFileExpanded(file: PatchSetFile) {
+  toggleFileExpanded(file: PatchSetFile) {
     // Is the path in the list of expanded diffs? If so, remove it, otherwise
     // add it to the list.
-    const indexInExpanded = this._expandedFiles.findIndex(
+    const indexInExpanded = this.expandedFiles.findIndex(
       f => f.path === file.path
     );
     if (indexInExpanded === -1) {
-      this.push('_expandedFiles', file);
+      this.expandedFiles = this.expandedFiles.concat([file]);
     } else {
-      this.splice('_expandedFiles', indexInExpanded, 1);
+      this.expandedFiles = this.expandedFiles.filter(
+        (_val, idx) => idx !== indexInExpanded
+      );
     }
-    const indexInAll = this._files.findIndex(f => f.__path === file.path);
-    this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)[
+    const indexInAll = this.files.findIndex(f => f.__path === file.path);
+    this.shadowRoot!.querySelectorAll(`.${FILE_ROW_CLASS}`)[
       indexInAll
     ].scrollIntoView({block: 'nearest'});
   }
 
-  _toggleFileExpandedByIndex(index: number) {
-    this._toggleFileExpanded(this._computePatchSetFile(this._files[index]));
+  private toggleFileExpandedByIndex(index: number) {
+    this.toggleFileExpanded(this.computePatchSetFile(this.files[index]));
   }
 
-  _updateDiffPreferences() {
+  // Private but used in tests.
+  updateDiffPreferences() {
     if (!this.diffs.length) {
       return;
     }
-    // Re-render all expanded diffs sequentially.
-    this._renderInOrder(
-      this._expandedFiles,
-      this.diffs,
-      this._expandedFiles.length
+    this.reporting.reportInteraction(
+      Interaction.DIFF_AUTOCLOSE_RELOAD_FILELIST_PREFS
     );
+
+    // Re-render all expanded diffs sequentially.
+    this.renderInOrder(this.expandedFiles, this.diffs);
   }
 
-  _forEachDiff(fn: (host: GrDiffHost) => void) {
+  private forEachDiff(fn: (host: GrDiffHost) => void) {
     const diffs = this.diffs;
     for (let i = 0; i < diffs.length; i++) {
       fn(diffs[i]);
@@ -629,54 +1730,45 @@
     // expanded list.
     const newFiles: PatchSetFile[] = [];
     let path: string;
-    for (let i = 0; i < this._shownFiles.length; i++) {
-      path = this._shownFiles[i].__path;
-      if (!this._expandedFiles.some(f => f.path === path)) {
-        newFiles.push(this._computePatchSetFile(this._shownFiles[i]));
+    for (let i = 0; i < this.shownFiles.length; i++) {
+      path = this.shownFiles[i].__path;
+      if (!this.expandedFiles.some(f => f.path === path)) {
+        newFiles.push(this.computePatchSetFile(this.shownFiles[i]));
       }
     }
 
-    this.splice('_expandedFiles', 0, 0, ...newFiles);
+    this.expandedFiles = newFiles.concat(this.expandedFiles);
   }
 
   collapseAllDiffs() {
-    this._expandedFiles = [];
-    this.filesExpanded = this._computeExpandedFiles(
-      this._expandedFiles.length,
-      this._files.length
-    );
-    this.diffCursor?.handleDiffUpdate();
+    this.expandedFiles = [];
   }
 
   /**
    * Computes a string with the number of comments and unresolved comments.
    */
-  _computeCommentsString(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    file?: NormalizedFileInfo
-  ) {
+  computeCommentsString(file?: NormalizedFileInfo) {
     if (
-      changeComments === undefined ||
-      patchRange === undefined ||
+      this.changeComments === undefined ||
+      this.patchRange === undefined ||
       file?.__path === undefined
     ) {
       return '';
     }
-    return changeComments.computeCommentsString(patchRange, file.__path, file);
+    return this.changeComments.computeCommentsString(
+      this.patchRange,
+      file.__path,
+      file
+    );
   }
 
   /**
    * Computes a string with the number of drafts.
    */
-  _computeDraftsString(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    file?: NormalizedFileInfo
-  ) {
-    if (changeComments === undefined) return '';
-    const draftCount = changeComments.computeDraftCountForFile(
-      patchRange,
+  computeDraftsString(file?: NormalizedFileInfo) {
+    if (this.changeComments === undefined) return '';
+    const draftCount = this.changeComments.computeDraftCountForFile(
+      this.patchRange,
       file
     );
     if (draftCount === 0) return '';
@@ -685,15 +1777,12 @@
 
   /**
    * Computes a shortened string with the number of drafts.
+   * Private but used in tests.
    */
-  _computeDraftsStringMobile(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    file?: NormalizedFileInfo
-  ) {
-    if (changeComments === undefined) return '';
-    const draftCount = changeComments.computeDraftCountForFile(
-      patchRange,
+  computeDraftsStringMobile(file?: NormalizedFileInfo) {
+    if (this.changeComments === undefined) return '';
+    const draftCount = this.changeComments.computeDraftCountForFile(
+      this.patchRange,
       file
     );
     return draftCount === 0 ? '' : `${draftCount}d`;
@@ -702,50 +1791,36 @@
   /**
    * Computes a shortened string with the number of comments.
    */
-  _computeCommentsStringMobile(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    file?: NormalizedFileInfo
-  ) {
+  computeCommentsStringMobile(file?: NormalizedFileInfo) {
     if (
-      changeComments === undefined ||
-      patchRange === undefined ||
+      this.changeComments === undefined ||
+      this.patchRange === undefined ||
       file === undefined
     ) {
       return '';
     }
     const commentThreadCount =
-      changeComments.computeCommentThreadCount({
-        patchNum: patchRange.basePatchNum,
+      this.changeComments.computeCommentThreadCount({
+        patchNum: this.patchRange.basePatchNum,
         path: file.__path,
       }) +
-      changeComments.computeCommentThreadCount({
-        patchNum: patchRange.patchNum,
+      this.changeComments.computeCommentThreadCount({
+        patchNum: this.patchRange.patchNum,
         path: file.__path,
       });
     return commentThreadCount === 0 ? '' : `${commentThreadCount}c`;
   }
 
-  // private but used in test
-  _reviewFile(path: string, reviewed?: boolean) {
-    if (this.editMode) {
-      return Promise.resolve();
-    }
-    const index = this._files.findIndex(file => file.__path === path);
-    reviewed = reviewed || !this._files[index].isReviewed;
-
-    this.set(['_files', index, 'isReviewed'], reviewed);
-    if (index < this._shownFiles.length) {
-      this.notifyPath(`_shownFiles.${index}.isReviewed`);
-    }
-
+  // Private but used in tests.
+  reviewFile(path: string, reviewed?: boolean) {
+    if (this.editMode) return Promise.resolve();
+    reviewed = reviewed ?? !this.reviewed.includes(path);
     return this._saveReviewedState(path, reviewed);
   }
 
   _saveReviewedState(path: string, reviewed: boolean) {
-    if (!this.changeNum || !this.patchRange) {
-      throw new Error('changeNum and patchRange must be set');
-    }
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.patchRange, 'patchRange');
 
     return this.getChangeModel().setReviewedFilesStatus(
       this.changeNum,
@@ -755,40 +1830,13 @@
     );
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  _getReviewedFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
-    if (this.editMode) {
-      return Promise.resolve([]);
-    }
-    return this.restApiService.getReviewedFiles(changeNum, patchRange.patchNum);
-  }
-
-  _normalizeChangeFilesResponse(
-    response: FileNameToReviewedFileInfoMap
-  ): NormalizedFileInfo[] {
-    const paths = Object.keys(response).sort(specialFilePathCompare);
-    const files: NormalizedFileInfo[] = [];
-    for (let i = 0; i < paths.length; i++) {
-      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;
-      info.size_delta = info.size_delta || 0;
-      files.push(info);
-    }
-    return files;
-  }
-
   /**
    * Returns true if the event e is a click on an element.
    *
    * The click is: mouse click or pressing Enter or Space key
    * P.S> Screen readers sends click event as well
    */
-  _isClickEvent(e: MouseEvent | KeyboardEvent) {
+  private isClickEvent(e: MouseEvent | KeyboardEvent) {
     if (e.type === 'click') {
       return true;
     }
@@ -797,41 +1845,43 @@
     return ke.type === 'keydown' && isSpaceOrEnter;
   }
 
-  _fileActionClick(
+  private fileActionClick(
     e: MouseEvent | KeyboardEvent,
     fileAction: (file: PatchSetFile) => void
   ) {
-    if (this._isClickEvent(e)) {
-      const fileRow = this._getFileRowFromEvent(e);
+    if (this.isClickEvent(e)) {
+      const fileRow = this.getFileRowFromEvent(e);
       if (!fileRow) {
         return;
       }
       // Prevent default actions (e.g. scrolling for space key)
       e.preventDefault();
-      // Prevent _handleFileListClick handler call
+      // Prevent handleFileListClick handler call
       e.stopPropagation();
       this.fileCursor.setCursor(fileRow.element);
       fileAction(fileRow.file);
     }
   }
 
-  _reviewedClick(e: MouseEvent | KeyboardEvent) {
-    this._fileActionClick(e, file => this._reviewFile(file.path));
+  // Private but used in tests.
+  reviewedClick(e: MouseEvent | KeyboardEvent) {
+    this.fileActionClick(e, file => this.reviewFile(file.path));
   }
 
-  _expandedClick(e: MouseEvent | KeyboardEvent) {
-    this._fileActionClick(e, file => this._toggleFileExpanded(file));
+  private expandedClick(e: MouseEvent | KeyboardEvent) {
+    this.fileActionClick(e, file => this.toggleFileExpanded(file));
   }
 
   /**
    * Handle all events from the file list dom-repeat so event handlers don't
    * have to get registered for potentially very long lists.
+   * Private but used in tests.
    */
-  _handleFileListClick(e: MouseEvent) {
+  handleFileListClick(e: MouseEvent) {
     if (!e.target) {
       return;
     }
-    const fileRow = this._getFileRowFromEvent(e);
+    const fileRow = this.getFileRowFromEvent(e);
     if (!fileRow) {
       return;
     }
@@ -851,10 +1901,10 @@
 
     e.preventDefault();
     this.fileCursor.setCursor(fileRow.element);
-    this._toggleFileExpanded(file);
+    this.toggleFileExpanded(file);
   }
 
-  _getFileRowFromEvent(e: Event): FileRow | null {
+  private getFileRowFromEvent(e: Event): FileRow | null {
     // Traverse upwards to find the row element if the target is not the row.
     let row = e.target as HTMLElement;
     while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
@@ -875,7 +1925,7 @@
   /**
    * Generates file range from file info object.
    */
-  _computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
+  private computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
     const fileData: PatchSetFile = {
       path: file.__path,
     };
@@ -885,90 +1935,109 @@
     return fileData;
   }
 
-  _handleLeftPane() {
-    if (this._noDiffsExpanded()) return;
+  private handleLeftPane() {
+    if (this.noDiffsExpanded()) return;
     this.diffCursor?.moveLeft();
   }
 
-  _handleRightPane() {
-    if (this._noDiffsExpanded()) return;
+  private handleRightPane() {
+    if (this.noDiffsExpanded()) return;
     this.diffCursor?.moveRight();
   }
 
-  _handleToggleInlineDiff() {
+  private handleToggleInlineDiff() {
     if (this.fileCursor.index === -1) return;
-    this._toggleFileExpandedByIndex(this.fileCursor.index);
+    this.toggleFileExpandedByIndex(this.fileCursor.index);
   }
 
-  _handleCursorNext(e: KeyboardEvent) {
+  // Private but used in tests.
+  handleCursorNext(e: KeyboardEvent) {
+    // We want to allow users to use arrow keys for standard browser scrolling
+    // when files are not expanded. That is also why we use the `preventDefault`
+    // option when registering the shortcut.
+    if (this.filesExpanded !== FilesExpandedState.ALL && e.key === Key.DOWN) {
+      return;
+    }
+
+    e.preventDefault();
+    e.stopPropagation();
     if (this.filesExpanded === FilesExpandedState.ALL) {
       this.diffCursor?.moveDown();
-      this._displayLine = true;
+      this.displayLine = true;
     } else {
-      if (e.key === Key.DOWN) return;
       this.fileCursor.next({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
 
-  _handleCursorPrev(e: KeyboardEvent) {
+  // Private but used in tests.
+  handleCursorPrev(e: KeyboardEvent) {
+    // We want to allow users to use arrow keys for standard browser scrolling
+    // when files are not expanded. That is also why we use the `preventDefault`
+    // option when registering the shortcut.
+    if (this.filesExpanded !== FilesExpandedState.ALL && e.key === Key.UP) {
+      return;
+    }
+
+    e.preventDefault();
+    e.stopPropagation();
     if (this.filesExpanded === FilesExpandedState.ALL) {
       this.diffCursor?.moveUp();
-      this._displayLine = true;
+      this.displayLine = true;
     } else {
-      if (e.key === Key.UP) return;
       this.fileCursor.previous({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
 
-  _handleNewComment() {
+  private handleNewComment() {
     this.classList.remove('hideComments');
     this.diffCursor?.createCommentInPlace();
   }
 
+  // Private but used in tests.
   handleOpenFile() {
     if (this.filesExpanded === FilesExpandedState.ALL) {
-      this._openCursorFile();
+      this.openCursorFile();
       return;
     }
-    this._openSelectedFile();
+    this.openSelectedFile();
   }
 
-  _handleNextChunk() {
-    if (this._noDiffsExpanded()) return;
+  private handleNextChunk() {
+    if (this.noDiffsExpanded()) return;
     this.diffCursor?.moveToNextChunk();
   }
 
-  _handleNextComment() {
-    if (this._noDiffsExpanded()) return;
+  private handleNextComment() {
+    if (this.noDiffsExpanded()) return;
     this.diffCursor?.moveToNextCommentThread();
   }
 
-  _handlePrevChunk() {
-    if (this._noDiffsExpanded()) return;
+  private handlePrevChunk() {
+    if (this.noDiffsExpanded()) return;
     this.diffCursor?.moveToPreviousChunk();
   }
 
-  _handlePrevComment() {
-    if (this._noDiffsExpanded()) return;
+  private handlePrevComment() {
+    if (this.noDiffsExpanded()) return;
     this.diffCursor?.moveToPreviousCommentThread();
   }
 
-  _handleToggleFileReviewed() {
-    if (!this._files[this.fileCursor.index]) {
+  private handleToggleFileReviewed() {
+    if (!this.files[this.fileCursor.index]) {
       return;
     }
-    this._reviewFile(this._files[this.fileCursor.index].__path);
+    this.reviewFile(this.files[this.fileCursor.index].__path);
   }
 
-  _handleToggleLeftPane() {
-    this._forEachDiff(diff => {
+  private handleToggleLeftPane() {
+    this.forEachDiff(diff => {
       diff.toggleLeftDiff();
     });
   }
 
-  _toggleInlineDiffs() {
+  private toggleInlineDiffs() {
     if (this.filesExpanded === FilesExpandedState.ALL) {
       this.collapseAllDiffs();
     } else {
@@ -976,70 +2045,85 @@
     }
   }
 
-  _openCursorFile() {
+  // Private but used in tests.
+  openCursorFile() {
     const diff = this.diffCursor?.getTargetDiffElement();
     if (!this.change || !diff || !this.patchRange || !diff.path) {
       throw new Error('change, diff and patchRange must be all set and valid');
     }
-    GerritNav.navigateToDiff(
-      this.change,
-      diff.path,
-      this.patchRange.patchNum,
-      this.patchRange.basePatchNum
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        path: diff.path,
+        patchNum: this.patchRange.patchNum,
+        basePatchNum: this.patchRange.basePatchNum,
+      })
     );
   }
 
-  _openSelectedFile(index?: number) {
+  // Private but used in tests.
+  openSelectedFile(index?: number) {
     if (index !== undefined) {
       this.fileCursor.setCursorAtIndex(index);
     }
-    if (!this._files[this.fileCursor.index]) {
+    if (!this.files[this.fileCursor.index]) {
       return;
     }
     if (!this.change || !this.patchRange) {
       throw new Error('change and patchRange must be set');
     }
-    GerritNav.navigateToDiff(
-      this.change,
-      this._files[this.fileCursor.index].__path,
-      this.patchRange.patchNum,
-      this.patchRange.basePatchNum
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        path: this.files[this.fileCursor.index].__path,
+        patchNum: this.patchRange.patchNum,
+        basePatchNum: this.patchRange.basePatchNum,
+      })
     );
   }
 
-  _shouldHideChangeTotals(_patchChange: PatchChange): boolean {
-    return _patchChange.inserted === 0 && _patchChange.deleted === 0;
+  // Private but used in tests.
+  shouldHideChangeTotals(patchChange: PatchChange): boolean {
+    return patchChange.inserted === 0 && patchChange.deleted === 0;
   }
 
-  _shouldHideBinaryChangeTotals(_patchChange: PatchChange) {
+  // Private but used in tests.
+  shouldHideBinaryChangeTotals(patchChange: PatchChange) {
     return (
-      _patchChange.size_delta_inserted === 0 &&
-      _patchChange.size_delta_deleted === 0
+      patchChange.size_delta_inserted === 0 &&
+      patchChange.size_delta_deleted === 0
     );
   }
 
-  _computeDiffURL(
-    change?: ParsedChangeInfo,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: RevisionPatchSetNum,
-    path?: string,
-    editMode?: boolean
-  ) {
+  // Private but used in tests
+  computeDiffURL(path?: string) {
     if (
-      change === undefined ||
-      patchNum === undefined ||
+      this.change === undefined ||
+      this.patchRange?.patchNum === undefined ||
       path === undefined ||
-      editMode === undefined
+      this.editMode === undefined
     ) {
       return;
     }
-    if (editMode && path !== SpecialFilePath.MERGE_LIST) {
-      return GerritNav.getEditUrlForDiff(change, path, patchNum);
+    if (this.editMode && path !== SpecialFilePath.MERGE_LIST) {
+      return createEditUrl({
+        changeNum: this.change._number,
+        project: this.change.project,
+        path,
+        patchNum: this.patchRange.patchNum,
+      });
     }
-    return GerritNav.getUrlForDiff(change, path, patchNum, basePatchNum);
+    return createDiffUrl({
+      changeNum: this.change._number,
+      project: this.change.project,
+      path,
+      patchNum: this.patchRange.patchNum,
+      basePatchNum: this.patchRange.basePatchNum,
+    });
   }
 
-  _formatBytes(bytes?: number) {
+  // Private but used in tests.
+  formatBytes(bytes?: number) {
     if (!bytes) return '+/-0 B';
     const bits = 1024;
     const decimals = 1;
@@ -1052,7 +2136,8 @@
     return `${prepend}${value} ${sizes[exponent]}`;
   }
 
-  _formatPercentage(size?: number, delta?: number) {
+  // Private but used in tests.
+  formatPercentage(size?: number, delta?: number) {
     if (size === undefined || delta === undefined) {
       return '';
     }
@@ -1066,110 +2151,52 @@
     return `(${delta > 0 ? '+' : '-'}${percentage}%)`;
   }
 
-  _computeBinaryClass(delta?: number) {
+  private computeBinaryClass(delta?: number) {
     if (!delta) {
       return;
     }
     return delta > 0 ? 'added' : 'removed';
   }
 
-  _computeClass(baseClass?: string, path?: string) {
+  private computeClass(baseClass?: string, path?: string) {
     const classes = [];
-    if (baseClass) {
-      classes.push(baseClass);
-    }
-    if (
-      path === SpecialFilePath.COMMIT_MESSAGE ||
-      path === SpecialFilePath.MERGE_LIST
-    ) {
-      classes.push('invisible');
-    }
+    if (baseClass) classes.push(baseClass);
+    if (isMagicPath(path)) classes.push('invisible');
     return classes.join(' ');
   }
 
-  _computePathClass(
-    path: string | undefined,
-    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
-  ) {
-    return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
+  private computePathClass(path: string | undefined) {
+    return this.isFileExpanded(path) ? 'expanded' : '';
   }
 
-  _computeShowHideIcon(
-    path: string | undefined,
-    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
-  ) {
-    return this._isFileExpanded(path, expandedFilesRecord)
-      ? 'gr-icons:expand-less'
-      : 'gr-icons:expand-more';
+  private computeShowHideIcon(path: string | undefined) {
+    return this.isFileExpanded(path) ? 'expand_less' : 'expand_more';
   }
 
-  _computeShowNumCleanlyMerged(cleanlyMergedPaths: string[]): boolean {
-    return cleanlyMergedPaths.length > 0;
+  private computeShowNumCleanlyMerged(): boolean {
+    return this.cleanlyMergedPaths.length > 0;
   }
 
-  _computeCleanlyMergedText(cleanlyMergedPaths: string[]): string {
-    const fileCount = pluralize(cleanlyMergedPaths.length, 'file');
+  private computeCleanlyMergedText(): string {
+    const fileCount = pluralize(this.cleanlyMergedPaths.length, 'file');
     return `${fileCount} merged cleanly in Parent 1`;
   }
 
-  _handleShowParent1(): void {
+  private handleShowParent1(): void {
     if (!this.change || !this.patchRange) return;
-    GerritNav.navigateToChange(this.change, {
-      patchNum: this.patchRange.patchNum,
-      basePatchNum: -1 as BasePatchSetNum, // Parent 1
-    });
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change: this.change,
+        patchNum: this.patchRange.patchNum,
+        basePatchNum: -1 as BasePatchSetNum, // Parent 1
+      })
+    );
   }
 
-  @observe(
-    '_filesByPath',
-    'changeComments',
-    'patchRange',
-    'reviewed',
-    '_loading'
-  )
-  _computeFiles(
-    filesByPath?: FileNameToFileInfoMap,
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    reviewed?: string[],
-    loading?: boolean
-  ) {
-    // Polymer 2: check for undefined
-    if (
-      filesByPath === undefined ||
-      changeComments === undefined ||
-      patchRange === undefined ||
-      reviewed === undefined ||
-      loading === undefined
-    ) {
-      return;
-    }
-    // Await all promises resolving from reload. @See Issue 9057
-    if (loading || !changeComments) {
-      return;
-    }
-    const commentedPaths = changeComments.getPaths(patchRange);
-    const files: FileNameToReviewedFileInfoMap = {...filesByPath};
-    addUnmodifiedFiles(files, commentedPaths);
-    const reviewedSet = new Set(reviewed || []);
-    for (const [filePath, reviewedFileInfo] of Object.entries(files)) {
-      reviewedFileInfo.isReviewed = reviewedSet.has(filePath);
-    }
-    this._files = this._normalizeChangeFilesResponse(files);
-  }
+  private computeFilesShown(): NormalizedFileInfo[] {
+    const previousNumFilesShown = this.shownFiles ? this.shownFiles.length : 0;
 
-  _computeFilesShown(
-    numFilesShown: number,
-    files: NormalizedFileInfo[]
-  ): NormalizedFileInfo[] | undefined {
-    // Polymer 2: check for undefined
-    if (numFilesShown === undefined || files === undefined) return undefined;
-
-    const previousNumFilesShown = this._shownFiles
-      ? this._shownFiles.length
-      : 0;
-
-    const filesShown = files.slice(0, numFilesShown);
+    const filesShown = this.files.slice(0, this.numFilesShown);
     this.dispatchEvent(
       new CustomEvent('files-shown-changed', {
         detail: {length: filesShown.length},
@@ -1178,13 +2205,13 @@
       })
     );
 
-    // Start the timer for the rendering work hwere because this is where the
-    // _shownFiles property is being set, and _shownFiles is used in the
+    // Start the timer for the rendering work here because this is where the
+    // shownFiles property is being set, and shownFiles is used in the
     // dom-repeat binding.
     this.reporting.time(Timing.FILE_RENDER);
 
     // How many more files are being shown (if it's an increase).
-    this._reportinShownFilesIncrement = Math.max(
+    this.reportinShownFilesIncrement = Math.max(
       0,
       filesShown.length - previousNumFilesShown
     );
@@ -1192,59 +2219,56 @@
     return filesShown;
   }
 
-  _updateDiffCursor() {
+  // Private but used in tests.
+  updateDiffCursor() {
     // Overwrite the cursor's list of diffs:
     this.diffCursor?.replaceDiffs(this.diffs);
   }
 
-  _filesChanged() {
-    if (this._files && this._files.length > 0) {
-      flush();
-      this.fileCursor.stops = Array.from(
-        this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)
-      );
-      this.fileCursor.setCursorAtIndex(this.selectedIndex, true);
-    }
+  async filesChanged() {
+    if (this.expandedFiles.length > 0) this.expandedFiles = [];
+    await this.updateCleanlyMergedPaths();
+    if (!this.files || this.files.length === 0) return;
+    await this.updateComplete;
+    this.fileCursor.stops = Array.from(
+      this.shadowRoot?.querySelectorAll(`.${FILE_ROW_CLASS}`) ?? []
+    );
+    this.fileCursor.setCursorAtIndex(this.selectedIndex, true);
   }
 
-  _incrementNumFilesShown() {
+  private incrementNumFilesShown() {
     this.numFilesShown += this.fileListIncrement;
   }
 
-  _computeFileListControlClass(
-    numFilesShown?: number,
-    files?: NormalizedFileInfo[]
-  ) {
-    if (numFilesShown === undefined || files === undefined) return 'invisible';
-    return numFilesShown >= files.length ? 'invisible' : '';
+  private computeFileListControlClass() {
+    return this.numFilesShown >= this.files.length ? 'invisible' : '';
   }
 
-  _computeIncrementText(numFilesShown?: number, files?: NormalizedFileInfo[]) {
-    if (numFilesShown === undefined || files === undefined) return '';
-    const text = Math.min(this.fileListIncrement, files.length - numFilesShown);
+  private computeIncrementText() {
+    const text = Math.min(
+      this.fileListIncrement,
+      this.files.length - this.numFilesShown
+    );
     return `Show ${text} more`;
   }
 
-  _computeShowAllText(files: NormalizedFileInfo[]) {
-    if (!files) {
+  private computeShowAllText() {
+    return `Show all ${this.files.length} files`;
+  }
+
+  private computeWarnShowAll() {
+    return this.files.length > WARN_SHOW_ALL_THRESHOLD;
+  }
+
+  private computeShowAllWarning() {
+    if (!this.computeWarnShowAll()) {
       return '';
     }
-    return `Show all ${files.length} files`;
+    return `Warning: showing all ${this.files.length} files may take several seconds.`;
   }
 
-  _computeWarnShowAll(files: NormalizedFileInfo[]) {
-    return files.length > WARN_SHOW_ALL_THRESHOLD;
-  }
-
-  _computeShowAllWarning(files: NormalizedFileInfo[]) {
-    if (!this._computeWarnShowAll(files)) {
-      return '';
-    }
-    return `Warning: showing all ${files.length} files may take several seconds.`;
-  }
-
-  _showAllFiles() {
-    this.numFilesShown = this._files.length;
+  private showAllFiles() {
+    this.numFilesShown = this.files.length;
   }
 
   /**
@@ -1256,33 +2280,22 @@
    *
    * @return 'true' if val is true-like, otherwise false
    */
-  _booleanToString(val?: unknown) {
+  private booleanToString(val?: unknown) {
     return val ? 'true' : 'false';
   }
 
-  _isFileExpanded(
-    path: string | undefined,
-    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
-  ) {
-    return expandedFilesRecord.base.some(f => f.path === path);
+  private isFileExpanded(path: string | undefined) {
+    return this.expandedFiles.some(f => f.path === path);
   }
 
-  _isFileExpandedStr(
-    path: string | undefined,
-    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
-  ) {
-    return this._booleanToString(
-      this._isFileExpanded(path, expandedFilesRecord)
-    );
+  private isFileExpandedStr(path: string | undefined) {
+    return this.booleanToString(this.isFileExpanded(path));
   }
 
-  private _computeExpandedFiles(
-    expandedCount: number,
-    totalCount: number
-  ): FilesExpandedState {
-    if (expandedCount === 0) {
+  private computeExpandedFiles(): FilesExpandedState {
+    if (this.expandedFiles.length === 0) {
       return FilesExpandedState.NONE;
-    } else if (expandedCount === totalCount) {
+    } else if (this.expandedFiles.length === this.files.length) {
       return FilesExpandedState.ALL;
     }
     return FilesExpandedState.SOME;
@@ -1294,44 +2307,35 @@
    * order by waiting for the previous diff to finish before starting the next
    * one.
    *
-   * @param record The splice record in the expanded paths list.
+   * @param newFiles The new files that have been added.
+   * Private but used in tests.
    */
-  @observe('_expandedFiles.splices')
-  _expandedFilesChanged(record?: PolymerSpliceChange<PatchSetFile[]>) {
+  async expandedFilesChanged(oldFiles: Array<PatchSetFile>) {
     // Clear content for any diffs that are not open so if they get re-opened
     // the stale content does not flash before it is cleared and reloaded.
     const collapsedDiffs = this.diffs.filter(
-      diff => this._expandedFiles.findIndex(f => f.path === diff.path) === -1
+      diff => this.expandedFiles.findIndex(f => f.path === diff.path) === -1
     );
-    this._clearCollapsedDiffs(collapsedDiffs);
+    this.clearCollapsedDiffs(collapsedDiffs);
 
-    if (!record) {
-      return;
-    } // Happens after "Collapse all" clicked.
+    this.filesExpanded = this.computeExpandedFiles();
 
-    this.filesExpanded = this._computeExpandedFiles(
-      this._expandedFiles.length,
-      this._files.length
-    );
-
-    // Find the paths introduced by the new index splices:
-    const newFiles = record.indexSplices.flatMap(splice =>
-      splice.object.slice(splice.index, splice.index + splice.addedCount)
+    const newFiles = this.expandedFiles.filter(
+      file => (oldFiles ?? []).findIndex(f => f.path === file.path) === -1
     );
 
     // Required so that the newly created diff view is included in this.diffs.
-    flush();
+    await this.updateComplete;
 
     if (newFiles.length) {
-      this._renderInOrder(newFiles, this.diffs, newFiles.length);
+      await this.renderInOrder(newFiles, this.diffs);
     }
-
-    this._updateDiffCursor();
+    this.updateDiffCursor();
     this.diffCursor?.reInitAndUpdateStops();
   }
 
   // private but used in test
-  _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
+  clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
     for (const diff of collapsedDiffs) {
       diff.cancel();
       diff.clearDiffContent();
@@ -1347,31 +2351,33 @@
    *
    * @param initialCount The total number of paths in the pass.
    */
-  async _renderInOrder(
-    files: PatchSetFile[],
-    diffElements: GrDiffHost[],
-    initialCount: number
-  ) {
+  async renderInOrder(files: PatchSetFile[], diffElements: GrDiffHost[]) {
     this.reporting.time(Timing.FILE_EXPAND_ALL);
 
     for (const file of files) {
       const path = file.path;
-      const diffElem = this._findDiffByPath(path, diffElements);
-      if (diffElem) {
-        diffElem.prefetchDiff();
-      }
-    }
-
-    await asyncForeach(files, (file, cancel) => {
-      const path = file.path;
-      this._cancelForEachDiff = cancel;
-
-      const diffElem = this._findDiffByPath(path, diffElements);
+      const diffElem = this.findDiffByPath(path, diffElements);
       if (!diffElem) {
         this.reporting.error(
+          'GrFileList',
           new Error(`Did not find <gr-diff-host> element for ${path}`)
         );
-        return Promise.resolve();
+        return;
+      }
+      diffElem.prefetchDiff();
+    }
+
+    await asyncForeach(files, async (file, cancel) => {
+      const path = file.path;
+      this.cancelForEachDiff = cancel;
+
+      const diffElem = this.findDiffByPath(path, diffElements);
+      if (!diffElem) {
+        this.reporting.error(
+          'GrFileList',
+          new Error(`Did not find <gr-diff-host> element for ${path}`)
+        );
+        return;
       }
       if (!this.diffPrefs) {
         throw new Error('diffPrefs must be set');
@@ -1383,21 +2389,22 @@
       // control over which diffs were actually seen. And for lots of diffs
       // that would even be a problem for write QPS quota.
       if (
-        this._loggedIn &&
+        this.loggedIn &&
         !this.diffPrefs.manual_review &&
-        initialCount === 1
+        files.length === 1
       ) {
-        this._reviewFile(path, true);
+        await this.reviewFile(path, true);
       }
-      return diffElem.reload();
+      await diffElem.reload();
     });
 
-    this._cancelForEachDiff = undefined;
+    this.cancelForEachDiff = undefined;
     this.reporting.timeEnd(Timing.FILE_EXPAND_ALL, {
-      count: initialCount,
+      count: files.length,
       height: this.clientHeight,
     });
-    /* Block diff cursor from auto scrolling after files are done rendering.
+    /*
+    * 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.
@@ -1405,7 +2412,7 @@
     * 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
+    * Using reInit resulted in diffCursor.row being set which
     * prevented the issue of scrolling to top when we expand the second
     * file individually.
     */
@@ -1413,17 +2420,17 @@
   }
 
   /** Cancel the rendering work of every diff in the list */
-  _cancelDiffs() {
-    if (this._cancelForEachDiff) {
-      this._cancelForEachDiff();
+  private cancelDiffs() {
+    if (this.cancelForEachDiff) {
+      this.cancelForEachDiff();
     }
-    this._forEachDiff(d => d.cancel());
+    this.forEachDiff(d => d.cancel());
   }
 
   /**
    * In the given NodeList of diff elements, find the diff for the given path.
    */
-  private _findDiffByPath(path: string, diffElements: GrDiffHost[]) {
+  private findDiffByPath(path: string, diffElements: GrDiffHost[]) {
     for (let i = 0; i < diffElements.length; i++) {
       if (diffElements[i].path === path) {
         return diffElements[i];
@@ -1432,62 +2439,19 @@
     return undefined;
   }
 
-  _handleEscKey() {
-    this._displayLine = false;
-  }
-
-  /**
-   * Update the loading class for the file list rows. The update is inside a
-   * debouncer so that the file list doesn't flash gray when the API requests
-   * are reasonably fast.
-   */
-  _loadingChanged(loading?: boolean) {
-    this.loadingTask = debounce(
-      this.loadingTask,
-      () => {
-        // Only show set the loading if there have been files loaded to show. In
-        // this way, the gray loading style is not shown on initial loads.
-        this.classList.toggle('loading', loading && !!this._files.length);
-      },
-      LOADING_DEBOUNCE_INTERVAL
-    );
-  }
-
-  _editModeChanged(editMode?: boolean) {
-    this.classList.toggle('editMode', editMode);
-  }
-
-  _computeReviewedClass(isReviewed?: boolean) {
-    return isReviewed ? 'isReviewed' : '';
-  }
-
-  _computeReviewedText(isReviewed?: boolean) {
-    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
-  }
-
-  /**
-   * Given a file path, return whether that path should have visible size bars
-   * and be included in the size bars calculation.
-   */
-  _showBarsForPath(path?: string) {
-    return (
-      path !== SpecialFilePath.COMMIT_MESSAGE &&
-      path !== SpecialFilePath.MERGE_LIST
-    );
+  // Private but used in tests.
+  handleEscKey() {
+    this.displayLine = false;
   }
 
   /**
    * Compute size bar layout values from the file list.
+   * Private but used in tests.
    */
-  _computeSizeBarLayout(
-    shownFilesRecord?: ElementPropertyDeepChange<GrFileList, '_shownFiles'>
-  ) {
+  computeSizeBarLayout() {
     const stats: SizeBarLayout = createDefaultSizeBarLayout();
-    if (!shownFilesRecord || !shownFilesRecord.base) {
-      return stats;
-    }
-    shownFilesRecord.base
-      .filter(f => this._showBarsForPath(f.__path))
+    this.shownFiles
+      .filter(f => !isMagicPath(f.__path))
       .forEach(f => {
         if (f.lines_inserted) {
           stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
@@ -1509,14 +2473,15 @@
 
   /**
    * Get the width of the addition bar for a file.
+   * Private but used in tests.
    */
-  _computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+  computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
     if (
       !file ||
       !stats ||
       stats.maxInserted === 0 ||
       !file.lines_inserted ||
-      !this._showBarsForPath(file.__path)
+      !!isMagicPath(file.__path)
     ) {
       return 0;
     }
@@ -1527,22 +2492,24 @@
 
   /**
    * Get the x-offset of the addition bar for a file.
+   * Private but used in tests.
    */
-  _computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+  computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
     if (!file || !stats) return;
-    return stats.maxAdditionWidth - this._computeBarAdditionWidth(file, stats);
+    return stats.maxAdditionWidth - this.computeBarAdditionWidth(file, stats);
   }
 
   /**
    * Get the width of the deletion bar for a file.
+   * Private but used in tests.
    */
-  _computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+  computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
     if (
       !file ||
       !stats ||
       stats.maxDeleted === 0 ||
       !file.lines_deleted ||
-      !this._showBarsForPath(file.__path)
+      !!isMagicPath(file.__path)
     ) {
       return 0;
     }
@@ -1554,15 +2521,16 @@
   /**
    * Get the x-offset of the deletion bar for a file.
    */
-  _computeBarDeletionX(stats: SizeBarLayout) {
+  private computeBarDeletionX(stats: SizeBarLayout) {
     return stats.deletionOffset;
   }
 
-  _computeSizeBarsClass(showSizeBars?: boolean, path?: string) {
+  // Private but used in tests.
+  computeSizeBarsClass(path?: string) {
     let hideClass = '';
-    if (!showSizeBars) {
+    if (!this.showSizeBars) {
       hideClass = 'hide';
-    } else if (!this._showBarsForPath(path)) {
+    } else if (isMagicPath(path)) {
       hideClass = 'invisible';
     }
     return `sizeBars ${hideClass}`;
@@ -1574,18 +2542,15 @@
    * Ideally, there should be a better way to enforce the expectation of the
    * dependencies between dynamic endpoints.
    */
-  _computeShowDynamicColumns(
-    headerEndpoints?: string,
-    contentEndpoints?: string,
-    summaryEndpoints?: string
-  ) {
-    return (
-      headerEndpoints &&
-      contentEndpoints &&
-      summaryEndpoints &&
-      headerEndpoints.length &&
-      headerEndpoints.length === contentEndpoints.length &&
-      headerEndpoints.length === summaryEndpoints.length
+  private computeShowDynamicColumns() {
+    return !!(
+      this.dynamicHeaderEndpoints &&
+      this.dynamicContentEndpoints &&
+      this.dynamicSummaryEndpoints &&
+      this.dynamicHeaderEndpoints.length &&
+      this.dynamicHeaderEndpoints.length ===
+        this.dynamicContentEndpoints.length &&
+      this.dynamicHeaderEndpoints.length === this.dynamicSummaryEndpoints.length
     );
   }
 
@@ -1593,22 +2558,21 @@
    * Shows registered dynamic prepended columns iff the 'header', 'content'
    * endpoints are registered the exact same number of times.
    */
-  _computeShowPrependedDynamicColumns(
-    headerEndpoints?: string,
-    contentEndpoints?: string
-  ) {
-    return (
-      headerEndpoints &&
-      contentEndpoints &&
-      headerEndpoints.length &&
-      headerEndpoints.length === contentEndpoints.length
+  private computeShowPrependedDynamicColumns() {
+    return !!(
+      this.dynamicPrependedHeaderEndpoints &&
+      this.dynamicPrependedContentEndpoints &&
+      this.dynamicPrependedHeaderEndpoints.length &&
+      this.dynamicPrependedHeaderEndpoints.length ===
+        this.dynamicPrependedContentEndpoints.length
     );
   }
 
   /**
    * Returns true if none of the inline diffs have been expanded.
+   * Private but used in tests.
    */
-  _noDiffsExpanded() {
+  noDiffsExpanded() {
     return this.filesExpanded === FilesExpandedState.NONE;
   }
 
@@ -1618,45 +2582,23 @@
    * rendering.
    *
    * @param index The index of the row being rendered.
+   * Private but used in tests.
    */
-  _reportRenderedRow(index: number) {
-    if (index === this._shownFiles.length - 1) {
+  reportRenderedRow(index: number) {
+    if (index === this.shownFiles.length - 1) {
       setTimeout(() => {
         this.reporting.timeEnd(Timing.FILE_RENDER, {
-          count: this._reportinShownFilesIncrement,
+          count: this.reportinShownFilesIncrement,
         });
       }, 1);
     }
-    return '';
   }
 
-  _reviewedTitle(reviewed?: boolean) {
-    if (reviewed) {
-      return 'Mark as not reviewed (shortcut: r)';
-    }
-
-    return 'Mark as reviewed (shortcut: r)';
-  }
-
-  _handleReloadingDiffPreference() {
+  private handleReloadingDiffPreference() {
     this.userModel.getDiffPreferences();
   }
 
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeDisplayPath(path: string) {
-    return computeDisplayPath(path);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeTruncatedPath(path: string) {
-    return computeTruncatedPath(path);
-  }
-
-  _getOldPath(file: NormalizedFileInfo) {
+  private getOldPath(file: NormalizedFileInfo) {
     // The gr-endpoint-decorator is waiting until all gr-endpoint-param
     // values are updated.
     // The old_path property is undefined for added files, and the
@@ -1666,9 +2608,3 @@
     return file.old_path ?? null;
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-file-list': GrFileList;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
deleted file mode 100644
index 67ca9be..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ /dev/null
@@ -1,823 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    .row {
-      align-items: center;
-      border-top: 1px solid var(--border-color);
-      display: flex;
-      min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
-      padding: var(--spacing-xs) var(--spacing-l);
-    }
-    /* The class defines a content visible only to screen readers */
-    .noCommentsScreenReaderText {
-      opacity: 0;
-      max-width: 1px;
-      overflow: hidden;
-      display: none;
-      vertical-align: top;
-    }
-    div[role='gridcell']
-      > div.comments
-      > span:empty
-      + span:empty
-      + span.noCommentsScreenReaderText {
-      /* inline-block instead of block, such that it can control width */
-      display: inline-block;
-    }
-    :host(.loading) .row {
-      opacity: 0.5;
-    }
-    :host(.editMode) .hideOnEdit {
-      display: none;
-    }
-    .showOnEdit {
-      display: none;
-    }
-    :host(.editMode) .showOnEdit {
-      display: initial;
-    }
-    .invisible {
-      visibility: hidden;
-    }
-    .header-row {
-      background-color: var(--background-color-secondary);
-    }
-    .controlRow {
-      align-items: center;
-      display: flex;
-      height: 2.25em;
-      justify-content: center;
-    }
-    .controlRow.invisible,
-    .show-hide.invisible {
-      display: none;
-    }
-    .reviewed,
-    .status {
-      align-items: center;
-      display: inline-flex;
-    }
-    .reviewed {
-      display: inline-block;
-      text-align: left;
-      width: 1.5em;
-    }
-    .file-row {
-      cursor: pointer;
-    }
-    .file-row.expanded {
-      border-bottom: 1px solid var(--border-color);
-      position: -webkit-sticky;
-      position: sticky;
-      top: 0;
-      /* Has to visible above the diff view, and by default has a lower
-         z-index. setting to 1 places it directly above. */
-      z-index: 1;
-    }
-    .file-row:hover {
-      background-color: var(--hover-background-color);
-    }
-    .file-row.selected {
-      background-color: var(--selection-background-color);
-    }
-    .file-row.expanded,
-    .file-row.expanded:hover {
-      background-color: var(--expanded-background-color);
-    }
-    .path {
-      cursor: pointer;
-      flex: 1;
-      /* Wrap it into multiple lines if too long. */
-      white-space: normal;
-      word-break: break-word;
-    }
-    .oldPath {
-      color: var(--deemphasized-text-color);
-    }
-    .header-stats {
-      text-align: center;
-      min-width: 7.5em;
-    }
-    .stats {
-      text-align: right;
-      min-width: 7.5em;
-    }
-    .comments {
-      padding-left: var(--spacing-l);
-      min-width: 7.5em;
-      white-space: nowrap;
-    }
-    .row:not(.header-row) .stats,
-    .total-stats {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      display: flex;
-    }
-    .sizeBars {
-      margin-left: var(--spacing-m);
-      min-width: 7em;
-      text-align: center;
-    }
-    .sizeBars.hide {
-      display: none;
-    }
-    .added,
-    .removed {
-      display: inline-block;
-      min-width: 3.5em;
-    }
-    .added {
-      color: var(--positive-green-text-color);
-    }
-    .removed {
-      color: var(--negative-red-text-color);
-      text-align: left;
-      min-width: 4em;
-      padding-left: var(--spacing-s);
-    }
-    .drafts {
-      color: var(--error-foreground);
-      font-weight: var(--font-weight-bold);
-    }
-    .show-hide-icon:focus {
-      outline: none;
-    }
-    .show-hide {
-      margin-left: var(--spacing-s);
-      width: 1.9em;
-    }
-    .fileListButton {
-      margin: var(--spacing-m);
-    }
-    .totalChanges {
-      justify-content: flex-end;
-      text-align: right;
-    }
-    .warning {
-      color: var(--deemphasized-text-color);
-    }
-    input.show-hide {
-      display: none;
-    }
-    label.show-hide {
-      cursor: pointer;
-      display: block;
-      min-width: 2em;
-    }
-    gr-diff {
-      display: block;
-      overflow-x: auto;
-    }
-    .truncatedFileName {
-      display: none;
-    }
-    .mobile {
-      display: none;
-    }
-    .reviewed {
-      margin-left: var(--spacing-xxl);
-      width: 15em;
-    }
-    .reviewedSwitch {
-      color: var(--link-color);
-      opacity: 0;
-      justify-content: flex-end;
-      width: 100%;
-    }
-    .reviewedSwitch:hover {
-      cursor: pointer;
-      opacity: 100;
-    }
-    .showParentButton {
-      line-height: var(--line-height-normal);
-      margin-bottom: calc(var(--spacing-s) * -1);
-      margin-left: var(--spacing-m);
-      margin-top: calc(var(--spacing-s) * -1);
-    }
-    .row:focus {
-      outline: none;
-    }
-    .row:hover .reviewedSwitch,
-    .row:focus-within .reviewedSwitch,
-    .row.expanded .reviewedSwitch {
-      opacity: 100;
-    }
-    .reviewedLabel {
-      color: var(--deemphasized-text-color);
-      margin-right: var(--spacing-l);
-      opacity: 0;
-    }
-    .reviewedLabel.isReviewed {
-      display: initial;
-      opacity: 100;
-    }
-    .editFileControls {
-      width: 7em;
-    }
-    .markReviewed:focus {
-      outline: none;
-    }
-    .markReviewed,
-    .pathLink {
-      display: inline-block;
-      margin: -2px 0;
-      padding: var(--spacing-s) 0;
-      text-decoration: none;
-    }
-    .pathLink:hover span.fullFileName,
-    .pathLink:hover span.truncatedFileName {
-      text-decoration: underline;
-    }
-
-    /** copy on file path **/
-    .pathLink gr-copy-clipboard,
-    .oldPath gr-copy-clipboard {
-      display: inline-block;
-      visibility: hidden;
-      vertical-align: bottom;
-      --gr-button-padding: 0px;
-    }
-    .row:focus-within gr-copy-clipboard,
-    .row:hover gr-copy-clipboard {
-      visibility: visible;
-    }
-
-    @media screen and (max-width: 1200px) {
-      gr-endpoint-decorator.extra-col {
-        display: none;
-      }
-    }
-
-    @media screen and (max-width: 1000px) {
-      .reviewed {
-        display: none;
-      }
-    }
-
-    @media screen and (max-width: 800px) {
-      .desktop {
-        display: none;
-      }
-      .mobile {
-        display: block;
-      }
-      .row.selected {
-        background-color: var(--view-background-color);
-      }
-      .stats {
-        display: none;
-      }
-      .reviewed,
-      .status {
-        justify-content: flex-start;
-      }
-      .comments {
-        min-width: initial;
-      }
-      .expanded .fullFileName,
-      .truncatedFileName {
-        display: inline;
-      }
-      .expanded .truncatedFileName,
-      .fullFileName {
-        display: none;
-      }
-    }
-    :host(.hideComments) {
-      --gr-comment-thread-display: none;
-    }
-  </style>
-  <h3 class="assistive-tech-only">File list</h3>
-  <div
-    id="container"
-    on-click="_handleFileListClick"
-    role="grid"
-    aria-label="Files list"
-  >
-    <div class="header-row row" role="row">
-      <!-- endpoint: change-view-file-list-header-prepend -->
-      <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
-        <template
-          is="dom-repeat"
-          items="[[_dynamicPrependedHeaderEndpoints]]"
-          as="headerEndpoint"
-        >
-          <gr-endpoint-decorator
-            class="prepended-col"
-            name$="[[headerEndpoint]]"
-            role="columnheader"
-          >
-            <gr-endpoint-param name="change" value="[[change]]">
-            </gr-endpoint-param>
-            <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-            </gr-endpoint-param>
-            <gr-endpoint-param name="files" value="[[_files]]">
-            </gr-endpoint-param>
-          </gr-endpoint-decorator>
-        </template>
-      </template>
-      <div class="path" role="columnheader">File</div>
-      <div class="comments desktop" role="columnheader">Comments</div>
-      <div class="comments mobile" role="columnheader" title="Comments">C</div>
-      <div class="sizeBars desktop" role="columnheader">Size</div>
-      <div class="header-stats" role="columnheader">Delta</div>
-      <!-- endpoint: change-view-file-list-header -->
-      <template is="dom-if" if="[[_showDynamicColumns]]">
-        <template
-          is="dom-repeat"
-          items="[[_dynamicHeaderEndpoints]]"
-          as="headerEndpoint"
-        >
-          <gr-endpoint-decorator
-            class="extra-col"
-            name$="[[headerEndpoint]]"
-            role="columnheader"
-          >
-          </gr-endpoint-decorator>
-        </template>
-      </template>
-      <!-- Empty div here exists to keep spacing in sync with file rows. -->
-      <div
-        class="reviewed hideOnEdit"
-        hidden$="[[!_loggedIn]]"
-        aria-hidden="true"
-      ></div>
-      <div class="editFileControls showOnEdit" aria-hidden="true"></div>
-      <div class="show-hide" aria-hidden="true"></div>
-    </div>
-
-    <template
-      is="dom-repeat"
-      items="[[_shownFiles]]"
-      id="files"
-      as="file"
-      initial-count="[[fileListIncrement]]"
-      target-framerate="1"
-    >
-      [[_reportRenderedRow(index)]]
-      <div class="stickyArea">
-        <div
-          class$="file-row row [[_computePathClass(file.__path, _expandedFiles.*)]]"
-          data-file$="[[_computePatchSetFile(file)]]"
-          tabindex="-1"
-          role="row"
-        >
-          <!-- endpoint: change-view-file-list-content-prepend -->
-          <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
-            <template
-              is="dom-repeat"
-              items="[[_dynamicPrependedContentEndpoints]]"
-              as="contentEndpoint"
-            >
-              <gr-endpoint-decorator
-                class="prepended-col"
-                name="[[contentEndpoint]]"
-                role="gridcell"
-              >
-                <gr-endpoint-param name="change" value="[[change]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="changeNum" value="[[changeNum]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="path" value="[[file.__path]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="oldPath" value="[[_getOldPath(file)]]">
-                </gr-endpoint-param>
-              </gr-endpoint-decorator>
-            </template>
-          </template>
-          <!-- TODO: Remove data-url as it appears its not used -->
-          <span
-            data-url="[[_computeDiffURL(change, patchRange.basePatchNum, patchRange.patchNum, file.__path, editMode)]]"
-            class="path"
-            role="gridcell"
-          >
-            <a
-              class="pathLink"
-              href$="[[_computeDiffURL(change, patchRange.basePatchNum, patchRange.patchNum, file.__path, editMode)]]"
-            >
-              <span
-                title$="[[_computeDisplayPath(file.__path)]]"
-                class="fullFileName"
-              >
-                [[_computeDisplayPath(file.__path)]]
-              </span>
-              <span
-                title$="[[_computeDisplayPath(file.__path)]]"
-                class="truncatedFileName"
-              >
-                [[_computeTruncatedPath(file.__path)]]
-              </span>
-              <gr-file-status-chip file="[[file]]"></gr-file-status-chip>
-              <gr-copy-clipboard
-                hideInput=""
-                text="[[file.__path]]"
-              ></gr-copy-clipboard>
-            </a>
-            <template is="dom-if" if="[[file.old_path]]">
-              <div class="oldPath" title$="[[file.old_path]]">
-                [[file.old_path]]
-                <gr-copy-clipboard
-                  hideInput=""
-                  text="[[file.old_path]]"
-                ></gr-copy-clipboard>
-              </div>
-            </template>
-          </span>
-          <div role="gridcell">
-            <div class="comments desktop">
-              <span class="drafts"
-                ><!-- This comments ensure that span is empty when the function
-                returns empty string.
-              -->[[_computeDraftsString(changeComments, patchRange, file)]]<!-- This comments ensure that span is empty when
-                the function returns empty string.
-           --></span
-              >
-              <span
-                ><!--
-              -->[[_computeCommentsString(changeComments, patchRange, file)]]<!--
-           --></span
-              >
-              <span class="noCommentsScreenReaderText">
-                <!-- Screen readers read the following content only if 2 other
-              spans in the parent div is empty. The content is not visible on
-              the page.
-              Without this span, screen readers don't navigate correctly inside
-              table, because empty div doesn't rendered. For example, VoiceOver
-              jumps back to the whole table.
-              We can use &nbsp instead, but it sounds worse.
-              -->
-                No comments
-              </span>
-            </div>
-            <div class="comments mobile">
-              <span class="drafts"
-                ><!-- This comments ensure that span is empty when the function
-                returns empty string.
-              -->[[_computeDraftsStringMobile(changeComments, patchRange,
-                file)]]<!-- This comments ensure that span is empty when
-                the function returns empty string.
-           --></span
-              >
-              <span
-                ><!--
-             -->[[_computeCommentsStringMobile(changeComments, patchRange,
-                file)]]<!--
-           --></span
-              >
-              <span class="noCommentsScreenReaderText">
-                <!-- The same as for desktop comments -->
-                No comments
-              </span>
-            </div>
-          </div>
-          <div class="desktop" role="gridcell">
-            <!-- The content must be in a separate div. It guarantees, that
-              gridcell always visible for screen readers.
-              For example, without a nested div screen readers pronounce the
-              "Commit message" row content with incorrect column headers.
-            -->
-            <div
-              class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]"
-              aria-label="A bar that represents the addition and deletion ratio for the current file"
-            >
-              <svg width="61" height="8">
-                <rect
-                  x$="[[_computeBarAdditionX(file, _sizeBarLayout)]]"
-                  y="0"
-                  height="8"
-                  fill="var(--positive-green-text-color)"
-                  width$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]"
-                ></rect>
-                <rect
-                  x$="[[_computeBarDeletionX(_sizeBarLayout)]]"
-                  y="0"
-                  height="8"
-                  fill="var(--negative-red-text-color)"
-                  width$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]"
-                ></rect>
-              </svg>
-            </div>
-          </div>
-          <div class="stats" role="gridcell">
-            <!-- The content must be in a separate div. It guarantees, that
-            gridcell always visible for screen readers.
-            For example, without a nested div screen readers pronounce the
-            "Commit message" row content with incorrect column headers.
-            -->
-            <div class$="[[_computeClass('', file.__path)]]">
-              <span
-                class="added"
-                tabindex="0"
-                aria-label$="[[file.lines_inserted]] lines added"
-                hidden$="[[file.binary]]"
-              >
-                +[[file.lines_inserted]]
-              </span>
-              <span
-                class="removed"
-                tabindex="0"
-                aria-label$="[[file.lines_deleted]] lines removed"
-                hidden$="[[file.binary]]"
-              >
-                -[[file.lines_deleted]]
-              </span>
-              <span
-                class$="[[_computeBinaryClass(file.size_delta)]]"
-                hidden$="[[!file.binary]]"
-              >
-                [[_formatBytes(file.size_delta)]] [[_formatPercentage(file.size,
-                file.size_delta)]]
-              </span>
-            </div>
-          </div>
-          <!-- endpoint: change-view-file-list-content -->
-          <template is="dom-if" if="[[_showDynamicColumns]]">
-            <template
-              is="dom-repeat"
-              items="[[_dynamicContentEndpoints]]"
-              as="contentEndpoint"
-            >
-              <div class$="[[_computeClass('', file.__path)]]" role="gridcell">
-                <gr-endpoint-decorator
-                  class="extra-col"
-                  name="[[contentEndpoint]]"
-                >
-                  <gr-endpoint-param name="change" value="[[change]]">
-                  </gr-endpoint-param>
-                  <gr-endpoint-param name="changeNum" value="[[changeNum]]">
-                  </gr-endpoint-param>
-                  <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-                  </gr-endpoint-param>
-                  <gr-endpoint-param name="path" value="[[file.__path]]">
-                  </gr-endpoint-param>
-                </gr-endpoint-decorator>
-              </div>
-            </template>
-          </template>
-          <div
-            class="reviewed hideOnEdit"
-            role="gridcell"
-            hidden$="[[!_loggedIn]]"
-          >
-            <span
-              class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]"
-              aria-hidden$="[[!file.isReviewed]]"
-              >Reviewed</span
-            >
-            <!-- Do not use input type="checkbox" with hidden input and
-                  visible label here. Screen readers don't read/interract
-                  correctly with such input.
-              -->
-            <span
-              class="reviewedSwitch"
-              role="switch"
-              tabindex="0"
-              on-click="_reviewedClick"
-              on-keydown="_reviewedClick"
-              aria-label="Reviewed"
-              aria-checked$="[[_booleanToString(file.isReviewed)]]"
-            >
-              <!-- Trick with tabindex to avoid outline on mouse focus, but
-                preserve focus outline for keyboard navigation -->
-              <span
-                tabindex="-1"
-                class="markReviewed"
-                title$="[[_reviewedTitle(file.isReviewed)]]"
-                >[[_computeReviewedText(file.isReviewed)]]</span
-              >
-            </span>
-          </div>
-          <div
-            class="editFileControls showOnEdit"
-            role="gridcell"
-            aria-hidden$="[[!editMode]]"
-          >
-            <template is="dom-if" if="[[editMode]]">
-              <gr-edit-file-controls
-                class$="[[_computeClass('', file.__path)]]"
-                file-path="[[file.__path]]"
-              ></gr-edit-file-controls>
-            </template>
-          </div>
-          <div class="show-hide" role="gridcell">
-            <!-- Do not use input type="checkbox" with hidden input and
-                visible label here. Screen readers don't read/interract
-                correctly with such input.
-            -->
-            <span
-              class="show-hide"
-              data-path$="[[file.__path]]"
-              data-expand="true"
-              role="switch"
-              tabindex="0"
-              aria-checked$="[[_isFileExpandedStr(file.__path, _expandedFiles.*)]]"
-              aria-label="Expand file"
-              on-click="_expandedClick"
-              on-keydown="_expandedClick"
-            >
-              <!-- Trick with tabindex to avoid outline on mouse focus, but
-              preserve focus outline for keyboard navigation -->
-              <iron-icon
-                class="show-hide-icon"
-                tabindex="-1"
-                id="icon"
-                icon="[[_computeShowHideIcon(file.__path, _expandedFiles.*)]]"
-              >
-              </iron-icon>
-            </span>
-          </div>
-        </div>
-        <template
-          is="dom-if"
-          if="[[_isFileExpanded(file.__path, _expandedFiles.*)]]"
-        >
-          <gr-diff-host
-            no-auto-render=""
-            show-load-failure=""
-            display-line="[[_displayLine]]"
-            hidden="[[!_isFileExpanded(file.__path, _expandedFiles.*)]]"
-            change-num="[[changeNum]]"
-            change="[[change]]"
-            patch-range="[[patchRange]]"
-            file="[[_computePatchSetFile(file)]]"
-            path="[[file.__path]]"
-            prefs="[[diffPrefs]]"
-            project-name="[[change.project]]"
-            no-render-on-prefs-change=""
-          ></gr-diff-host>
-        </template>
-      </div>
-    </template>
-    <template
-      is="dom-if"
-      if="[[_computeShowNumCleanlyMerged(_cleanlyMergedPaths)]]"
-    >
-      <div class="row">
-        <!-- endpoint: change-view-file-list-content-prepend -->
-        <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
-          <template
-            is="dom-repeat"
-            items="[[_dynamicPrependedContentEndpoints]]"
-            as="contentEndpoint"
-          >
-            <gr-endpoint-decorator
-              class="prepended-col"
-              name="[[contentEndpoint]]"
-              role="gridcell"
-            >
-              <gr-endpoint-param name="change" value="[[change]]">
-              </gr-endpoint-param>
-              <gr-endpoint-param name="changeNum" value="[[changeNum]]">
-              </gr-endpoint-param>
-              <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-              </gr-endpoint-param>
-              <gr-endpoint-param
-                name="cleanlyMergedPaths"
-                value="[[_cleanlyMergedPaths]]"
-              >
-              </gr-endpoint-param>
-              <gr-endpoint-param
-                name="cleanlyMergedOldPaths"
-                value="[[_cleanlyMergedOldPaths]]"
-              >
-              </gr-endpoint-param>
-            </gr-endpoint-decorator>
-          </template>
-        </template>
-        <div role="gridcell">
-          <div>
-            <span class="cleanlyMergedText">
-              [[_computeCleanlyMergedText(_cleanlyMergedPaths)]]
-            </span>
-            <gr-button
-              link
-              class="showParentButton"
-              on-click="_handleShowParent1"
-            >
-              Show Parent 1
-            </gr-button>
-          </div>
-        </div>
-      </div>
-    </template>
-  </div>
-  <div class="row totalChanges" hidden$="[[_hideChangeTotals]]">
-    <div class="total-stats">
-      <div>
-        <span
-          class="added"
-          tabindex="0"
-          aria-label$="Total [[_patchChange.inserted]] lines added"
-        >
-          +[[_patchChange.inserted]]
-        </span>
-        <span
-          class="removed"
-          tabindex="0"
-          aria-label$="Total [[_patchChange.deleted]] lines removed"
-        >
-          -[[_patchChange.deleted]]
-        </span>
-      </div>
-    </div>
-    <!-- endpoint: change-view-file-list-summary -->
-    <template is="dom-if" if="[[_showDynamicColumns]]">
-      <template
-        is="dom-repeat"
-        items="[[_dynamicSummaryEndpoints]]"
-        as="summaryEndpoint"
-      >
-        <gr-endpoint-decorator class="extra-col" name="[[summaryEndpoint]]">
-          <gr-endpoint-param
-            name="change"
-            value="[[change]]"
-          ></gr-endpoint-param>
-          <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-          </gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </template>
-    </template>
-    <!-- Empty div here exists to keep spacing in sync with file rows. -->
-    <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
-    <div class="editFileControls showOnEdit"></div>
-    <div class="show-hide"></div>
-  </div>
-  <div class="row totalChanges" hidden$="[[_hideBinaryChangeTotals]]">
-    <div class="total-stats">
-      <span
-        class="added"
-        aria-label$="Total bytes inserted: [[_formatBytes(_patchChange.size_delta_inserted)]] "
-      >
-        [[_formatBytes(_patchChange.size_delta_inserted)]]
-        [[_formatPercentage(_patchChange.total_size,
-        _patchChange.size_delta_inserted)]]
-      </span>
-      <span
-        class="removed"
-        aria-label$="Total bytes removed: [[_formatBytes(_patchChange.size_delta_deleted)]]"
-      >
-        [[_formatBytes(_patchChange.size_delta_deleted)]]
-        [[_formatPercentage(_patchChange.total_size,
-        _patchChange.size_delta_deleted)]]
-      </span>
-    </div>
-  </div>
-  <div
-    class$="row controlRow [[_computeFileListControlClass(numFilesShown, _files)]]"
-  >
-    <gr-button
-      class="fileListButton"
-      id="incrementButton"
-      link=""
-      on-click="_incrementNumFilesShown"
-    >
-      [[_computeIncrementText(numFilesShown, _files)]]
-    </gr-button>
-    <gr-tooltip-content
-      has-tooltip="[[_computeWarnShowAll(_files)]]"
-      show-icon="[[_computeWarnShowAll(_files)]]"
-      title$="[[_computeShowAllWarning(_files)]]"
-    >
-      <gr-button
-        class="fileListButton"
-        id="showAllButton"
-        link=""
-        on-click="_showAllFiles"
-      >
-        [[_computeShowAllText(_files)]] </gr-button
-      ><!--
-  --></gr-tooltip-content>
-  </div>
-  <gr-diff-preferences-dialog
-    id="diffPreferencesDialog"
-    diff-prefs="{{diffPrefs}}"
-    on-reload-diff-preference="_handleReloadingDiffPreference"
-  >
-  </gr-diff-preferences-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_screenshot_test.ts
new file mode 100644
index 0000000..f80f48b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_screenshot_test.ts
@@ -0,0 +1,58 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import {fixture, html} from '@open-wc/testing';
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {FileInfo, PARENT, RevisionPatchSetNum} from '../../../api/rest-api';
+import {normalize} from '../../../models/change/files-model';
+import {PatchRange} from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../api/diff';
+import {NormalizedFileInfo, GrFileList} from './gr-file-list';
+import './gr-file-list';
+
+suite('gr-file-list screenshot tests', () => {
+  let element: GrFileList;
+
+  function createFiles(
+    count: number,
+    fileInfo: FileInfo
+  ): NormalizedFileInfo[] {
+    return Array.from(Array(count).keys()).map(index =>
+      normalize(fileInfo, `/file${index}`)
+    );
+  }
+
+  setup(async () => {
+    const patchRange: PatchRange = {
+      basePatchNum: PARENT,
+      patchNum: 2 as RevisionPatchSetNum,
+    };
+    const diffPrefs: DiffPreferencesInfo = {
+      context: 10,
+      tab_size: 8,
+      font_size: 12,
+      line_length: 100,
+      ignore_whitespace: 'IGNORE_NONE',
+    };
+    element = await fixture(
+      html`<gr-file-list
+        .patchRange=${patchRange}
+        .diffPrefs=${diffPrefs}
+      ></gr-file-list>`
+    );
+  });
+
+  test('screenshot', async () => {
+    element.files = [
+      ...createFiles(3, {lines_inserted: 9}),
+      ...createFiles(2, {lines_deleted: 14}),
+    ];
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-file-list');
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index cc977f1..a7d9e28 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -1,41 +1,28 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
-  listenOnce,
   mockPromise,
   query,
-  spyRestApi,
   stubRestApi,
+  waitUntil,
+  pressKey,
+  stubElement,
+  waitEventLoop,
 } from '../../../test/test-utils';
 import {
   BasePatchSetNum,
   CommitId,
-  EditPatchSetNum,
+  EDIT,
   NumericChangeId,
-  PatchRange,
-  PatchSetNum,
+  PARENT,
   RepoName,
   RevisionPatchSetNum,
   Timestamp,
@@ -49,280 +36,317 @@
   createParsedChange,
   createRevision,
 } from '../../../test/test-data-generators';
-import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {
+  createDefaultDiffPrefs,
+  DiffViewMode,
+} from '../../../constants/constants';
 import {
   assertIsDefined,
   queryAll,
   queryAndAssert,
 } from '../../../utils/common-util';
 import {GrFileList, NormalizedFileInfo} from './gr-file-list';
-import {DiffPreferencesInfo} from '../../../types/diff';
+import {FileInfo, PatchSetNumber} from '../../../api/rest-api';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {ParsedChangeInfo} from '../../../types/types';
+import {normalize} from '../../../models/change/files-model';
 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);
+import {GrIcon} from '../../shared/gr-icon/gr-icon';
+import {fixture, html, assert} from '@open-wc/testing';
+import {Modifier} from '../../../utils/dom-util';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
-    await runA11yAudit(basicFixture);
+    assert.isAccessible(await fixture(html`<gr-file-list></gr-file-list>`));
   });
 });
 
-function createFilesByPath(count: number) {
-  return Array(count)
-    .fill(0)
-    .reduce((_filesByPath, _, idx) => {
-      _filesByPath[`'/file${idx}`] = {lines_inserted: 9};
-      return _filesByPath;
-    }, {});
+function createFiles(
+  count: number,
+  fileInfo: FileInfo = {}
+): NormalizedFileInfo[] {
+  const files = Array(count).fill({});
+  return files.map((_, idx) => normalize(fileInfo, `'/file${idx}`));
 }
 
 suite('gr-file-list tests', () => {
   let element: GrFileList;
-  let commentApiWrapper: any;
 
   let saveStub: sinon.SinonStub;
 
-  suite('basic tests', () => {
+  suite('basic tests', async () => {
     setup(async () => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
-      stub('gr-date-formatter', '_loadTimeFormat').callsFake(() =>
+      stubElement('gr-date-formatter', 'loadTimeFormat').callsFake(() =>
         Promise.resolve()
       );
-      stub('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
-      stub('gr-diff-host', 'prefetchDiff').callsFake(() => {});
+      stubElement('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
+      stubElement('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 = await fixture(html`<gr-file-list></gr-file-list>`);
 
-      element._loading = false;
-      element.diffPrefs = {} as DiffPreferencesInfo;
+      element.diffPrefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        ignore_whitespace: 'IGNORE_NONE',
+      };
       element.numFilesShown = 200;
       element.patchRange = {
-        basePatchNum: 'PARENT' as BasePatchSetNum,
+        basePatchNum: PARENT,
         patchNum: 2 as RevisionPatchSetNum,
       };
       saveStub = sinon
         .stub(element, '_saveReviewedState')
         .callsFake(() => Promise.resolve());
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to complete.
+      await waitEventLoop();
     });
 
     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>
+      assert.shadowDom.equal(
+        element,
+        /* 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">
+              <div class="status" role="gridcell"></div>
+              <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>
+              <div aria-hidden="true" class="hideOnEdit reviewed"></div>
+              <div aria-hidden="true" class="editFileControls showOnEdit"></div>
+              <div aria-hidden="true" class="show-hide"></div>
             </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="">
+          <div class="controlRow invisible row">
             <gr-button
               aria-disabled="false"
               class="fileListButton"
-              id="showAllButton"
+              id="incrementButton"
               link=""
               role="button"
               tabindex="0"
             >
-              Show all 0 files
+              Show -200 more
             </gr-button>
-          </gr-tooltip-content>
-        </div>
-        <gr-diff-preferences-dialog
-          id="diffPreferencesDialog"
-        ></gr-diff-preferences-dialog>`);
+            <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();
+    test('renders file row', async () => {
+      element.files = createFiles(1, {lines_inserted: 9});
+      await element.updateComplete;
       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>
+      assert.dom.equal(
+        fileRows?.[0],
+        /* HTML */ `<div
+          class="file-row row"
+          data-file='{"path":"&apos;/file0"}'
+          role="row"
+          tabindex="-1"
+        >
+          <div class="status" role="gridcell">
+            <gr-file-status></gr-file-status>
           </div>
-          <div class="comments mobile">
-            <span class="drafts"> </span> <span> </span>
-            <span class="noCommentsScreenReaderText"> No comments </span>
+          <span class="path" role="gridcell">
+            <a class="pathLink">
+              <span class="fullFileName" title="'/file0">
+                <span class="newFilePath"> '/ </span>
+                <span class="fileName"> file0 </span>
+              </span>
+              <span class="truncatedFileName" title="'/file0"> …/file0 </span>
+              <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
+            </a>
+          </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>
-        <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 class="desktop" role="gridcell">
+            <div
+              aria-label="A bar that represents the addition and deletion ratio for the current file"
+              class="hide sizeBars"
+            ></div>
           </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"
-          >
+          <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=""> +/-0 B </span>
+            </div>
+          </div>
+          <div class="hideOnEdit reviewed" role="gridcell">
+            <span aria-hidden="true" class="reviewedLabel"> Reviewed </span>
             <span
-              class="markReviewed"
-              tabindex="-1"
-              title="Mark as reviewed (shortcut: r)"
+              aria-checked="false"
+              aria-label="Reviewed"
+              class="reviewedSwitch"
+              role="switch"
+              tabindex="0"
             >
-              MARK REVIEWED
+              <span
+                class="markReviewed"
+                tabindex="-1"
+                title="Mark as reviewed (shortcut: r)"
+              >
+                MARK REVIEWED
+              </span>
             </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>`);
+          </div>
+          <div
+            aria-hidden="true"
+            class="editFileControls showOnEdit"
+            role="gridcell"
+          ></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"
+            >
+              <gr-icon
+                icon="expand_more"
+                class="show-hide-icon"
+                id="icon"
+                tabindex="-1"
+              ></gr-icon>
+            </span>
+          </div>
+        </div>`
+      );
     });
 
-    test('correct number of files are shown', () => {
-      element.fileListIncrement = 300;
-      element._filesByPath = createFilesByPath(500);
+    test('renders file paths', async () => {
+      element.files = createFiles(2, {lines_inserted: 9});
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
 
-      flush();
+      assert.dom.equal(
+        fileRows[0].querySelector('.path'),
+        /* HTML */ `
+          <span class="path" role="gridcell">
+            <a class="pathLink">
+              <span class="fullFileName" title="'/file0">
+                <span class="newFilePath"> '/ </span>
+                <span class="fileName"> file0 </span>
+              </span>
+              <span class="truncatedFileName" title="'/file0"> …/file0 </span>
+              <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
+            </a>
+          </span>
+        `
+      );
+      // The second row will have a matchingFilePath instead of newFilePath.
+      assert.dom.equal(
+        fileRows[1].querySelector('.path'),
+        /* HTML */ `
+          <span class="path" role="gridcell">
+            <a class="pathLink">
+              <span class="fullFileName" title="'/file1">
+                <span class="matchingFilePath"> '/ </span>
+                <span class="fileName"> file1 </span>
+              </span>
+              <span class="truncatedFileName" title="'/file1"> …/file1 </span>
+              <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
+            </a>
+          </span>
+        `
+      );
+    });
+
+    test('renders file status column', async () => {
+      element.files = createFiles(1, {lines_inserted: 9});
+      element.filesLeftBase = createFiles(1, {lines_inserted: 9});
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+      const statusCol = queryAndAssert(fileRows?.[0], '.status');
+      assert.dom.equal(
+        statusCol,
+        /* HTML */ `
+          <div class="extended status" role="gridcell">
+            <gr-file-status></gr-file-status>
+            <gr-icon class="file-status-arrow" icon="arrow_right_alt"></gr-icon>
+            <gr-file-status></gr-file-status>
+          </div>
+        `
+      );
+    });
+
+    test('renders file status column header', async () => {
+      element.files = createFiles(1, {lines_inserted: 9});
+      element.filesLeftBase = createFiles(1, {lines_inserted: 9});
+      element.patchRange!.basePatchNum = 1 as PatchSetNumber;
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.header-row');
+      const statusCol = queryAndAssert(fileRows?.[0], '.status');
+      assert.dom.equal(
+        statusCol,
+        /* HTML */ `
+          <div class="extended status" role="gridcell">
+            <gr-tooltip-content has-tooltip="" title="Patchset 1">
+              <div class="content">1</div>
+            </gr-tooltip-content>
+            <gr-icon class="file-status-arrow" icon="arrow_right_alt"></gr-icon>
+            <gr-tooltip-content has-tooltip="" title="Patchset 2">
+              <div class="content">2</div>
+            </gr-tooltip-content>
+          </div>
+        `
+      );
+    });
+
+    test('correct number of files are shown', async () => {
+      element.fileListIncrement = 100;
+      element.files = createFiles(250);
+      await element.updateComplete;
+      await waitEventLoop();
+
       assert.equal(
         queryAll<HTMLDivElement>(element, '.file-row').length,
         element.numFilesShown
@@ -334,199 +358,257 @@
           element,
           '#incrementButton'
         ).textContent!.trim(),
-        'Show 300 more'
+        'Show 50 more'
       );
       assert.equal(
         queryAndAssert<GrButton>(element, '#showAllButton').textContent!.trim(),
-        'Show all 500 files'
+        'Show all 250 files'
       );
 
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#showAllButton'));
-      flush();
+      queryAndAssert<GrButton>(element, '#showAllButton').click();
+      await element.updateComplete;
+      await waitEventLoop();
 
-      assert.equal(element.numFilesShown, 500);
-      assert.equal(element._shownFiles.length, 500);
+      assert.equal(element.numFilesShown, 250);
+      assert.equal(element.shownFiles.length, 250);
       assert.isTrue(controlRow.classList.contains('invisible'));
     });
 
-    test('rendering each row calls the _reportRenderedRow method', () => {
-      const renderedStub = sinon.stub(element, '_reportRenderedRow');
-      element._filesByPath = createFilesByPath(10);
+    test('rendering each row calls the reportRenderedRow method', async () => {
+      const renderedStub = sinon.stub(element, 'reportRenderedRow');
+      element.files = createFiles(10);
+      await element.updateComplete;
+
       assert.equal(queryAll<HTMLDivElement>(element, '.file-row').length, 10);
       assert.equal(renderedStub.callCount, 10);
     });
 
-    test('calculate totals for patch number', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {
+    test('calculate totals for patch number', async () => {
+      element.files = [
+        {
+          __path: '/COMMIT_MSG',
           lines_inserted: 9,
           size: 0,
           size_delta: 0,
         },
-        '/MERGE_LIST': {
+        {
+          __path: '/MERGE_LIST',
           lines_inserted: 9,
           size: 0,
           size_delta: 0,
         },
-        'file_added_in_rev2.txt': {
+        {
+          __path: 'file_added_in_rev2.txt',
           lines_inserted: 1,
           lines_deleted: 1,
           size_delta: 10,
           size: 100,
         },
-        'myfile.txt': {
+        {
+          __path: 'myfile.txt',
           lines_inserted: 1,
           lines_deleted: 1,
           size_delta: 10,
           size: 100,
         },
-      };
+      ];
+      await element.updateComplete;
 
-      assert.deepEqual(element._patchChange, {
+      let patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
         inserted: 2,
         deleted: 2,
         size_delta_inserted: 0,
         size_delta_deleted: 0,
         total_size: 0,
       });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
+      assert.isTrue(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
 
       // Test with a commit message that isn't the first file.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {
+      element.files = [
+        {
+          __path: 'file_added_in_rev2.txt',
           lines_inserted: 1,
           lines_deleted: 1,
           size: 0,
           size_delta: 0,
         },
-        '/COMMIT_MSG': {
+        {
+          __path: '/COMMIT_MSG',
           lines_inserted: 9,
           size: 0,
           size_delta: 0,
         },
-        '/MERGE_LIST': {
+        {
+          __path: '/MERGE_LIST',
           lines_inserted: 9,
           size: 0,
           size_delta: 0,
         },
-        'myfile.txt': {
+        {
+          __path: 'myfile.txt',
           lines_inserted: 1,
           lines_deleted: 1,
           size: 0,
           size_delta: 0,
         },
-      };
+      ];
+      await element.updateComplete;
 
-      assert.deepEqual(element._patchChange, {
+      patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
         inserted: 2,
         deleted: 2,
         size_delta_inserted: 0,
         size_delta_deleted: 0,
         total_size: 0,
       });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
+      assert.isTrue(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
 
       // Test with no commit message.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {
+      element.files = [
+        {
+          __path: 'file_added_in_rev2.txt',
           lines_inserted: 1,
           lines_deleted: 1,
           size: 0,
           size_delta: 0,
         },
-        'myfile.txt': {
+        {
+          __path: 'myfile.txt',
           lines_inserted: 1,
           lines_deleted: 1,
           size: 0,
           size_delta: 0,
         },
-      };
+      ];
+      await element.updateComplete;
 
-      assert.deepEqual(element._patchChange, {
+      patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
         inserted: 2,
         deleted: 2,
         size_delta_inserted: 0,
         size_delta_deleted: 0,
         total_size: 0,
       });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
+      assert.isTrue(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
 
       // Test with files missing either lines_inserted or lines_deleted.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {
+      element.files = [
+        {
+          __path: 'file_added_in_rev2.txt',
           lines_inserted: 1,
           size: 0,
           size_delta: 0,
         },
-        'myfile.txt': {
+        {
+          __path: 'myfile.txt',
           lines_deleted: 1,
           size: 0,
           size_delta: 0,
         },
-      };
-      assert.deepEqual(element._patchChange, {
+      ];
+      await element.updateComplete;
+
+      patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
         inserted: 1,
         deleted: 1,
         size_delta_inserted: 0,
         size_delta_deleted: 0,
         total_size: 0,
       });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
+      assert.isTrue(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
     });
 
-    test('binary only files', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {
+    test('binary only files', async () => {
+      element.files = [
+        {
+          __path: '/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, {
+        {
+          __path: 'file_binary_1',
+          binary: true,
+          size_delta: 10,
+          size: 100,
+        },
+        {
+          __path: 'file_binary_2',
+          binary: true,
+          size_delta: -5,
+          size: 120,
+        },
+      ];
+      await element.updateComplete;
+
+      const patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
         inserted: 0,
         deleted: 0,
         size_delta_inserted: 10,
         size_delta_deleted: -5,
         total_size: 220,
       });
-      assert.isFalse(element._hideBinaryChangeTotals);
-      assert.isTrue(element._hideChangeTotals);
+      assert.isFalse(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isTrue(element.shouldHideChangeTotals(patchChange));
     });
 
-    test('binary and regular files', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {
+    test('binary and regular files', async () => {
+      element.files = [
+        {
+          __path: '/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': {
+        {
+          __path: 'file_binary_1',
+          binary: true,
+          size_delta: 10,
+          size: 100,
+        },
+        {
+          __path: 'file_binary_2',
+          binary: true,
+          size_delta: -5,
+          size: 120,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_deleted: 5,
+          size_delta: -10,
+          size: 100,
+        },
+        {
+          __path: 'myfile2.txt',
           lines_inserted: 10,
           size: 0,
           size_delta: 0,
         },
-      };
-      assert.deepEqual(element._patchChange, {
+      ];
+      await element.updateComplete;
+
+      const patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
         inserted: 10,
         deleted: 5,
         size_delta_inserted: 10,
         size_delta_deleted: -5,
         total_size: 220,
       });
-      assert.isFalse(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
+      assert.isFalse(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
     });
 
-    test('_formatBytes function', () => {
+    test('formatBytes function', () => {
       const table = {
         '64': '+64 B',
         '1023': '+1023 B',
@@ -541,11 +623,11 @@
         '0': '+/-0 B',
       };
       for (const [bytes, expected] of Object.entries(table)) {
-        assert.equal(element._formatBytes(Number(bytes)), expected);
+        assert.equal(element.formatBytes(Number(bytes)), expected);
       }
     });
 
-    test('_formatPercentage function', () => {
+    test('formatPercentage function', () => {
       const table = [
         {size: 100, delta: 100, display: ''},
         {size: 195060, delta: 64, display: '(+0%)'},
@@ -557,7 +639,7 @@
 
       for (const item of table) {
         assert.equal(
-          element._formatPercentage(item.size, item.delta),
+          element.formatPercentage(item.size, item.delta),
           item.display
         );
       }
@@ -566,12 +648,12 @@
     test('comment filtering', () => {
       element.changeComments = createChangeComments();
       const parentTo1 = {
-        basePatchNum: 'PARENT' as BasePatchSetNum,
+        basePatchNum: PARENT,
         patchNum: 1 as RevisionPatchSetNum,
       };
 
       const parentTo2 = {
-        basePatchNum: 'PARENT' as BasePatchSetNum,
+        basePatchNum: PARENT,
         patchNum: 2 as RevisionPatchSetNum,
       };
 
@@ -580,224 +662,253 @@
         patchNum: 2 as RevisionPatchSetNum,
       };
 
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeCommentsStringMobile(
-          element.changeComments,
-          parentTo1,
-          {__path: '/COMMIT_MSG', size: 0, size_delta: 0}
-        ),
+        element.computeCommentsStringMobile({
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
         '2c'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+        element.computeCommentsStringMobile({
           __path: '/COMMIT_MSG',
           size: 0,
           size_delta: 0,
         }),
         '3c'
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsString(element.changeComments, parentTo1, {
+        element.computeDraftsString({
           __path: 'unresolved.file',
           size: 0,
           size_delta: 0,
         }),
         '1 draft'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsString(element.changeComments, _1To2, {
+        element.computeDraftsString({
           __path: 'unresolved.file',
           size: 0,
           size_delta: 0,
         }),
         '1 draft'
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, parentTo1, {
+        element.computeDraftsStringMobile({
           __path: 'unresolved.file',
           size: 0,
           size_delta: 0,
         }),
         '1d'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+        element.computeDraftsStringMobile({
           __path: 'unresolved.file',
           size: 0,
           size_delta: 0,
         }),
         '1d'
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeCommentsStringMobile(
-          element.changeComments,
-          parentTo1,
-          {__path: 'myfile.txt', size: 0, size_delta: 0}
-        ),
+        element.computeCommentsStringMobile({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
         '1c'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+        element.computeCommentsStringMobile({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         '3c'
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsString(element.changeComments, parentTo1, {
+        element.computeDraftsString({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsString(element.changeComments, _1To2, {
+        element.computeDraftsString({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, parentTo1, {
+        element.computeDraftsStringMobile({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+        element.computeDraftsStringMobile({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeCommentsStringMobile(
-          element.changeComments,
-          parentTo1,
-          {__path: 'file_added_in_rev2.txt', size: 0, size_delta: 0}
-        ),
-        ''
-      );
-      assert.equal(
-        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+        element.computeCommentsStringMobile({
           __path: 'file_added_in_rev2.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsString(element.changeComments, parentTo1, {
+        element.computeCommentsStringMobile({
           __path: 'file_added_in_rev2.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsString(element.changeComments, _1To2, {
+        element.computeDraftsString({
           __path: 'file_added_in_rev2.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, parentTo1, {
+        element.computeDraftsString({
           __path: 'file_added_in_rev2.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+        element.computeDraftsStringMobile({
           __path: 'file_added_in_rev2.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeCommentsStringMobile(
-          element.changeComments,
-          parentTo2,
-          {__path: '/COMMIT_MSG', size: 0, size_delta: 0}
-        ),
+        element.computeDraftsStringMobile({
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      element.patchRange = parentTo2;
+      assert.equal(
+        element.computeCommentsStringMobile({
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
         '1c'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+        element.computeCommentsStringMobile({
           __path: '/COMMIT_MSG',
           size: 0,
           size_delta: 0,
         }),
         '3c'
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsString(element.changeComments, parentTo1, {
+        element.computeDraftsString({
           __path: '/COMMIT_MSG',
           size: 0,
           size_delta: 0,
         }),
         '2 drafts'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsString(element.changeComments, _1To2, {
+        element.computeDraftsString({
           __path: '/COMMIT_MSG',
           size: 0,
           size_delta: 0,
         }),
         '2 drafts'
       );
+      element.patchRange = parentTo1;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, parentTo1, {
+        element.computeDraftsStringMobile({
           __path: '/COMMIT_MSG',
           size: 0,
           size_delta: 0,
         }),
         '2d'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+        element.computeDraftsStringMobile({
           __path: '/COMMIT_MSG',
           size: 0,
           size_delta: 0,
         }),
         '2d'
       );
+      element.patchRange = parentTo2;
       assert.equal(
-        element._computeCommentsStringMobile(
-          element.changeComments,
-          parentTo2,
-          {__path: 'myfile.txt', size: 0, size_delta: 0}
-        ),
+        element.computeCommentsStringMobile({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
         '2c'
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeCommentsStringMobile(element.changeComments, _1To2, {
+        element.computeCommentsStringMobile({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         '3c'
       );
+      element.patchRange = parentTo2;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, parentTo2, {
+        element.computeDraftsStringMobile({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
         }),
         ''
       );
+      element.patchRange = _1To2;
       assert.equal(
-        element._computeDraftsStringMobile(element.changeComments, _1To2, {
+        element.computeDraftsStringMobile({
           __path: 'myfile.txt',
           size: 0,
           size_delta: 0,
@@ -806,32 +917,25 @@
       );
     });
 
-    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},
-        };
+      setup(async () => {
+        element.files = [
+          normalize({}, '/COMMIT_MSG'),
+          normalize({}, 'file_added_in_rev2.txt'),
+          normalize({}, 'myfile.txt'),
+        ];
         element.changeNum = 42 as NumericChangeId;
         element.patchRange = {
-          basePatchNum: 'PARENT' as BasePatchSetNum,
+          basePatchNum: PARENT,
           patchNum: 2 as RevisionPatchSetNum,
         };
-        element.change = {_number: 42 as NumericChangeId} as ParsedChangeInfo;
+        element.change = {
+          _number: 42 as NumericChangeId,
+          project: 'test-project',
+        } as ParsedChangeInfo;
         element.fileCursor.setCursorAtIndex(0);
+        await element.updateComplete;
+        await waitEventLoop();
       });
 
       test('toggle left diff via shortcut', () => {
@@ -841,14 +945,12 @@
         const diffsStub = sinon
           .stub(element, 'diffs')
           .get(() => [{toggleLeftDiff: toggleLeftDiffStub}]);
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'A');
+        pressKey(element, 'A');
         assert.isTrue(toggleLeftDiffStub.calledOnce);
         diffsStub.restore();
       });
 
-      test('keyboard shortcuts', () => {
-        flush();
-
+      test('keyboard shortcuts', async () => {
         const items = [...queryAll<HTMLDivElement>(element, '.file-row')];
         element.fileCursor.stops = items;
         element.fileCursor.setCursorAtIndex(0);
@@ -857,46 +959,43 @@
         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');
+        pressKey(element, 'J');
         assert.equal(element.fileCursor.index, 0);
         // down should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
+        pressKey(element, 'ArrowDown');
         assert.equal(element.fileCursor.index, 0);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+        pressKey(element, 'j');
         assert.equal(element.fileCursor.index, 1);
         assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+        pressKey(element, 'j');
 
-        const navStub = sinon.stub(GerritNav, 'navigateToDiff');
+        const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
         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');
+        pressKey(element, 'K');
         assert.equal(element.fileCursor.index, 2);
 
         // up should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'up');
+        pressKey(element, 'ArrowUp');
         assert.equal(element.fileCursor.index, 2);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        pressKey(element, 'k');
         assert.equal(element.fileCursor.index, 1);
         assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
+        pressKey(element, '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'
+        assert.equal(setUrlStub.callCount, 1);
+        assert.equal(
+          setUrlStub.lastCall.firstArg,
+          '/c/test-project/+/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');
+        pressKey(element, 'k');
+        pressKey(element, 'k');
+        pressKey(element, 'k');
         assert.equal(element.fileCursor.index, 1);
         assert.equal(element.selectedIndex, 1);
         assertIsDefined(element.diffCursor);
@@ -905,81 +1004,85 @@
           element.diffCursor,
           'createCommentInPlace'
         );
-        MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
+        pressKey(element, 'c');
         assert.isTrue(createCommentInPlaceStub.called);
       });
 
-      test('i key shows/hides selected inline diff', () => {
-        const paths = Object.keys(element._filesByPath!);
-        sinon.stub(element, '_expandedFilesChanged');
-        flush();
+      test('i key shows/hides selected inline diff', async () => {
+        const paths = element.files.map(f => f.__path);
+        sinon.stub(element, 'expandedFilesChanged');
         const files = [...queryAll<HTMLDivElement>(element, '.file-row')];
         element.fileCursor.stops = files;
         element.fileCursor.setCursorAtIndex(0);
+        await element.updateComplete;
         assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
+        assert.equal(element.expandedFiles.length, 0);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-        flush();
+        pressKey(element, 'i');
+        await element.updateComplete;
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[0]);
-        assert.equal(element._expandedFiles.length, 1);
-        assert.equal(element._expandedFiles[0].path, paths[0]);
+        assert.equal(element.expandedFiles.length, 1);
+        assert.equal(element.expandedFiles[0].path, paths[0]);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-        flush();
+        pressKey(element, 'i');
+        await element.updateComplete;
         assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
+        assert.equal(element.expandedFiles.length, 0);
 
         element.fileCursor.setCursorAtIndex(1);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-        flush();
+        pressKey(element, 'i');
+        await element.updateComplete;
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[1]);
-        assert.equal(element._expandedFiles.length, 1);
-        assert.equal(element._expandedFiles[0].path, paths[1]);
+        assert.equal(element.expandedFiles.length, 1);
+        assert.equal(element.expandedFiles[0].path, paths[1]);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
-        flush();
+        pressKey(element, 'I');
+        await element.updateComplete;
         assert.equal(element.diffs.length, paths.length);
-        assert.equal(element._expandedFiles.length, paths.length);
+        assert.equal(element.expandedFiles.length, paths.length);
         for (const diff of element.diffs) {
-          assert.isTrue(element._expandedFiles.some(f => f.path === diff.path));
+          assert.isTrue(element.expandedFiles.some(f => f.path === diff.path));
         }
         // since _expandedFilesChanged is stubbed
         element.filesExpanded = FilesExpandedState.ALL;
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
-        flush();
+        pressKey(element, 'I');
+        await element.updateComplete;
         assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
+        assert.equal(element.expandedFiles.length, 0);
       });
 
-      test('r key toggles reviewed flag', () => {
-        const reducer = (accum: number, file: NormalizedFileInfo) =>
-          file.isReviewed ? ++accum : accum;
-        const getNumReviewed = () => element._files.reduce(reducer, 0);
-        flush();
+      test('r key sets reviewed flag', async () => {
+        await element.updateComplete;
 
-        // Default state should be unreviewed.
-        assert.equal(getNumReviewed(), 0);
+        pressKey(element, 'r');
+        await element.updateComplete;
 
-        // Press the review key to toggle it (set the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        flush();
-        assert.equal(getNumReviewed(), 1);
+        assert.isTrue(saveStub.called);
+        assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
+      });
 
-        // Press the review key to toggle it (clear the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.equal(getNumReviewed(), 0);
+      test('r key clears reviewed flag', async () => {
+        element.reviewed = ['/COMMIT_MSG'];
+        await element.updateComplete;
+
+        pressKey(element, 'r');
+        await element.updateComplete;
+
+        assert.isTrue(saveStub.called);
+        assert.isTrue(
+          saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false)
+        );
       });
 
       suite('handleOpenFile', () => {
         let interact: Function;
 
         setup(() => {
-          const openCursorStub = sinon.stub(element, '_openCursorFile');
-          const openSelectedStub = sinon.stub(element, '_openSelectedFile');
-          const expandStub = sinon.stub(element, '_toggleFileExpanded');
+          const openCursorStub = sinon.stub(element, 'openCursorFile');
+          const openSelectedStub = sinon.stub(element, 'openSelectedFile');
+          const expandStub = sinon.stub(element, 'toggleFileExpanded');
 
           interact = function () {
             openCursorStub.reset();
@@ -1022,63 +1125,41 @@
         const moveRightStub = sinon.stub(element.diffCursor, 'moveRight');
 
         let noDiffsExpanded = true;
-        sinon
-          .stub(element, '_noDiffsExpanded')
-          .callsFake(() => noDiffsExpanded);
+        sinon.stub(element, 'noDiffsExpanded').callsFake(() => noDiffsExpanded);
 
-        MockInteractions.pressAndReleaseKeyOn(
-          element,
-          73,
-          'shift',
-          'ArrowLeft'
-        );
+        pressKey(element, 'ArrowLeft', Modifier.SHIFT_KEY);
         assert.isFalse(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(
-          element,
-          73,
-          'shift',
-          'ArrowRight'
-        );
+        pressKey(element, 'ArrowRight', Modifier.SHIFT_KEY);
         assert.isFalse(moveRightStub.called);
 
         noDiffsExpanded = false;
 
-        MockInteractions.pressAndReleaseKeyOn(
-          element,
-          73,
-          'shift',
-          'ArrowLeft'
-        );
+        pressKey(element, 'ArrowLeft', Modifier.SHIFT_KEY);
         assert.isTrue(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(
-          element,
-          73,
-          'shift',
-          'ArrowRight'
-        );
+        pressKey(element, 'ArrowRight', Modifier.SHIFT_KEY);
         assert.isTrue(moveRightStub.called);
       });
     });
 
-    test('file review status', () => {
+    test('file review status', async () => {
       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.files = [
+        normalize({}, '/COMMIT_MSG'),
+        normalize({}, 'file_added_in_rev2.txt'),
+        normalize({}, 'myfile.txt'),
+      ];
       element.changeNum = 42 as NumericChangeId;
       element.patchRange = {
-        basePatchNum: 'PARENT' as BasePatchSetNum,
+        basePatchNum: PARENT,
         patchNum: 2 as RevisionPatchSetNum,
       };
       element.fileCursor.setCursorAtIndex(0);
-      const reviewSpy = sinon.spy(element, '_reviewFile');
-      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
 
-      flush();
-      queryAll(element, '.row:not(.header-row)');
+      const reviewSpy = sinon.spy(element, 'reviewFile');
+      const toggleExpandSpy = sinon.spy(element, 'toggleFileExpanded');
+
+      await element.updateComplete;
+
       const fileRows = queryAll(element, '.row:not(.header-row)');
       const checkSelector = 'span.reviewedSwitch[role="switch"]';
       const commitMsg = fileRows[0].querySelector(checkSelector);
@@ -1090,165 +1171,187 @@
       assert.equal(myFile!.getAttribute('aria-checked'), 'true');
 
       const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
-      const markReviewLabel = fileRows[0].querySelector('.markReviewed');
+      assert.isOk(commitReviewLabel);
+      const markReviewLabel =
+        fileRows[0].querySelector<HTMLSpanElement>('.markReviewed');
+      assert.isOk(markReviewLabel);
       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');
+      const clickSpy = sinon.spy(element, 'reviewedClick');
+      markReviewLabel!.click();
+      await element.updateComplete;
+
+      assert.isTrue(clickSpy.calledOnce);
       assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
       assert.isTrue(reviewSpy.calledOnce);
+      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
 
-      MockInteractions.tap(markReviewLabel!);
+      element.reviewed = ['myfile.txt'];
+      await element.updateComplete;
+
+      assert.isFalse(commitReviewLabel!.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel!.textContent, 'MARK REVIEWED');
+
+      markReviewLabel!.click();
+      await element.updateComplete;
+
       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);
+
+      element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      await element.updateComplete;
+
+      assert.isTrue(commitReviewLabel!.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel!.textContent, 'MARK UNREVIEWED');
     });
 
-    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},
-      };
+    test('handleFileListClick', async () => {
+      element.files = [
+        normalize({}, '/COMMIT_MSG'),
+        normalize({}, 'f1.txt'),
+        normalize({}, 'f2.txt'),
+      ];
       element.changeNum = 42 as NumericChangeId;
       element.patchRange = {
-        basePatchNum: 'PARENT' as BasePatchSetNum,
+        basePatchNum: PARENT,
         patchNum: 2 as RevisionPatchSetNum,
       };
+      await element.updateComplete;
 
-      const clickSpy = sinon.spy(element, '_handleFileListClick');
-      const reviewStub = sinon.stub(element, '_reviewFile');
-      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
+      const clickSpy = sinon.spy(element, 'handleFileListClick');
+      const reviewStub = sinon.stub(element, 'reviewFile');
+      const toggleExpandSpy = sinon.spy(element, 'toggleFileExpanded');
 
       const row = queryAndAssert(
         element,
         '.row[data-file=\'{"path":"f1.txt"}\']'
       );
 
-      // Click on the expand button, resulting in _toggleFileExpanded being
-      // called and not resulting in a call to _reviewFile.
+      // Click on the expand button, resulting in toggleFileExpanded being
+      // called and resulting in a call to reviewFile().
       queryAndAssert<HTMLDivElement>(row, 'div.show-hide').click();
+      await element.updateComplete;
+
       assert.isTrue(clickSpy.calledOnce);
       assert.isTrue(toggleExpandSpy.calledOnce);
-      assert.isFalse(reviewStub.called);
+      await waitUntil(() => reviewStub.calledOnce);
 
       // Click inside the diff. This should result in no additional calls to
-      // _toggleFileExpanded or _reviewFile.
+      // toggleFileExpanded or reviewFile.
       queryAndAssert<GrDiffHost>(element, 'gr-diff-host').click();
+      await element.updateComplete;
       assert.isTrue(clickSpy.calledTwice);
       assert.isTrue(toggleExpandSpy.calledOnce);
-      assert.isFalse(reviewStub.called);
+      assert.isTrue(reviewStub.calledOnce);
     });
 
-    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},
-      };
+    test('handleFileListClick editMode', async () => {
+      element.files = [
+        normalize({}, '/COMMIT_MSG'),
+        normalize({}, 'f1.txt'),
+        normalize({}, 'f2.txt'),
+      ];
       element.changeNum = 42 as NumericChangeId;
       element.patchRange = {
-        basePatchNum: 'PARENT' as BasePatchSetNum,
+        basePatchNum: PARENT,
         patchNum: 2 as RevisionPatchSetNum,
       };
       element.editMode = true;
-      flush();
-      const clickSpy = sinon.spy(element, '_handleFileListClick');
-      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
+      await element.updateComplete;
 
-      // Tap the edit controls. Should be ignored by _handleFileListClick.
-      MockInteractions.tap(queryAndAssert(element, '.editFileControls'));
+      const clickSpy = sinon.spy(element, 'handleFileListClick');
+      const toggleExpandSpy = sinon.spy(element, 'toggleFileExpanded');
+
+      // Tap the edit controls. Should be ignored by handleFileListClick.
+      queryAndAssert<HTMLDivElement>(element, '.editFileControls').click();
+      await element.updateComplete;
+
       assert.isTrue(clickSpy.calledOnce);
       assert.isFalse(toggleExpandSpy.called);
     });
 
-    test('checkbox shows/hides diff inline', () => {
-      element._filesByPath = {
-        'myfile.txt': {size: 0, size_delta: 0},
-      };
+    test('checkbox shows/hides diff inline', async () => {
+      element.files = [normalize({}, 'myfile.txt')];
       element.changeNum = 42 as NumericChangeId;
       element.patchRange = {
-        basePatchNum: 'PARENT' as BasePatchSetNum,
+        basePatchNum: PARENT,
         patchNum: 2 as RevisionPatchSetNum,
       };
       element.fileCursor.setCursorAtIndex(0);
-      sinon.stub(element, '_expandedFilesChanged');
-      flush();
+      sinon.stub(element, 'expandedFilesChanged');
+      await element.updateComplete;
       const fileRows = queryAll(element, '.row:not(.header-row)');
       // Because the label surrounds the input, the tap event is triggered
       // there first.
       const showHideCheck = fileRows[0].querySelector(
         'span.show-hide[role="switch"]'
       );
-      const showHideLabel = showHideCheck!.querySelector('.show-hide-icon');
+      const showHideLabel =
+        showHideCheck!.querySelector<GrIcon>('.show-hide-icon');
       assert.equal(showHideCheck!.getAttribute('aria-checked'), 'false');
-      MockInteractions.tap(showHideLabel!);
+      showHideLabel!.click();
+      await element.updateComplete;
+
       assert.equal(showHideCheck!.getAttribute('aria-checked'), 'true');
       assert.notEqual(
-        element._expandedFiles.findIndex(f => f.path === 'myfile.txt'),
+        element.expandedFiles.findIndex(f => f.path === 'myfile.txt'),
         -1
       );
     });
 
-    test('diff mode correctly toggles the diffs', () => {
-      element._filesByPath = {
-        'myfile.txt': {size: 0, size_delta: 0},
-      };
+    test('diff mode correctly toggles the diffs', async () => {
+      element.files = [normalize({}, 'myfile.txt')];
       element.changeNum = 42 as NumericChangeId;
       element.patchRange = {
-        basePatchNum: 'PARENT' as BasePatchSetNum,
+        basePatchNum: PARENT,
         patchNum: 2 as RevisionPatchSetNum,
       };
-      const updateDiffPrefSpy = sinon.spy(element, '_updateDiffPreferences');
+      const updateDiffPrefSpy = sinon.spy(element, 'updateDiffPreferences');
       element.fileCursor.setCursorAtIndex(0);
-      flush();
+      await element.updateComplete;
 
       // Tap on a file to generate the diff.
-      const row = queryAll(element, '.row:not(.header-row) span.show-hide')[0];
+      const row = queryAll<HTMLSpanElement>(
+        element,
+        '.row:not(.header-row) span.show-hide'
+      )[0];
 
-      MockInteractions.tap(row);
-      flush();
-      element.set('diffViewMode', 'UNIFIED_DIFF');
+      row.click();
+
+      element.diffViewMode = DiffViewMode.UNIFIED;
+      await element.updateComplete;
+
       assert.isTrue(updateDiffPrefSpy.called);
     });
 
     test('expanded attribute not set on path when not expanded', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {size: 0, size_delta: 0},
-      };
+      element.files = [normalize({}, '/COMMIT_MSG')];
       assert.isNotOk(query(element, 'expanded'));
     });
 
-    test('tapping row ignores links', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {size: 0, size_delta: 0},
-      };
+    test('tapping row ignores links', async () => {
+      element.files = [normalize({}, '/COMMIT_MSG')];
       element.changeNum = 42 as NumericChangeId;
       element.patchRange = {
-        basePatchNum: 'PARENT' as BasePatchSetNum,
+        basePatchNum: PARENT,
         patchNum: 2 as RevisionPatchSetNum,
       };
-      sinon.stub(element, '_expandedFilesChanged');
-      flush();
-      const commitMsgFile = queryAll(
+      sinon.stub(element, 'expandedFilesChanged');
+      await element.updateComplete;
+      const commitMsgFile = queryAll<HTMLAnchorElement>(
         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');
+      const togglePathSpy = sinon.spy(element, 'toggleFileExpanded');
 
-      MockInteractions.tap(commitMsgFile);
-      flush();
+      commitMsgFile.click();
+      await element.updateComplete;
       assert(togglePathSpy.notCalled, 'file is opened as diff view');
       assert.isNotOk(query(element, '.expanded'));
       assert.notEqual(
@@ -1257,66 +1360,77 @@
       );
     });
 
-    test('_toggleFileExpanded', () => {
+    test('toggleFileExpanded', async () => {
       const path = 'path/to/my/file.txt';
-      element._filesByPath = {[path]: {size: 0, size_delta: 0}};
-      const renderSpy = sinon.spy(element, '_renderInOrder');
-      const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
+      element.files = [normalize({}, path)];
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await waitEventLoop();
+
+      const renderSpy = sinon.spy(element, 'renderInOrder');
+      const collapseStub = sinon.stub(element, 'clearCollapsedDiffs');
 
       assert.equal(
-        queryAndAssert<IronIconElement>(element, 'iron-icon').icon,
-        'gr-icons:expand-more'
+        queryAndAssert<GrIcon>(element, 'gr-icon').icon,
+        'expand_more'
       );
-      assert.equal(element._expandedFiles.length, 0);
-      element._toggleFileExpanded({path});
-      flush();
+      assert.equal(element.expandedFiles.length, 0);
+      element.toggleFileExpanded({path});
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await waitEventLoop();
+
       assert.equal(collapseStub.lastCall.args[0].length, 0);
       assert.equal(
-        queryAndAssert<IronIconElement>(element, 'iron-icon').icon,
-        'gr-icons:expand-less'
+        queryAndAssert<GrIcon>(element, 'gr-icon').icon,
+        'expand_less'
       );
 
       assert.equal(renderSpy.callCount, 1);
-      assert.isTrue(element._expandedFiles.some(f => f.path === path));
-      element._toggleFileExpanded({path});
-      flush();
+      assert.isTrue(element.expandedFiles.some(f => f.path === path));
+      element.toggleFileExpanded({path});
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await waitEventLoop();
 
       assert.equal(
-        queryAndAssert<IronIconElement>(element, 'iron-icon').icon,
-        'gr-icons:expand-more'
+        queryAndAssert<GrIcon>(element, 'gr-icon').icon,
+        'expand_more'
       );
       assert.equal(renderSpy.callCount, 1);
-      assert.isFalse(element._expandedFiles.some(f => f.path === path));
+      assert.isFalse(element.expandedFiles.some(f => f.path === path));
       assert.equal(collapseStub.lastCall.args[0].length, 1);
     });
 
-    test('expandAllDiffs and collapseAllDiffs', () => {
-      const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
+    test('expandAllDiffs and collapseAllDiffs', async () => {
+      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.files = [normalize({}, path)];
+      // Wait for diffs to be computed.
+      await element.updateComplete;
+      await waitEventLoop();
       element.expandAllDiffs();
-      flush();
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await waitEventLoop();
       assert.equal(element.filesExpanded, FilesExpandedState.ALL);
-      assert.isTrue(reInitStub.calledOnce);
+      assert.isTrue(reInitStub.calledTwice);
       assert.equal(collapseStub.lastCall.args[0].length, 0);
 
       element.collapseAllDiffs();
-      flush();
-      assert.equal(element._expandedFiles.length, 0);
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await waitEventLoop();
+      assert.equal(element.expandedFiles.length, 0);
       assert.equal(element.filesExpanded, FilesExpandedState.NONE);
-      assert.isTrue(cursorUpdateStub.calledOnce);
       assert.equal(collapseStub.lastCall.args[0].length, 1);
     });
 
-    test('_expandedFilesChanged', async () => {
-      sinon.stub(element, '_reviewFile');
+    test('expandedFilesChanged', async () => {
+      sinon.stub(element, 'reviewFile');
       const path = 'path/to/my/file.txt';
       const promise = mockPromise();
       const diffs = [
@@ -1342,11 +1456,13 @@
         },
       ];
       sinon.stub(element, 'diffs').get(() => diffs);
-      element.push('_expandedFiles', {path});
+      element.expandedFiles = element.expandedFiles.concat([{path}]);
+      await element.updateComplete;
+      await waitEventLoop();
       await promise;
     });
 
-    test('_clearCollapsedDiffs', () => {
+    test('clearCollapsedDiffs', () => {
       // Have to type as any because the type is 'GrDiffHost'
       // which would require stubbing so many different
       // methods / properties that it isn't worth it.
@@ -1354,34 +1470,33 @@
         cancel: sinon.stub(),
         clearDiffContent: sinon.stub(),
       } as any;
-      element._clearCollapsedDiffs([diff]);
+      element.clearCollapsedDiffs([diff]);
       assert.isTrue(diff.cancel.calledOnce);
       assert.isTrue(diff.clearDiffContent.calledOnce);
     });
 
-    test('filesExpanded value updates to correct enum', () => {
-      element._filesByPath = {
-        'foo.bar': {size: 0, size_delta: 0},
-        'baz.bar': {size: 0, size_delta: 0},
-      };
-      flush();
+    test('filesExpanded value updates to correct enum', async () => {
+      element.files = [normalize({}, 'foo.bar'), normalize({}, 'baz.bar')];
+      await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.NONE);
-      element.push('_expandedFiles', {path: 'baz.bar'});
-      flush();
+      element.expandedFiles.push({path: 'baz.bar'});
+      element.expandedFilesChanged([{path: 'baz.bar'}]);
+      await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.SOME);
-      element.push('_expandedFiles', {path: 'foo.bar'});
-      flush();
+      element.expandedFiles.push({path: 'foo.bar'});
+      element.expandedFilesChanged([{path: 'foo.bar'}]);
+      await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.ALL);
       element.collapseAllDiffs();
-      flush();
+      await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.NONE);
       element.expandAllDiffs();
-      flush();
+      await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.ALL);
     });
 
-    test('_renderInOrder', async () => {
-      const reviewStub = sinon.stub(element, '_reviewFile');
+    test('renderInOrder', async () => {
+      const reviewStub = sinon.stub(element, 'reviewFile');
       let callCount = 0;
       // Have to type as any because the type is 'GrDiffHost'
       // which would require stubbing so many different
@@ -1415,18 +1530,13 @@
           },
         },
       ] as any;
-      element._renderInOrder(
-        [{path: 'p2'}, {path: 'p1'}, {path: 'p0'}],
-        diffs,
-        3
-      );
-      await flush();
+      element.renderInOrder([{path: 'p2'}, {path: 'p1'}, {path: 'p0'}], diffs);
+      await element.updateComplete;
       assert.isFalse(reviewStub.called);
     });
 
-    test('_renderInOrder logged in', async () => {
-      element._loggedIn = true;
-      const reviewStub = sinon.stub(element, '_reviewFile');
+    test('renderInOrder logged in', async () => {
+      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
@@ -1443,15 +1553,27 @@
           },
         },
       ] as any;
-      element._renderInOrder([{path: 'p2'}], diffs, 1);
-      await flush();
+      element.renderInOrder([{path: 'p2'}], diffs);
+      await element.updateComplete;
       assert.equal(reviewStub.callCount, 1);
     });
 
-    test('_renderInOrder respects diffPrefs.manual_review', async () => {
-      element._loggedIn = true;
-      element.diffPrefs = {manual_review: true} as DiffPreferencesInfo;
-      const reviewStub = sinon.stub(element, '_reviewFile');
+    test('renderInOrder respects diffPrefs.manual_review', async () => {
+      element.diffPrefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        ignore_whitespace: 'IGNORE_NONE',
+        manual_review: true,
+      };
+      const reviewStub = sinon.stub(element, 'reviewFile');
       // Have to type as any because the type is 'GrDiffHost'
       // which would require stubbing so many different
       // methods / properties that it isn't worth it.
@@ -1466,74 +1588,27 @@
         },
       ] as any;
 
-      element._renderInOrder([{path: 'p'}], diffs, 1);
-      await flush();
+      element.renderInOrder([{path: 'p'}], diffs);
+      await element.updateComplete;
       assert.isFalse(reviewStub.called);
       delete element.diffPrefs.manual_review;
-      element._renderInOrder([{path: 'p'}], diffs, 1);
-      await flush();
+      element.renderInOrder([{path: 'p'}], diffs);
+      await element.updateComplete;
+      // Wait for renderInOrder to finish
+      await waitEventLoop();
       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 () => {
+        element.files = [
+          normalize({size: 0, size_delta: 0}, 'conflictingFile.js'),
+        ];
         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},
@@ -1558,15 +1633,15 @@
         element.changeNum = changeWithMultipleParents._number;
         element.change = changeWithMultipleParents;
         element.patchRange = {
-          basePatchNum: 'PARENT' as BasePatchSetNum,
+          basePatchNum: PARENT,
           patchNum: 1 as RevisionPatchSetNum,
         };
-        await flush();
+        await element.updateComplete;
+        await waitEventLoop();
       });
 
       test('displays cleanly merged file count', async () => {
-        await element.reload();
-        await flush();
+        await waitUntil(() => !!query(element, '.cleanlyMergedText'));
 
         const message = queryAndAssert<HTMLSpanElement>(
           element,
@@ -1579,15 +1654,14 @@
         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();
+        await element.updateCleanlyMergedPaths();
+        await element.updateComplete;
+        await waitUntil(() => !!query(element, '.cleanlyMergedText'));
 
         const message = queryAndAssert(
           element,
@@ -1597,8 +1671,7 @@
       });
 
       test('displays button for navigating to parent 1 base', async () => {
-        await element.reload();
-        await flush();
+        await waitUntil(() => !!query(element, '.showParentButton'));
 
         queryAndAssert(element, '.showParentButton');
       });
@@ -1607,8 +1680,6 @@
         filesStub.restore();
         stubRestApi('getChangeOrEditFiles')
           .onFirstCall()
-          .resolves({'conflictingFile.js': {size: 0, size_delta: 0}})
-          .onSecondCall()
           .resolves({
             'conflictingFile.js': {size: 0, size_delta: 0},
             'cleanlyMergedFile.js': {
@@ -1617,10 +1688,9 @@
               size_delta: 0,
             },
           });
-        await element.reload();
-        await flush();
+        await element.updateCleanlyMergedPaths();
 
-        assert.deepEqual(element._cleanlyMergedOldPaths, [
+        assert.deepEqual(element.cleanlyMergedOldPaths, [
           'cleanlyMergedFileOldName.js',
         ]);
       });
@@ -1630,8 +1700,8 @@
           basePatchNum: 1 as BasePatchSetNum,
           patchNum: 2 as RevisionPatchSetNum,
         };
-        await element.reload();
-        await flush();
+        await element.updateCleanlyMergedPaths();
+        await element.updateComplete;
 
         assert.notOk(query(element, '.cleanlyMergedText'));
         assert.notOk(query(element, '.showParentButton'));
@@ -1640,10 +1710,10 @@
       test('not shown in edit mode', async () => {
         element.patchRange = {
           basePatchNum: 1 as BasePatchSetNum,
-          patchNum: EditPatchSetNum,
+          patchNum: EDIT,
         };
-        await element.reload();
-        await flush();
+        await element.updateCleanlyMergedPaths();
+        await element.updateComplete;
 
         assert.notOk(query(element, '.cleanlyMergedText'));
         assert.notOk(query(element, '.showParentButton'));
@@ -1653,100 +1723,74 @@
 
   suite('diff url file list', () => {
     test('diff url', () => {
-      const diffStub = sinon
-        .stub(GerritNav, 'getUrlForDiff')
-        .returns('/c/gerrit/+/1/1/index.php');
-      const change = {
+      element.change = {
         ...createParsedChange(),
         _number: 1 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
       const path = 'index.php';
-      assert.equal(
-        element._computeDiffURL(
-          change,
-          undefined,
-          1 as RevisionPatchSetNum,
-          path,
-          false
-        ),
-        '/c/gerrit/+/1/1/index.php'
-      );
-      diffStub.restore();
+      element.editMode = false;
+      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1/index.php');
     });
 
     test('diff url commit msg', () => {
-      const diffStub = sinon
-        .stub(GerritNav, 'getUrlForDiff')
-        .returns('/c/gerrit/+/1/1//COMMIT_MSG');
-      const change = {
+      element.change = {
         ...createParsedChange(),
         _number: 1 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.editMode = false;
       const path = '/COMMIT_MSG';
-      assert.equal(
-        element._computeDiffURL(
-          change,
-          undefined,
-          1 as RevisionPatchSetNum,
-          path,
-          false
-        ),
-        '/c/gerrit/+/1/1//COMMIT_MSG'
-      );
-      diffStub.restore();
+      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1//COMMIT_MSG');
     });
 
     test('edit url', () => {
-      const editStub = sinon
-        .stub(GerritNav, 'getEditUrlForDiff')
-        .returns('/c/gerrit/+/1/edit/index.php,edit');
-      const change = {
+      element.change = {
         ...createParsedChange(),
         _number: 1 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.editMode = true;
       const path = 'index.php';
       assert.equal(
-        element._computeDiffURL(
-          change,
-          undefined,
-          1 as RevisionPatchSetNum,
-          path,
-          true
-        ),
-        '/c/gerrit/+/1/edit/index.php,edit'
+        element.computeDiffURL(path),
+        '/c/gerrit/+/1/1/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 = {
+      element.change = {
         ...createParsedChange(),
         _number: 1 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.editMode = true;
       const path = '/COMMIT_MSG';
       assert.equal(
-        element._computeDiffURL(
-          change,
-          undefined,
-          1 as RevisionPatchSetNum,
-          path,
-          true
-        ),
-        '/c/gerrit/+/1/edit//COMMIT_MSG,edit'
+        element.computeDiffURL(path),
+        '/c/gerrit/+/1/1//COMMIT_MSG,edit'
       );
-      editStub.restore();
     });
   });
 
   suite('size bars', () => {
-    test('_computeSizeBarLayout', () => {
+    test('computeSizeBarLayout', async () => {
       const defaultSizeBarLayout = {
         maxInserted: 0,
         maxDeleted: 0,
@@ -1755,37 +1799,39 @@
         deletionOffset: 0,
       };
 
-      assert.deepEqual(
-        element._computeSizeBarLayout(undefined),
-        defaultSizeBarLayout
-      );
-      assert.deepEqual(
-        element._computeSizeBarLayout(
-          {} as PolymerDeepPropertyChange<
-            NormalizedFileInfo[],
-            NormalizedFileInfo[]
-          >
-        ),
-        defaultSizeBarLayout
-      );
-      assert.deepEqual(
-        element._computeSizeBarLayout({base: []} as any),
-        defaultSizeBarLayout
-      );
+      element.files = [];
+      await element.updateComplete;
+      assert.deepEqual(element.computeSizeBarLayout(), defaultSizeBarLayout);
 
-      const files = [
-        {__path: '/COMMIT_MSG', lines_inserted: 10000},
-        {__path: 'foo', lines_inserted: 4, lines_deleted: 10},
-        {__path: 'bar', lines_inserted: 5, lines_deleted: 8},
+      element.files = [
+        {
+          __path: '/COMMIT_MSG',
+          lines_inserted: 10000,
+          size_delta: 10000,
+          size: 10000,
+        },
+        {
+          __path: 'foo',
+          lines_inserted: 4,
+          lines_deleted: 10,
+          size_delta: 14,
+          size: 20,
+        },
+        {
+          __path: 'bar',
+          lines_inserted: 5,
+          lines_deleted: 8,
+          size_delta: 13,
+          size: 21,
+        },
       ];
-      const layout = element._computeSizeBarLayout({
-        base: files,
-      } as PolymerDeepPropertyChange<NormalizedFileInfo[], NormalizedFileInfo[]>);
+      await element.updateComplete;
+      const layout = element.computeSizeBarLayout();
       assert.equal(layout.maxInserted, 5);
       assert.equal(layout.maxDeleted, 10);
     });
 
-    test('_computeBarAdditionWidth', () => {
+    test('computeBarAdditionWidth', () => {
       const file = {
         __path: 'foo/bar.baz',
         lines_inserted: 5,
@@ -1803,27 +1849,27 @@
 
       // Uses half the space when file is half the largest addition and there
       // are no deletions.
-      assert.equal(element._computeBarAdditionWidth(file, stats), 30);
+      assert.equal(element.computeBarAdditionWidth(file, stats), 30);
 
       // If there are no insertions, there is no width.
       stats.maxInserted = 0;
-      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+      assert.equal(element.computeBarAdditionWidth(file, stats), 0);
 
       // If the insertions is not present on the file, there is no width.
       stats.maxInserted = 10;
       file.lines_inserted = 0;
-      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+      assert.equal(element.computeBarAdditionWidth(file, stats), 0);
 
       // If the file is a commit message, returns zero.
       file.lines_inserted = 5;
       file.__path = '/COMMIT_MSG';
-      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+      assert.equal(element.computeBarAdditionWidth(file, stats), 0);
 
       // Width bottoms-out at the minimum width.
       file.__path = 'stuff.txt';
       file.lines_inserted = 1;
       stats.maxInserted = 1000000;
-      assert.equal(element._computeBarAdditionWidth(file, stats), 1.5);
+      assert.equal(element.computeBarAdditionWidth(file, stats), 1.5);
     });
 
     test('_computeBarAdditionX', () => {
@@ -1841,10 +1887,10 @@
         maxDeletionWidth: 0,
         deletionOffset: 60,
       };
-      assert.equal(element._computeBarAdditionX(file, stats), 30);
+      assert.equal(element.computeBarAdditionX(file, stats), 30);
     });
 
-    test('_computeBarDeletionWidth', () => {
+    test('computeBarDeletionWidth', () => {
       const file = {
         __path: 'foo/bar.baz',
         lines_inserted: 0,
@@ -1862,42 +1908,41 @@
 
       // Uses a quarter the space when file is half the largest deletions and
       // there are equal additions.
-      assert.equal(element._computeBarDeletionWidth(file, stats), 15);
+      assert.equal(element.computeBarDeletionWidth(file, stats), 15);
 
       // If there are no deletions, there is no width.
       stats.maxDeleted = 0;
-      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+      assert.equal(element.computeBarDeletionWidth(file, stats), 0);
 
       // If the deletions is not present on the file, there is no width.
       stats.maxDeleted = 10;
       file.lines_deleted = 0;
-      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+      assert.equal(element.computeBarDeletionWidth(file, stats), 0);
 
       // If the file is a commit message, returns zero.
       file.lines_deleted = 5;
       file.__path = '/COMMIT_MSG';
-      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+      assert.equal(element.computeBarDeletionWidth(file, stats), 0);
 
       // Width bottoms-out at the minimum width.
       file.__path = 'stuff.txt';
       file.lines_deleted = 1;
       stats.maxDeleted = 1000000;
-      assert.equal(element._computeBarDeletionWidth(file, stats), 1.5);
+      assert.equal(element.computeBarDeletionWidth(file, stats), 1.5);
     });
 
     test('_computeSizeBarsClass', () => {
+      element.showSizeBars = false;
       assert.equal(
-        element._computeSizeBarsClass(false, 'foo/bar.baz'),
+        element.computeSizeBarsClass('foo/bar.baz'),
         'sizeBars hide'
       );
+      element.showSizeBars = true;
       assert.equal(
-        element._computeSizeBarsClass(true, '/COMMIT_MSG'),
+        element.computeSizeBarsClass('/COMMIT_MSG'),
         'sizeBars invisible'
       );
-      assert.equal(
-        element._computeSizeBarsClass(true, 'foo/bar.baz'),
-        'sizeBars '
-      );
+      assert.equal(element.computeSizeBarsClass('foo/bar.baz'), 'sizeBars ');
     });
   });
 
@@ -1907,7 +1952,7 @@
 
     const commitMsgComments = [
       {
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         path: '/p',
         id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
         line: 20,
@@ -1916,7 +1961,7 @@
         unresolved: true,
       },
       {
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         path: '/p',
         id: '503008e2_0ab203ee' as UrlEncodedCommentId,
         line: 10,
@@ -1925,7 +1970,7 @@
         unresolved: true,
       },
       {
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         path: '/p',
         id: 'cc788d2c_cb1d728c' as UrlEncodedCommentId,
         line: 20,
@@ -1954,8 +1999,7 @@
         syntax_highlighting: true,
         ignore_whitespace: 'IGNORE_NONE',
       };
-      diff.diff = createDiff();
-      await listenOnce(diff, 'render');
+      await diff.waitForReloadToRender();
     }
 
     async function renderAndGetNewDiffs(index: number) {
@@ -1966,8 +2010,8 @@
       }
 
       assertIsDefined(element.diffCursor);
-      element._updateDiffCursor();
-      element.diffCursor.handleDiffUpdate();
+      element.updateDiffCursor();
+      element.diffCursor.reInitCursor();
       return diffs;
     }
 
@@ -1976,63 +2020,78 @@
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      stub('gr-date-formatter', '_loadTimeFormat').callsFake(() =>
+      stubElement('gr-date-formatter', 'loadTimeFormat').callsFake(() =>
         Promise.resolve()
       );
-      stub('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
-      stub('gr-diff-host', 'prefetchDiff').callsFake(() => {});
+      stubRestApi('getDiff').callsFake(() => Promise.resolve(createDiff()));
+      stubElement('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 = await fixture(html`<gr-file-list></gr-file-list>`);
+      element.diffPrefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        ignore_whitespace: 'IGNORE_NONE',
+      };
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
         project: 'testRepo' as RepoName,
       };
-      reviewFileStub = sinon.stub(element, '_reviewFile');
+      reviewFileStub = sinon.stub(element, 'reviewFile');
 
-      element._loading = false;
       element.numFilesShown = 75;
       element.selectedIndex = 0;
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9, size: 0, size_delta: 0},
-        'file_added_in_rev2.txt': {
+      element.files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9, size: 0, size_delta: 0},
+        {
+          __path: 'file_added_in_rev2.txt',
           lines_inserted: 1,
           lines_deleted: 1,
           size_delta: 10,
           size: 100,
         },
-        'myfile.txt': {
+        {
+          __path: '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,
+        basePatchNum: PARENT,
         patchNum: 2 as RevisionPatchSetNum,
       };
       sinon
         .stub(window, 'fetch')
         .callsFake(() => Promise.resolve(new Response()));
-      await flush();
+      await element.updateComplete;
     });
 
     test('cursor with individually opened files', async () => {
-      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-      await flush();
+      await element.updateComplete;
+      pressKey(element, 'i');
+
+      await waitUntil(async () => {
+        const diffs = await renderAndGetNewDiffs(0);
+        return diffs.length > 0;
+      });
       let diffs = await renderAndGetNewDiffs(0);
       const diffStops = diffs[0].getCursorStops();
 
       // 1 diff should be rendered.
       assert.equal(diffs.length, 1);
+      assert.isTrue(diffStops.length > 12);
 
       // No line number is selected.
       assert.isFalse(
@@ -2040,18 +2099,19 @@
       );
 
       // Tapping content on a line selects the line number.
-      MockInteractions.tap(
-        queryAll(diffStops[10] as HTMLElement, '.contentText')[0]
-      );
-      await flush();
+      queryAll<HTMLDivElement>(
+        diffStops[10] as HTMLElement,
+        '.contentText'
+      )[0].click();
+      await element.updateComplete;
       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();
+      pressKey(element, 'j');
+      await element.updateComplete;
       assert.isTrue(
         (diffStops[10] as HTMLElement).classList.contains('target-row')
       );
@@ -2062,8 +2122,8 @@
       // The file cursor is now at 1.
       assert.equal(element.fileCursor.index, 1);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-      await flush();
+      pressKey(element, 'i');
+      await element.updateComplete;
       diffs = await renderAndGetNewDiffs(1);
 
       // Two diffs should be rendered.
@@ -2081,14 +2141,15 @@
     });
 
     test('cursor with toggle all files', async () => {
-      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
-      await flush();
+      pressKey(element, 'I');
+      await element.updateComplete;
 
       const diffs = await renderAndGetNewDiffs(0);
       const diffStops = diffs[0].getCursorStops();
 
       // 1 diff should be rendered.
       assert.equal(diffs.length, 3);
+      assert.isTrue(diffStops.length > 12);
 
       // No line number is selected.
       assert.isFalse(
@@ -2096,18 +2157,19 @@
       );
 
       // Tapping content on a line selects the line number.
-      MockInteractions.tap(
-        queryAll(diffStops[10] as HTMLElement, '.contentText')[0]
-      );
-      await flush();
+      queryAll<HTMLDivElement>(
+        diffStops[10] as HTMLElement,
+        '.contentText'
+      )[0].click();
+      await element.updateComplete;
       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();
+      pressKey(element, 'j');
+      await element.updateComplete;
       assert.isFalse(
         (diffStops[10] as HTMLElement).classList.contains('target-row')
       );
@@ -2125,7 +2187,7 @@
       let fileRows: NodeListOf<HTMLDivElement>;
 
       setup(() => {
-        sinon.stub(element, '_renderInOrder').returns(Promise.resolve());
+        sinon.stub(element, 'renderInOrder').returns(Promise.resolve());
         assertIsDefined(element.diffCursor);
         nextCommentStub = sinon.stub(
           element.diffCursor,
@@ -2135,72 +2197,77 @@
         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();
+      test('correct number of files expanded', async () => {
+        pressKey(fileRows[0], 'i');
+        await element.updateComplete;
         assert.equal(element.filesExpanded, FilesExpandedState.SOME);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        pressKey(element, 'n');
+        await element.updateComplete;
         assert.isTrue(nextChunkStub.calledOnce);
       });
 
       test('N key with some files expanded', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
-        await flush();
+        pressKey(fileRows[0], 'i');
+        await element.updateComplete;
         assert.equal(element.filesExpanded, FilesExpandedState.SOME);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
+        pressKey(element, 'N');
+        await element.updateComplete;
         assert.isTrue(nextCommentStub.calledOnce);
       });
 
       test('n key with all files expanded', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
-        await flush();
+        pressKey(fileRows[0], 'I');
+        await element.updateComplete;
         assert.equal(element.filesExpanded, FilesExpandedState.ALL);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        pressKey(element, 'n');
+        await element.updateComplete;
         assert.isTrue(nextChunkStub.calledOnce);
       });
 
       test('N key with all files expanded', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
-        await flush();
+        pressKey(fileRows[0], 'I');
+        await element.updateComplete;
         assert.equal(element.filesExpanded, FilesExpandedState.ALL);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
+        pressKey(element, 'N');
+        await element.updateComplete;
         assert.isTrue(nextCommentStub.called);
       });
     });
 
-    test('_openSelectedFile behavior', async () => {
-      const _filesByPath = element._filesByPath;
-      element.set('_filesByPath', {});
-      const navStub = sinon.stub(GerritNav, 'navigateToDiff');
+    test('openSelectedFile behavior', async () => {
+      const files = element.files;
+      element.files = [];
+      await element.updateComplete;
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
       // Noop when there are no files.
-      element._openSelectedFile();
-      assert.isFalse(navStub.called);
+      element.openSelectedFile();
+      assert.isFalse(setUrlStub.calledOnce);
 
-      element.set('_filesByPath', _filesByPath);
-      await flush();
+      element.files = files;
+      await element.updateComplete;
       // Navigates when a file is selected.
-      element._openSelectedFile();
-      assert.isTrue(navStub.called);
+      element.openSelectedFile();
+      assert.isTrue(setUrlStub.calledOnce);
     });
 
-    test('_displayLine', () => {
+    test('displayLine', () => {
       element.filesExpanded = FilesExpandedState.ALL;
 
-      element._displayLine = false;
-      element._handleCursorNext(new KeyboardEvent('keydown'));
-      assert.isTrue(element._displayLine);
+      element.displayLine = false;
+      element.handleCursorNext(new KeyboardEvent('keydown'));
+      assert.isTrue(element.displayLine);
 
-      element._displayLine = false;
-      element._handleCursorPrev(new KeyboardEvent('keydown'));
-      assert.isTrue(element._displayLine);
+      element.displayLine = false;
+      element.handleCursorPrev(new KeyboardEvent('keydown'));
+      assert.isTrue(element.displayLine);
 
-      element._displayLine = true;
-      element._handleEscKey();
-      assert.isFalse(element._displayLine);
+      element.displayLine = true;
+      element.handleEscKey();
+      assert.isFalse(element.displayLine);
     });
 
     suite('editMode behavior', () => {
@@ -2209,26 +2276,15 @@
         const saveReviewStub = sinon.stub(element, '_saveReviewedState');
 
         element.editMode = false;
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        pressKey(element, 'r');
         assert.isTrue(saveReviewStub.calledOnce);
 
         element.editMode = true;
-        await flush();
+        await element.updateComplete;
 
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        pressKey(element, '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 () => {
@@ -2238,7 +2294,7 @@
       );
 
       element.editMode = true;
-      await flush();
+      await element.updateComplete;
 
       // Commit message should not have edit controls.
       const editControls = Array.from(
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index 3d7ab55..fcfe209 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
@@ -21,7 +10,7 @@
 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 {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 
 interface DisplayGroup {
@@ -121,7 +110,7 @@
           id="filterInput"
           .bindValue=${this.filterText}
           @bind-value-changed=${(e: BindValueChangeEvent) => {
-            this.filterText = e.detail.value;
+            this.filterText = e.detail.value ?? '';
           }}
         >
           <input placeholder="Filter" />
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 e3d1e02..3851255 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
@@ -1,35 +1,49 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-included-in-dialog';
 import {GrIncludedInDialog} from './gr-included-in-dialog';
 import {BranchName, IncludedInInfo, TagName} from '../../../types/common';
 import {IronInputElement} from '@polymer/iron-input';
 import {queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-included-in-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-included-in-dialog', () => {
   let element: GrIncludedInDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-included-in-dialog></gr-included-in-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <header>
+          <h1 class="heading-1" id="title">Included In:</h1>
+          <span class="closeButtonContainer">
+            <gr-button
+              aria-disabled="false"
+              id="closeButton"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              Close
+            </gr-button>
+          </span>
+          <iron-input id="filterInput">
+            <input placeholder="Filter" />
+          </iron-input>
+        </header>
+        <div>Loading...</div>
+      `
+    );
   });
 
   test('computeGroups', () => {
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 e2852f6..50c5caf 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
@@ -1,25 +1,14 @@
 /**
  * @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 '@polymer/iron-selector/iron-selector';
 import '../../shared/gr-button/gr-button';
 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 {customElement, property, query, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {IronSelectorElement} from '@polymer/iron-selector/iron-selector';
 import {
   LabelNameToInfoMap,
@@ -27,9 +16,6 @@
   DetailedLabelInfo,
 } from '../../../types/common';
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {classMap} from 'lit/directives/class-map';
 import {Label} from '../../../utils/label-util';
 import {LabelNameToValuesMap} from '../../../api/rest-api';
 
@@ -68,12 +54,6 @@
   @state()
   private selectedValueText = 'No value selected';
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  private readonly isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-
   static override get styles() {
     return [
       sharedStyles,
@@ -86,9 +66,7 @@
         }
         /* We want the :hover highlight to extend to the border of the dialog. */
         .labelNameCell {
-          padding-left: var(--spacing-xl);
-        }
-        .labelNameCell.newSubmitRequirements {
+          padding-left: var(--label-score-padding-left, 0);
           width: 160px;
         }
         .selectedValueCell {
@@ -100,9 +78,6 @@
           white-space: nowrap;
         }
         .selectedValueCell {
-          width: 75%;
-        }
-        .selectedValueCell.newSubmitRequirements {
           width: 52%;
         }
         .labelMessage {
@@ -175,13 +150,7 @@
 
   override render() {
     return html`
-      <span
-        class=${classMap({
-          labelNameCell: true,
-          newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
-        })}
-        id="labelName"
-        aria-hidden="true"
+      <span class="labelNameCell" id="labelName" aria-hidden="true"
         >${this.label?.name ?? ''}</span
       >
       ${this.renderButtonsCell()} ${this.renderSelectedValue()}
@@ -230,7 +199,7 @@
     return items.map(
       (value, index) => html`
         <gr-button
-          role="radio"
+          role="button"
           title=${ifDefined(this.computeLabelValueTitle(value))}
           data-vote=${this._computeVoteAttribute(
             Number(value),
@@ -257,12 +226,7 @@
 
   private renderSelectedValue() {
     return html`
-      <div
-        class=${classMap({
-          selectedValueCell: true,
-          newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
-        })}
-      >
+      <div class="selectedValueCell">
         <span id="selectedValueLabel">${this.selectedValueText}</span>
       </div>
     `;
@@ -361,7 +325,6 @@
 
   // Private but used in tests.
   _computeLabelValue() {
-    // Polymer 2+ undefined check
     if (!this.labels || !this.permittedLabels || !this.label) {
       return undefined;
     }
@@ -419,13 +382,23 @@
     return this.permittedLabels[this.label.name] || [];
   }
 
-  private computeLabelValueTitle(value: string) {
+  // private but used in tests
+  computeLabelValueTitle(value: string) {
     if (!this.labels || !this.label) return '';
-    const label = this.labels[this.label.name];
-    if (label && (label as DetailedLabelInfo).values) {
+    const label = this.labels[this.label.name] as DetailedLabelInfo;
+    if (label && label.values) {
+      // In case the user already voted a certain value and then selects 0
+      // we should show "Reset Vote" instead of "No Value selected"
+      if (
+        Number(value) === 0 &&
+        this.label.value &&
+        Number(this.label.value) !== 0
+      ) {
+        return 'Reset Vote';
+      }
       // TODO(TS): maybe add a type guard for DetailedLabelInfo and
       // QuickLabelInfo
-      return (label as DetailedLabelInfo).values![value];
+      return label.values[value];
     } else {
       return '';
     }
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts
index 24d4f9b..8752a6c 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts
@@ -1,33 +1,21 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitEventLoop} from '../../../test/test-utils';
 
 suite('gr-label-row-score tests', () => {
   let element: GrLabelScoreRow;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-label-score-row></gr-label-score-row>`);
     element.labels = {
       'Code-Review': {
         values: {
@@ -79,7 +67,7 @@
     };
 
     await element.updateComplete;
-    await flush();
+    await waitEventLoop();
   });
 
   function checkAriaCheckedValid() {
@@ -119,6 +107,17 @@
     checkAriaCheckedValid();
   });
 
+  test('Reset Vote title', () => {
+    // User already voted +1 so we show reset vote
+    assert.equal(element.computeLabelValueTitle('0'), 'Reset Vote');
+    element.label = {
+      name: 'Verified',
+      value: '0',
+    };
+    // User voted 0 and selected 0 hence no score
+    assert.equal(element.computeLabelValueTitle('0'), 'No score');
+  });
+
   test('_computeVoteAttribute', () => {
     let value = 1;
     let index = 0;
@@ -227,14 +226,14 @@
     element.label = {name: 'Verified', value: ' 0'};
     await element.updateComplete;
     // Wait for @selected-item-changed to fire
-    await flush();
+    await waitEventLoop();
 
     const selector = element.labelSelector;
     assert.strictEqual(selector!.selected, ' 0');
     const selectedValueLabel = element.shadowRoot!.querySelector(
       '#selectedValueLabel'
     );
-    assert.strictEqual(selectedValueLabel!.textContent!.trim(), 'No score');
+    assert.strictEqual(selectedValueLabel!.textContent!.trim(), 'Reset Vote');
     checkAriaCheckedValid();
   });
 
@@ -312,73 +311,81 @@
   });
 
   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=""
+    assert.shadowDom.equal(
+      element,
+      /* 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-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>
-    `);
+            <gr-button
+              aria-disabled="false"
+              aria-label="-1"
+              data-name="Verified"
+              data-value="-1"
+              role="button"
+              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="button"
+              tabindex="0"
+              data-vote="neutral"
+              title="Reset Vote"
+              votechip=""
+              flatten=""
+            >
+              <gr-tooltip-content
+                light-tooltip=""
+                title="Reset Vote"
+                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="button"
+              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 dd2a83e..850ae09 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
@@ -1,37 +1,25 @@
 /**
  * @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-label-score-row/gr-label-score-row';
 import '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {
   ChangeInfo,
   AccountInfo,
   LabelNameToValueMap,
 } from '../../../types/common';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
-import {getAppContext} from '../../../services/app-context';
 import {
   getTriggerVotes,
-  showNewSubmitRequirements,
   computeLabels,
   Label,
   computeOrderedLabelValues,
   getDefaultValue,
+  getApplicableLabels,
 } from '../../../utils/label-util';
 import {ChangeStatus} from '../../../constants/constants';
 import {fontStyles} from '../../../styles/gr-font-styles';
@@ -48,8 +36,6 @@
   @property({type: Object})
   account?: AccountInfo;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       fontStyles,
@@ -57,8 +43,6 @@
         .scoresTable {
           display: table;
           width: 100%;
-        }
-        .scoresTable.newSubmitRequirements {
           table-layout: fixed;
         }
         .mergedMessage,
@@ -70,7 +54,7 @@
         .permissionMessage {
           width: 100%;
           color: var(--deemphasized-text-color);
-          padding-left: var(--spacing-xl);
+          padding-left: var(--label-score-padding-left, 0);
         }
         gr-label-score-row:hover {
           background-color: var(--hover-background-color);
@@ -78,12 +62,12 @@
         gr-label-score-row {
           display: table-row;
         }
-        .heading-3 {
-          padding-left: var(--spacing-xl);
-          margin-bottom: var(--spacing-m);
+        .heading-4 {
+          padding-left: var(--label-score-padding-left, 0);
+          margin-bottom: var(--spacing-s);
           margin-top: var(--spacing-l);
         }
-        .heading-3:first-of-type {
+        .heading-4:first-of-type {
           margin-top: 0;
         }
       `,
@@ -91,38 +75,26 @@
   }
 
   override render() {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return this.renderNewSubmitRequirements();
-    } else {
-      return this.renderOldSubmitRequirements();
-    }
-  }
-
-  private renderOldSubmitRequirements() {
-    const labels = computeLabels(this.account, this.change);
-    return html`${this.renderLabels(labels)}${this.renderErrorMessages()}`;
-  }
-
-  private renderNewSubmitRequirements() {
     return html`${this.renderSubmitReqsLabels()}${this.renderTriggerVotes()}
     ${this.renderErrorMessages()}`;
   }
 
   private renderSubmitReqsLabels() {
     const triggerVotes = getTriggerVotes(this.change);
-    const labels = computeLabels(this.account, this.change).filter(
-      label => !triggerVotes.includes(label.name)
-    );
+    const applicableLabels = getApplicableLabels(this.change);
+    const labels = computeLabels(this.account, this.change)
+      .filter(label => !triggerVotes.includes(label.name))
+      .filter(label => applicableLabels.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>
+      return html`<h3 class="heading-4">Submit requirements votes</h3>
         <div class="permissionMessage">You don't have permission to vote</div>`;
     }
-    return html`<h3 class="heading-3">Submit requirements votes</h3>
+    return html`<h3 class="heading-4">Submit requirements votes</h3>
       ${this.renderLabels(labels)}`;
   }
 
@@ -137,21 +109,15 @@
         label => !this.permittedLabels || this.permittedLabels[label.name]
       ).length === 0
     ) {
-      return html`<h3 class="heading-3">Trigger Votes</h3>
+      return html`<h3 class="heading-4">Trigger Votes</h3>
         <div class="permissionMessage">You don't have permission to vote</div>`;
     }
-    return html`<h3 class="heading-3">Trigger Votes</h3>
+    return html`<h3 class="heading-4">Trigger Votes</h3>
       ${this.renderLabels(labels)}`;
   }
 
   private renderLabels(labels: Label[]) {
-    const newSubReqs = showNewSubmitRequirements(
-      this.flagsService,
-      this.change
-    );
-    return html`<div
-      class="scoresTable ${newSubReqs ? 'newSubmitRequirements' : ''}"
-    >
+    return html`<div class="scoresTable">
       ${labels
         .filter(
           label =>
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 2751163..2e02402 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
@@ -1,23 +1,16 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-label-scores';
-import {isHidden, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  isHidden,
+  queryAndAssert,
+  stubRestApi,
+  waitEventLoop,
+} from '../../../test/test-utils';
 import {GrLabelScores} from './gr-label-scores';
 import {AccountId} from '../../../types/common';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
@@ -27,8 +20,7 @@
 } from '../../../test/test-data-generators';
 import {ChangeStatus} from '../../../constants/constants';
 import {getVoteForAccount} from '../../../utils/label-util';
-
-const basicFixture = fixtureFromElement('gr-label-scores');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-label-scores tests', () => {
   const accountId = 123 as AccountId;
@@ -36,7 +28,7 @@
 
   setup(async () => {
     stubRestApi('getLoggedIn').resolves(false);
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-label-scores></gr-label-scores>`);
     element.change = {
       ...createChange(),
       labels: {
@@ -86,6 +78,25 @@
     await element.updateComplete;
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <h3 class="heading-4">Trigger Votes</h3>
+        <div class="scoresTable">
+          <gr-label-score-row name="Code-Review"> </gr-label-score-row>
+          <gr-label-score-row name="Verified"> </gr-label-score-row>
+        </div>
+        <div class="mergedMessage" hidden="">
+          Because this change has been merged, votes may not be decreased.
+        </div>
+        <div class="abandonedMessage" hidden="">
+          Because this change has been abandoned, you cannot vote.
+        </div>
+      `
+    );
+  });
+
   test('get and set label scores', async () => {
     for (const label of Object.keys(element.permittedLabels!)) {
       const row = queryAndAssert<GrLabelScoreRow>(
@@ -131,7 +142,7 @@
         ...createChange(),
         status: ChangeStatus.ABANDONED,
       };
-      await flush();
+      await waitEventLoop();
       assert.isFalse(isHidden(queryAndAssert(element, '.abandonedMessage')));
       assert.isTrue(isHidden(queryAndAssert(element, '.mergedMessage')));
     });
@@ -140,7 +151,7 @@
         ...createChange(),
         status: ChangeStatus.MERGED,
       };
-      await flush();
+      await waitEventLoop();
       assert.isFalse(isHidden(queryAndAssert(element, '.mergedMessage')));
       assert.isTrue(isHidden(queryAndAssert(element, '.abandonedMessage')));
     });
@@ -149,7 +160,7 @@
         ...createChange(),
         status: ChangeStatus.NEW,
       };
-      await flush();
+      await waitEventLoop();
       assert.isTrue(isHidden(queryAndAssert(element, '.mergedMessage')));
       assert.isTrue(isHidden(queryAndAssert(element, '.abandonedMessage')));
     });
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
index 4bf9d10..9799880 100644
--- a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-trigger-vote/gr-trigger-vote';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {ChangeInfo} from '../../../api/rest-api';
 import {
   ChangeMessage,
@@ -25,8 +14,6 @@
 } from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {getTriggerVotes} from '../../../utils/label-util';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 const VOTE_RESET_TEXT = '0 (vote reset)';
 
@@ -102,8 +89,6 @@
     `;
   }
 
-  private readonly flagsService = getAppContext().flagsService;
-
   override render() {
     const scores = this._getScores(this.message, this.labelExtremes);
     const triggerVotes = getTriggerVotes(this.change);
@@ -112,7 +97,6 @@
 
   private renderScore(score: Score, triggerVotes: string[]) {
     if (
-      this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) &&
       score.label &&
       triggerVotes.includes(score.label) &&
       !score.value?.includes(VOTE_RESET_TEXT)
@@ -136,7 +120,6 @@
   }
 
   _computeScoreClass(score?: Score, labelExtremes?: LabelExtreme) {
-    // Polymer 2: check for undefined
     if (score === undefined || labelExtremes === undefined) {
       return '';
     }
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
index 74d1114..a757b37 100644
--- 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-message-scores';
 import {
   createChange,
@@ -24,15 +12,37 @@
 } from '../../../test/test-data-generators';
 import {queryAll, stubFlags} from '../../../test/test-utils';
 import {GrMessageScores} from './gr-message-scores';
-
-const basicFixture = fixtureFromElement('gr-message-scores');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-message-score tests', () => {
   let element: GrMessageScores;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-message-scores></gr-message-scores>`);
+  });
+
+  test('render', 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;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <span class="max positive score"> Verified +1 </span>
+        <span class="min negative score"> Code-Review -2 </span>
+        <span class="positive score"> Trybot-Label3 +1 </span>
+      `
+    );
   });
 
   test('votes', async () => {
@@ -140,7 +150,7 @@
 
   test('reset vote', async () => {
     stubFlags('isEnabled').returns(true);
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-message-scores></gr-message-scores>`);
     element.change = {
       ...createChange(),
       labels: {
@@ -163,17 +173,23 @@
       element.shadowRoot?.querySelectorAll('gr-trigger-vote');
     assert.equal(triggerChips?.length, 1);
     const triggerChip = triggerChips?.[0];
-    expect(triggerChip).shadowDom.equal(`<div class="container">
+    assert.shadowDom.equal(
+      triggerChip,
+      `<div class="container">
       <span class="label">Auto-Submit</span>
       <gr-vote-chip></gr-vote-chip>
-    </div>`);
+    </div>`
+    );
     const voteChips = triggerChip?.shadowRoot?.querySelectorAll('gr-vote-chip');
     assert.equal(voteChips?.length, 1);
-    expect(voteChips?.[0]).shadowDom.equal('');
+    assert.shadowDom.equal(voteChips?.[0], '');
     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>`);
+    assert.dom.equal(
+      scoreChips?.[0],
+      /* HTML */ `
+        <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 f9afd8c..a4da747 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -1,40 +1,26 @@
 /**
  * @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 '@polymer/iron-icon/iron-icon';
 import '../../shared/gr-account-label/gr-account-label';
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-formatted-text/gr-formatted-text';
 import '../gr-message-scores/gr-message-scores';
-import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
 import {MessageTag, SpecialFilePath} from '../../../constants/constants';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {
   ChangeInfo,
   ServerInfo,
-  ConfigInfo,
-  RepoName,
   ReviewInputTag,
   NumericChangeId,
   ChangeMessageId,
-  PatchSetNum,
+  RevisionPatchSetNum,
   AccountInfo,
   BasePatchSetNum,
   LabelNameToInfoMap,
@@ -45,11 +31,12 @@
   isFormattedReviewerUpdate,
   LabelExtreme,
   PATCH_SET_PREFIX_PATTERN,
+  isUnresolved,
 } 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 {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
@@ -57,8 +44,10 @@
 } 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 {when} from 'lit/directives/when.js';
 import {FormattedReviewerUpdateInfo} from '../../../types/types';
+import {resolve} from '../../../models/dependency';
+import {createChangeUrl} from '../../../models/views/change';
 
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -114,9 +103,6 @@
   @property({type: Boolean})
   hideAutomated = false;
 
-  @property({type: String})
-  projectName?: RepoName;
-
   /**
    * A mapping from label names to objects representing the minimum and
    * maximum possible values for that label.
@@ -124,9 +110,6 @@
   @property({type: Object})
   labelExtremes?: LabelExtreme;
 
-  @state()
-  private projectConfig?: ConfigInfo;
-
   @property({type: Boolean})
   loggedIn = false;
 
@@ -138,6 +121,11 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  // for COMMENTS_AUTOCLOSE logging purposes only
+  readonly uid = performance.now().toString(36) + Math.random().toString(36);
+
   constructor() {
     super();
     this.addEventListener('click', e => this.handleClick(e));
@@ -157,171 +145,168 @@
   }
 
   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;
+    return [
+      css`
+        :host {
+          display: block;
+          position: relative;
+          cursor: pointer;
+          overflow-y: hidden;
         }
-        .commentsSummary {
-          min-width: 0px;
+        :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 {
-          width: 100px;
+          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-icon {
+          margin-right: var(--spacing-m);
+          color: var(--deemphasized-text-color);
         }
         .dateContainer .patchset:before {
-          content: 'PS ';
+          content: 'Patchset ';
         }
-      }
-    `;
-  }
-
-  override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('projectName')) {
-      this.projectNameChanged();
-    }
+        .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 gr-icon {
+          cursor: pointer;
+          vertical-align: top;
+        }
+        .commentsSummary {
+          margin-right: var(--spacing-s);
+        }
+        .expanded .commentsSummary {
+          display: none;
+        }
+        gr-icon.commentsIcon {
+          vertical-align: top;
+        }
+        gr-icon.unresolved.commentsIcon {
+          color: var(--warning-foreground);
+        }
+        .numberOfComments {
+          padding-right: var(--spacing-m);
+        }
+        gr-account-label::part(gr-account-label-text) {
+          font-weight: var(--font-weight-bold);
+        }
+        @media screen and (max-width: 50em) {
+          .expanded .content {
+            padding-left: 0;
+          }
+          .commentsSummary {
+            min-width: 0px;
+          }
+          .authorLabel {
+            width: 100px;
+          }
+          .dateContainer .patchset:before {
+            content: 'PS ';
+          }
+        }
+      `,
+    ];
   }
 
   override render() {
@@ -351,6 +336,7 @@
       )}
       <gr-account-label
         .account=${this.author}
+        .change=${this.change}
         class="authorLabel"
       ></gr-account-label>
       <gr-message-scores
@@ -361,14 +347,51 @@
     </div>`;
   }
 
+  private renderCommentIcon({
+    commentThreadsCount,
+    unresolved,
+  }: {
+    commentThreadsCount: number;
+    unresolved: boolean;
+  }) {
+    if (commentThreadsCount === 0) {
+      return nothing;
+    }
+    return html` <span
+      class="numberOfComments"
+      title=${pluralize(
+        commentThreadsCount,
+        (unresolved ? 'unresolved' : 'resolved') + ' comment'
+      )}
+    >
+      <gr-icon
+        small
+        icon=${unresolved ? 'chat_bubble' : 'mark_chat_read'}
+        ?filled=${unresolved}
+        class="${unresolved ? 'unresolved ' : ''}commentsIcon"
+      ></gr-icon>
+      ${commentThreadsCount}</span
+    >`;
+  }
+
   private renderCommentsSummary() {
     if (!this.commentThreads?.length) return nothing;
 
-    const commentCountText = pluralize(this.commentThreads.length, 'comment');
+    const unresolvedThreadsCount =
+      this.commentThreads.filter(isUnresolved).length;
+    const resolvedThreadsCount =
+      this.commentThreads.length - unresolvedThreadsCount;
+
     return html`
       <div class="commentsSummary">
-        <iron-icon icon="gr-icons:comment" class="commentsIcon"></iron-icon>
-        <span class="numberOfComments">${commentCountText}</span>
+        ${this.renderCommentIcon({
+          commentThreadsCount: unresolvedThreadsCount,
+          unresolved: true,
+        })}
+        ${this.renderCommentIcon({
+          commentThreadsCount: resolvedThreadsCount,
+          unresolved: false,
+        })}
       </div>
     `;
   }
@@ -400,10 +423,9 @@
     );
     return html`
       <gr-formatted-text
-        noTrailingMargin
         class="message hideOnCollapsed"
+        .markdown=${true}
         .content=${messageContentExpanded}
-        .config=${this.projectConfig?.commentlinks}
       ></gr-formatted-text>
       ${when(messageContentExpanded, () => this.renderActionContainer())}
       <gr-thread-list
@@ -492,12 +514,12 @@
           </span>
         `
       )}
-      <iron-icon
+      <gr-icon
         id="expandToggle"
         @click=${this.toggleExpanded}
         title="Toggle expanded state"
         icon=${this.computeExpandToggleIcon()}
-      ></iron-icon>
+      ></gr-icon>
     </span>`;
   }
 
@@ -562,20 +584,21 @@
   private isNewPatchsetTag(tag?: ReviewInputTag) {
     return (
       tag === MessageTag.TAG_NEW_PATCHSET ||
-      tag === MessageTag.TAG_NEW_WIP_PATCHSET
+      tag === MessageTag.TAG_NEW_WIP_PATCHSET ||
+      tag === MessageTag.TAG_NEW_PATCHSET_OUTDATED_VOTES
     );
   }
 
   // Private but used in tests
   handleViewPatchsetDiff(e: Event) {
     if (!this.message || !this.change) return;
-    let patchNum: PatchSetNum;
-    let basePatchNum: PatchSetNum;
+    let patchNum: RevisionPatchSetNum;
+    let basePatchNum: BasePatchSetNum;
     if (this.message.message.match(UPLOADED_NEW_PATCHSET_PATTERN)) {
       const match = this.message.message.match(UPLOADED_NEW_PATCHSET_PATTERN)!;
       if (isNaN(Number(match[1])))
         throw new Error('invalid patchnum in message');
-      patchNum = Number(match[1]) as PatchSetNum;
+      patchNum = Number(match[1]) as RevisionPatchSetNum;
       basePatchNum = computePredecessor(patchNum)!;
     } else if (this.message.message.match(MERGED_PATCHSET_PATTERN)) {
       const match = this.message.message.match(MERGED_PATCHSET_PATTERN)!;
@@ -589,7 +612,9 @@
       patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
       basePatchNum = computePredecessor(patchNum)!;
     }
-    GerritNav.navigateToChange(this.change, {patchNum, basePatchNum});
+    this.getNavigation().setUrl(
+      createChangeUrl({change: this.change, patchNum, basePatchNum})
+    );
     // stop propagation to stop message expansion
     e.stopPropagation();
   }
@@ -764,20 +789,8 @@
       });
   }
 
-  private projectNameChanged() {
-    if (!this.projectName) {
-      this.projectConfig = undefined;
-      return;
-    }
-    this.restApiService.getProjectConfig(this.projectName).then(config => {
-      this.projectConfig = config;
-    });
-  }
-
   private computeExpandToggleIcon() {
-    return this.message?.expanded
-      ? 'gr-icons:expand-less'
-      : 'gr-icons:expand-more';
+    return this.message?.expanded ? 'expand_less' : 'expand_more';
   }
 
   private toggleExpanded(e: Event) {
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 6e550ac..34292d6 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
@@ -1,23 +1,11 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-message';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   createAccountWithIdNameAndEmail,
   createChange,
@@ -25,35 +13,36 @@
   createComment,
   createRevisions,
   createLabelInfo,
+  createCommentThread,
 } from '../../../test/test-data-generators';
 import {
   mockPromise,
   query,
   queryAndAssert,
   stubRestApi,
+  waitEventLoop,
 } from '../../../test/test-utils';
 import {GrMessage} from './gr-message';
 import {
   AccountId,
-  BasePatchSetNum,
   ChangeMessageId,
   EmailAddress,
   NumericChangeId,
-  PatchSetNum,
+  RevisionPatchSetNum,
   ReviewInputTag,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {
   ChangeMessageDeletedEventDetail,
   ReplyEventDetail,
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {CommentSide} from '../../../constants/constants';
-import {SinonStubbedMember} from 'sinon';
+import {SinonStub} from 'sinon';
 import {html} from 'lit';
-import {fixture} from '@open-wc/testing-helpers';
+import {fixture, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-message tests', () => {
   let element: GrMessage;
@@ -75,7 +64,7 @@
         },
         date: '2016-01-12 20:24:49.448000000' as Timestamp,
         message: 'Uploaded patch set 1.',
-        _revision_number: 1 as PatchSetNum,
+        _revision_number: 1 as RevisionPatchSetNum,
         expanded: true,
       };
 
@@ -84,9 +73,9 @@
         assert.deepEqual(e.detail.message, element.message);
         promise.resolve();
       });
-      await flush();
+      await waitEventLoop();
       assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
-      tap(queryAndAssert(element, '.replyBtn'));
+      queryAndAssert<GrButton>(element, '.replyBtn').click();
       await promise;
     });
 
@@ -101,7 +90,7 @@
         },
         date: '2016-01-12 20:24:49.448000000' as Timestamp,
         message: 'Uploaded patch set 1.',
-        _revision_number: 1 as PatchSetNum,
+        _revision_number: 1 as RevisionPatchSetNum,
         expanded: true,
       };
       await element.updateComplete;
@@ -121,7 +110,7 @@
         },
         date: '2016-01-12 20:24:49.448000000' as Timestamp,
         message: 'Uploaded patch set 1.',
-        _revision_number: 1 as PatchSetNum,
+        _revision_number: 1 as RevisionPatchSetNum,
         expanded: true,
       };
       await element.updateComplete;
@@ -138,7 +127,7 @@
           promise.resolve();
         }
       );
-      tap(queryAndAssert(element, '.deleteBtn'));
+      queryAndAssert<GrButton>(element, '.deleteBtn').click();
       await element.updateComplete;
       assert.isTrue(queryAndAssert<GrButton>(element, '.deleteBtn').disabled);
       await promise;
@@ -153,36 +142,38 @@
       await element.updateComplete;
 
       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
+      assert.shadowDom.equal(
+        element,
+        /* 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>
-          <span class="dateContainer">
-            <span class="date">
-              <gr-date-formatter showdateandtime="" withtooltip="">
-              </gr-date-formatter>
+            <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>
+              <gr-icon
+                icon="expand_more"
+                id="expandToggle"
+                title="Toggle expanded state"
+              ></gr-icon>
             </span>
-            <iron-icon
-              icon="gr-icons:expand-more"
-              id="expandToggle"
-              title="Toggle expanded state"
-            >
-            </iron-icon>
-          </span>
-        </div>
-      </div>`);
+          </div>
+        </div>`
+      );
 
       element.hideAutomated = true;
       await element.updateComplete;
 
-      expect(element).shadowDom.to.equal(/* HTML */ '');
+      assert.shadowDom.equal(element, /* HTML */ '');
     });
 
     test('reviewer message treated as autogenerated', async () => {
@@ -195,36 +186,38 @@
       await element.updateComplete;
 
       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
+      assert.shadowDom.equal(
+        element,
+        /* 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>
-          <span class="dateContainer">
-            <span class="date">
-              <gr-date-formatter showdateandtime="" withtooltip="">
-              </gr-date-formatter>
+            <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>
+              <gr-icon
+                icon="expand_more"
+                id="expandToggle"
+                title="Toggle expanded state"
+              ></gr-icon>
             </span>
-            <iron-icon
-              icon="gr-icons:expand-more"
-              id="expandToggle"
-              title="Toggle expanded state"
-            >
-            </iron-icon>
-          </span>
-        </div>
-      </div>`);
+          </div>
+        </div>`
+      );
 
       element.hideAutomated = true;
       await element.updateComplete;
 
-      expect(element).shadowDom.to.equal(/* HTML */ '');
+      assert.shadowDom.equal(element, /* HTML */ '');
     });
 
     test('batch reviewer message treated as autogenerated', async () => {
@@ -238,37 +231,39 @@
       await element.updateComplete;
 
       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
+      assert.shadowDom.equal(
+        element,
+        /* 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>
-          <div class="content"></div>
-          <span class="dateContainer">
-            <span class="date">
-              <gr-date-formatter showdateandtime="" withtooltip="">
-              </gr-date-formatter>
+            <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>
+              <gr-icon
+                icon="expand_more"
+                id="expandToggle"
+                title="Toggle expanded state"
+              ></gr-icon>
             </span>
-            <iron-icon
-              icon="gr-icons:expand-more"
-              id="expandToggle"
-              title="Toggle expanded state"
-            >
-            </iron-icon>
-          </span>
-        </div>
-      </div>`);
+          </div>
+        </div>`
+      );
 
       element.hideAutomated = true;
       await element.updateComplete;
 
-      expect(element).shadowDom.to.equal(/* HTML */ '');
+      assert.shadowDom.equal(element, /* HTML */ '');
     });
 
     test('tag that is not autogenerated prefix does not hide', async () => {
@@ -296,22 +291,81 @@
               <gr-date-formatter showdateandtime="" withtooltip="">
               </gr-date-formatter>
             </span>
-            <iron-icon
-              icon="gr-icons:expand-more"
+            <gr-icon
+              icon="expand_more"
               id="expandToggle"
               title="Toggle expanded state"
-            >
-            </iron-icon>
+            ></gr-icon>
           </span>
         </div>
       </div>`;
-      expect(element).shadowDom.to.equal(rendered);
+      assert.shadowDom.equal(element, rendered);
 
       element.hideAutomated = true;
       await element.updateComplete;
       console.error(element.computeIsAutomated());
 
-      expect(element).shadowDom.to.equal(rendered);
+      assert.shadowDom.equal(element, rendered);
+    });
+
+    test('renders comment message', async () => {
+      element.commentThreads = [
+        createCommentThread([
+          createComment({message: 'hello 1', unresolved: true}),
+        ]),
+        createCommentThread([createComment({message: 'hello 2'})]),
+      ];
+      element.message = {
+        ...createChangeMessage(),
+        commentThreads: element.commentThreads,
+      };
+      await element.updateComplete;
+
+      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="commentsSummary">
+            <span class="numberOfComments" title="1 unresolved comment">
+              <gr-icon
+                class="commentsIcon unresolved"
+                small
+                filled
+                icon="chat_bubble"
+              >
+              </gr-icon>
+              1
+            </span>
+            <span class="numberOfComments" title="1 resolved comment">
+              <gr-icon
+                class="commentsIcon"
+                small
+                icon="mark_chat_read"
+              ></gr-icon>
+              1
+            </span>
+          </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>
+            <gr-icon
+              icon="expand_more"
+              id="expandToggle"
+              title="Toggle expanded state"
+            ></gr-icon>
+          </span>
+        </div>
+      </div>`;
+      assert.shadowDom.equal(element, rendered);
     });
 
     test('reply button hidden unless logged in', () => {
@@ -360,19 +414,19 @@
 
       const stub = sinon.stub();
       element.addEventListener('message-anchor-tap', stub);
-      const dateEl = queryAndAssert(element, '.date');
+      const dateEl = queryAndAssert<HTMLSpanElement>(element, '.date');
       assert.ok(dateEl);
-      tap(dateEl);
+      dateEl.click();
 
       assert.isTrue(stub.called);
       assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message?.id});
     });
 
     suite('uploaded patchset X message navigates to X - 1 vs  X', () => {
-      let navStub: SinonStubbedMember<typeof GerritNav.navigateToChange>;
+      let setUrlStub: SinonStub;
       setup(() => {
         element.change = {...createChange(), revisions: createRevisions(4)};
-        navStub = sinon.stub(GerritNav, 'navigateToChange');
+        setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
       });
 
       test('Patchset 1 navigates to Base', () => {
@@ -381,12 +435,9 @@
           message: 'Uploaded patch set 1.',
         };
         element.handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(
-          navStub.calledWithExactly(element.change!, {
-            patchNum: 1 as PatchSetNum,
-            basePatchNum: 'PARENT' as BasePatchSetNum,
-          })
-        );
+
+        assert.isTrue(setUrlStub.calledOnce);
+        assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
       });
 
       test('Patchset X navigates to X vs X - 1', () => {
@@ -395,23 +446,20 @@
           message: 'Uploaded patch set 2.',
         };
         element.handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(
-          navStub.calledWithExactly(element.change!, {
-            patchNum: 2 as PatchSetNum,
-            basePatchNum: 1 as BasePatchSetNum,
-          })
-        );
+
+        assert.isTrue(setUrlStub.calledOnce);
+        assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1..2');
 
         element.message = {
           ...createChangeMessage(),
           message: 'Uploaded patch set 200.',
         };
         element.handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(
-          navStub.calledWithExactly(element.change!, {
-            patchNum: 200 as PatchSetNum,
-            basePatchNum: 199 as BasePatchSetNum,
-          })
+
+        assert.isTrue(setUrlStub.calledTwice);
+        assert.equal(
+          setUrlStub.lastCall.firstArg,
+          '/c/test-project/+/42/199..200'
         );
       });
 
@@ -421,12 +469,9 @@
           message: 'Commit message updated.',
         };
         element.handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(
-          navStub.calledWithExactly(element.change!, {
-            patchNum: 4 as PatchSetNum,
-            basePatchNum: 3 as BasePatchSetNum,
-          })
-        );
+
+        assert.isTrue(setUrlStub.calledOnce);
+        assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3..4');
       });
 
       test('Merged patchset change message', () => {
@@ -435,12 +480,9 @@
           message: 'abcd↵3 is the latest approved patch-set.↵abc',
         };
         element.handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(
-          navStub.calledWithExactly(element.change!, {
-            patchNum: 4 as PatchSetNum,
-            basePatchNum: 3 as BasePatchSetNum,
-          })
-        );
+
+        assert.isTrue(setUrlStub.calledOnce);
+        assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3..4');
       });
     });
 
@@ -701,7 +743,7 @@
         },
         date: '2016-01-12 20:24:49.448000000' as Timestamp,
         message: 'Uploaded patch set 1.',
-        _revision_number: 1 as PatchSetNum,
+        _revision_number: 1 as RevisionPatchSetNum,
         expanded: true,
       };
 
@@ -729,7 +771,7 @@
               ...createComment(),
               change_message_id:
                 '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3' as ChangeMessageId,
-              patch_set: 1 as PatchSetNum,
+              patch_set: 1 as RevisionPatchSetNum,
               id: 'e365b138_bed65caa' as UrlEncodedCommentId,
               updated: '2020-05-15 13:35:56.000000000' as Timestamp,
               message: 'testing the load',
@@ -737,7 +779,7 @@
               path: '/PATCHSET_LEVEL',
             },
           ],
-          patchNum: 1 as PatchSetNum,
+          patchNum: 1 as RevisionPatchSetNum,
           path: '/PATCHSET_LEVEL',
           rootId: 'e365b138_bed65caa' as UrlEncodedCommentId,
           commentSide: CommentSide.REVISION,
@@ -756,7 +798,7 @@
           comments: [
             {
               ...createComment(),
-              patch_set: 1 as PatchSetNum,
+              patch_set: 1 as RevisionPatchSetNum,
               id: 'e365b138_bed65caa' as UrlEncodedCommentId,
               updated: '2020-05-15 13:35:56.000000000' as Timestamp,
               message: 'testing the load',
@@ -765,7 +807,7 @@
             },
             {
               change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
-              patch_set: 1 as PatchSetNum,
+              patch_set: 1 as RevisionPatchSetNum,
               id: 'd6efcc85_4cbbb6f4' as UrlEncodedCommentId,
               in_reply_to: 'e365b138_bed65caa' as UrlEncodedCommentId,
               updated: '2020-05-15 16:55:28.000000000' as Timestamp,
@@ -775,7 +817,7 @@
               __draft: true,
             },
           ],
-          patchNum: 1 as PatchSetNum,
+          patchNum: 1 as RevisionPatchSetNum,
           path: '/PATCHSET_LEVEL',
           rootId: 'e365b138_bed65caa' as UrlEncodedCommentId,
           commentSide: CommentSide.REVISION,
@@ -806,7 +848,7 @@
         },
         date: '2016-01-12 20:24:49.448000000' as Timestamp,
         message: 'Uploaded patch set 1.',
-        _revision_number: 1 as PatchSetNum,
+        _revision_number: 1 as RevisionPatchSetNum,
         expanded: true,
       };
       await element.updateComplete;
@@ -834,7 +876,7 @@
         },
         date: '2016-01-12 20:24:49.448000000' as Timestamp,
         message: 'not empty',
-        _revision_number: 1 as PatchSetNum,
+        _revision_number: 1 as RevisionPatchSetNum,
         expanded: true,
       };
       await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index f6b0ad4..5417127 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
@@ -1,35 +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
  */
-import {Subscription} from 'rxjs';
 import '@polymer/paper-toggle-button/paper-toggle-button';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
 import '../gr-message/gr-message';
 import '../../../styles/gr-paper-styles';
-import '../../../styles/shared-styles';
-import {htmlTemplate} from './gr-messages-list_html';
-import {
-  Shortcut,
-  ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {parseDate} from '../../../utils/date-util';
 import {MessageTag} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
-import {customElement, property} from '@polymer/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {
   ChangeId,
   ChangeMessageId,
@@ -37,18 +18,10 @@
   LabelNameToInfoMap,
   NumericChangeId,
   PatchSetNum,
-  RepoName,
-  ReviewerUpdateInfo,
   VotingRangeInfo,
 } from '../../../types/common';
-import {
-  CommentThread,
-  isRobot,
-  LabelExtreme,
-} from '../../../utils/comment-util';
+import {CommentThread, isRobot} from '../../../utils/comment-util';
 import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
 import {getVotingRange} from '../../../utils/label-util';
 import {
   FormattedReviewerUpdateInfo,
@@ -56,8 +29,21 @@
 } 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';
+import {resolve} from '../../../models/dependency';
+import {query, queryAll} from '../../../utils/common-util';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {when} from 'lit/directives/when.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {
+  Shortcut,
+  ShortcutSection,
+  shortcutsServiceToken,
+} from '../../../services/shortcuts/shortcuts-service';
+import {GrFormattedText} from '../../shared/gr-formatted-text/gr-formatted-text';
+import {Interaction} from '../../../constants/reporting';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -121,6 +107,9 @@
  *
  * 3. Everything beyond the ~ character is cut off from the tag. That gives
  * tools control over which messages will be hidden.
+ *
+ * 4. (Non-WIP) patchset uploads get a separate tag when they invalidate any
+ * votes.
  */
 function computeTag(message: CombinedMessage) {
   if (!message.tag) {
@@ -133,6 +122,15 @@
     return hasRobotComments ? 'autogenerated:has-robot-comments' : undefined;
   }
 
+  if (message.tag === MessageTag.TAG_NEW_PATCHSET) {
+    const hasOutdatedVotes =
+      isChangeMessageInfo(message) &&
+      message.message.indexOf('\nOutdated Votes:\n') !== -1;
+
+    return hasOutdatedVotes
+      ? MessageTag.TAG_NEW_PATCHSET_OUTDATED_VOTES
+      : MessageTag.TAG_NEW_PATCHSET;
+  }
   if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
     return MessageTag.TAG_NEW_PATCHSET;
   }
@@ -168,6 +166,63 @@
 }
 
 /**
+ * Merges change messages and reviewer updates into one array. Also processes
+ * all messages and updates, aligns or massages some of the properties.
+ */
+function computeCombinedMessages(
+  messages: ChangeMessageInfo[],
+  reviewerUpdates: FormattedReviewerUpdateInfo[],
+  commentThreads: CommentThread[]
+): CombinedMessage[] {
+  let mi = 0;
+  let ri = 0;
+  let combinedMessages: CombinedMessage[] = [];
+  let mDate;
+  let rDate;
+  for (let i = 0; i < messages.length; i++) {
+    // TODO(TS): clone message instead and avoid API object mutation
+    (messages[i] as CombinedMessage)._index = i;
+  }
+
+  while (mi < messages.length || ri < reviewerUpdates.length) {
+    if (mi >= messages.length) {
+      combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
+      break;
+    }
+    if (ri >= reviewerUpdates.length) {
+      combinedMessages = combinedMessages.concat(messages.slice(mi));
+      break;
+    }
+    mDate = mDate || parseDate(messages[mi].date);
+    rDate = rDate || parseDate(reviewerUpdates[ri].date);
+    if (rDate < mDate) {
+      combinedMessages.push(reviewerUpdates[ri++]);
+      rDate = null;
+    } else {
+      combinedMessages.push(messages[mi++]);
+      mDate = null;
+    }
+  }
+
+  for (let i = 0; i < combinedMessages.length; i++) {
+    const message = combinedMessages[i];
+    if (message.expanded === undefined) {
+      message.expanded = false;
+    }
+    message.commentThreads = computeThreads(message, commentThreads);
+    message._revision_number = computeRevision(message, combinedMessages);
+    message.tag = computeTag(message);
+  }
+  // computeIsImportant() depends on tags and revision numbers already being
+  // updated for all messages, so we have to compute this in its own forEach
+  // loop.
+  combinedMessages.forEach(m => {
+    m.isImportant = computeIsImportant(m, combinedMessages);
+  });
+  return combinedMessages;
+}
+
+/**
  * Unimportant messages are initially hidden.
  *
  * Human messages are always important. They have an undefined tag.
@@ -194,69 +249,78 @@
   computeIsImportant,
 };
 
-export interface GrMessagesList {
-  $: {
-    messageRepeat: DomRepeat;
-  };
-}
-
 @customElement('gr-messages-list')
-export class GrMessagesList extends DIPolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrMessagesList extends LitElement {
+  // TODO: Evaluate if we still need to have display: flex on the :host and
+  // .header.
+  static override get styles() {
+    return [
+      sharedStyles,
+      paperStyles,
+      css`
+        :host {
+          display: flex;
+          justify-content: space-between;
+        }
+        .header {
+          align-items: center;
+          border-bottom: 1px solid var(--border-color);
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .highlighted {
+          animation: 3s fadeOut;
+        }
+        @keyframes fadeOut {
+          0% {
+            background-color: var(--emphasis-color);
+          }
+          100% {
+            background-color: var(--view-background-color);
+          }
+        }
+        .container {
+          align-items: center;
+          display: flex;
+        }
+        .hiddenEntries {
+          color: var(--deemphasized-text-color);
+        }
+        gr-message:not(:last-of-type) {
+          border-bottom: 1px solid var(--border-color);
+        }
+      `,
+    ];
   }
 
-  // Private internal @state, derived from the application state.
-  @property({type: Object})
-  change?: ParsedChangeInfo;
-
-  // Private internal @state, derived from the application state.
-  @property({type: String})
-  changeNum?: ChangeId | NumericChangeId;
-
   @property({type: Array})
   messages: ChangeMessageInfo[] = [];
 
   @property({type: Array})
-  reviewerUpdates: ReviewerUpdateInfo[] = [];
-
-  // Private internal @state, derived from the application state.
-  @property({type: Object})
-  commentThreads: CommentThread[] = [];
-
-  // Private internal @state, derived from the application state.
-  @property({type: String})
-  projectName?: RepoName;
-
-  // Private internal @state, derived from the application state.
-  @property({type: Boolean})
-  showReplyButtons = false;
+  reviewerUpdates: FormattedReviewerUpdateInfo[] = [];
 
   @property({type: Object})
   labels?: LabelNameToInfoMap;
 
-  @property({type: String})
-  _expandAllState = ExpandAllState.EXPAND_ALL;
+  @state()
+  private change?: ParsedChangeInfo;
 
-  @property({type: String, computed: '_computeExpandAllTitle(_expandAllState)'})
-  _expandAllTitle = '';
+  @state()
+  private changeNum?: ChangeId | NumericChangeId;
 
-  @property({type: Boolean, observer: '_observeShowAllActivity'})
-  _showAllActivity = false;
+  @state()
+  private commentThreads: CommentThread[] = [];
 
-  @property({
-    type: Array,
-    computed:
-      '_computeCombinedMessages(messages, reviewerUpdates, ' +
-      'commentThreads)',
-    observer: '_combinedMessagesChanged',
-  })
-  _combinedMessages: CombinedMessage[] = [];
+  @state()
+  expandAllState = ExpandAllState.EXPAND_ALL;
 
-  @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
-  _labelExtremes: LabelExtreme = {};
+  // Private but used in tests.
+  @state()
+  showAllActivity = false;
 
-  private readonly userModel = getAppContext().userModel;
+  @state()
+  private combinedMessages: CombinedMessage[] = [];
 
   // Private but used in tests.
   readonly getCommentsModel = resolve(this, commentsModelToken);
@@ -265,45 +329,115 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly shortcuts = getAppContext().shortcutsService;
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
-  private subscriptions: Subscription[] = [];
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.subscriptions.push(
-      this.getCommentsModel().threads$.subscribe(x => {
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getCommentsModel().threads$,
+      x => {
         this.commentThreads = x;
-      })
+      }
     );
-    this.subscriptions.push(
-      this.changeModel().change$.subscribe(x => {
+    subscribe(
+      this,
+      () => this.changeModel().change$,
+      x => {
         this.change = x;
-      })
+      }
     );
-    this.subscriptions.push(
-      this.userModel.loggedIn$.subscribe(x => {
-        this.showReplyButtons = x;
-      })
-    );
-    this.subscriptions.push(
-      this.changeModel().repo$.subscribe(x => {
-        this.projectName = x;
-      })
-    );
-    this.subscriptions.push(
-      this.changeModel().changeNum$.subscribe(x => {
+    subscribe(
+      this,
+      () => this.changeModel().changeNum$,
+      x => {
         this.changeNum = x;
-      })
+      }
+    );
+    // for COMMENTS_AUTOCLOSE logging purposes only
+    this.reporting.reportInteraction(
+      Interaction.COMMENTS_AUTOCLOSE_MESSAGES_LIST_CREATED
     );
   }
 
-  override disconnectedCallback() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
+  override updated(): void {
+    // for COMMENTS_AUTOCLOSE logging purposes only
+    const messages = this.shadowRoot!.querySelectorAll('gr-message');
+    if (messages.length > 0) {
+      this.reporting.reportInteraction(
+        Interaction.COMMENTS_AUTOCLOSE_MESSAGES_LIST_UPDATED,
+        {uid: messages[0].uid}
+      );
     }
-    this.subscriptions = [];
-    super.disconnectedCallback();
+  }
+
+  override willUpdate(changedProperties: PropertyValues): void {
+    if (
+      changedProperties.has('messages') ||
+      changedProperties.has('reviewerUpdates') ||
+      changedProperties.has('commentThreads')
+    ) {
+      this.combinedMessages = computeCombinedMessages(
+        this.messages ?? [],
+        this.reviewerUpdates ?? [],
+        this.commentThreads ?? []
+      );
+      this.combinedMessagesChanged();
+    }
+  }
+
+  override render() {
+    const labelExtremes = this.computeLabelExtremes();
+    return html`${this.renderHeader()}
+    ${this.combinedMessages
+      .filter(m => this.showAllActivity || m.isImportant)
+      .map(
+        message => html`<gr-message
+          .change=${this.change}
+          .changeNum=${this.changeNum}
+          .message=${message}
+          .commentThreads=${message.commentThreads}
+          @message-anchor-tap=${this.handleAnchorClick}
+          .labelExtremes=${labelExtremes}
+          data-message-id=${ifDefined(getMessageId(message) as String)}
+        ></gr-message>`
+      )}`;
+  }
+
+  private renderHeader() {
+    return html`<div class="header">
+      <div id="showAllActivityToggleContainer" class="container">
+        ${when(
+          this.combinedMessages.some(m => !m.isImportant),
+          () => html`
+            <paper-toggle-button
+              class="showAllActivityToggle"
+              ?checked=${this.showAllActivity}
+              @change=${this.handleShowAllActivityChanged}
+              aria-labelledby="showAllEntriesLabel"
+              role="switch"
+              @click=${this.onTapShowAllActivityToggle}
+            ></paper-toggle-button>
+            <div id="showAllEntriesLabel" aria-hidden="true">
+              <span>Show all entries</span>
+              <span class="hiddenEntries" ?hidden=${this.showAllActivity}>
+                (${this.combinedMessages.filter(m => !m.isImportant).length}
+                hidden)
+              </span>
+            </div>
+            <span class="transparent separator"></span>
+          `
+        )}
+      </div>
+      <gr-button
+        id="collapse-messages"
+        link
+        .title=${this.computeExpandAllTitle()}
+        @click=${this.handleExpandCollapseTap}
+      >
+        ${this.expandAllState}
+      </gr-button>
+    </div>`;
   }
 
   async scrollToMessage(messageID: string) {
@@ -312,21 +446,25 @@
       | GrMessage
       | undefined;
 
-    if (!el && this._showAllActivity) {
+    if (!el && this.showAllActivity) {
       this.reporting.error(
+        'GrMessagesList scroll',
         new Error(`Failed to scroll to message: ${messageID}`)
       );
       return;
     }
     if (!el || !el.message) {
-      this._showAllActivity = true;
+      this.showAllActivity = true;
       setTimeout(() => this.scrollToMessage(messageID));
       return;
     }
 
     el.message.expanded = true;
+    // Must wait for message to expand and render before we can scroll to it
     el.requestUpdate();
     await el.updateComplete;
+    await query<GrFormattedText>(el, 'gr-formatted-text.message')
+      ?.updateComplete;
     let top = el.offsetTop;
     for (
       let offsetParent = el.offsetParent as HTMLElement | null;
@@ -336,102 +474,28 @@
       top += offsetParent.offsetTop;
     }
     window.scrollTo(0, top);
-    this._highlightEl(el);
+    this.highlightEl(el);
   }
 
-  _observeShowAllActivity() {
-    // We have to call render() such that the dom-repeat filter picks up the
-    // change.
-    this.$.messageRepeat.render();
+  private handleShowAllActivityChanged(e: Event) {
+    this.showAllActivity = (e.target as HTMLInputElement).checked ?? false;
   }
 
-  /**
-   * Filter for the dom-repeat of combinedMessages.
-   */
-  _isMessageVisible(message: CombinedMessage) {
-    return this._showAllActivity || message.isImportant;
-  }
-
-  /**
-   * Merges change messages and reviewer updates into one array. Also processes
-   * all messages and updates, aligns or massages some of the properties.
-   */
-  _computeCombinedMessages(
-    messages: ChangeMessageInfo[] | undefined,
-    reviewerUpdates: FormattedReviewerUpdateInfo[] | undefined,
-    commentThreads: CommentThread[]
-  ) {
-    if (messages === undefined || reviewerUpdates === undefined) return;
-
-    let mi = 0;
-    let ri = 0;
-    let combinedMessages: CombinedMessage[] = [];
-    let mDate;
-    let rDate;
-    for (let i = 0; i < messages.length; i++) {
-      // TODO(TS): clone message instead and avoid API object mutation
-      (messages[i] as CombinedMessage)._index = i;
-    }
-
-    while (mi < messages.length || ri < reviewerUpdates.length) {
-      if (mi >= messages.length) {
-        combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
-        break;
-      }
-      if (ri >= reviewerUpdates.length) {
-        combinedMessages = combinedMessages.concat(messages.slice(mi));
-        break;
-      }
-      mDate = mDate || parseDate(messages[mi].date);
-      rDate = rDate || parseDate(reviewerUpdates[ri].date);
-      if (rDate < mDate) {
-        combinedMessages.push(reviewerUpdates[ri++]);
-        rDate = null;
-      } else {
-        combinedMessages.push(messages[mi++]);
-        mDate = null;
-      }
-    }
-
-    for (let i = 0; i < combinedMessages.length; i++) {
-      const message = combinedMessages[i];
-      if (message.expanded === undefined) {
-        message.expanded = false;
-      }
-      message.commentThreads = computeThreads(message, commentThreads);
-      message._revision_number = computeRevision(message, combinedMessages);
-      message.tag = computeTag(message);
-    }
-    // computeIsImportant() depends on tags and revision numbers already being
-    // updated for all messages, so we have to compute this in its own forEach
-    // loop.
-    combinedMessages.forEach(m => {
-      m.isImportant = computeIsImportant(m, combinedMessages);
-    });
-    return combinedMessages;
-  }
-
-  _updateExpandedStateOfAllMessages(exp: boolean) {
-    if (!this._combinedMessages) return;
-
-    for (let i = 0; i < this._combinedMessages.length; i++) {
-      this._combinedMessages[i].expanded = exp;
-      this.notifyPath(`_combinedMessages.${i}.expanded`);
-    }
+  private refreshMessages() {
     for (const message of queryAll<GrMessage>(this, 'gr-message')) {
-      message.requestUpdate('message');
+      message.requestUpdate();
     }
   }
 
-  _computeExpandAllTitle(_expandAllState?: string) {
-    if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
-      return this.shortcuts.createTitle(
+  private computeExpandAllTitle() {
+    if (this.expandAllState === ExpandAllState.COLLAPSE_ALL) {
+      return this.getShortcutsService().createTitle(
         Shortcut.COLLAPSE_ALL_MESSAGES,
         ShortcutSection.ACTIONS
       );
     }
-    if (_expandAllState === ExpandAllState.EXPAND_ALL) {
-      return this.shortcuts.createTitle(
+    if (this.expandAllState === ExpandAllState.EXPAND_ALL) {
+      return this.getShortcutsService().createTitle(
         Shortcut.EXPAND_ALL_MESSAGES,
         ShortcutSection.ACTIONS
       );
@@ -439,8 +503,10 @@
     return '';
   }
 
-  _highlightEl(el: HTMLElement) {
-    const highlightedEls = this.root!.querySelectorAll('.highlighted');
+  // Private but used in tests.
+  highlightEl(el: HTMLElement) {
+    const highlightedEls =
+      this.shadowRoot?.querySelectorAll('.highlighted') ?? [];
     for (const highlightedEl of highlightedEls) {
       highlightedEl.classList.remove('highlighted');
     }
@@ -452,42 +518,36 @@
     el.classList.add('highlighted');
   }
 
+  // Private but used in tests.
   handleExpandCollapse(expand: boolean) {
-    this._expandAllState = expand
+    this.expandAllState = expand
       ? ExpandAllState.COLLAPSE_ALL
       : ExpandAllState.EXPAND_ALL;
-    this._updateExpandedStateOfAllMessages(expand);
+    if (!this.combinedMessages) return;
+    for (let i = 0; i < this.combinedMessages.length; i++) {
+      this.combinedMessages[i].expanded = expand;
+    }
+    this.refreshMessages();
   }
 
-  _handleExpandCollapseTap(e: Event) {
+  private handleExpandCollapseTap(e: Event) {
     e.preventDefault();
     this.handleExpandCollapse(
-      this._expandAllState === ExpandAllState.EXPAND_ALL
+      this.expandAllState === ExpandAllState.EXPAND_ALL
     );
   }
 
-  _handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) {
+  private handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) {
     this.scrollToMessage(e.detail.id);
   }
 
-  _isVisibleShowAllActivityToggle(messages: CombinedMessage[] = []) {
-    return messages.some(m => !m.isImportant);
-  }
-
-  _computeHiddenEntriesCount(messages: CombinedMessage[] = []) {
-    return messages.filter(m => !m.isImportant).length;
-  }
-
   /**
-   * Called when this._combinedMessages has changed.
+   * Called when this.combinedMessages has changed.
    */
-  _combinedMessagesChanged(combinedMessages?: CombinedMessage[]) {
-    if (!combinedMessages) return;
-    if (combinedMessages.length === 0) return;
-    for (let i = 0; i < combinedMessages.length; i++) {
-      this.notifyPath(`_combinedMessages.${i}.commentThreads`);
-    }
-    const tags = combinedMessages.map(
+  private combinedMessagesChanged() {
+    if (this.combinedMessages.length === 0) return;
+    this.refreshMessages();
+    const tags = this.combinedMessages.map(
       message =>
         message.tag || (message as FormattedReviewerUpdateInfo).type || 'none'
     );
@@ -496,7 +556,7 @@
         acc[val] = (acc[val] || 0) + 1;
         return acc;
       },
-      {all: combinedMessages.length} as TagsCountReportInfo
+      {all: this.combinedMessages.length} as TagsCountReportInfo
     );
     this.reporting.reportInteraction('messages-count', tagsCounted);
   }
@@ -504,20 +564,15 @@
   /**
    * Compute a mapping from label name to objects representing the minimum and
    * maximum possible values for that label.
+   * Private but used in tests.
    */
-  _computeLabelExtremes(
-    labelRecord: PolymerDeepPropertyChange<
-      LabelNameToInfoMap,
-      LabelNameToInfoMap
-    >
-  ) {
+  computeLabelExtremes() {
     const extremes: {[labelName: string]: VotingRangeInfo} = {};
-    const labels = labelRecord.base;
-    if (!labels) {
+    if (!this.labels) {
       return extremes;
     }
-    for (const key of Object.keys(labels)) {
-      const range = getVotingRange(labels[key]);
+    for (const key of Object.keys(this.labels)) {
+      const range = getVotingRange(this.labels[key]);
       if (range) {
         extremes[key] = range;
       }
@@ -528,7 +583,7 @@
   /**
    * Work around a issue on iOS when clicking turns into double tap
    */
-  _onTapShowAllActivityToggle(e: Event) {
+  private onTapShowAllActivityToggle(e: Event) {
     e.preventDefault();
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
deleted file mode 100644
index 087ee19..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: flex;
-      justify-content: space-between;
-    }
-    .header {
-      align-items: center;
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .highlighted {
-      animation: 3s fadeOut;
-    }
-    @keyframes fadeOut {
-      0% {
-        background-color: var(--emphasis-color);
-      }
-      100% {
-        background-color: var(--view-background-color);
-      }
-    }
-    .container {
-      align-items: center;
-      display: flex;
-    }
-    .hiddenEntries {
-      color: var(--deemphasized-text-color);
-    }
-    gr-message:not(:last-of-type) {
-      border-bottom: 1px solid var(--border-color);
-    }
-  </style>
-  <style include="gr-paper-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="header">
-    <div id="showAllActivityToggleContainer" class="container">
-      <template
-        is="dom-if"
-        if="[[_isVisibleShowAllActivityToggle(_combinedMessages)]]"
-      >
-        <paper-toggle-button
-          class="showAllActivityToggle"
-          checked="{{_showAllActivity}}"
-          aria-labelledby="showAllEntriesLabel"
-          role="switch"
-          on-click="_onTapShowAllActivityToggle"
-        ></paper-toggle-button>
-        <div id="showAllEntriesLabel" aria-hidden="true">
-          <span>Show all entries</span>
-          <span class="hiddenEntries" hidden$="[[_showAllActivity]]">
-            ([[_computeHiddenEntriesCount(_combinedMessages)]] hidden)
-          </span>
-        </div>
-        <span class="transparent separator"></span>
-      </template>
-    </div>
-    <gr-button
-      id="collapse-messages"
-      link=""
-      title="[[_expandAllTitle]]"
-      on-click="_handleExpandCollapseTap"
-    >
-      [[_expandAllState]]
-    </gr-button>
-  </div>
-  <template
-    id="messageRepeat"
-    is="dom-repeat"
-    items="[[_combinedMessages]]"
-    as="message"
-    filter="_isMessageVisible"
-  >
-    <gr-message
-      change="[[change]]"
-      change-num="[[changeNum]]"
-      message="[[message]]"
-      comment-threads="[[message.commentThreads]]"
-      project-name="[[projectName]]"
-      show-reply-button="[[showReplyButtons]]"
-      on-message-anchor-tap="_handleAnchorClick"
-      label-extremes="[[_labelExtremes]]"
-      data-message-id$="[[message.id]]"
-    ></gr-message>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index b9cb616..2e62718 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -1,26 +1,12 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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,
@@ -37,22 +23,15 @@
   NumericChangeId,
   PatchSetNum,
   ReviewInputTag,
+  RevisionPatchSetNum,
   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>
-`);
+import {html} from 'lit';
+import {fixture, assert} from '@open-wc/testing';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {PaperToggleButtonElement} from '@polymer/paper-toggle-button';
 
 const author = {
   _account_id: 42 as AccountId,
@@ -67,7 +46,7 @@
     change_message_id: '8a7b6c5d',
     updated: '2016-01-01 01:02:03.000000000' as Timestamp,
     line: 1,
-    patch_set: 1 as PatchSetNum,
+    patch_set: 1 as RevisionPatchSetNum,
     author,
   };
 };
@@ -99,8 +78,6 @@
   let element: GrMessagesList;
   let messages: ChangeMessageInfo[];
 
-  let commentApiWrapper: any;
-
   const getMessages = function () {
     return queryAll<GrMessage>(element, 'gr-message');
   };
@@ -156,16 +133,36 @@
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
 
       messages = generateRandomMessages(3);
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = queryAndAssert<GrMessagesList>(
-        commentApiWrapper,
-        '#messagesList'
+      element = await fixture<GrMessagesList>(
+        html`<gr-messages-list></gr-messages-list>`
       );
       await element.getCommentsModel().reloadComments(0 as NumericChangeId);
       element.messages = messages;
-      await flush();
+      await element.updateComplete;
+    });
+
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="header">
+            <div class="container" id="showAllActivityToggleContainer"></div>
+            <gr-button
+              aria-disabled="false"
+              id="collapse-messages"
+              link=""
+              role="button"
+              tabindex="0"
+              title="Expand all messages (shortcut: x)"
+            >
+              Expand All
+            </gr-button>
+          </div>
+          <gr-message data-message-id="${messages[0].id}"> </gr-message>
+          <gr-message data-message-id="${messages[1].id}"> </gr-message>
+          <gr-message data-message-id="${messages[2].id}"> </gr-message>
+        `
+      );
     });
 
     test('expand/collapse all', async () => {
@@ -175,16 +172,19 @@
         message.message = {...message.message, expanded: false};
         await message.updateComplete;
       }
-      MockInteractions.tap(allMessageEls[1]);
+      allMessageEls[1].click();
+      await element.updateComplete;
       assert.isTrue(allMessageEls[1].message?.expanded);
 
-      MockInteractions.tap(queryAndAssert(element, '#collapse-messages'));
+      queryAndAssert<GrButton>(element, '#collapse-messages').click();
+      await element.updateComplete;
       allMessageEls = getMessages();
       for (const message of allMessageEls) {
         assert.isTrue(message.message?.expanded);
       }
 
-      MockInteractions.tap(queryAndAssert(element, '#collapse-messages'));
+      queryAndAssert<GrButton>(element, '#collapse-messages').click();
+      await element.updateComplete;
       allMessageEls = getMessages();
       for (const message of allMessageEls) {
         assert.isFalse(message.message?.expanded);
@@ -230,7 +230,7 @@
       }
 
       const scrollToStub = sinon.stub(window, 'scrollTo');
-      const highlightStub = sinon.stub(element, '_highlightEl');
+      const highlightStub = sinon.stub(element, 'highlightEl');
 
       await element.scrollToMessage('invalid');
 
@@ -255,7 +255,7 @@
 
     test('scroll to message offscreen', async () => {
       const scrollToStub = sinon.stub(window, 'scrollTo');
-      const highlightStub = sinon.stub(element, '_highlightEl');
+      const highlightStub = sinon.stub(element, 'highlightEl');
       element.messages = generateRandomMessages(25);
       await element.updateComplete;
       assert.isFalse(scrollToStub.called);
@@ -271,7 +271,7 @@
       );
     });
 
-    test('associating messages with comments', () => {
+    test('associating messages with comments', async () => {
       // Have to type as any otherwise fails with
       // Argument of type 'ChangeMessageInfo[]' is not assignable to
       // parameter of type 'ConcatArray<never>'.
@@ -295,14 +295,14 @@
         } as CombinedMessage
       );
       element.messages = messages;
-      flush();
+      await element.updateComplete;
       const messageElements = getMessages();
       assert.equal(messageElements.length, messages.length);
       assert.deepEqual(messageElements[1].message, messages[1]);
       assert.deepEqual(messageElements[2].message, messages[2]);
     });
 
-    test('threads', () => {
+    test('threads', async () => {
       const messages = [
         {
           _index: 5,
@@ -314,7 +314,7 @@
         },
       ];
       element.messages = messages;
-      flush();
+      await element.updateComplete;
       const messageElements = getMessages();
       // threads
       assert.equal(messageElements[0].message!.commentThreads.length, 3);
@@ -343,6 +343,19 @@
       assert.equal(TEST_ONLY.computeTag(m), MessageTag.TAG_NEW_PATCHSET);
     });
 
+    test('updateTag for outdated votes', () => {
+      const m = randomMessage();
+      m.tag = MessageTag.TAG_NEW_PATCHSET as ReviewInputTag;
+      m.message = '\nUploaded patch set 35.\n\nOutdated Votes:\n';
+      assert.equal(
+        TEST_ONLY.computeTag(m),
+        MessageTag.TAG_NEW_PATCHSET_OUTDATED_VOTES
+      );
+
+      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;
@@ -468,7 +481,7 @@
       assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
     });
 
-    test('isImportant is evaluated after tag update', () => {
+    test('isImportant is evaluated after tag update', async () => {
       const m1 = randomMessage({
         ...randomMessage(),
         tag: MessageTag.TAG_NEW_PATCHSET as ReviewInputTag,
@@ -480,12 +493,12 @@
         _revision_number: 2 as PatchSetNum,
       });
       element.messages = [m1, m2];
-      flush();
+      await element.updateComplete;
       assert.isFalse((m1 as CombinedMessage).isImportant);
       assert.isTrue((m2 as CombinedMessage).isImportant);
     });
 
-    test('messages without author do not throw', () => {
+    test('messages without author do not throw', async () => {
       const messages = [
         {
           _index: 5,
@@ -496,7 +509,7 @@
         },
       ];
       element.messages = messages;
-      flush();
+      await element.updateComplete;
       const messageEls = getMessages();
       assert.equal(messageEls.length, 1);
       assert.equal(messageEls[0].message!.message, messages[0].message);
@@ -507,9 +520,7 @@
     let element: GrMessagesList;
     let messages: ChangeMessageInfo[];
 
-    let commentApiWrapper: any;
-
-    setup(() => {
+    setup(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
@@ -519,6 +530,12 @@
         randomMessage(),
         randomMessage({
           ...randomMessage(),
+          tag: MessageTag.TAG_NEW_PATCHSET as ReviewInputTag,
+          message:
+            '\nUploaded patch set 35.\n\nInitial upload\n\nOutdated Votes:\n',
+        }),
+        randomMessage({
+          ...randomMessage(),
           tag: 'auto' as ReviewInputTag,
           _revision_number: 2 as PatchSetNum,
         }),
@@ -529,15 +546,11 @@
         }),
       ];
 
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = queryAndAssert<GrMessagesList>(
-        commentApiWrapper,
-        '#messagesList'
+      element = await fixture<GrMessagesList>(
+        html`<gr-messages-list></gr-messages-list>`
       );
       element.messages = messages;
-      flush();
+      await element.updateComplete;
     });
 
     test('hide autogenerated button is not hidden', () => {
@@ -547,62 +560,62 @@
 
     test('one unimportant message is hidden initially', () => {
       const displayedMsgs = queryAll<GrMessage>(element, 'gr-message');
-      assert.equal(displayedMsgs.length, 2);
+      assert.equal(displayedMsgs.length, 3);
     });
 
-    test('unimportant messages hidden after toggle', () => {
-      element._showAllActivity = true;
-      const toggle = queryAndAssert(element, '.showAllActivityToggle');
+    test('unimportant messages hidden after toggle', async () => {
+      element.showAllActivity = true;
+      await element.updateComplete;
+      const toggle = queryAndAssert<PaperToggleButtonElement>(
+        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();
+      toggle.click();
+      await element.updateComplete;
       const displayedMsgs = queryAll<GrMessage>(element, 'gr-message');
       assert.equal(displayedMsgs.length, 3);
     });
 
-    test('_computeLabelExtremes', () => {
-      const computeSpy = sinon.spy(element, '_computeLabelExtremes');
+    test('unimportant messages shown after toggle', async () => {
+      element.showAllActivity = false;
+      await element.updateComplete;
+      const toggle = queryAndAssert<PaperToggleButtonElement>(
+        element,
+        '.showAllActivityToggle'
+      );
+      assert.isOk(toggle);
+      toggle.click();
+      await element.updateComplete;
+      const displayedMsgs = queryAll<GrMessage>(element, 'gr-message');
+      assert.equal(displayedMsgs.length, 4);
+    });
 
+    test('_computeLabelExtremes', () => {
       // Have to type as any to be able to use null.
       element.labels = null as any;
-      assert.isTrue(computeSpy.calledOnce);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+      assert.deepEqual(element.computeLabelExtremes(), {});
 
       element.labels = {};
-      assert.isTrue(computeSpy.calledTwice);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+      assert.deepEqual(element.computeLabelExtremes(), {});
 
       element.labels = {'my-label': {}};
-      assert.isTrue(computeSpy.calledThrice);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+      assert.deepEqual(element.computeLabelExtremes(), {});
 
       element.labels = {'my-label': {values: {}}};
-      assert.equal(computeSpy.callCount, 4);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+      assert.deepEqual(element.computeLabelExtremes(), {});
 
       element.labels = {
         'my-label': {values: {'-12': {}}},
       } as LabelNameToInfoMap;
-      assert.equal(computeSpy.callCount, 5);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {
+      assert.deepEqual(element.computeLabelExtremes(), {
         'my-label': {min: -12, max: -12},
       });
 
       element.labels = {
         'my-label': {values: {'-2': {}, '-1': {}, '0': {}, '+1': {}, '+2': {}}},
       } as LabelNameToInfoMap;
-      assert.equal(computeSpy.callCount, 6);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {
+      assert.deepEqual(element.computeLabelExtremes(), {
         'my-label': {min: -2, max: 2},
       });
 
@@ -610,8 +623,7 @@
         'my-label': {values: {'-12': {}}},
         'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
       } as LabelNameToInfoMap;
-      assert.equal(computeSpy.callCount, 7);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {
+      assert.deepEqual(element.computeLabelExtremes(), {
         'my-label': {min: -12, max: -12},
         'other-label': {min: -1, max: 1},
       });
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index cbb29de..69fd142 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
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
   ChangeInfo,
@@ -24,11 +13,11 @@
 } from '../../../types/common';
 import {ChangeStatus} from '../../../constants/constants';
 import {isChangeInfo} from '../../../utils/change-util';
-import {ifDefined} from 'lit/directives/if-defined';
+import {ifDefined} from 'lit/directives/if-defined.js';
 
 @customElement('gr-related-change')
 export class GrRelatedChange extends LitElement {
-  @property()
+  @property({type: Object})
   change?: ChangeInfo | RelatedChangeAndCommitInfo;
 
   @property()
@@ -37,17 +26,17 @@
   @property()
   label?: string;
 
-  @property()
+  @property({type: Boolean, attribute: 'show-submittable-check'})
   showSubmittableCheck = false;
 
-  @property()
+  @property({type: Boolean, attribute: 'show-change-status'})
   showChangeStatus = false;
 
   /*
    * Needed for calculation if change is direct or indirect ancestor/descendant
    * to current change.
    */
-  @property()
+  @property({type: Array})
   connectedRevisions?: CommitId[];
 
   static override get styles() {
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 05fe62c..11fe0e2 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
@@ -1,50 +1,38 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import './gr-related-change';
+import './gr-related-collapse';
 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 {classMap} from 'lit/directives/class-map';
-import {LitElement, css, html, nothing, TemplateResult} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import '../../shared/gr-icon/gr-icon';
+import {classMap} from 'lit/directives/class-map.js';
+import {LitElement, css, html, TemplateResult} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
-  SubmittedTogetherInfo,
   ChangeInfo,
+  CommitId,
+  PatchSetNum,
   RelatedChangeAndCommitInfo,
   RelatedChangesInfo,
-  PatchSetNum,
-  CommitId,
+  RevisionPatchSetNum,
+  SubmittedTogetherInfo,
 } from '../../../types/common';
 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,
+  getChangeNumber,
   getRevisionKey,
-  isChangeInfo,
 } from '../../../utils/change-util';
-import {Interaction} from '../../../constants/reporting';
-import {fontStyles} from '../../../styles/gr-font-styles';
-
-/** What is the maximum number of shown changes in collapsed list? */
-const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
+import {DEFALT_NUM_CHANGES_WHEN_COLLAPSED} from './gr-related-collapse';
+import {createChangeUrl} from '../../../models/views/change';
 
 export interface ChangeMarkersInList {
   showCurrentChangeArrow: boolean;
@@ -63,13 +51,13 @@
 
 @customElement('gr-related-changes-list')
 export class GrRelatedChangesList extends LitElement {
-  @property()
+  @property({type: Object})
   change?: ParsedChangeInfo;
 
   @property({type: String})
   patchNum?: PatchSetNum;
 
-  @property()
+  @property({type: Boolean})
   mergeable?: boolean;
 
   @state()
@@ -120,6 +108,20 @@
           height: 1px;
           min-width: 20px;
         }
+        .repo {
+          margin-left: var(--spacing-m);
+        }
+        .repo,
+        .branch {
+          color: var(--primary-text-color);
+        }
+        @media screen and (max-width: 1400px) {
+          .repo,
+          .branch {
+            display: none;
+          }
+        }
+
         gr-related-collapse[collapsed] .marker.arrow {
           visibility: visible;
           min-width: auto;
@@ -221,13 +223,15 @@
                 .change=${change}
                 .connectedRevisions=${connectedRevisions}
                 .href=${change?._change_number
-                  ? GerritNav.getUrlForChangeById(
-                      change._change_number,
-                      change.project,
-                      change._revision_number as PatchSetNum
-                    )
+                  ? createChangeUrl({
+                      changeNum: change._change_number,
+                      project: change.project,
+                      usp: 'related-change',
+                      patchNum: change._revision_number as RevisionPatchSetNum,
+                    })
                   : ''}
-                .showChangeStatus=${true}
+                show-change-status
+                show-submittable-check
                 >${change.commit.subject}</gr-related-change
               >
             </div>`
@@ -274,16 +278,7 @@
             >
               ${this.renderMarkers(
                 submittedTogetherMarkersPredicate(index)
-              )}<gr-related-change
-                .label=${this.renderChangeTitle(change)}
-                .change=${change}
-                .href=${GerritNav.getUrlForChangeById(
-                  change._number,
-                  change.project
-                )}
-                .showSubmittableCheck=${true}
-                >${this.renderChangeLine(change)}</gr-related-change
-              >
+              )}${this.renderSubmittedTogetherLine(change, true)}
             </div>`
         )}
       </gr-related-collapse>
@@ -293,6 +288,24 @@
     </section>`;
   }
 
+  private renderSubmittedTogetherLine(
+    change: ChangeInfo,
+    showSubmittabilityCheck: boolean
+  ) {
+    const truncatedRepo = truncatePath(change.project, 2);
+    return html`
+      <gr-related-change
+        .label=${this.renderChangeTitle(change)}
+        .change=${change}
+        .href=${createChangeUrl({change, usp: 'submitted-together'})}
+        ?show-submittable-check=${showSubmittabilityCheck}
+        >${change.subject}</gr-related-change
+      >
+      <span class="repo" .title=${change.project}>${truncatedRepo}</span
+      ><span class="branch">&nbsp;|&nbsp;${change.branch}&nbsp;</span>
+    `;
+  }
+
   private renderSameTopic(
     isFirst: boolean,
     sectionSize: (section: Section) => number
@@ -324,15 +337,7 @@
             >
               ${this.renderMarkers(
                 sameTopicMarkersPredicate(index)
-              )}<gr-related-change
-                .change=${change}
-                .label=${this.renderChangeTitle(change)}
-                .href=${GerritNav.getUrlForChangeById(
-                  change._number,
-                  change.project
-                )}
-                >${this.renderChangeLine(change)}</gr-related-change
-              >
+              )}${this.renderSubmittedTogetherLine(change, false)}
             </div>`
         )}
       </gr-related-collapse>
@@ -371,10 +376,7 @@
                 mergeConflictsMarkersPredicate(index)
               )}<gr-related-change
                 .change=${change}
-                .href=${GerritNav.getUrlForChangeById(
-                  change._number,
-                  change.project
-                )}
+                .href=${createChangeUrl({change, usp: 'merge-conflict'})}
                 >${change.subject}</gr-related-change
               >
             </div>`
@@ -415,10 +417,7 @@
                 cherryPicksMarkersPredicate(index)
               )}<gr-related-change
                 .change=${change}
-                .href=${GerritNav.getUrlForChangeById(
-                  change._number,
-                  change.project
-                )}
+                .href=${createChangeUrl({change, usp: 'cherry-pick'})}
                 >${change.branch}: ${change.subject}</gr-related-change
               >
             </div>`
@@ -431,13 +430,6 @@
     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(
     relatedChangesLen: number,
     submittedTogetherLen: number,
@@ -560,7 +552,7 @@
         role="img"
         class="marker arrow"
         aria-label="Arrow marking change has collapsed ancestors"
-        ><iron-icon icon="gr-icons:arrowDropUp"></iron-icon
+        ><gr-icon icon="arrow_drop_up"></gr-icon
       ></span> `;
     }
     if (changeMarkers.showBottomArrow) {
@@ -568,7 +560,7 @@
         role="img"
         class="marker arrow"
         aria-label="Arrow marking change has collapsed descendants"
-        ><iron-icon icon="gr-icons:arrowDropDown"></iron-icon
+        ><gr-icon icon="arrow_drop_down"></gr-icon
       ></span> `;
     }
     return html`<span class="marker space"></span>`;
@@ -647,29 +639,12 @@
     a?: ChangeInfo | RelatedChangeAndCommitInfo,
     b?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
   ) {
-    const aNum = this._getChangeNumber(a);
-    const bNum = this._getChangeNumber(b);
+    if (!a || !b) return false;
+    const aNum = getChangeNumber(a);
+    const bNum = getChangeNumber(b);
     return aNum === bNum;
   }
 
-  /**
-   * Get the change number from either a ChangeInfo (such as those included in
-   * SubmittedTogetherInfo responses) or get the change number from a
-   * RelatedChangeAndCommitInfo (such as those included in a
-   * RelatedChangesInfo response).
-   */
-  _getChangeNumber(
-    change?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
-  ) {
-    // Default to 0 if change property is not defined.
-    if (!change) return 0;
-
-    if (isChangeInfo(change)) {
-      return change._number;
-    }
-    return change._change_number;
-  }
-
   /*
    * A list of commit ids connected to change to understand if other change
    * is direct or indirect ancestor / descendant.
@@ -711,92 +686,8 @@
   }
 }
 
-@customElement('gr-related-collapse')
-export class GrRelatedCollapse extends LitElement {
-  @property()
-  override title = '';
-
-  @property({type: Boolean})
-  showAll = false;
-
-  @property({type: Boolean, reflect: true})
-  collapsed = true;
-
-  @property()
-  length = 0;
-
-  @property()
-  numChangesWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
-
-  private readonly reporting = getAppContext().reportingService;
-
-  static override get styles() {
-    return [
-      sharedStyles,
-      fontStyles,
-      css`
-        .title {
-          color: var(--deemphasized-text-color);
-          display: flex;
-          align-self: flex-end;
-          margin-left: 20px;
-        }
-        gr-button {
-          display: flex;
-        }
-        gr-button iron-icon {
-          color: inherit;
-          --iron-icon-height: 18px;
-          --iron-icon-width: 18px;
-        }
-        .container {
-          justify-content: space-between;
-          display: flex;
-          margin-bottom: var(--spacing-s);
-        }
-        :host(.first) .container {
-          margin-bottom: var(--spacing-m);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    const title = html`<h3 class="title heading-3">${this.title}</h3>`;
-
-    const collapsible = this.length > this.numChangesWhenCollapsed;
-    this.collapsed = !this.showAll && collapsible;
-
-    let button: TemplateResult | typeof nothing = nothing;
-    if (collapsible) {
-      let buttonText = 'Show less';
-      let buttonIcon = 'expand-less';
-      if (!this.showAll) {
-        buttonText = `Show all (${this.length})`;
-        buttonIcon = 'expand-more';
-      }
-      button = html`<gr-button link="" @click=${this.toggle}
-        >${buttonText}<iron-icon icon="gr-icons:${buttonIcon}"></iron-icon
-      ></gr-button>`;
-    }
-
-    return html`<div class="container">${title}${button}</div>
-      <div><slot></slot></div>`;
-  }
-
-  private toggle(e: MouseEvent) {
-    e.stopPropagation();
-    this.showAll = !this.showAll;
-    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
-      sectionName: this.title,
-      toState: this.showAll ? 'Show all' : 'Show less',
-    });
-  }
-}
-
 declare global {
   interface HTMLElementTagNameMap {
     'gr-related-changes-list': GrRelatedChangesList;
-    'gr-related-collapse': GrRelatedCollapse;
   }
 }
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 f8c8317..dcc6039 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
@@ -1,25 +1,14 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
+import {fixture, html, assert} from '@open-wc/testing';
 import {SinonStubbedMember} from 'sinon';
 import {PluginApi} from '../../../api/plugin';
 import {ChangeStatus} from '../../../constants/constants';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {
   createChange,
   createCommitInfoWithRequiredCommit,
@@ -34,6 +23,7 @@
   queryAndAssert,
   resetPlugins,
   stubRestApi,
+  waitEventLoop,
 } from '../../../test/test-utils';
 import {
   ChangeId,
@@ -46,23 +36,24 @@
   SubmittedTogetherInfo,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
+import {getChangeNumber} from '../../../utils/change-util';
 import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import './gr-related-changes-list';
 import {
   ChangeMarkersInList,
   GrRelatedChangesList,
-  GrRelatedCollapse,
   Section,
 } from './gr-related-changes-list';
-
-const basicFixture = fixtureFromElement('gr-related-changes-list');
+import {GrRelatedCollapse} from './gr-related-collapse';
 
 suite('gr-related-changes-list', () => {
   let element: GrRelatedChangesList;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-related-changes-list></gr-related-changes-list>`
+    );
   });
 
   suite('show when collapsed', () => {
@@ -204,6 +195,72 @@
       element.patchNum = 1 as PatchSetNum;
     });
 
+    test('render', async () => {
+      stubRestApi('getRelatedChanges').returns(
+        Promise.resolve(relatedChangeInfo)
+      );
+      stubRestApi('getChangesSubmittedTogether').returns(
+        Promise.resolve(submittedTogether)
+      );
+      stubRestApi('getChangeCherryPicks').returns(
+        Promise.resolve([createChange()])
+      );
+      await element.reload();
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="related-changes-section">
+            <gr-endpoint-param name="change"> </gr-endpoint-param>
+            <gr-endpoint-slot name="top"> </gr-endpoint-slot>
+            <section id="relatedChanges">
+              <gr-related-collapse class="first" title="Relation chain">
+                <div class="relatedChangeLine show-when-collapsed">
+                  <span class="marker space"> </span>
+                  <gr-related-change
+                    show-change-status=""
+                    show-submittable-check=""
+                  >
+                    Test commit subject
+                  </gr-related-change>
+                </div>
+              </gr-related-collapse>
+            </section>
+            <section id="submittedTogether">
+              <gr-related-collapse title="Submitted together">
+                <div class="relatedChangeLine show-when-collapsed">
+                  <span
+                    aria-label="Arrow marking current change"
+                    class="arrowToCurrentChange marker"
+                    role="img"
+                  >
+                    ➔
+                  </span>
+                  <gr-related-change show-submittable-check="">
+                    Test subject
+                  </gr-related-change>
+                  <span class="repo" title="test-project">test-project</span>
+                  <span class="branch">&nbsp;|&nbsp;test-branch&nbsp;</span>
+                </div>
+              </gr-related-collapse>
+              <div class="note" hidden="">(+ )</div>
+            </section>
+            <section id="cherryPicks">
+              <gr-related-collapse title="Cherry picks">
+                <div class="relatedChangeLine show-when-collapsed">
+                  <span class="marker space"> </span>
+                  <gr-related-change>
+                    test-branch: Test subject
+                  </gr-related-change>
+                </div>
+              </gr-related-collapse>
+            </section>
+            <gr-endpoint-slot name="bottom"> </gr-endpoint-slot>
+          </gr-endpoint-decorator>
+        `
+      );
+    });
+
     test('first list', async () => {
       stubRestApi('getRelatedChanges').returns(
         Promise.resolve(relatedChangeInfo)
@@ -245,6 +302,7 @@
         Promise.resolve([createChange()])
       );
       await element.reload();
+
       const relatedChanges = queryAndAssert<GrRelatedCollapse>(
         queryAndAssert<HTMLElement>(element, '#relatedChanges'),
         'gr-related-collapse'
@@ -302,16 +360,18 @@
       change_id: '456' as ChangeId,
       _number: 1 as NumericChangeId,
     };
-    assert.equal(element._getChangeNumber(change1), 0);
-    assert.equal(element._getChangeNumber(change2), 1);
+    assert.equal(getChangeNumber(change1), 0);
+    assert.equal(getChangeNumber(change2), 1);
   });
 
   suite('get conflicts tests', () => {
     let element: GrRelatedChangesList;
     let conflictsStub: SinonStubbedMember<RestApiService['getChangeConflicts']>;
 
-    setup(() => {
-      element = basicFixture.instantiate();
+    setup(async () => {
+      element = await fixture(
+        html`<gr-related-changes-list></gr-related-changes-list>`
+      );
       conflictsStub = stubRestApi('getChangeConflicts').returns(
         Promise.resolve(undefined)
       );
@@ -585,9 +645,11 @@
   suite('gr-related-changes-list plugin tests', () => {
     let element: GrRelatedChangesList;
 
-    setup(() => {
+    setup(async () => {
       resetPlugins();
-      element = basicFixture.instantiate();
+      element = await fixture(
+        html`<gr-related-changes-list></gr-related-changes-list>`
+      );
     });
 
     teardown(() => {
@@ -615,7 +677,7 @@
         'http://some/plugins/url1.js'
       );
       getPluginLoader().loadPlugins([]);
-      await flush();
+      await waitEventLoop();
       assert.strictEqual(hookEl!.plugin, plugin!);
       assert.strictEqual(hookEl!.change, element.change);
     });
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
new file mode 100644
index 0000000..61136d7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, css, html, nothing, TemplateResult} from 'lit';
+import '../../shared/gr-icon/gr-icon';
+import {customElement, property} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {getAppContext} from '../../../services/app-context';
+import {Interaction} from '../../../constants/reporting';
+import {fontStyles} from '../../../styles/gr-font-styles';
+
+/** What is the maximum number of shown changes in collapsed list? */
+export const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
+
+@customElement('gr-related-collapse')
+export class GrRelatedCollapse extends LitElement {
+  @property()
+  override title = '';
+
+  @property({type: Boolean})
+  showAll = false;
+
+  @property({type: Boolean, reflect: true})
+  collapsed = true;
+
+  @property({type: Number})
+  length = 0;
+
+  @property({type: Number})
+  numChangesWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
+
+  private readonly reporting = getAppContext().reportingService;
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      css`
+        .title {
+          color: var(--deemphasized-text-color);
+          display: flex;
+          align-self: flex-end;
+          margin-left: 20px;
+        }
+        gr-button {
+          display: flex;
+        }
+        gr-button gr-icon {
+          color: inherit;
+          font-size: 18px;
+        }
+        .container {
+          justify-content: space-between;
+          display: flex;
+          margin-bottom: var(--spacing-s);
+        }
+        :host(.first) .container {
+          margin-bottom: var(--spacing-m);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const title = html`<h3 class="title heading-3">${this.title}</h3>`;
+
+    const collapsible = this.length > this.numChangesWhenCollapsed;
+    this.collapsed = !this.showAll && collapsible;
+
+    let button: TemplateResult | typeof nothing = nothing;
+    if (collapsible) {
+      const buttonText = this.showAll
+        ? 'Show less'
+        : `Show all (${this.length})`;
+      const buttonIcon = this.showAll ? 'expand_less' : 'expand_more';
+      button = html`<gr-button link="" @click=${this.toggle}
+        >${buttonText}<gr-icon icon=${buttonIcon}></gr-icon
+      ></gr-button>`;
+    }
+
+    return html`<div class="container">${title}${button}</div>
+      <div><slot></slot></div>`;
+  }
+
+  private toggle(e: MouseEvent) {
+    e.stopPropagation();
+    this.showAll = !this.showAll;
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
+      sectionName: this.title,
+      toState: this.showAll ? 'Show all' : 'Show less',
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-related-collapse': GrRelatedCollapse;
+  }
+}
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
index 5bd5d08..6fba4e4 100644
--- 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
@@ -1,41 +1,27 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-reply-dialog';
 import {
   queryAndAssert,
   resetPlugins,
   stubRestApi,
+  waitEventLoop,
 } 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 {fixture, html, assert} from '@open-wc/testing';
 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');
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 suite('gr-reply-dialog-it tests', () => {
   let element: GrReplyDialog;
@@ -98,17 +84,17 @@
     resetPlugins();
   });
 
-  test('submit blocked when invalid email is supplied to ccs', () => {
+  test('submit blocked when invalid email is supplied to ccs', async () => {
     const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
 
     element.ccsList!.entry!.setText('test');
-    MockInteractions.tap(queryAndAssert(element, 'gr-button.send'));
+    queryAndAssert<GrButton>(element, 'gr-button.send').click();
     assert.isFalse(element.ccsList!.submitEntryText());
     assert.isFalse(sendStub.called);
-    flush();
+    await waitEventLoop();
 
     element.ccsList!.entry!.setText('test@test.test');
-    MockInteractions.tap(queryAndAssert(element, 'gr-button.send'));
+    queryAndAssert<GrButton>(element, 'gr-button.send').click();
     assert.isTrue(sendStub.called);
   });
 
@@ -128,20 +114,12 @@
       undefined,
       'http://test.com/plugins/lgtm.js'
     );
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-reply-dialog></gr-reply-dialog>`);
     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();
+    await waitEventLoop();
+    await waitEventLoop();
     const labelScoreRows = queryAndAssert(
       element.getLabelScores(),
       'gr-label-score-row[name="Code-Review"]'
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 3e766c9..cd42a9f 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
@@ -1,36 +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 '@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';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-formatted-text/gr-formatted-text';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-account-list/gr-account-list';
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
 import '../../../styles/shared-styles';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GrReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {getAppContext} from '../../../services/app-context';
 import {
   ChangeStatus,
@@ -39,16 +24,19 @@
   SpecialFilePath,
 } from '../../../constants/constants';
 import {
-  accountOrGroupKey,
-  isReviewerOrCC,
-  mapReviewer,
+  getUserId,
+  isAccountNewlyAdded,
   removeServiceUsers,
+  toReviewInput,
 } from '../../../utils/account-util';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {TargetElement} from '../../../api/plugin';
-import {FixIronA11yAnnouncer} from '../../../types/types';
 import {
-  AccountAddition,
+  FixIronA11yAnnouncer,
+  notUndefined,
+  ParsedChangeInfo,
+} from '../../../types/types';
+import {
   AccountInfoInput,
   AccountInput,
   AccountInputDetail,
@@ -61,7 +49,6 @@
   AccountInfo,
   AttentionSetInput,
   ChangeInfo,
-  CommentInput,
   GroupInfo,
   isAccount,
   isDetailedLabelInfo,
@@ -75,6 +62,7 @@
   ServerInfo,
   SuggestedReviewerGroupInfo,
   Suggestion,
+  UserId,
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
@@ -83,12 +71,20 @@
   areSetsEqual,
   assertIsDefined,
   containsAll,
+  difference,
   queryAndAssert,
 } from '../../../utils/common-util';
-import {CommentThread, isUnresolved} from '../../../utils/comment-util';
-import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+import {
+  CommentThread,
+  DraftInfo,
+  getFirstComment,
+  isDraft,
+  isPatchsetLevel,
+  isUnresolved,
+  UnsavedInfo,
+} from '../../../utils/comment-util';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrOverlay, GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
 import {
   getApprovalInfo,
   getMaxAccounts,
@@ -103,23 +99,38 @@
   fireServerError,
 } from '../../../utils/event-util';
 import {ErrorCallback} from '../../../api/rest';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {StorageLocation} from '../../../services/storage/gr-storage';
+import {DelayedTask} from '../../../utils/async-util';
 import {Interaction, Timing} from '../../../constants/reporting';
-import {getReplyByReason} from '../../../utils/attention-set-util';
-import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
+import {
+  getMentionedReason,
+  getReplyByReason,
+} from '../../../utils/attention-set-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 {
+  ConfigInfo,
+  LabelNameToValuesMap,
+  PatchSetNumber,
+} 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;
+import {when} from 'lit/directives/when.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {customElement, property, state, query} from 'lit/decorators.js';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {hasHumanReviewer, isOwner} from '../../../utils/change-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {
+  CommentEditingChangedDetail,
+  GrComment,
+} from '../../shared/gr-comment/gr-comment';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {Key, Modifier} from '../../../utils/dom-util';
+import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 
 export enum FocusTarget {
   ANY = 'any',
@@ -205,8 +216,12 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  // Private but used in tests.
+  readonly getCommentsModel = resolve(this, commentsModelToken);
+
+  // TODO: update type to only ParsedChangeInfo
   @property({type: Object})
-  change?: ChangeInfo;
+  change?: ParsedChangeInfo | ChangeInfo;
 
   @property({type: String})
   patchNum?: PatchSetNum;
@@ -217,8 +232,8 @@
   @property({type: Boolean, reflect: true})
   disabled = false;
 
-  @property({type: Array})
-  draftCommentThreads: CommentThread[] | undefined;
+  @state()
+  draftCommentThreads: CommentThread[] = [];
 
   @property({type: Object})
   permittedLabels?: LabelNameToValuesMap;
@@ -226,9 +241,6 @@
   @property({type: Object})
   projectConfig?: ConfigInfo;
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
-
   @query('#reviewers') reviewersList?: GrAccountList;
 
   @query('#ccs') ccsList?: GrAccountList;
@@ -239,13 +251,13 @@
 
   @query('#labelScores') labelScores?: GrLabelScores;
 
-  @query('#textarea') textarea?: GrTextarea;
-
   @query('#reviewerConfirmationOverlay')
   reviewerConfirmationOverlay?: GrOverlay;
 
+  @state() serverConfig?: ServerInfo;
+
   @state()
-  draft = '';
+  patchsetLevelDraftMessage = '';
 
   @state()
   filterReviewerSuggestion: (input: Suggestion) => boolean;
@@ -262,8 +274,48 @@
   @state()
   account?: AccountInfo;
 
+  get ccs() {
+    return [
+      ...this._ccs,
+      ...this.mentionedUsers.filter(v => !this.isAlreadyReviewerOrCC(v)),
+    ];
+  }
+
+  /**
+   * We pass the ccs object to AccountInput for modifying where it needs to
+   * add a value to CC. The returned value contains both mentionedUsers and
+   * normal ccs hence separate the two when setting ccs.
+   */
+  set ccs(ccs: AccountInput[]) {
+    this._ccs = ccs.filter(
+      cc =>
+        !this.mentionedUsers.some(
+          mentionedCC => getUserId(mentionedCC) === getUserId(cc)
+        )
+    );
+    this.requestUpdate('ccs', ccs);
+  }
+
   @state()
-  ccs: (AccountInfoInput | GroupInfoInput)[] = [];
+  _ccs: AccountInput[] = [];
+
+  /**
+   * Maintain a separate list of users added to cc due to being mentioned in
+   * unresolved drafts.
+   * If the draft is discarded or edited to remove the mention then we want to
+   * remove the user from being added to CC.
+   * Instead of figuring out when we should remove the mentioned user ie when
+   * they get removed from the last comment, we recompute this property when
+   * any of the draft comments change.
+   * If we add the user to the existing ccs object then we cannot differentiate
+   * if the user was added manually to CC or added due to being mentioned hence
+   * we cannot reset the mentioned ccs when drafts change.
+   */
+  @state()
+  mentionedUsers: AccountInput[] = [];
+
+  @state()
+  mentionedUsersInUnresolvedDrafts: AccountInfo[] = [];
 
   @state()
   attentionCcsCount = 0;
@@ -275,9 +327,6 @@
   messagePlaceholder?: string;
 
   @state()
-  owner?: AccountInfo;
-
-  @state()
   uploader?: AccountInfo;
 
   @state()
@@ -292,9 +341,6 @@
   reviewerPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
   @state()
-  previewFormatting = false;
-
-  @state()
   sendButtonLabel?: string;
 
   @state()
@@ -324,31 +370,38 @@
   attentionExpanded = false;
 
   @state()
-  currentAttentionSet: Set<AccountId> = new Set();
+  currentAttentionSet: Set<UserId> = new Set();
 
   @state()
-  newAttentionSet: Set<AccountId> = new Set();
+  newAttentionSet: Set<UserId> = new Set();
 
   @state()
   sendDisabled?: boolean;
 
   @state()
-  isResolvedPatchsetLevelComment = true;
+  patchsetLevelDraftIsResolved = true;
 
   @state()
-  allReviewers: (AccountInfo | GroupInfo)[] = [];
+  patchsetLevelComment?: UnsavedInfo | DraftInfo;
 
   private readonly restApiService: RestApiService =
     getAppContext().restApiService;
 
-  private readonly storage = getAppContext().storageService;
-
   private readonly jsAPI = getAppContext().jsApiService;
 
+  private readonly flagsService = getAppContext().flagsService;
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly accountsModel = getAppContext().accountsModel;
+
+  private latestPatchNum?: PatchSetNumber;
+
   storeTask?: DelayedTask;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  private isLoggedIn = false;
+
+  private readonly shortcuts = new ShortcutController(this);
 
   static override styles = [
     sharedStyles,
@@ -357,6 +410,7 @@
         background-color: var(--dialog-background-color);
         display: block;
         max-height: 90vh;
+        --label-score-padding-left: var(--spacing-xl);
       }
       :host([disabled]) {
         pointer-events: none;
@@ -436,37 +490,13 @@
         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);
@@ -493,24 +523,16 @@
       #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 {
+      .attention .edit-attention-button gr-icon {
         color: inherit;
+        /* The line-height:26px hack (see below) requires us to do this.
+           Normally the gr-icon would account for a proper positioning
+           within the standard line-height:20px context. */
+        top: 5px;
       }
       .attention a,
       .attention-detail a {
@@ -571,7 +593,7 @@
         margin-top: var(--spacing-m);
         background-color: var(--assignee-highlight-color);
       }
-      .attentionTip div iron-icon {
+      .attentionTip div gr-icon {
         margin-right: var(--spacing-s);
       }
       .patchsetLevelContainer {
@@ -585,66 +607,82 @@
       .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.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
+
+    this.shortcuts.addLocal({key: Key.ESC}, () => this.cancel());
+    this.shortcuts.addLocal(
+      {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
+      () => this.submit()
+    );
+    this.shortcuts.addLocal(
+      {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
+      () => this.submit()
+    );
+
+    subscribe(
+      this,
+      () => getAppContext().userModel.loggedIn$,
+      isLoggedIn => (this.isLoggedIn = isLoggedIn)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().mentionedUsersInDrafts$,
+      x => {
+        this.mentionedUsers = x;
+        this.reviewersMutated =
+          this.reviewersMutated || this.mentionedUsers.length > 0;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().mentionedUsersInUnresolvedDrafts$,
+      x => {
+        if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+          return;
+        }
+        this.mentionedUsersInUnresolvedDrafts = x.filter(
+          v => !this.isAlreadyReviewerOrCC(v)
+        );
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().patchsetLevelDrafts$,
+      x => (this.patchsetLevelComment = x[0])
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().draftThreads$,
+      threads =>
+        (this.draftCommentThreads = threads.filter(
+          t => !(isDraft(getFirstComment(t)) && isPatchsetLevel(t))
+        ))
+    );
   }
 
   override connectedCallback() {
@@ -656,20 +694,22 @@
       if (account) this.account = account;
     });
 
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
-        this.submit()
-      )
+    this.addEventListener(
+      'comment-editing-changed',
+      (e: CustomEvent<CommentEditingChangedDetail>) => {
+        // Patchset level comment is always in editing mode which means it would
+        // set commentEditing = true and the send button would be permanently
+        // disabled.
+        if (e.detail.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) return;
+        const commentList = queryAndAssert<GrThreadList>(this, '#commentList');
+        // It can be one or more comments were in editing mode. Wwitching one
+        // thread in editing, we need to check if there are still other threads
+        // in editing.
+        this.commentEditing = Array.from(commentList.threadElements ?? []).some(
+          thread => thread.editing
+        );
+      }
     );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
-        this.submit()
-      )
-    );
-    this.cleanups.push(addShortcut(this, {key: Key.ESC}, _ => this.cancel()));
-    this.addEventListener('comment-editing-changed', e => {
-      this.commentEditing = (e as CustomEvent).detail;
-    });
 
     // Plugins on reply-reviewers endpoint can take advantage of these
     // events to add / remove reviewers
@@ -688,10 +728,51 @@
     });
   }
 
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('ccPendingConfirmation')) {
+      this.pendingConfirmationUpdated(this.ccPendingConfirmation);
+    }
+    if (changedProperties.has('reviewerPendingConfirmation')) {
+      this.pendingConfirmationUpdated(this.reviewerPendingConfirmation);
+    }
+    if (changedProperties.has('change')) {
+      this.computeUploader();
+      this.rebuildReviewerArrays();
+    }
+    if (changedProperties.has('canBeStarted')) {
+      this.computeMessagePlaceholder();
+      this.computeSendButtonLabel();
+    }
+    if (changedProperties.has('reviewFormatting')) {
+      this.handleHeightChanged();
+    }
+    if (changedProperties.has('draftCommentThreads')) {
+      this.handleHeightChanged();
+    }
+    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('mentionedUsersInUnresolvedDrafts') ||
+      changedProperties.has('includeComments') ||
+      changedProperties.has('labelsChanged') ||
+      changedProperties.has('patchsetLevelDraftMessage') ||
+      changedProperties.has('mentionedCCs')
+    ) {
+      this.computeNewAttention();
+    }
+  }
+
   override disconnectedCallback() {
-    this.storeTask?.cancel();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
+    this.storeTask?.flush();
     super.disconnectedCallback();
   }
 
@@ -706,7 +787,7 @@
               name="change"
               .value=${this.change}
             ></gr-endpoint-param>
-            <gr-endpoint-param name="reviewers" .value=${this.allReviewers}>
+            <gr-endpoint-param name="reviewers" .value=${[...this.reviewers]}>
             </gr-endpoint-param>
             ${this.renderReviewerList()}
             <gr-endpoint-slot name="below"></gr-endpoint-slot>
@@ -717,17 +798,6 @@
         <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">
@@ -751,8 +821,10 @@
         <div class="peopleListLabel">Reviewers</div>
         <gr-account-list
           id="reviewers"
-          .accounts=${this.getAccountListCopy(this.reviewers)}
-          @account-added=${this.accountAdded}
+          .accounts=${[...this.reviewers]}
+          .change=${this.change}
+          .reviewerState=${ReviewerState.REVIEWER}
+          @account-added=${this.handleAccountAdded}
           @accounts-changed=${this.handleReviewersChanged}
           .removableValues=${this.change?.removable_reviewers}
           .filter=${this.filterReviewerSuggestion}
@@ -777,10 +849,11 @@
         <div class="peopleListLabel">CC</div>
         <gr-account-list
           id="ccs"
-          .accounts=${this.getAccountListCopy(this.ccs)}
-          @account-added=${this.accountAdded}
+          .accounts=${[...this.ccs]}
+          .change=${this.change}
+          .reviewerState=${ReviewerState.CC}
+          @account-added=${this.handleAccountAdded}
           @accounts-changed=${this.handleCcsChanged}
-          .removableValues=${this.change?.removable_reviewers}
           .filter=${this.filterCCSuggestion}
           .pendingConfirmation=${this.ccPendingConfirmation}
           @pending-confirmation-changed=${this.handleCcsConfirmationChanged}
@@ -841,6 +914,38 @@
     `;
   }
 
+  // TODO: move to comment-util
+  private createDraft(): UnsavedInfo {
+    return {
+      patch_set: this.latestPatchNum,
+      message: this.patchsetLevelDraftMessage,
+      unresolved: !this.patchsetLevelDraftIsResolved,
+      path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+      __unsaved: true,
+    };
+  }
+
+  private renderPatchsetLevelComment() {
+    if (!this.patchsetLevelComment)
+      this.patchsetLevelComment = this.createDraft();
+    return html`
+      <gr-comment
+        id="patchsetLevelComment"
+        .comment=${this.patchsetLevelComment}
+        .comments=${[this.patchsetLevelComment]}
+        @comment-unresolved-changed=${(e: ValueChangedEvent<boolean>) => {
+          this.patchsetLevelDraftIsResolved = !e.detail.value;
+        }}
+        @comment-text-changed=${(e: ValueChangedEvent<string>) => {
+          this.patchsetLevelDraftMessage = e.detail.value;
+        }}
+        .messagePlaceholder=${this.messagePlaceholder}
+        hide-header
+        permanent-editing-mode
+      ></gr-comment>
+    `;
+  }
+
   private renderReplyText() {
     if (!this.change) return;
     return html`
@@ -848,48 +953,15 @@
         class=${classMap({
           patchsetLevelContainer: true,
           [this.getUnresolvedPatchsetLevelClass(
-            this.isResolvedPatchsetLevelComment
+            this.patchsetLevelDraftIsResolved
           )]: 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>
+          ${this.renderPatchsetLevelComment()}
           <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>
     `;
   }
@@ -914,7 +986,7 @@
           () => html`
             <gr-thread-list
               id="commentList"
-              .threads=${this.draftCommentThreads!}
+              .threads=${this.draftCommentThreads}
               hide-dropdown
             >
             </gr-thread-list>
@@ -974,8 +1046,10 @@
                 role="button"
                 tabindex="0"
               >
-                <iron-icon icon="gr-icons:edit"></iron-icon>
-                Modify
+                <div>
+                  <gr-icon icon="edit" filled small></gr-icon>
+                  <span>Modify</span>
+                </div>
               </gr-button>
             </gr-tooltip-content>
           </div>
@@ -984,10 +1058,7 @@
               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>
+              <gr-icon icon="help" title="read documentation"></gr-icon>
             </a>
           </div>
         </div>
@@ -1009,10 +1080,7 @@
               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>
+              <gr-icon icon="help" title="read documentation"></gr-icon>
             </a>
           </div>
         </div>
@@ -1026,9 +1094,9 @@
           <div class="peopleListLabel">Owner</div>
           <div class="peopleListValues">
             <gr-account-label
-              .account=${this.owner}
-              ?forceAttention=${this.computeHasNewAttention(this.owner)}
-              .selected=${this.computeHasNewAttention(this.owner)}
+              .account=${this.change?.owner}
+              ?forceAttention=${this.computeHasNewAttention(this.change?.owner)}
+              .selected=${this.computeHasNewAttention(this.change?.owner)}
               .hideHovercard=${true}
               .selectionChipStyle=${true}
               @click=${this.handleAttentionClick}
@@ -1058,7 +1126,7 @@
         <div class="peopleList">
           <div class="peopleListLabel">Reviewers</div>
           <div class="peopleListValues">
-            ${this.removeServiceUsers(this.reviewers).map(
+            ${removeServiceUsers(this.reviewers).map(
               account => html`
                 <gr-account-label
                   .account=${account}
@@ -1080,7 +1148,7 @@
             <div class="peopleList">
               <div class="peopleListLabel">CC</div>
               <div class="peopleListValues">
-                ${this.removeServiceUsers(this.ccs).map(
+                ${removeServiceUsers(this.ccs).map(
                   account => html`
                     <gr-account-label
                       .account=${account}
@@ -1098,19 +1166,11 @@
           `
         )}
         ${when(
-          this.computeShowAttentionTip(
-            this.account,
-            this.owner,
-            this.currentAttentionSet,
-            this.newAttentionSet
-          ),
+          this.computeShowAttentionTip(),
           () => html`
             <div class="attentionTip">
-              <iron-icon
-                class="pointer"
-                icon="gr-icons:lightbulb-outline"
-              ></iron-icon>
-              Be mindful of requiring attention from too many users.
+              <gr-icon icon="lightbulb"></gr-icon>
+              Please be mindful of requiring attention from too many users.
             </div>
           `
         )}
@@ -1207,10 +1267,7 @@
     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.patchsetLevelDraftMessage = quote;
     }
     if (this.restApiService.hasPendingDiffDrafts()) {
       this.savingComments = true;
@@ -1222,15 +1279,17 @@
   }
 
   hasDrafts() {
-    if (this.draftCommentThreads === undefined) return false;
-    return this.draft.length > 0 || this.draftCommentThreads.length > 0;
+    return (
+      this.patchsetLevelDraftMessage.length > 0 ||
+      this.draftCommentThreads.length > 0
+    );
   }
 
   override focus() {
     this.focusOn(FocusTarget.ANY);
   }
 
-  getFocusStops() {
+  getFocusStops(): GrOverlayStops | undefined {
     const end = this.sendDisabled ? this.cancelButton : this.sendButton;
     if (!this.reviewersList?.focusStart || !end) return undefined;
     return {
@@ -1239,16 +1298,6 @@
     };
   }
 
-  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;
@@ -1270,22 +1319,20 @@
     return selectorEl?.selectedValue;
   }
 
-  accountAdded(e: CustomEvent<AccountInputDetail>) {
+  // TODO: Combine logic into handleReviewersChanged & handleCCsChanged and
+  // remove account-added event from GrAccountList.
+  handleAccountAdded(e: CustomEvent<AccountInputDetail>) {
     const account = e.detail.account;
-    const key = accountOrGroupKey(account);
+    const key = getUserId(account);
     const reviewerType =
       (e.target as GrAccountList).getAttribute('id') === 'ccs'
         ? ReviewerType.CC
         : ReviewerType.REVIEWER;
     const isReviewer = ReviewerType.REVIEWER === reviewerType;
-    const array = isReviewer ? this.ccs : this.reviewers;
-    const index = array.findIndex(
-      reviewer => accountOrGroupKey(reviewer) === key
-    );
-    if (index >= 0) {
-      // Remove any accounts that already exist as a CC for reviewer
-      // or vice versa.
-      array.splice(index, 1);
+    const reviewerList = isReviewer ? this.ccsList : this.reviewersList;
+    // Remove any accounts that already exist as a CC for reviewer
+    // or vice versa.
+    if (reviewerList?.removeAccount(account)) {
       const moveFrom = isReviewer ? 'CC' : 'reviewer';
       const moveTo = isReviewer ? 'reviewer' : 'CC';
       const id = account.name || key;
@@ -1294,50 +1341,49 @@
     }
   }
 
-  getUnresolvedPatchsetLevelClass(isResolvedPatchsetLevelComment: boolean) {
-    return isResolvedPatchsetLevelComment ? 'resolved' : 'unresolved';
+  getUnresolvedPatchsetLevelClass(patchsetLevelDraftIsResolved: boolean) {
+    return patchsetLevelDraftIsResolved ? 'resolved' : 'unresolved';
   }
 
-  computeReviewers(change: ChangeInfo) {
+  computeReviewers() {
     const reviewers: ReviewerInput[] = [];
-    const addToReviewInput = (
-      additions: AccountAddition[],
-      state?: ReviewerState
-    ) => {
-      additions.forEach(addition => {
-        const reviewer = mapReviewer(addition);
-        if (state) reviewer.state = state;
-        reviewers.push(reviewer);
-      });
-    };
-    addToReviewInput(this.reviewersList!.additions(), ReviewerState.REVIEWER);
-    addToReviewInput(this.ccsList!.additions(), ReviewerState.CC);
-    addToReviewInput(
-      this.reviewersList!.removals().filter(
-        r =>
-          isReviewerOrCC(change, r) &&
-          // ignore removal from reviewer request if being added to CC
-          !this.ccsList!.additions().some(
-            account => mapReviewer(account).reviewer === mapReviewer(r).reviewer
-          )
-      ),
-      ReviewerState.REMOVED
+    const reviewerAdditions = this.reviewersList?.additions() ?? [];
+    reviewers.push(
+      ...reviewerAdditions.map(v => toReviewInput(v, ReviewerState.REVIEWER))
     );
-    addToReviewInput(
-      this.ccsList!.removals().filter(
-        r =>
-          isReviewerOrCC(change, r) &&
-          // ignore removal from CC request if being added as reviewer
-          !this.reviewersList!.additions().some(
-            account => mapReviewer(account).reviewer === mapReviewer(r).reviewer
-          )
-      ),
-      ReviewerState.REMOVED
+
+    const ccAdditions = this.ccsList?.additions() ?? [];
+    reviewers.push(...ccAdditions.map(v => toReviewInput(v, ReviewerState.CC)));
+
+    // ignore removal from reviewer request if being added as CC
+    let removals = difference(
+      this.reviewersList?.removals() ?? [],
+      ccAdditions,
+      (a, b) => getUserId(a) === getUserId(b)
+    ).map(v => toReviewInput(v, ReviewerState.REMOVED));
+    reviewers.push(...removals);
+
+    // ignore removal from CC request if being added as reviewer
+    removals = difference(
+      this.ccsList?.removals() ?? [],
+      reviewerAdditions,
+      (a, b) => getUserId(a) === getUserId(b)
+    ).map(v => toReviewInput(v, ReviewerState.REMOVED));
+    reviewers.push(...removals);
+
+    // The owner is returned as a reviewer in the ChangeInfo object in some
+    // cases, and trying to remove the owner as a reviewer returns in a
+    // 500 server error.
+    return reviewers.filter(
+      reviewerInput =>
+        !(
+          this.change?.owner._account_id === reviewerInput.reviewer &&
+          reviewerInput.state === ReviewerState.REMOVED
+        )
     );
-    return reviewers;
   }
 
-  send(includeComments: boolean, startReview: boolean) {
+  async send(includeComments: boolean, startReview: boolean) {
     this.reporting.time(Timing.SEND_REPLY);
     const labels = this.getLabelScores().getLabelValues();
 
@@ -1352,14 +1398,41 @@
       reviewInput.ready = true;
     }
 
+    this.disabled = true;
+
     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)) {
-        reviewInput.add_to_attention_set.push({user, reason});
+    const allAccounts = this.allAccounts();
+
+    const newAttentionSetAdditions: AccountInfo[] = Array.from(
+      this.newAttentionSet
+    )
+      .filter(user => !this.currentAttentionSet.has(user))
+      .map(user => allAccounts.find(a => getUserId(a) === user))
+      .filter(notUndefined);
+
+    const newAttentionSetUsers = (
+      await Promise.all(
+        newAttentionSetAdditions.map(a => this.accountsModel.fillDetails(a))
+      )
+    ).filter(notUndefined);
+
+    for (const user of newAttentionSetUsers) {
+      let reason;
+      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+        reason =
+          getMentionedReason(
+            this.draftCommentThreads,
+            this.account,
+            user,
+            this.serverConfig
+          ) ?? '';
+      } else {
+        reason = getReplyByReason(this.account, this.serverConfig);
       }
+      reviewInput.add_to_attention_set.push({user: getUserId(user), reason});
     }
     reviewInput.remove_from_attention_set = [];
     for (const user of this.currentAttentionSet) {
@@ -1373,19 +1446,14 @@
       reviewInput.remove_from_attention_set
     );
 
-    if (this.draft) {
-      const comment: CommentInput = {
-        message: this.draft,
-        unresolved: !this.isResolvedPatchsetLevelComment,
-      };
-      reviewInput.comments = {
-        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
-      };
-    }
+    const patchsetLevelComment = queryAndAssert<GrComment>(
+      this,
+      '#patchsetLevelComment'
+    );
+    await patchsetLevelComment.save();
 
     assertIsDefined(this.change, 'change');
-    reviewInput.reviewers = this.computeReviewers(this.change);
-    this.disabled = true;
+    reviewInput.reviewers = this.computeReviewers();
 
     const errFn = (r?: Response | null) => this.handle400Error(r);
     return this.saveReview(reviewInput, errFn)
@@ -1400,7 +1468,7 @@
           return;
         }
 
-        this.draft = '';
+        this.patchsetLevelDraftMessage = '';
         this.includeComments = true;
         this.dispatchEvent(
           new CustomEvent('send', {
@@ -1426,10 +1494,7 @@
     if (!section || section === FocusTarget.ANY) {
       section = this.chooseFocusTarget();
     }
-    if (section === FocusTarget.BODY) {
-      const textarea = queryAndAssert<GrTextarea>(this, 'gr-textarea');
-      setTimeout(() => textarea.getNativeTextarea().focus());
-    } else if (section === FocusTarget.REVIEWERS) {
+    if (section === FocusTarget.REVIEWERS) {
       const reviewerEntry = this.reviewersList?.focusStart;
       setTimeout(() => reviewerEntry?.focus());
     } else if (section === FocusTarget.CCS) {
@@ -1439,22 +1504,12 @@
   }
 
   chooseFocusTarget() {
-    // If we are the owner and the reviewers field is empty, focus on that.
-    if (
-      this.account &&
-      this.change &&
-      this.change.owner &&
-      this.account._account_id === this.change.owner._account_id &&
-      (!this.reviewers || this.reviewers?.length === 0)
-    ) {
-      return FocusTarget.REVIEWERS;
-    }
-
-    // Default to BODY.
-    return FocusTarget.BODY;
+    if (!isOwner(this.change, this.account)) return FocusTarget.BODY;
+    if (hasHumanReviewer(this.change)) return FocusTarget.BODY;
+    return FocusTarget.REVIEWERS;
   }
 
-  isOwner(account?: AccountInfo, change?: ChangeInfo) {
+  isOwner(account?: AccountInfo, change?: ParsedChangeInfo | ChangeInfo) {
     if (!account || !change || !change.owner) return false;
     return account._account_id === change.owner._account_id;
   }
@@ -1481,7 +1536,7 @@
     const jsonPromise = this.restApiService.getResponseObject(response.clone());
     return jsonPromise.then((parsed: ParsedJSON) => {
       const result = parsed as ReviewResult;
-      // Only perform custom error handling for 400s and a parseable
+      // Only perform custom error handling for 400s and a parsable
       // ReviewResult response.
       if (response.status === 400 && result && result.reviewers) {
         const errors: string[] = [];
@@ -1512,43 +1567,15 @@
       : 'Say something nice...';
   }
 
-  changeUpdated() {
-    if (this.change === undefined) return;
-    this.rebuildReviewerArrays();
-  }
-
   rebuildReviewerArrays() {
     if (!this.change?.owner || !this.change?.reviewers) return;
-    this.owner = this.change.owner;
+    const getAccounts = (state: ReviewerState) =>
+      Object.values(this.change?.reviewers[state] ?? []).filter(
+        account => account._account_id !== this.change!.owner._account_id
+      );
 
-    const reviewers = [];
-    const ccs = [];
-
-    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 (!this.change.reviewers[key]) continue;
-        for (const entry of this.change.reviewers[key]!) {
-          if (entry._account_id === this.owner._account_id) {
-            continue;
-          }
-          switch (key) {
-            case 'REVIEWER':
-              reviewers.push(entry);
-              break;
-            case 'CC':
-              ccs.push(entry);
-              break;
-          }
-        }
-      }
-    }
-
-    this.ccs = ccs;
-    this.reviewers = reviewers;
+    this.ccs = getAccounts(ReviewerState.CC);
+    this.reviewers = getAccounts(ReviewerState.REVIEWER);
   }
 
   handleAttentionModify() {
@@ -1569,14 +1596,13 @@
   }
 
   handleAttentionClick(e: Event) {
-    const id = (e.target as GrAccountChip)?.account?._account_id;
-    if (!id) return;
+    const targetAccount = (e.target as GrAccountChip)?.account;
+    if (!targetAccount) return;
+    const id = getUserId(targetAccount);
+    if (!id || !this.account || !this.change?.owner) return;
 
-    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 self = id === getUserId(this.account) ? '_SELF' : '';
+    const role = id === getUserId(this.change.owner) ? 'OWNER' : '_REVIEWER';
 
     if (this.newAttentionSet.has(id)) {
       this.newAttentionSet.delete(id);
@@ -1594,19 +1620,14 @@
   }
 
   computeHasNewAttention(account?: AccountInfo) {
-    return !!(
-      account &&
-      account._account_id &&
-      this.newAttentionSet?.has(account._account_id)
-    );
+    return !!(account && this.newAttentionSet?.has(getUserId(account)));
   }
 
   computeNewAttention() {
     if (
       this.account?._account_id === undefined ||
       this.change === undefined ||
-      this.includeComments === undefined ||
-      this.draftCommentThreads === undefined
+      this.includeComments === undefined
     ) {
       return;
     }
@@ -1618,6 +1639,7 @@
     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(
@@ -1625,6 +1647,11 @@
       )
     );
     const newAttention = new Set(this.currentAttentionSet);
+
+    for (const user of this.mentionedUsersInUnresolvedDrafts) {
+      newAttention.add(getUserId(user));
+    }
+
     if (this.change.status === ChangeStatus.NEW) {
       // Add everyone that the user is replying to in a comment thread.
       this.computeCommentAccounts(draftCommentThreads).forEach(id =>
@@ -1641,7 +1668,11 @@
         );
       this.reviewers
         .filter(r => isAccount(r))
-        .filter(r => r._pendingAdd || (this.canBeStarted && isOwner))
+        .filter(
+          r =>
+            isAccountNewlyAdded(r, ReviewerState.REVIEWER, this.change) ||
+            (this.canBeStarted && isOwner)
+        )
         .filter(notIsReviewerAndHasDraftOrLabel)
         .forEach(r => newAttention.add((r as AccountInfo)._account_id!));
       // Add owner and uploader, if someone else replies.
@@ -1667,30 +1698,26 @@
     // Finally make sure that everyone in the attention set is still active as
     // owner, reviewer or cc.
     const allAccountIds = this.allAccounts()
-      .map(a => a._account_id)
+      .map(a => getUserId(a))
       .filter(id => !!id);
-    this.newAttentionSet = new Set(
-      [...newAttention].filter(id => allAccountIds.includes(id))
-    );
-    this.attentionExpanded = this.computeShowAttentionTip(
-      this.account,
-      this.change.owner,
-      this.currentAttentionSet,
-      this.newAttentionSet
-    );
+    this.newAttentionSet = new Set([
+      ...[...newAttention].filter(id => allAccountIds.includes(id)),
+    ]);
+
+    this.attentionExpanded = this.computeShowAttentionTip();
   }
 
-  computeShowAttentionTip(
-    currentUser?: AccountInfo,
-    owner?: AccountInfo,
-    currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>
-  ) {
-    if (!currentUser || !owner || !currentAttentionSet || !newAttentionSet)
+  computeShowAttentionTip() {
+    if (
+      !this.account ||
+      !this.change?.owner ||
+      !this.currentAttentionSet ||
+      !this.newAttentionSet
+    )
       return false;
-    const isOwner = currentUser._account_id === owner._account_id;
-    const addedIds = [...newAttentionSet].filter(
-      id => !currentAttentionSet.has(id)
+    const isOwner = this.account._account_id === this.change.owner._account_id;
+    const addedIds = [...this.newAttentionSet].filter(
+      id => !this.currentAttentionSet.has(id)
     );
     return isOwner && addedIds.length > 2;
   }
@@ -1729,6 +1756,7 @@
       return 'No additions to the attention set.';
     }
     this.reporting.error(
+      'computeDoNotUpdateMessage',
       new Error(
         'computeDoNotUpdateMessage()' +
           'should not be called when users were added to the attention set.'
@@ -1750,8 +1778,8 @@
       .filter(account => !!account) as AccountInfo[];
   }
 
-  findAccountById(accountId: AccountId) {
-    return this.allAccounts().find(r => r._account_id === accountId);
+  findAccountById(userId: UserId) {
+    return this.allAccounts().find(r => getUserId(r) === userId);
   }
 
   allAccounts() {
@@ -1763,10 +1791,6 @@
     return removeServiceUsers(allAccounts.filter(isAccount));
   }
 
-  removeServiceUsers(accounts: AccountInfo[]) {
-    return removeServiceUsers(accounts);
-  }
-
   computeUploader() {
     if (
       !this.change?.current_revision ||
@@ -1799,21 +1823,22 @@
       let entry: AccountInfo | GroupInfo;
       if (isReviewerAccountSuggestion(suggestion)) {
         entry = suggestion.account;
-        if (entry._account_id === this.owner?._account_id) {
+        if (entry._account_id === this.change?.owner?._account_id) {
           return false;
         }
       } else if (isReviewerGroupSuggestion(suggestion)) {
         entry = suggestion.group;
       } else {
         this.reporting.error(
+          'Reviewer Suggestion',
           new Error(`Suggestion is neither account nor group: ${suggestion}`)
         );
         return false;
       }
 
-      const key = accountOrGroupKey(entry);
+      const key = getUserId(entry);
       const finder = (entry: AccountInfo | GroupInfo) =>
-        accountOrGroupKey(entry) === key;
+        getUserId(entry) === key;
       if (isCCs) {
         return this.ccs.find(finder) === undefined;
       }
@@ -1826,17 +1851,20 @@
     this.cancel();
   }
 
-  cancel() {
+  async cancel() {
     assertIsDefined(this.change, 'change');
-    if (!this.owner) throw new Error('missing required owner property');
+    if (!this.change?.owner) throw new Error('missing required owner property');
     this.dispatchEvent(
       new CustomEvent('cancel', {
         composed: true,
         bubbles: false,
       })
     );
-    queryAndAssert<GrTextarea>(this, 'gr-textarea').closeDropdown();
-    this.reviewersList?.clearPendingRemovals();
+    const patchsetLevelComment = queryAndAssert<GrComment>(
+      this,
+      '#patchsetLevelComment'
+    );
+    await patchsetLevelComment.save();
     this.rebuildReviewerArrays();
   }
 
@@ -1909,6 +1937,7 @@
       return;
     }
     this.reporting.error(
+      'confirmPendingReviewer',
       new Error('confirmPendingReviewer called without pending confirm')
     );
   }
@@ -1923,20 +1952,6 @@
     this.focusOn(target);
   }
 
-  getStorageLocation(): StorageLocation {
-    assertIsDefined(this.change, 'change');
-    return {
-      changeNum: this.change._number,
-      patchNum: '@change',
-      path: '@change',
-    };
-  }
-
-  loadStoredDraft() {
-    const draft = this.storage.getDraftComment(this.getStorageLocation());
-    return draft?.message ?? '';
-  }
-
   handleAccountTextEntry() {
     // When either of the account entries has input added to the autocomplete,
     // it should trigger the save button to enable/
@@ -1945,19 +1960,16 @@
     this.reviewersMutated = true;
   }
 
-  draftChanged(oldDraft: string) {
-    this.storeTask = debounce(
-      this.storeTask,
-      () => {
-        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 (this.draft.length) {
-          this.storage.setDraftComment(this.getStorageLocation(), this.draft);
-        }
-      },
-      STORAGE_DEBOUNCE_INTERVAL_MS
+  private alreadyExists(ccs: AccountInput[], user: AccountInfoInput) {
+    return ccs
+      .filter(cc => isAccount(cc))
+      .some(cc => getUserId(cc) === getUserId(user));
+  }
+
+  private isAlreadyReviewerOrCC(user: AccountInfo) {
+    return (
+      this.alreadyExists(this.reviewers, user) ||
+      this.alreadyExists(this._ccs, user)
     );
   }
 
@@ -1974,18 +1986,13 @@
       Object.keys(this.getLabelScores().getLabelValues(false)).length !== 0;
   }
 
-  // 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.reviewers = [...e.detail.value];
     this.reviewersMutated = true;
   }
 
   handleCcsChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) {
-    this.ccs = e.detail.value.slice();
+    this.ccs = [...e.detail.value];
     this.reviewersMutated = true;
   }
 
@@ -2026,8 +2033,7 @@
   computeSendButtonDisabled() {
     if (
       this.canBeStarted === undefined ||
-      this.draftCommentThreads === undefined ||
-      this.draft === undefined ||
+      this.patchsetLevelDraftMessage === undefined ||
       this.reviewersMutated === undefined ||
       this.labelsChanged === undefined ||
       this.includeComments === undefined ||
@@ -2050,10 +2056,11 @@
     );
     const revotingOrNewVote = this.labelsChanged || existingVote;
     const hasDrafts =
-      this.includeComments && this.draftCommentThreads.length > 0;
+      (this.includeComments && this.draftCommentThreads.length > 0) ||
+      this.patchsetLevelDraftMessage.length > 0;
     return (
       !hasDrafts &&
-      !this.draft.length &&
+      !this.patchsetLevelDraftMessage.length &&
       !this.reviewersMutated &&
       !revotingOrNewVote
     );
@@ -2075,25 +2082,27 @@
     this.dispatchEvent(new CustomEvent('send-disabled-changed'));
   }
 
-  getReviewerSuggestionsProvider(change?: ChangeInfo) {
+  getReviewerSuggestionsProvider(change?: ChangeInfo | ParsedChangeInfo) {
     if (!change) return;
-    const provider = GrReviewerSuggestionsProvider.create(
+    const provider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      change._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
+      ReviewerState.REVIEWER,
+      this.serverConfig,
+      this.isLoggedIn,
+      change
     );
-    provider.init();
     return provider;
   }
 
-  getCcSuggestionsProvider(change?: ChangeInfo) {
+  getCcSuggestionsProvider(change?: ChangeInfo | ParsedChangeInfo) {
     if (!change) return;
-    const provider = GrReviewerSuggestionsProvider.create(
+    const provider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      change._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.CC
+      ReviewerState.CC,
+      this.serverConfig,
+      this.isLoggedIn,
+      change
     );
-    provider.init();
     return provider;
   }
 
@@ -2120,10 +2129,6 @@
     }
     this.reporting.reportInteraction('attention-set-actions', {actions});
   }
-
-  computeAllReviewers() {
-    this.allReviewers = [...this.reviewers];
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index c800c87..acd1755 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
@@ -1,55 +1,41 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-reply-dialog';
 import {
   addListenerForTest,
+  isVisible,
   mockPromise,
+  pressKey,
+  query,
   queryAll,
   queryAndAssert,
+  stubFlags,
   stubRestApi,
-  stubStorage,
 } from '../../../test/test-utils';
-import {
-  ChangeStatus,
-  ReviewerState,
-  SpecialFilePath,
-} from '../../../constants/constants';
+import {ChangeStatus, ReviewerState} from '../../../constants/constants';
 import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {StandardLabels} from '../../../utils/label-util';
 import {
+  createAccountWithEmail,
   createAccountWithId,
   createChange,
   createComment,
   createCommentThread,
   createDraft,
   createRevision,
+  createServiceUserWithId,
 } from '../../../test/test-data-generators';
-import {
-  pressAndReleaseKeyOn,
-  tap,
-} from '@polymer/iron-test-helpers/mock-interactions';
-import {FocusTarget, GrReplyDialog} from './gr-reply-dialog';
+import {GrReplyDialog} from './gr-reply-dialog';
 import {
   AccountId,
   AccountInfo,
   CommitId,
   DetailedLabelInfo,
+  EmailAddress,
   GroupId,
   GroupName,
   NumericChangeId,
@@ -57,8 +43,11 @@
   ReviewerInput,
   ReviewInput,
   ReviewResult,
+  RevisionPatchSetNum,
   Suggestion,
+  Timestamp,
   UrlEncodedCommentId,
+  UserId,
 } from '../../../types/common';
 import {CommentThread} from '../../../utils/comment-util';
 import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
@@ -66,9 +55,13 @@
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, waitUntil, assert} from '@open-wc/testing';
 import {accountKey} from '../../../utils/account-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {Key, Modifier} from '../../../utils/dom-util';
+import {GrComment} from '../../shared/gr-comment/gr-comment';
 
 function cloneableResponse(status: number, text: string) {
   return {
@@ -95,13 +88,12 @@
   let changeNum: NumericChangeId;
   let patchNum: PatchSetNum;
 
-  let getDraftCommentStub: sinon.SinonStub;
-  let setDraftCommentStub: sinon.SinonStub;
-  let eraseDraftCommentStub: sinon.SinonStub;
-
   let lastId = 1;
   const makeAccount = function () {
-    return {_account_id: lastId++ as AccountId};
+    return {
+      _account_id: lastId++ as AccountId,
+      email: `${lastId}.com` as EmailAddress,
+    };
   };
   const makeGroup = function () {
     return {id: `${lastId++}` as GroupId};
@@ -124,6 +116,7 @@
       owner: {
         _account_id: 999 as AccountId,
         display_name: 'Kermit',
+        email: 'abcd' as EmailAddress,
       },
       labels: {
         Verified: {
@@ -151,10 +144,7 @@
       'Code-Review': ['-1', ' 0', '+1'],
       Verified: ['-1', ' 0', '+1'],
     };
-
-    getDraftCommentStub = stubStorage('getDraftComment');
-    setDraftCommentStub = stubStorage('setDraftComment');
-    eraseDraftCommentStub = stubStorage('eraseDraftComment');
+    element.draftCommentThreads = [];
 
     await element.updateComplete;
   });
@@ -193,11 +183,151 @@
     return promise;
   }
 
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div tabindex="-1">
+          <section class="peopleContainer">
+            <gr-endpoint-decorator name="reply-reviewers">
+              <gr-endpoint-param name="change"> </gr-endpoint-param>
+              <gr-endpoint-param name="reviewers"> </gr-endpoint-param>
+              <div class="peopleList">
+                <div class="peopleListLabel">Reviewers</div>
+                <gr-account-list id="reviewers"> </gr-account-list>
+                <gr-endpoint-slot name="right"> </gr-endpoint-slot>
+              </div>
+              <gr-endpoint-slot name="below"> </gr-endpoint-slot>
+            </gr-endpoint-decorator>
+            <div class="peopleList">
+              <div class="peopleListLabel">CC</div>
+              <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
+            </div>
+            <gr-overlay
+              aria-hidden="true"
+              id="reviewerConfirmationOverlay"
+              style="outline: none; display: none;"
+            >
+              <div class="reviewerConfirmation">
+                Group
+                <span class="groupName"> </span>
+                has
+                <span class="groupSize"> </span>
+                members.
+                <br />
+                Are you sure you want to add them all?
+              </div>
+              <div class="reviewerConfirmationButtons">
+                <gr-button aria-disabled="false" role="button" tabindex="0">
+                  Yes
+                </gr-button>
+                <gr-button aria-disabled="false" role="button" tabindex="0">
+                  No
+                </gr-button>
+              </div>
+            </gr-overlay>
+          </section>
+          <section class="labelsContainer">
+            <gr-endpoint-decorator name="reply-label-scores">
+              <gr-label-scores id="labelScores"> </gr-label-scores>
+              <gr-endpoint-param name="change"> </gr-endpoint-param>
+            </gr-endpoint-decorator>
+            <div id="pluginMessage"></div>
+          </section>
+          <section class="newReplyDialog textareaContainer">
+            <div class="patchsetLevelContainer resolved">
+              <gr-endpoint-decorator name="reply-text">
+                <gr-comment
+                  hide-header=""
+                  id="patchsetLevelComment"
+                  permanent-editing-mode=""
+                >
+                </gr-comment>
+                <gr-endpoint-param name="change"> </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+          </section>
+          <div class="newReplyDialog stickyBottom">
+            <gr-endpoint-decorator name="reply-bottom">
+              <gr-endpoint-param name="change"> </gr-endpoint-param>
+              <section class="attention">
+                <div class="attentionSummary">
+                  <div>
+                    <span> No changes to the attention set. </span>
+                    <gr-tooltip-content
+                      has-tooltip=""
+                      title="Edit attention set changes"
+                    >
+                      <gr-button
+                        aria-disabled="true"
+                        disabled=""
+                        class="edit-attention-button"
+                        data-action-key="edit"
+                        data-action-type="change"
+                        data-label="Edit"
+                        link=""
+                        position-below=""
+                        role="button"
+                        tabindex="-1"
+                      >
+                        <div>
+                          <gr-icon icon="edit" filled small></gr-icon>
+                          <span>Modify</span>
+                        </div>
+                      </gr-button>
+                    </gr-tooltip-content>
+                  </div>
+                  <div>
+                    <a
+                      href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+                      target="_blank"
+                    >
+                      <gr-icon icon="help" title="read documentation"></gr-icon>
+                    </a>
+                  </div>
+                </div>
+              </section>
+              <gr-endpoint-slot name="above-actions"> </gr-endpoint-slot>
+              <section class="actions">
+                <div class="left"></div>
+                <div class="right">
+                  <gr-button
+                    aria-disabled="false"
+                    class="action cancel"
+                    id="cancelButton"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Cancel
+                  </gr-button>
+                  <gr-tooltip-content has-tooltip="" title="Send reply">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      class="action send"
+                      id="sendButton"
+                      primary=""
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Send
+                    </gr-button>
+                  </gr-tooltip-content>
+                </div>
+              </section>
+            </gr-endpoint-decorator>
+          </div>
+        </div>
+      `
+    );
+  });
+
   test('default to publishing draft comments with reply', async () => {
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
     await element.updateComplete;
-    element.draft = 'I wholeheartedly disapprove';
+    element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove';
     element.draftCommentThreads = [createCommentThread([createComment()])];
 
     element.includeComments = true;
@@ -206,7 +336,7 @@
     // This is needed on non-Blink engines most likely due to the ways in
     // which the dom-repeat elements are stamped.
     await element.updateComplete;
-    tap(queryAndAssert(element, '.send'));
+    queryAndAssert<GrButton>(element, '.send').click();
     await element.updateComplete;
 
     const review = await saveReviewPromise;
@@ -216,14 +346,6 @@
         'Code-Review': 0,
         Verified: 0,
       },
-      comments: {
-        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
-          {
-            message: 'I wholeheartedly disapprove',
-            unresolved: false,
-          },
-        ],
-      },
       reviewers: [],
       add_to_attention_set: [
         {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
@@ -238,14 +360,17 @@
 
   test('modified attention set', async () => {
     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);
+
+    // required so that "Send" button is enabled
+    element.canBeStarted = true;
     await element.updateComplete;
 
-    tap(queryAndAssert(element, '.send'));
+    element.account = {_account_id: 123 as AccountId};
+    element.newAttentionSet = new Set([314 as AccountId]);
+    element.uploader = createAccountWithId(314);
+    const saveReviewPromise = interceptSaveReview();
+
+    queryAndAssert<GrButton>(element, '.send').click();
     const review = await saveReviewPromise;
 
     assert.deepEqual(review, {
@@ -258,6 +383,7 @@
         {reason: '<GERRIT_ACCOUNT_123> replied on the change', user: 314},
       ],
       reviewers: [],
+      ready: true,
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
@@ -265,14 +391,17 @@
 
   test('modified attention set by anonymous', async () => {
     await element.updateComplete;
-    element.account = {};
-    element.newAttentionSet = new Set([314 as AccountId]);
-    const saveReviewPromise = interceptSaveReview();
-    const modifyButton = queryAndAssert(element, '.edit-attention-button');
-    tap(modifyButton);
+
+    // required so that "Send" button is enabled
+    element.canBeStarted = true;
     await element.updateComplete;
 
-    tap(queryAndAssert(element, '.send'));
+    element.account = {};
+    element.uploader = createAccountWithId(314);
+    element.newAttentionSet = new Set([314 as AccountId]);
+    const saveReviewPromise = interceptSaveReview();
+
+    queryAndAssert<GrButton>(element, '.send').click();
     const review = await saveReviewPromise;
 
     assert.deepEqual(review, {
@@ -282,9 +411,11 @@
         Verified: 0,
       },
       add_to_attention_set: [
-        {reason: 'Anonymous replied on the change', user: 314},
+        // Name coming from createUserConfig in test-data-generator
+        {reason: 'Name of user not set replied on the change', user: 314},
       ],
       reviewers: [],
+      ready: true,
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
@@ -346,7 +477,7 @@
       };
     }
     element.change = change;
-    element.ccs = [];
+    element._ccs = [];
     element.draftCommentThreads = draftThreads;
     element.includeComments = includeComments;
 
@@ -759,22 +890,28 @@
     await element.updateComplete;
 
     element.reviewers = [
-      {_account_id: 1 as AccountId, _pendingAdd: true},
-      {_account_id: 2 as AccountId, _pendingAdd: true},
+      {_account_id: 1 as AccountId},
+      {_account_id: 2 as AccountId},
     ];
-    element.ccs = [];
+    element._ccs = [];
     element.draftCommentThreads = [];
     element.includeComments = true;
     element.canBeStarted = true;
     await element.updateComplete;
-    assert.sameMembers([...element.newAttentionSet], [1, 2]);
+    assert.sameMembers(
+      [...element.newAttentionSet],
+      [1 as AccountId, 2 as AccountId]
+    );
 
     // 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).
     element.labelsChanged = true;
     await element.updateComplete;
-    assert.sameMembers([...element.newAttentionSet], [2, 5]);
+    assert.sameMembers(
+      [...element.newAttentionSet],
+      [2 as AccountId, 5 as AccountId]
+    );
   });
 
   test('computeNewAttention when sending wip change for review', async () => {
@@ -783,6 +920,12 @@
       owner: {_account_id: 1 as AccountId},
       status: ChangeStatus.NEW,
       attention_set: {},
+      reviewers: {
+        [ReviewerState.REVIEWER]: [
+          {...createAccountWithId(2)},
+          {...createAccountWithId(3)},
+        ],
+      },
     };
     // let rebuildReviewers triggered by change update finish running
     await element.updateComplete;
@@ -792,7 +935,7 @@
       {...createAccountWithId(3)},
     ];
 
-    element.ccs = [];
+    element._ccs = [];
     element.draftCommentThreads = [];
     element.includeComments = false;
     element.account = {_account_id: 1 as AccountId};
@@ -808,7 +951,10 @@
     element.canBeStarted = true;
     element.computeNewAttention();
     await element.updateComplete;
-    assert.sameMembers([...element.newAttentionSet], [2, 3]);
+    assert.sameMembers(
+      [...element.newAttentionSet],
+      [2 as AccountId, 3 as AccountId]
+    );
 
     // ... but not when someone else replies.
     element.account = {_account_id: 4 as AccountId};
@@ -821,7 +967,7 @@
       {_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'}];
+    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);
@@ -907,52 +1053,8 @@
     assert.sameMembers(actualAccounts, [1, 2, 4]);
   });
 
-  test('toggle resolved checkbox', async () => {
-    const checkboxEl = queryAndAssert(
-      element,
-      '#resolvedPatchsetLevelCommentCheckbox'
-    );
-    tap(checkboxEl);
-
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    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 element.updateComplete;
-    tap(queryAndAssert(element, '.send'));
-
-    const review = await saveReviewPromise;
-    assert.deepEqual(review, {
-      drafts: 'PUBLISH_ALL_REVISIONS',
-      labels: {
-        'Code-Review': 0,
-        Verified: 0,
-      },
-      comments: {
-        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
-          {
-            message: 'I wholeheartedly disapprove',
-            unresolved: true,
-          },
-        ],
-      },
-      reviewers: [],
-      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('label picker', async () => {
-    element.draft = 'I wholeheartedly disapprove';
+    element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove';
     element.draftCommentThreads = [createCommentThread([createComment()])];
 
     const saveReviewPromise = interceptSaveReview();
@@ -967,30 +1069,19 @@
     // This is needed on non-Blink engines most likely due to the ways in
     // which the dom-repeat elements are stamped.
     await element.updateComplete;
-    tap(queryAndAssert(element, '.send'));
+    queryAndAssert<GrButton>(element, '.send').click();
     assert.isTrue(element.disabled);
 
     const review = await saveReviewPromise;
     await element.updateComplete;
-    assert.isFalse(
-      element.disabled,
-      'Element should be enabled when done sending reply.'
-    );
-    assert.equal(element.draft.length, 0);
+    await waitUntil(() => element.disabled === false);
+    assert.equal(element.patchsetLevelDraftMessage.length, 0);
     assert.deepEqual(review, {
       drafts: 'PUBLISH_ALL_REVISIONS',
       labels: {
         'Code-Review': -1,
         Verified: -1,
       },
-      comments: {
-        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
-          {
-            message: 'I wholeheartedly disapprove',
-            unresolved: false,
-          },
-        ],
-      },
       reviewers: [],
       add_to_attention_set: [
         {user: 999, reason: '<GERRIT_ACCOUNT_1> replied on the change'},
@@ -1004,20 +1095,20 @@
     element.draftCommentThreads = [createCommentThread([createComment()])];
     await element.updateComplete;
 
-    tap(queryAndAssert(element, '#includeComments'));
+    queryAndAssert<HTMLInputElement>(element, '#includeComments').click();
     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 element.updateComplete;
-    element.draft = 'I wholeheartedly disapprove';
+    element.patchsetLevelDraftMessage = '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 element.updateComplete;
-    tap(queryAndAssert(element, '.send'));
+    queryAndAssert<GrButton>(element, '.send').click();
 
     const review = await saveReviewPromise;
     await element.updateComplete;
@@ -1027,14 +1118,6 @@
         'Code-Review': 0,
         Verified: 0,
       },
-      comments: {
-        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
-          {
-            message: 'I wholeheartedly disapprove',
-            unresolved: false,
-          },
-        ],
-      },
       reviewers: [],
       add_to_attention_set: [
         {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
@@ -1084,11 +1167,6 @@
     return document.activeElement;
   }
 
-  function isVisible(el: Element) {
-    assert.ok(el);
-    return getComputedStyle(el).getPropertyValue('display') !== 'none';
-  }
-
   function overlayObserver(mode: string) {
     return new Promise(resolve => {
       function listener() {
@@ -1120,11 +1198,11 @@
   }
 
   async function testConfirmationDialog(cc?: boolean) {
-    const yesButton = queryAndAssert(
+    const yesButton = queryAndAssert<GrButton>(
       element,
       '.reviewerConfirmationButtons gr-button:first-child'
     );
-    const noButton = queryAndAssert(
+    const noButton = queryAndAssert<GrButton>(
       element,
       '.reviewerConfirmationButtons gr-button:last-child'
     );
@@ -1182,7 +1260,7 @@
       ).innerText.indexOf(expected),
       -1
     );
-    tap(noButton); // close the overlay
+    noButton.click(); // close the overlay
 
     await observer;
     assert.isFalse(
@@ -1198,14 +1276,8 @@
     );
 
     // No reviewer/CC should have been added.
-    assert.equal(
-      queryAndAssert<GrAccountList>(element, '#ccs').additions().length,
-      0
-    );
-    assert.equal(
-      queryAndAssert<GrAccountList>(element, '#reviewers').additions().length,
-      0
-    );
+    assert.equal(element.ccsList?.additions().length, 0);
+    assert.equal(element.reviewersList?.additions().length, 0);
 
     // Reopen confirmation dialog.
     observer = overlayObserver('opened');
@@ -1228,24 +1300,18 @@
       isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
     );
     observer = overlayObserver('closed');
-    tap(yesButton); // Confirm the group.
+    yesButton.click(); // Confirm the group.
 
     await observer;
     assert.isFalse(
       isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
     );
     const additions = cc
-      ? queryAndAssert<GrAccountList>(element, '#ccs').additions()
-      : queryAndAssert<GrAccountList>(element, '#reviewers').additions();
+      ? element.ccsList?.additions()
+      : element.reviewersList?.additions();
     assert.deepEqual(additions, [
       {
-        group: {
-          id: 'id' as GroupId,
-          name: 'name' as GroupName,
-          confirmed: true,
-          _group: true,
-          _pendingAdd: true,
-        },
+        name: 'name' as GroupName,
       },
     ]);
 
@@ -1278,13 +1344,6 @@
     testConfirmationDialog(false);
   });
 
-  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', () => {
     assert.isFalse(element.reviewersMutated);
     assert.isTrue(queryAndAssert<GrAccountList>(element, '#ccs').allowAnyInput);
@@ -1297,61 +1356,6 @@
     assert.isTrue(element.reviewersMutated);
   });
 
-  test('gets draft from storage on open', () => {
-    const storedDraft = 'hello world';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, storedDraft);
-  });
-
-  test('gets draft from storage even when text is already present', () => {
-    const storedDraft = 'hello world';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.draft = 'foo bar';
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, storedDraft);
-  });
-
-  test('blank if no stored draft', () => {
-    getDraftCommentStub.returns(null);
-    element.draft = 'foo bar';
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, '');
-  });
-
-  test('does not check stored draft when quote is present', () => {
-    const storedDraft = 'hello world';
-    const quote = '> foo bar';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.open(FocusTarget.ANY, quote);
-    assert.isFalse(getDraftCommentStub.called);
-    assert.equal(element.draft, quote);
-  });
-
-  test('updates stored draft on edits', async () => {
-    const clock = sinon.useFakeTimers();
-
-    const firstEdit = 'hello';
-    const location = element.getStorageLocation();
-
-    element.draft = firstEdit;
-    clock.tick(1000);
-    await element.updateComplete;
-    await element.storeTask?.flush();
-
-    assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
-
-    element.draft = '';
-    clock.tick(1000);
-    await element.updateComplete;
-    await element.storeTask?.flush();
-
-    assert.isTrue(eraseDraftCommentStub.calledWith(location));
-  });
-
   test('400 converts to human-readable server-error', async () => {
     stubRestApi('saveChangeReview').callsFake(
       (_changeNum, _patchNum, _review, errFn) => {
@@ -1416,9 +1420,10 @@
     const cc2 = makeGroup();
     let filter = element.filterReviewerSuggestionGenerator(false);
 
-    element.owner = owner;
+    element.change = createChange();
+    element.change.owner = owner;
     element.reviewers = [reviewer1, reviewer2];
-    element.ccs = [cc1, cc2];
+    element._ccs = [cc1, cc2];
 
     assert.isTrue(filter({account: makeAccount()} as Suggestion));
     assert.isTrue(filter({group: makeGroup()} as Suggestion));
@@ -1448,20 +1453,29 @@
     // 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');
+    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
+    assert.equal(
+      element?.shadowRoot?.activeElement?.id,
+      'patchsetLevelComment'
+    );
 
     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');
+    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
+    assert.equal(
+      element?.shadowRoot?.activeElement?.id,
+      'patchsetLevelComment'
+    );
 
     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');
+    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
+    assert.equal(
+      element?.shadowRoot?.activeElement?.id,
+      'patchsetLevelComment'
+    );
 
     element.focusOn(element.FocusTarget.REVIEWERS);
     clock.tick(1);
@@ -1485,28 +1499,22 @@
 
   test('chooseFocusTarget', () => {
     element.account = undefined;
-    assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
 
-    element.account = {_account_id: 1 as AccountId};
-    assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
+    element.account = element.change!.owner;
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.REVIEWERS);
 
-    element.change!.owner = {_account_id: 2 as AccountId};
-    assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
+    element.change!.reviewers.REVIEWER = [createAccountWithId(314)];
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
 
-    element.change!.owner._account_id = 1 as AccountId;
-    assert.strictEqual(
-      element.chooseFocusTarget(),
-      element.FocusTarget.REVIEWERS
-    );
+    element.change!.reviewers.REVIEWER = [createServiceUserWithId(314)];
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.REVIEWERS);
 
-    element.reviewers = [];
-    assert.strictEqual(
-      element.chooseFocusTarget(),
-      element.FocusTarget.REVIEWERS
-    );
-
-    element.reviewers.push({});
-    assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
+    element.change!.reviewers.REVIEWER = [
+      createAccountWithId(314),
+      createServiceUserWithId(314),
+    ];
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
   });
 
   test('only send labels that have changed', async () => {
@@ -1522,16 +1530,20 @@
     element.addEventListener('send', () => {
       promise.resolve();
     });
-    // Without wrapping this test in flush(), the below two calls to
-    // tap() cause a race in some situations in shadow DOM.
-    // The send button can be tapped before the others, causing the test to
-    // fail.
+    // Without wrapping this test in await element.updateComplete, the below two
+    // calls to 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<GrLabelScoreRow>(
       queryAndAssert(element, 'gr-label-scores'),
       'gr-label-score-row[name="Verified"]'
     );
     el.setSelectedValue('-1');
-    tap(queryAndAssert(element, '.send'));
+
+    // required so that "Send" button is enabled
+    element.canBeStarted = true;
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(element, '.send').click();
     await promise;
   });
 
@@ -1543,9 +1555,14 @@
     const cc2 = makeAccount();
     const cc3 = makeAccount();
     const cc4 = makeAccount();
-    element.reviewers = [reviewer1, reviewer2, reviewer3];
-    element.ccs = [cc1, cc2, cc3, cc4];
-    element.reviewers.push(cc1);
+    element.reviewersList!.accounts = [reviewer1, reviewer2, reviewer3];
+    element.ccsList!.accounts = [cc1, cc2, cc3, cc4];
+    await element.updateComplete;
+    element.reviewersList!.accounts.push(cc1);
+
+    element.reviewers = element.reviewersList!.accounts;
+    element.ccs = element.ccsList!.accounts;
+
     element.reviewersList!.dispatchEvent(
       new CustomEvent('account-added', {
         detail: {account: cc1},
@@ -1556,7 +1573,7 @@
     assert.deepEqual(element.reviewers, [reviewer1, reviewer2, reviewer3, cc1]);
     assert.deepEqual(element.ccs, [cc2, cc3, cc4]);
 
-    element.reviewers.push(cc4);
+    element.reviewersList!.addAccountItem({account: cc4, count: 1});
     element.reviewersList!.dispatchEvent(
       new CustomEvent('account-added', {
         detail: {account: cc4},
@@ -1564,7 +1581,7 @@
     );
     await element.updateComplete;
 
-    element.reviewers.push(cc3);
+    element.reviewersList!.addAccountItem({account: cc3, count: 1});
     element.reviewersList!.dispatchEvent(
       new CustomEvent('account-added', {
         detail: {account: cc3},
@@ -1586,20 +1603,23 @@
   test('update attention section when reviewers and ccs change', async () => {
     element.account = makeAccount();
     element.reviewers = [makeAccount(), makeAccount()];
-    element.ccs = [makeAccount(), makeAccount()];
+    element._ccs = [makeAccount(), makeAccount()];
     element.draftCommentThreads = [];
 
-    const modifyButton = queryAndAssert(element, '.edit-attention-button');
-    tap(modifyButton);
+    const modifyButton = queryAndAssert<GrButton>(
+      element,
+      '.edit-attention-button'
+    );
+    modifyButton.click();
 
     await element.updateComplete;
 
     assert.isFalse(element.attentionExpanded);
 
-    element.draft = 'a test comment';
+    element.patchsetLevelDraftMessage = 'a test comment';
     await element.updateComplete;
 
-    tap(modifyButton);
+    modifyButton.click();
 
     await element.updateComplete;
 
@@ -1611,14 +1631,14 @@
     assert.equal(accountLabels.length, 5);
 
     element.reviewers = [...element.reviewers, makeAccount()];
-    element.ccs = [...element.ccs, 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);
 
-    tap(queryAndAssert(element, '.edit-attention-button'));
+    queryAndAssert<GrButton>(element, '.edit-attention-button').click();
     await element.updateComplete;
 
     assert.isTrue(element.attentionExpanded);
@@ -1629,14 +1649,14 @@
 
     element.reviewers.pop();
     element.reviewers.pop();
-    element.ccs.pop();
-    element.ccs.pop();
+    element._ccs.pop();
+    element._ccs.pop();
     element.reviewers = [...element.reviewers];
-    element.ccs = [...element.ccs]; // trigger willUpdate observer
+    element._ccs = [...element.ccs]; // trigger willUpdate observer
 
     await element.updateComplete;
 
-    tap(queryAndAssert(element, '.edit-attention-button'));
+    queryAndAssert<GrButton>(element, '.edit-attention-button').click();
 
     await element.updateComplete;
 
@@ -1654,9 +1674,11 @@
     const cc2 = makeAccount();
     const cc3 = makeAccount();
     const cc4 = makeAccount();
-    element.reviewers = [reviewer1, reviewer2, reviewer3];
-    element.ccs = [cc1, cc2, cc3, cc4];
-    element.ccs.push(reviewer1);
+    element.reviewersList!.accounts = [reviewer1, reviewer2, reviewer3];
+    element.ccsList!.accounts = [cc1, cc2, cc3, cc4];
+    element.reviewers = element.reviewersList!.accounts;
+    element._ccs = element.ccsList!.accounts;
+    element._ccs.push(reviewer1);
     element.ccsList!.dispatchEvent(
       new CustomEvent('account-added', {
         detail: {account: reviewer1},
@@ -1664,11 +1686,14 @@
     );
 
     await element.updateComplete;
+    await element.updateComplete;
+    await element.updateComplete;
+    await element.updateComplete;
 
     assert.deepEqual(element.reviewers, [reviewer2, reviewer3]);
     assert.deepEqual(element.ccs, [cc1, cc2, cc3, cc4, reviewer1]);
 
-    element.ccs.push(reviewer3);
+    element.ccsList!.addAccountItem({account: reviewer3, count: 1});
     element.ccsList!.dispatchEvent(
       new CustomEvent('account-added', {
         detail: {account: reviewer3},
@@ -1676,7 +1701,7 @@
     );
     await element.updateComplete;
 
-    element.ccs.push(reviewer2);
+    element.ccsList!.addAccountItem({account: reviewer2, count: 1});
     element.ccsList!.dispatchEvent(
       new CustomEvent('account-added', {
         detail: {account: reviewer2},
@@ -1705,7 +1730,7 @@
     const cc2 = makeAccount();
     const cc3 = makeAccount();
     element.reviewers = [reviewer1, reviewer2];
-    element.ccs = [cc1, cc2, cc3];
+    element._ccs = [cc1, cc2, cc3];
 
     element.change!.reviewers = {
       [ReviewerState.CC]: [],
@@ -1827,22 +1852,14 @@
     await element.updateComplete;
     assert.equal(mutations.length, 5);
 
-    expect(mutations[0]).to.deep.equal(
-      mapReviewer(cc1, ReviewerState.REVIEWER)
-    );
-    expect(mutations[1]).to.deep.equal(
-      mapReviewer(cc2, ReviewerState.REVIEWER)
-    );
-    expect(mutations[2]).to.deep.equal(
-      mapReviewer(reviewer1, ReviewerState.CC)
-    );
-    expect(mutations[3]).to.deep.equal(
-      mapReviewer(reviewer2, ReviewerState.CC)
-    );
+    assert.deepEqual(mutations[0], mapReviewer(cc1, ReviewerState.REVIEWER));
+    assert.deepEqual(mutations[1], mapReviewer(cc2, ReviewerState.REVIEWER));
+    assert.deepEqual(mutations[2], mapReviewer(reviewer1, ReviewerState.CC));
+    assert.deepEqual(mutations[3], mapReviewer(reviewer2, ReviewerState.CC));
 
     // Only 1 account was initially part of the change
-    expect(mutations[4]).to.deep.equal({
-      reviewer: 33,
+    assert.deepEqual(mutations[4], {
+      reviewer: 33 as UserId,
       state: ReviewerState.REMOVED,
     });
   });
@@ -1853,13 +1870,15 @@
     const ccs = queryAndAssert<GrAccountList>(element, '#ccs');
     const reviewer1 = makeAccount();
     element.reviewers = [reviewer1];
-    element.ccs = [];
+    element._ccs = [];
 
     element.change!.reviewers = {
       [ReviewerState.CC]: [],
       [ReviewerState.REVIEWER]: [{_account_id: reviewer1._account_id}],
     };
 
+    await element.updateComplete;
+
     const mutations: ReviewerInput[] = [];
 
     stubSaveReview((review: ReviewInput) => {
@@ -1883,18 +1902,42 @@
     );
 
     await element.send(false, false);
-    expect(mutations).to.have.lengthOf(1);
+    assert.lengthOf(mutations, 1);
     // Only 1 account was initially part of the change
-    expect(mutations[0]).to.deep.equal({
+    assert.deepEqual(mutations[0], {
       reviewer: reviewer1._account_id,
       state: ReviewerState.CC,
     });
   });
 
+  test('Ignore removal requests from reviewer if owner', async () => {
+    await element.updateComplete;
+    const reviewer1 = makeAccount();
+    element.reviewers = [reviewer1];
+    element._ccs = [];
+    element.change!.owner = reviewer1;
+
+    element.change!.reviewers = {
+      [ReviewerState.CC]: [],
+      [ReviewerState.REVIEWER]: [{_account_id: reviewer1._account_id}],
+    };
+
+    await element.updateComplete;
+
+    const mutations: ReviewerInput[] = [];
+
+    stubSaveReview((review: ReviewInput) => {
+      mutations.push(...review.reviewers!);
+    });
+
+    await element.send(false, false);
+    assert.lengthOf(mutations, 0);
+  });
+
   test('emits cancel on esc key', async () => {
     const cancelHandler = sinon.spy();
     element.addEventListener('cancel', cancelHandler);
-    pressAndReleaseKeyOn(element, 27, null, 'Escape');
+    pressKey(element, Key.ESC);
     await element.updateComplete;
 
     assert.isTrue(cancelHandler.called);
@@ -1903,14 +1946,18 @@
   test('should not send on enter key', () => {
     stubSaveReview(() => undefined);
     element.addEventListener('send', () => assert.fail('wrongly called'));
-    pressAndReleaseKeyOn(element, 13, null, 'Enter');
+    pressKey(element, Key.ENTER);
   });
 
   test('emit send on ctrl+enter key', async () => {
+    // required so that "Send" button is enabled
+    element.canBeStarted = true;
+    await element.updateComplete;
+
     stubSaveReview(() => undefined);
     const promise = mockPromise();
     element.addEventListener('send', () => promise.resolve());
-    pressAndReleaseKeyOn(element, 13, 'ctrl', 'Enter');
+    pressKey(element, Key.ENTER, Modifier.CTRL_KEY);
     await promise;
   });
 
@@ -1993,13 +2040,13 @@
     });
 
     test('start review sets ready', async () => {
-      tap(queryAndAssert(element, '.send'));
+      queryAndAssert<GrButton>(element, '.send').click();
       await element.updateComplete;
       assert.isTrue(sendStub.calledWith(true, true));
     });
 
     test("save review doesn't set ready", async () => {
-      tap(queryAndAssert(element, '.save'));
+      queryAndAssert<GrButton>(element, '.save').click();
       await element.updateComplete;
       assert.isTrue(sendStub.calledWith(true, false));
     });
@@ -2019,11 +2066,11 @@
     const expectedError = new Error('test');
 
     setup(() => {
-      element.draft = expectedDraft;
+      element.patchsetLevelDraftMessage = expectedDraft;
     });
 
     function assertDialogOpenAndEnabled() {
-      assert.strictEqual(expectedDraft, element.draft);
+      assert.strictEqual(expectedDraft, element.patchsetLevelDraftMessage);
       assert.isFalse(element.disabled);
     }
 
@@ -2069,7 +2116,7 @@
     // Mock canBeStarted
     element.canBeStarted = true;
     element.draftCommentThreads = [];
-    element.draft = '';
+    element.patchsetLevelDraftMessage = '';
     element.reviewersMutated = false;
     element.labelsChanged = false;
     element.includeComments = false;
@@ -2083,7 +2130,7 @@
     // Mock everything false
     element.canBeStarted = false;
     element.draftCommentThreads = [];
-    element.draft = '';
+    element.patchsetLevelDraftMessage = '';
     element.reviewersMutated = false;
     element.labelsChanged = false;
     element.includeComments = false;
@@ -2097,7 +2144,7 @@
     // Mock nonempty comment draft array; with sending comments.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.draft = '';
+    element.patchsetLevelDraftMessage = '';
     element.reviewersMutated = false;
     element.labelsChanged = false;
     element.includeComments = true;
@@ -2111,7 +2158,7 @@
     // Mock nonempty comment draft array; without sending comments.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.draft = '';
+    element.patchsetLevelDraftMessage = '';
     element.reviewersMutated = false;
     element.labelsChanged = false;
     element.includeComments = false;
@@ -2126,7 +2173,7 @@
     // Mock nonempty change message.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.draft = 'test';
+    element.patchsetLevelDraftMessage = 'test';
     element.reviewersMutated = false;
     element.labelsChanged = false;
     element.includeComments = false;
@@ -2141,7 +2188,7 @@
     // Mock reviewers mutated.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.draft = '';
+    element.patchsetLevelDraftMessage = '';
     element.reviewersMutated = true;
     element.labelsChanged = false;
     element.includeComments = false;
@@ -2156,7 +2203,7 @@
     // Mock labels changed.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.draft = '';
+    element.patchsetLevelDraftMessage = '';
     element.reviewersMutated = false;
     element.labelsChanged = true;
     element.includeComments = false;
@@ -2171,7 +2218,7 @@
     // Whole dialog is disabled.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.draft = '';
+    element.patchsetLevelDraftMessage = '';
     element.reviewersMutated = false;
     element.labelsChanged = true;
     element.includeComments = false;
@@ -2189,7 +2236,7 @@
     ).all = [account];
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.draft = '';
+    element.patchsetLevelDraftMessage = '';
     element.reviewersMutated = false;
     element.labelsChanged = false;
     element.includeComments = false;
@@ -2206,7 +2253,7 @@
     element.draftCommentThreads = [];
     await element.updateComplete;
 
-    tap(queryAndAssert(element, 'gr-button.send'));
+    queryAndAssert<GrButton>(element, 'gr-button.send').click();
     assert.isFalse(sendStub.called);
 
     element.draftCommentThreads = [
@@ -2216,17 +2263,359 @@
             ...createDraft(),
             path: 'test',
             line: 1,
-            patch_set: 1 as PatchSetNum,
+            patch_set: 1 as RevisionPatchSetNum,
           },
         ]),
       },
     ];
     await element.updateComplete;
 
-    tap(queryAndAssert(element, 'gr-button.send'));
+    queryAndAssert<GrButton>(element, 'gr-button.send').click();
     assert.isTrue(sendStub.called);
   });
 
+  suite('patchset level comment using GrComment', () => {
+    setup(async () => {
+      element.account = createAccountWithId(1);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('renders GrComment', () => {
+      assert.dom.equal(
+        query(element, '.patchsetLevelContainer'),
+        /* HTML */ `
+          <div class="patchsetLevelContainer resolved">
+            <gr-endpoint-decorator name="reply-text">
+              <gr-comment
+                hide-header=""
+                id="patchsetLevelComment"
+                permanent-editing-mode=""
+              >
+              </gr-comment>
+              <gr-endpoint-param name="change"> </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+        `
+      );
+    });
+
+    test('send button updates state as text is typed in patchset comment', async () => {
+      assert.isTrue(element.computeSendButtonDisabled());
+
+      queryAndAssert<GrComment>(element, '#patchsetLevelComment').messageText =
+        'hello';
+      await waitUntil(() => element.patchsetLevelDraftMessage === 'hello');
+
+      assert.isFalse(element.computeSendButtonDisabled());
+
+      queryAndAssert<GrComment>(element, '#patchsetLevelComment').messageText =
+        '';
+      await waitUntil(() => element.patchsetLevelDraftMessage === '');
+
+      assert.isTrue(element.computeSendButtonDisabled());
+    });
+
+    test('sending patchset level comment', async () => {
+      const patchsetLevelComment = queryAndAssert<GrComment>(
+        element,
+        '#patchsetLevelComment'
+      );
+      const autoSaveStub = sinon
+        .stub(patchsetLevelComment, 'save')
+        .returns(Promise.resolve());
+
+      patchsetLevelComment.messageText = 'hello world';
+      await waitUntil(
+        () => element.patchsetLevelDraftMessage === 'hello world'
+      );
+
+      const saveReviewPromise = interceptSaveReview();
+
+      assert.deepEqual(autoSaveStub.callCount, 0);
+
+      queryAndAssert<GrButton>(element, '.send').click();
+
+      const review = await saveReviewPromise;
+
+      assert.deepEqual(autoSaveStub.callCount, 1);
+
+      assert.deepEqual(review, {
+        drafts: 'PUBLISH_ALL_REVISIONS',
+        labels: {
+          'Code-Review': 0,
+          Verified: 0,
+        },
+        reviewers: [],
+        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('comment is auto saved when dialog is canceled', async () => {
+      const patchsetLevelComment = queryAndAssert<GrComment>(
+        element,
+        '#patchsetLevelComment'
+      );
+      const autoSaveStub = sinon
+        .stub(patchsetLevelComment, 'save')
+        .returns(Promise.resolve());
+
+      patchsetLevelComment.messageText = 'hello world';
+
+      await waitUntil(
+        () => element.patchsetLevelDraftMessage === 'hello world'
+      );
+      assert.deepEqual(autoSaveStub.callCount, 0);
+
+      patchsetLevelComment.messageText = '';
+      queryAndAssert<GrButton>(element, '#cancelButton').click();
+
+      await waitUntil(() => autoSaveStub.callCount === 1);
+
+      assert.deepEqual(patchsetLevelComment.messageText, '');
+    });
+
+    test('replies to patchset level comments are not filtered out', async () => {
+      const draft = {...createDraft(), in_reply_to: '1' as UrlEncodedCommentId};
+      element.getCommentsModel().setState({
+        drafts: {
+          'abc.txt': [draft],
+        },
+        discardedDrafts: [],
+      });
+      await waitUntil(() => element.draftCommentThreads.length === 1);
+
+      // patchset level draft as a reply is not loaded in patchsetLevel comment
+      assert.equal(element.patchsetLevelDraftMessage, '');
+
+      assert.deepEqual(element.draftCommentThreads[0].comments[0], draft);
+    });
+  });
+
+  suite('mention users', () => {
+    setup(async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.MENTION_USERS)
+        .returns(true);
+      element.account = createAccountWithId(1);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('mentioned user in resolved draft is added to CC', async () => {
+      const account = {
+        ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+        _account_id: 1234 as AccountId,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      };
+      stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+      const draft = {
+        ...createDraft(),
+        message: 'hey @abcd@def take a look at this',
+      };
+      element.getCommentsModel().setState({
+        comments: {},
+        robotComments: {},
+        drafts: {
+          a: [draft],
+        },
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+
+      element.draftCommentThreads = [createCommentThread([draft])];
+      await waitUntil(() => element.mentionedUsers.length > 0);
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.mentionedUsers, [account]);
+      assert.deepEqual(element.ccs, [account]);
+
+      // owner(999) is added since (accountId = 1) replied to the change
+      assert.sameMembers([...element.newAttentionSet], [999 as AccountId]);
+    });
+
+    test('mentioned user in unresolved draft is added to CC and AttentionSet', async () => {
+      const account = {
+        ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+        _account_id: 1234 as AccountId,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      };
+      stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+      const draft = {
+        ...createDraft(),
+        message: 'hey @abcd@def.com take a look at this',
+        unresolved: true,
+      };
+      element.getCommentsModel().setState({
+        comments: {},
+        robotComments: {},
+        drafts: {
+          a: [draft],
+        },
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+      element.draftCommentThreads = [createCommentThread([draft])];
+      await waitUntil(
+        () => element.mentionedUsersInUnresolvedDrafts.length > 0
+      );
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.mentionedUsers, [account]);
+      assert.deepEqual(element.ccs, [account]);
+
+      // owner(999) is added since (accountId = 1) replied to the change
+      assert.sameMembers(
+        [...element.newAttentionSet],
+        [999 as AccountId, 1234 as AccountId]
+      );
+    });
+
+    test('mention user can be manually removed from attention set', async () => {
+      stubRestApi('getAccountDetails').returns(
+        Promise.resolve({
+          ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+          _account_id: 1234 as AccountId,
+          registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+        })
+      );
+      const draft = {
+        ...createDraft(),
+        message: 'hey @abcd@def.com take a look at this',
+        unresolved: true,
+      };
+      element.getCommentsModel().setState({
+        comments: {},
+        robotComments: {},
+        drafts: {
+          a: [draft],
+        },
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+      element.draftCommentThreads = [createCommentThread([draft])];
+      await waitUntil(
+        () => element.mentionedUsersInUnresolvedDrafts.length > 0
+      );
+
+      await element.updateComplete;
+
+      // owner(999) is added since (accountId = 1) replied to the change
+      assert.sameMembers(
+        [...element.newAttentionSet],
+        [999 as AccountId, 1234 as AccountId]
+      );
+
+      const modifyButton = queryAndAssert<GrButton>(
+        element,
+        '.edit-attention-button'
+      );
+      modifyButton.click();
+      await element.updateComplete;
+
+      const accountsChips = Array.from(
+        queryAll<GrAccountLabel>(element, '.attention-detail gr-account-label')
+      );
+      assert.deepEqual(accountsChips[1].account, {
+        email: 'abcd@def.com' as EmailAddress,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+        _account_id: 1234 as AccountId,
+      } as AccountInfo);
+      accountsChips[1].click();
+
+      await element.updateComplete;
+
+      assert.sameMembers([...element.newAttentionSet], [999 as AccountId]);
+    });
+
+    test('mention user who is already CCed', async () => {
+      const account = {
+        ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+        _account_id: 1234 as AccountId,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      };
+      stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+
+      element.getCommentsModel().setState({
+        comments: {},
+        robotComments: {},
+        drafts: {
+          a: [
+            {
+              ...createDraft(),
+              message: 'hey @abcd@def.com take a look at this',
+              unresolved: true,
+            },
+          ],
+        },
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+
+      await element.updateComplete;
+      await waitUntil(() => element.mentionedUsers.length > 0);
+
+      assert.deepEqual(element.ccs, [account]);
+      assert.deepEqual(element.mentionedUsers, [account]);
+      element._ccs = [account];
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.mentionedUsers, [account]);
+      assert.deepEqual(element.ccs, [account]);
+    });
+
+    test('mention user who is already a reviewer', async () => {
+      const account = {
+        ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+        _account_id: 1234 as AccountId,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      };
+      stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+      element.getCommentsModel().setState({
+        comments: {},
+        robotComments: {},
+        drafts: {
+          a: [
+            {
+              ...createDraft(),
+              message: 'hey @abcd@def.com take a look at this',
+              unresolved: true,
+            },
+          ],
+        },
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+
+      await element.updateComplete;
+      await waitUntil(() => element.mentionedUsers.length > 0);
+
+      assert.deepEqual(element.mentionedUsers, [account]);
+
+      // ensure updates to reviewers is reflected to mentionedUsers property
+      element.reviewers = [account];
+
+      await element.updateComplete;
+
+      // overall ccs is empty since we filter out existing reviewers
+      assert.deepEqual(element.ccs, []);
+      assert.deepEqual(element.mentionedUsers, [account]);
+      assert.deepEqual(element.reviewers, [account]);
+    });
+  });
+
   test('getFocusStops', async () => {
     // Setting draftCommentThreads to an empty object causes _sendDisabled to be
     // computed to false.
@@ -2244,7 +2633,7 @@
             ...createDraft(),
             path: 'test',
             line: 1,
-            patch_set: 1 as PatchSetNum,
+            patch_set: 1 as RevisionPatchSetNum,
           },
         ]),
       },
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 6740977..9408b82 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
@@ -1,24 +1,14 @@
 /**
  * @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 '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-vote-chip/gr-vote-chip';
 import {LitElement, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 
 import {
   ChangeInfo,
@@ -28,13 +18,7 @@
   isDetailedLabelInfo,
   LabelInfo,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {getAppContext} from '../../../services/app-context';
-import {
-  getApprovalInfo,
-  getCodeReviewLabel,
-  showNewSubmitRequirements,
-} from '../../../utils/label-util';
+import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
 import {sortReviewers} from '../../../utils/attention-set-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css} from 'lit';
@@ -68,8 +52,6 @@
 
   @state() showAllReviewers = false;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       sharedStyles,
@@ -89,18 +71,16 @@
           line-height: calc(var(--line-height-normal) + 2px + var(--spacing-s));
           margin-bottom: calc(0px - var(--spacing-s));
         }
-        .addReviewer iron-icon {
+        .addReviewer gr-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-padding: var(--spacing-s);
+          --margin: calc(0px - var(--spacing-s));
         }
         gr-button {
           line-height: var(--line-height-normal);
@@ -136,8 +116,11 @@
               class="addReviewer"
               @click=${this.handleAddTap}
               title=${this.ccsOnly ? 'Add CC' : 'Add reviewer'}
-              ><iron-icon icon="gr-icons:edit"></iron-icon
-            ></gr-button>
+            >
+              <div>
+                <gr-icon icon="edit" filled small></gr-icon>
+              </div>
+            </gr-button>
           </div>
         </div>
         <gr-button
@@ -162,67 +145,19 @@
         .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-vote-chip
+          slot="vote-chip"
+          .vote=${this.computeVote(reviewer)}
+          .label=${this.computeCodeReviewLabel()}
+          circle-shape
+        ></gr-vote-chip>
       </gr-account-chip>
     `;
   }
 
-  /**
-   * Returns max permitted score for reviewer.
-   */
-  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 (!this.change?.labels) {
-      return NaN;
-    }
-    const detailedLabel = this.change.labels[label];
-    if (!isDetailedLabelInfo(detailedLabel) || !detailedLabel.all) {
-      return NaN;
-    }
-    const approvalInfo = getApprovalInfo(detailedLabel, reviewer);
-    if (!approvalInfo) {
-      return NaN;
-    }
-    if (hasOwnProperty(approvalInfo, 'permitted_voting_range')) {
-      if (!approvalInfo.permitted_voting_range) return NaN;
-      return approvalInfo.permitted_voting_range.max;
-    } else if (hasOwnProperty(approvalInfo, 'value')) {
-      // If present, user can vote on the label.
-      return 0;
-    }
-    return NaN;
-  }
-
-  // private but used in tests
-  computeVoteableText(reviewer: AccountInfo) {
-    const change = this.change;
-    if (!change || !change.labels) {
-      return '';
-    }
-    const maxScores = [];
-    for (const label of Object.keys(change.labels)) {
-      const maxScore = this.getReviewerPermittedScore(reviewer, label);
-      if (isNaN(maxScore) || maxScore < 0) {
-        continue;
-      }
-      const scoreLabel = maxScore > 0 ? `+${maxScore}` : `${maxScore}`;
-      maxScores.push(`${label}: ${scoreLabel}`);
-    }
-    return maxScores.join(', ');
-  }
-
   private computeVote(reviewer: AccountInfo): ApprovalInfo | undefined {
     const codeReviewLabel = this.computeCodeReviewLabel();
     if (!codeReviewLabel || !isDetailedLabelInfo(codeReviewLabel)) return;
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 c6b1d5f..1f1bef3 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
@@ -1,41 +1,63 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-reviewer-list';
 import {mockPromise, queryAndAssert} from '../../../test/test-utils';
 import {GrReviewerList} from './gr-reviewer-list';
 import {
   createAccountDetailWithId,
   createChange,
-  createDetailedLabelInfo,
 } from '../../../test/test-data-generators';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {AccountId, EmailAddress} from '../../../types/common';
 import './gr-reviewer-list';
-
-const basicFixture = fixtureFromElement('gr-reviewer-list');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-reviewer-list tests', () => {
   let element: GrReviewerList;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-reviewer-list></gr-reviewer-list>`);
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <div>
+            <div class="controlsContainer" hidden="">
+              <gr-button
+                aria-disabled="false"
+                class="addReviewer"
+                id="addReviewer"
+                link=""
+                role="button"
+                tabindex="0"
+                title="Add reviewer"
+              >
+                <div>
+                  <gr-icon icon="edit" filled small></gr-icon>
+                </div>
+              </gr-button>
+            </div>
+          </div>
+          <gr-button
+            aria-disabled="false"
+            class="hiddenReviewers"
+            hidden=""
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            and 0 more
+          </gr-button>
+        </div>
+      `
+    );
   });
 
   test('controls hidden on immutable element', async () => {
@@ -265,71 +287,4 @@
     assert.equal(element.reviewers.length, 100);
     assert.isTrue(queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden);
   });
-
-  test('votable labels', async () => {
-    element.change = {
-      ...createChange(),
-      labels: {
-        Foo: {
-          ...createDetailedLabelInfo(),
-          all: [
-            {
-              _account_id: 7 as AccountId,
-              permitted_voting_range: {max: 2, min: 0},
-            },
-          ],
-        },
-        Bar: {
-          ...createDetailedLabelInfo(),
-          all: [
-            {
-              ...createAccountDetailWithId(1),
-              permitted_voting_range: {max: 1, min: 0},
-            },
-            {
-              _account_id: 7 as AccountId,
-              permitted_voting_range: {max: 1, min: 0},
-            },
-          ],
-        },
-        FooBar: {
-          ...createDetailedLabelInfo(),
-          all: [{_account_id: 7 as AccountId, value: 0}],
-        },
-      },
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-        FooBar: ['-1', ' 0'],
-      },
-    };
-    await element.updateComplete;
-
-    assert.strictEqual(
-      element.computeVoteableText({...createAccountDetailWithId(1)}),
-      'Bar: +1'
-    );
-    assert.strictEqual(
-      element.computeVoteableText({...createAccountDetailWithId(7)}),
-      'Foo: +2, Bar: +1, FooBar: 0'
-    );
-    assert.strictEqual(
-      element.computeVoteableText({...createAccountDetailWithId(2)}),
-      ''
-    );
-  });
-
-  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)}),
-      ''
-    );
-  });
 });
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
index 72f04e3..e4d9c12 100644
--- 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
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-submit-requirements/gr-submit-requirements';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {css, html, LitElement} from 'lit';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
 import {ParsedChangeInfo} from '../../../types/types';
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard_test.ts
new file mode 100644
index 0000000..f1ebd99
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard_test.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-submit-requirement-dashboard-hovercard';
+import {GrSubmitRequirementDashboardHovercard} from './gr-submit-requirement-dashboard-hovercard';
+import {createParsedChange} from '../../../test/test-data-generators';
+
+suite('gr-submit-requirement-dashboard-hovercard tests', () => {
+  let element: GrSubmitRequirementDashboardHovercard;
+  setup(async () => {
+    element = await fixture<GrSubmitRequirementDashboardHovercard>(
+      html`<gr-submit-requirement-dashboard-hovercard
+        .change=${createParsedChange()}
+      ></gr-submit-requirement-dashboard-hovercard>`
+    );
+    await element.updateComplete;
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <gr-submit-requirements
+            disable-endpoints=""
+            disable-hovercards=""
+            suppress-title=""
+          >
+          </gr-submit-requirements>
+        </div>
+      `
+    );
+  });
+});
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 a3ef937..66ad38c 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
@@ -1,22 +1,12 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-label-info/gr-label-info';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {
   AccountInfo,
   ChangeStatus,
@@ -30,7 +20,7 @@
   extractAssociatedLabels,
   getApprovalInfo,
   hasVotes,
-  iconForStatus,
+  iconForRequirement,
 } from '../../../utils/label-util';
 import {ParsedChangeInfo} from '../../../types/types';
 import {css, html, LitElement} from 'lit';
@@ -40,9 +30,12 @@
 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';
+import {
+  atomizeExpression,
+  SubmitRequirementExpressionAtomStatus,
+} from '../../../utils/submit-requirement-util';
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = HovercardMixin(LitElement);
@@ -107,10 +100,8 @@
         div.sectionIcon {
           flex: 0 0 30px;
         }
-        div.sectionIcon iron-icon {
+        div.sectionIcon gr-icon {
           position: relative;
-          width: 20px;
-          height: 20px;
         }
         .section.condition > .sectionContent {
           background-color: var(--gray-background);
@@ -123,7 +114,13 @@
         .expression {
           color: var(--gray-foreground);
         }
-        .button iron-icon {
+        .expression .failing.atom {
+          border-bottom: 2px solid var(--error-foreground);
+        }
+        .expression .passing.atom {
+          border-bottom: 2px solid var(--success-foreground);
+        }
+        .button gr-icon {
           color: inherit;
         }
         div.button {
@@ -131,21 +128,15 @@
           margin-top: var(--spacing-m);
           padding: var(--spacing-m) var(--spacing-xl) 0;
         }
-        .section.description > .sectionContent {
-          white-space: pre-wrap;
-        }
       `,
     ];
   }
 
   override render() {
     if (!this.requirement) return;
-    const icon = iconForStatus(this.requirement.status);
     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>
-        </div>
+        <div class="sectionIcon">${this.renderStatus(this.requirement)}</div>
         <div class="sectionContent">
           <h3 class="name heading-3">
             <span>${this.requirement.name}</span>
@@ -154,7 +145,7 @@
       </div>
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="small" icon="gr-icons:info-outline"></iron-icon>
+          <gr-icon class="small" icon="info"></gr-icon>
         </div>
         <div class="sectionContent">
           <div class="row">
@@ -169,6 +160,17 @@
     </div>`;
   }
 
+  private renderStatus(requirement: SubmitRequirementResultInfo) {
+    const icon = iconForRequirement(requirement);
+    return html`<gr-icon
+      class=${icon.icon}
+      icon=${icon.icon}
+      ?filled=${icon.filled}
+      role="img"
+      aria-label=${requirement.status.toLowerCase()}
+    ></gr-icon>`;
+  }
+
   private renderDescription() {
     let description = this.requirement?.description;
     if (this.requirement?.status === SubmitRequirementStatus.ERROR) {
@@ -182,9 +184,14 @@
     if (!description) return;
     return html`<div class="section description">
       <div class="sectionIcon">
-        <iron-icon icon="gr-icons:description"></iron-icon>
+        <gr-icon icon="description"></gr-icon>
       </div>
-      <div class="sectionContent">${description}</div>
+      <div class="sectionContent">
+        <gr-formatted-text
+          .markdown=${true}
+          .content=${description}
+        ></gr-formatted-text>
+      </div>
     </div>`;
   }
 
@@ -231,7 +238,7 @@
 
   private renderShowHideConditionButton() {
     const buttonText = this.expanded ? 'Hide conditions' : 'View conditions';
-    const icon = this.expanded ? 'expand-less' : 'expand-more';
+    const icon = this.expanded ? 'expand_less' : 'expand_more';
 
     return html` <div class="button">
       <gr-button
@@ -240,8 +247,8 @@
         @click=${(_: MouseEvent) => this.toggleConditionsVisibility()}
       >
         ${buttonText}
-        <iron-icon icon="gr-icons:${icon}"></iron-icon
-      ></gr-button>
+        <gr-icon .icon=${icon}></gr-icon>
+      </gr-button>
     </div>`;
   }
 
@@ -313,7 +320,11 @@
       },
     };
     return this.restApiService
-      .saveChangeReview(this.change._number, CURRENT, review)
+      .saveChangeReview(
+        this.change._number,
+        this.change.current_revision,
+        review
+      )
       .then(() => {
         fireReload(this, true);
       });
@@ -337,6 +348,32 @@
     `;
   }
 
+  private getClassFromAtomStatus(
+    status: SubmitRequirementExpressionAtomStatus
+  ) {
+    switch (status) {
+      case SubmitRequirementExpressionAtomStatus.PASSING:
+        return 'passing atom';
+      case SubmitRequirementExpressionAtomStatus.FAILING:
+        return 'failing atom';
+      default:
+        return 'atom';
+    }
+  }
+
+  private getTitleFromAtomStatus(
+    status: SubmitRequirementExpressionAtomStatus
+  ) {
+    switch (status) {
+      case SubmitRequirementExpressionAtomStatus.PASSING:
+        return 'Atom evaluates to True';
+      case SubmitRequirementExpressionAtomStatus.FAILING:
+        return 'Atom evaluates to False';
+      default:
+        return 'Atom value is unknown';
+    }
+  }
+
   private renderCondition(
     name: string,
     expression?: SubmitRequirementExpressionInfo
@@ -346,7 +383,17 @@
       <div class="section condition">
         <div class="sectionContent">
           ${name}:<br />
-          <span class="expression"> ${expression.expression} </span>
+          <span class="expression">
+            ${atomizeExpression(expression).map(part =>
+              part.isAtom
+                ? html`<span
+                    class=${this.getClassFromAtomStatus(part.atomStatus!)}
+                    title=${this.getTitleFromAtomStatus(part.atomStatus!)}
+                    >${part.value}</span
+                  >`
+                : part.value
+            )}
+          </span>
         </div>
       </div>
     `;
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
index ded88de..4e78e8a 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
@@ -1,22 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {fixture} from '@open-wc/testing-helpers';
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import './gr-submit-requirement-hovercard';
 import {GrSubmitRequirementHovercard} from './gr-submit-requirement-hovercard';
@@ -30,7 +18,7 @@
   createSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import {ParsedChangeInfo} from '../../../types/types';
-import {query, queryAndAssert} from '../../../test/test-utils';
+import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {ChangeStatus, SubmitRequirementResultInfo} from '../../../api/rest-api';
 
@@ -48,103 +36,120 @@
   });
 
   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>
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon
+                aria-label="satisfied"
+                role="img"
+                class="check_circle"
+                filled
+                icon="check_circle"
+              >
+              </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <h3 class="heading-3 name">
+                <span> Verified </span>
+              </h3>
             </div>
           </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="info"></gr-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
+              <gr-icon icon="expand_more"></gr-icon>
+            </gr-button>
+          </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"
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon
+                aria-label="satisfied"
+                role="img"
+                class="check_circle"
+                filled
+                icon="check_circle"
+              >
+              </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <h3 class="heading-3 name">
+                <span> Verified </span>
+              </h3>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="info"></gr-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"
             >
-            </iron-icon>
+              Hide conditions
+              <gr-icon icon="expand_less"></gr-icon>
+            </gr-button>
           </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 class="section condition">
+            <div class="sectionContent">
+              Submit condition:
+              <br />
+              <span class="expression">
+                <span class="passing atom" title="Atom evaluates to True">
+                  label:Verified=MAX
+                </span>
+                <span class="passing atom" title="Atom evaluates to True">
+                  -label:Verified=MIN
+                </span>
+              </span>
             </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 () => {
@@ -174,61 +179,68 @@
         .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>
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon
+                aria-label="satisfied"
+                role="img"
+                class="check_circle"
+                filled
+                icon="check_circle"
+              ></gr-icon>
+            </div>
+            <div class="sectionContent">
+              <h3 class="heading-3 name">
+                <span> Verified </span>
+              </h3>
+            </div>
           </div>
-          <div class="sectionContent">
-            <h3 class="heading-3 name">
-              <span> Verified </span>
-            </h3>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="info"></gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Status</div>
+                <div>SATISFIED</div>
+              </div>
+            </div>
           </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="section">
+            <div class="sectionIcon"></div>
             <div class="row">
-              <div class="title">Status</div>
-              <div>SATISFIED</div>
+              <div>
+                <gr-label-info> </gr-label-info>
+              </div>
             </div>
           </div>
-        </div>
-        <div class="section">
-          <div class="sectionIcon"></div>
-          <div class="row">
-            <div>
-              <gr-label-info> </gr-label-info>
+          <div class="section description">
+            <div class="sectionIcon">
+              <gr-icon icon="description"></gr-icon>
+            </div>
+            <div class="sectionContent">
+              <gr-formatted-text></gr-formatted-text>
             </div>
           </div>
-        </div>
-        <div class="section description">
-          <div class="sectionIcon">
-            <iron-icon icon="gr-icons:description"> </iron-icon>
+          <div class="button">
+            <gr-button
+              aria-disabled="false"
+              id="toggleConditionsButton"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              View conditions
+              <gr-icon icon="expand_more"></gr-icon>
+            </gr-button>
           </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', () => {
@@ -266,13 +278,16 @@
         ></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>
-      `);
+      assert.dom.equal(
+        quickApprove,
+        /* 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 () => {
@@ -306,6 +321,22 @@
       assert.isUndefined(query(element, '.quickApprove'));
     });
 
+    test('uses patchset from change', async () => {
+      const saveChangeReview = stubRestApi('saveChangeReview').resolves();
+      const element = await fixture<GrSubmitRequirementHovercard>(
+        html`<gr-submit-requirement-hovercard
+          .requirement=${submitRequirement}
+          .change=${change}
+          .account=${account}
+        ></gr-submit-requirement-hovercard>`
+      );
+
+      queryAndAssert<GrButton>(element, '.quickApprove > gr-button').click();
+
+      assert.equal(saveChangeReview.callCount, 1);
+      assert.equal(saveChangeReview.firstCall.args[1], change.current_revision);
+    });
+
     test('override button renders', async () => {
       const submitRequirement: SubmitRequirementResultInfo = {
         ...createSubmitRequirementResultInfo(),
@@ -344,13 +375,16 @@
         ></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>
-      `);
+      assert.dom.equal(
+        quickApprove,
+        /* 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 6e60a22..640b9d0 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
@@ -1,27 +1,17 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-label-info/gr-label-info';
+import '../../shared/gr-icon/gr-icon';
 import '../gr-submit-requirement-hovercard/gr-submit-requirement-hovercard';
 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 {LitElement, css, html, TemplateResult, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
 import {ParsedChangeInfo} from '../../../types/types';
 import {
   AccountInfo,
@@ -38,7 +28,7 @@
   getTriggerVotes,
   hasNeutralStatus,
   hasVotes,
-  iconForStatus,
+  iconForRequirement,
   orderSubmitRequirements,
 } from '../../../utils/label-util';
 import {fontStyles} from '../../../styles/gr-font-styles';
@@ -46,12 +36,14 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {CheckRun} from '../../../models/checks/checks-model';
 import {getResultsOf, hasResultsOf} from '../../../models/checks/checks-util';
-import {Category} from '../../../api/checks';
-import {fireShowPrimaryTab} from '../../../utils/event-util';
-import {PrimaryTab} from '../../../constants/constants';
+import {Category, RunStatus} from '../../../api/checks';
+import {fireShowTab} from '../../../utils/event-util';
+import {Tab} from '../../../constants/constants';
 import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
 import {resolve} from '../../../models/dependency';
 import {checksModelToken} from '../../../models/checks/checks-model';
+import {join} from 'lit/directives/join.js';
+import {map} from 'lit/directives/map.js';
 
 /**
  * @attr {Boolean} suppress-title - hide titles, currently for hovercard view
@@ -90,10 +82,8 @@
           margin: 0 0 var(--spacing-s);
           padding-top: var(--spacing-s);
         }
-        iron-icon {
-          width: var(--line-height-normal, 20px);
-          height: var(--line-height-normal, 20px);
-          vertical-align: top;
+        gr-icon {
+          font-size: var(--line-height-normal, 20px);
         }
         .requirements,
         section.trigger-votes {
@@ -119,9 +109,15 @@
         td {
           padding: var(--spacing-s);
           white-space: nowrap;
+          vertical-align: top;
         }
         .votes-cell {
           display: flex;
+          flex-flow: wrap;
+        }
+        .votes-cell .separator {
+          width: 100%;
+          margin-top: var(--spacing-s);
         }
         gr-vote-chip {
           margin-right: var(--spacing-s);
@@ -136,20 +132,24 @@
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
+      () => this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
       x => (this.runs = x)
     );
   }
 
   override render() {
+    return html`${this.renderSubmitRequirements()}${this.renderTriggerVotes()}`;
+  }
+
+  private renderSubmitRequirements() {
     const submit_requirements = orderSubmitRequirements(
       getRequirements(this.change)
     );
-
+    if (submit_requirements.length === 0) return nothing;
     return html` <h3
         class="metadata-title heading-3"
         id="submit-requirements-caption"
@@ -182,26 +182,23 @@
                 .mutable=${this.mutable ?? false}
               ></gr-submit-requirement-hovercard>
             `
-          )}
-      ${this.renderTriggerVotes()}`;
+          )}`;
   }
 
-  renderRequirement(requirement: SubmitRequirementResultInfo, index: number) {
+  private renderRequirement(
+    requirement: SubmitRequirementResultInfo,
+    index: number
+  ) {
     const row = html`
-     <td>${this.renderStatus(requirement.status)}</td>
+     <td>${this.renderStatus(requirement)}</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)}`
-          )}
+          ${this.renderEndpoint(requirement, this.renderVoteCell(requirement))}
         </td>
       </tr>
     `;
@@ -230,7 +227,7 @@
     if (this.disableEndpoints)
       return html`<div class="votes-cell">${slot}</div>`;
 
-    const endpointName = this.calculateEndpointName(requirement.name);
+    const endpointName = this.computeEndpointName(requirement.name);
     return html`<gr-endpoint-decorator class="votes-cell" name=${endpointName}>
       <gr-endpoint-param
         name="change"
@@ -244,46 +241,55 @@
     </gr-endpoint-decorator>`;
   }
 
-  renderStatus(status: SubmitRequirementStatus) {
-    const icon = iconForStatus(status);
-    return html`<iron-icon
-      class=${icon}
-      icon="gr-icons:${icon}"
+  private renderStatus(requirement: SubmitRequirementResultInfo) {
+    const icon = iconForRequirement(requirement);
+    return html`<gr-icon
+      class=${icon.icon}
+      ?filled=${icon.filled}
+      .icon=${icon.icon}
       role="img"
-      aria-label=${status.toLowerCase()}
-    ></iron-icon>`;
+      aria-label=${requirement.status.toLowerCase()}
+    ></gr-icon>`;
   }
 
-  renderVotesAndChecksChips(requirement: SubmitRequirementResultInfo) {
+  renderVoteCell(requirement: SubmitRequirementResultInfo) {
     if (requirement.status === SubmitRequirementStatus.ERROR) {
       return html`<span class="error">Error</span>`;
     }
+
     const requirementLabels = extractAssociatedLabels(requirement);
     const allLabels = this.change?.labels ?? {};
     const associatedLabels = Object.keys(allLabels).filter(label =>
       requirementLabels.includes(label)
     );
 
-    const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
-      label => !hasVotes(allLabels[label])
-    );
-
-    const checksChips = this.renderChecks(requirement);
-
     const requirementWithoutLabelToVoteOn = associatedLabels.length === 0;
     if (requirementWithoutLabelToVoteOn) {
       const status = capitalizeFirstLetter(requirement.status.toLowerCase());
-      return checksChips || html`${status}`;
+      return this.renderChecks(requirement) || html`${status}`;
     }
 
+    const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
+      label => !hasVotes(allLabels[label])
+    );
     if (everyAssociatedLabelsIsWithoutVotes) {
-      return checksChips || html`No votes`;
+      return this.renderChecks(requirement) || html`No votes`;
     }
 
-    return html`${associatedLabels.map(label =>
-      this.renderLabelVote(label, allLabels)
+    const associatedLabelsWithVotes = associatedLabels.filter(label =>
+      hasVotes(allLabels[label])
+    );
+
+    return html`${join(
+      map(
+        associatedLabelsWithVotes,
+        label =>
+          html`${this.renderLabelVote(label, allLabels)}
+          ${this.renderOverrideLabels(requirement, label)}`
+      ),
+      html`<span class="separator"></span>`
     )}
-    ${checksChips}`;
+    ${this.renderChecks(requirement)}`;
   }
 
   renderLabelVote(label: string, labels: LabelNameToInfoMap) {
@@ -309,57 +315,96 @@
     }
   }
 
-  renderChecks(requirement: SubmitRequirementResultInfo) {
-    const requirementLabels = extractAssociatedLabels(requirement);
-    const requirementRuns = this.runs
-      .filter(run => hasResultsOf(run, Category.ERROR))
-      .filter(
-        run => run.labelName && requirementLabels.includes(run.labelName)
-      );
-    const runsCount = requirementRuns.reduce(
-      (sum, run) => sum + getResultsOf(run, Category.ERROR).length,
-      0
-    );
-    if (runsCount === 0) return;
-    const links = [];
-    if (requirementRuns.length === 1 && requirementRuns[0].statusLink) {
-      links.push(requirementRuns[0].statusLink);
-    }
-    return html`<gr-checks-chip
-      .text=${`${runsCount}`}
-      .links=${links}
-      .statusOrCategory=${Category.ERROR}
-      @click=${() => {
-        fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
-          checksTab: {
-            statusOrCategory: Category.ERROR,
-          },
-        });
-      }}
-    ></gr-checks-chip>`;
-  }
-
-  renderOverrideLabels(requirement: SubmitRequirementResultInfo) {
+  renderOverrideLabels(
+    requirement: SubmitRequirementResultInfo,
+    forLabel: string
+  ) {
     if (requirement.status !== SubmitRequirementStatus.OVERRIDDEN) return;
     const requirementLabels = extractAssociatedLabels(
       requirement,
       'onlyOverride'
-    ).filter(label => {
-      const allLabels = this.change?.labels ?? {};
-      return allLabels[label] && hasVotes(allLabels[label]);
-    });
+    )
+      .filter(label => label === forLabel)
+      .filter(label => {
+        const allLabels = this.change?.labels ?? {};
+        return allLabels[label] && hasVotes(allLabels[label]);
+      });
     return requirementLabels.map(
       label => html`<span class="overrideLabel">${label}</span>`
     );
   }
 
+  renderChecks(requirement: SubmitRequirementResultInfo) {
+    const requirementLabels = extractAssociatedLabels(requirement);
+    const errorRuns = this.runs
+      .filter(run => hasResultsOf(run, Category.ERROR))
+      .filter(
+        run => run.labelName && requirementLabels.includes(run.labelName)
+      );
+    const errorRunsCount = errorRuns.reduce(
+      (sum, run) => sum + getResultsOf(run, Category.ERROR).length,
+      0
+    );
+    if (errorRunsCount > 0) {
+      return this.renderChecksCategoryChip(
+        errorRuns,
+        errorRunsCount,
+        Category.ERROR
+      );
+    }
+    const runningRuns = this.runs
+      .filter(r => r.isLatestAttempt)
+      .filter(
+        r => r.status === RunStatus.RUNNING || r.status === RunStatus.SCHEDULED
+      )
+      .filter(
+        run => run.labelName && requirementLabels.includes(run.labelName)
+      );
+
+    const runningRunsCount = runningRuns.length;
+    if (runningRunsCount > 0) {
+      return this.renderChecksCategoryChip(
+        runningRuns,
+        runningRunsCount,
+        RunStatus.RUNNING
+      );
+    }
+    return;
+  }
+
+  renderChecksCategoryChip(
+    runs: CheckRun[],
+    runsCount: Number,
+    category: Category | RunStatus
+  ) {
+    if (runsCount === 0) return;
+    const links = [];
+    if (runs.length === 1 && runs[0].statusLink) {
+      links.push(runs[0].statusLink);
+    }
+    return html`<gr-checks-chip
+      .text=${`${runsCount}`}
+      .links=${links}
+      .statusOrCategory=${category}
+      @click=${() => {
+        fireShowTab(this, Tab.CHECKS, false, {
+          checksTab: {
+            statusOrCategory: category,
+          },
+        });
+      }}
+    ></gr-checks-chip>`;
+  }
+
   renderTriggerVotes() {
     const labels = this.change?.labels ?? {};
     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>
+    return html`<h3 class="metadata-title heading-3">
+        ${this.computeTriggerVotesTitle()}
+      </h3>
       <section class="trigger-votes">
         ${triggerVotes.map(
           label =>
@@ -375,8 +420,17 @@
       </section>`;
   }
 
+  private computeTriggerVotesTitle() {
+    if (getRequirements(this.change).length === 0) {
+      // This is special case for old changes without submit requirements.
+      return 'Label Votes';
+    } else {
+      return 'Trigger Votes';
+    }
+  }
+
   // not private for tests
-  calculateEndpointName(requirementName: string) {
+  computeEndpointName(requirementName: string) {
     // remove class name annnotation after ~
     const name = requirementName.split('~')[0];
     const normalizedName = charsOnly(name).toLowerCase();
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
index 392fac91..fa12eaa 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -1,22 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {fixture} from '@open-wc/testing-helpers';
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import './gr-submit-requirements';
 import {GrSubmitRequirements} from './gr-submit-requirements';
@@ -28,9 +16,15 @@
   createSubmitRequirementExpressionInfo,
   createSubmitRequirementResultInfo,
   createNonApplicableSubmitRequirementResultInfo,
+  createRunResult,
+  createCheckResult,
 } from '../../../test/test-data-generators';
-import {SubmitRequirementResultInfo} from '../../../api/rest-api';
+import {
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../../../api/rest-api';
 import {ParsedChangeInfo} from '../../../types/types';
+import {RunStatus} from '../../../api/checks';
 
 suite('gr-submit-requirements tests', () => {
   let element: GrSubmitRequirements;
@@ -69,48 +63,55 @@
   });
 
   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>
-    `);
+    assert.shadowDom.equal(
+      element,
+      /* 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>
+                <gr-icon
+                  aria-label="satisfied"
+                  role="img"
+                  class="check_circle"
+                  filled
+                  icon="check_circle"
+                >
+                </gr-icon>
+              </td>
+              <td class="name">
+                <gr-limited-text class="name"></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', () => {
@@ -120,11 +121,14 @@
     });
     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>
-      `);
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ `
+          <div class="votes-cell">
+            <gr-vote-chip> </gr-vote-chip>
+          </div>
+        `
+      );
     });
 
     test('no votes', async () => {
@@ -137,9 +141,10 @@
       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>
-      `);
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ ' <div class="votes-cell">No votes</div> '
+      );
     });
 
     test('without label to vote on', async () => {
@@ -149,15 +154,139 @@
       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>
-      `);
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ ' <div class="votes-cell">Satisfied</div> '
+      );
+    });
+
+    test('checks', async () => {
+      element.runs = [
+        {
+          ...createRunResult(),
+          labelName: 'Verified',
+          results: [createCheckResult()],
+        },
+      ];
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ `
+          <div class="votes-cell">
+            <gr-vote-chip></gr-vote-chip>
+            <gr-checks-chip></gr-checks-chip>
+          </div>
+        `
+      );
+    });
+
+    test('running checks', async () => {
+      element.runs = [
+        {
+          ...createRunResult(),
+          status: RunStatus.RUNNING,
+          labelName: 'Verified',
+          results: [createCheckResult()],
+        },
+      ];
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ `
+          <div class="votes-cell">
+            <gr-vote-chip></gr-vote-chip>
+            <gr-checks-chip></gr-checks-chip>
+          </div>
+        `
+      );
+    });
+
+    test('with override label', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.labels = {
+        Override: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      };
+      modifiedChange.submit_requirements = [
+        {
+          ...createSubmitRequirementResultInfo(),
+          status: SubmitRequirementStatus.OVERRIDDEN,
+          override_expression_result: createSubmitRequirementExpressionInfo(
+            'label:Override=MAX -label:Override=MIN'
+          ),
+        },
+      ];
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ `<div class="votes-cell">
+          <gr-vote-chip> </gr-vote-chip>
+          <span class="overrideLabel"> Override </span>
+        </div>`
+      );
+    });
+
+    test('with override with 2 labels', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.labels = {
+        Override: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+        Override2: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      };
+      modifiedChange.submit_requirements = [
+        {
+          ...createSubmitRequirementResultInfo(),
+          status: SubmitRequirementStatus.OVERRIDDEN,
+          override_expression_result: createSubmitRequirementExpressionInfo(
+            'label:Override=MAX label:Override2=MAX'
+          ),
+        },
+      ];
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ `<div class="votes-cell">
+          <gr-vote-chip> </gr-vote-chip>
+          <span class="overrideLabel"> Override </span>
+          <span class="separator"></span>
+          <gr-vote-chip> </gr-vote-chip>
+          <span class="overrideLabel"> Override2 </span>
+        </div>`
+      );
     });
   });
 
   test('calculateEndpointName()', () => {
     assert.equal(
-      element.calculateEndpointName('code-owners~CodeOwnerSub'),
+      element.computeEndpointName('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 bf85d11..27b5097 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
 import '../../shared/gr-comment-thread/gr-comment-thread';
@@ -28,8 +17,11 @@
 import {
   CommentThread,
   getCommentAuthors,
+  getMentionedThreads,
   hasHumanReply,
+  isDraft,
   isDraftThread,
+  isMentionedThread,
   isRobotThread,
   isUnresolved,
   lastUpdated,
@@ -40,15 +32,18 @@
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property, queryAll, state} from 'lit/decorators';
+import {customElement, property, queryAll, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subscribe} from '../../lit/subscription-controller';
 import {ParsedChangeInfo} from '../../../types/types';
-import {repeat} from 'lit/directives/repeat';
+import {repeat} from 'lit/directives/repeat.js';
 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';
+import {Interaction} from '../../../constants/reporting';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {HtmlPatched} from '../../../utils/lit-util';
 
 enum SortDropdownState {
   TIMESTAMP = 'Latest timestamp',
@@ -135,7 +130,7 @@
 @customElement('gr-thread-list')
 export class GrThreadList extends LitElement {
   @queryAll('gr-comment-thread')
-  threadElements?: NodeList;
+  threadElements?: NodeListOf<GrCommentThread>;
 
   /**
    * Raw list of threads for the component to show.
@@ -201,19 +196,45 @@
   @state()
   draftsOnly = false;
 
+  @state()
+  mentionsOnly = false;
+
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly flagsService = getAppContext().flagsService;
+
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  private readonly patched = new HtmlPatched(key => {
+    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
+      component: this.tagName,
+      key: key.substring(0, 300),
+    });
+  });
+
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
-    subscribe(this, this.getChangeModel().change$, x => (this.change = x));
-    subscribe(this, this.userModel.account$, x => (this.account = x));
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
+    // for COMMENTS_AUTOCLOSE logging purposes only
+    this.reporting.reportInteraction(
+      Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED
+    );
   }
 
   override willUpdate(changed: PropertyValues) {
@@ -223,6 +244,9 @@
 
   private onCommentTabStateUpdate() {
     switch (this.commentTabState?.commentTab) {
+      case CommentTabState.MENTIONS:
+        this.handleOnlyMentions();
+        break;
       case CommentTabState.UNRESOLVED:
         this.handleOnlyUnresolved();
         break;
@@ -314,6 +338,17 @@
     ];
   }
 
+  override updated(): void {
+    // for COMMENTS_AUTOCLOSE logging purposes only
+    const threads = this.shadowRoot!.querySelectorAll('gr-comment-thread');
+    if (threads.length > 0) {
+      this.reporting.reportInteraction(
+        Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED,
+        {uid: threads[0].uid}
+      );
+    }
+  }
+
   override render() {
     return html`
       ${this.renderDropdown()}
@@ -387,16 +422,16 @@
           index === 0 || threads[index - 1].path !== threads[index].path;
         const separator =
           index !== 0 && isFirst
-            ? html`<div class="thread-separator"></div>`
+            ? this.patched.html`<div class="thread-separator"></div>`
             : undefined;
         const commentThread = this.renderCommentThread(thread, isFirst);
-        return html`${separator}${commentThread}`;
+        return this.patched.html`${separator}${commentThread}`;
       }
     );
   }
 
   private renderCommentThread(thread: CommentThread, isFirst: boolean) {
-    return html`
+    return this.patched.html`
       <gr-comment-thread
         .thread=${thread}
         show-file-path
@@ -436,6 +471,7 @@
   }
 
   private getCommentsDropdownValue() {
+    if (this.mentionsOnly) return CommentTabState.MENTIONS;
     if (this.draftsOnly) return CommentTabState.DRAFTS;
     if (this.unresolvedOnly) return CommentTabState.UNRESOLVED;
     return CommentTabState.SHOW_ALL;
@@ -457,6 +493,14 @@
       value: CommentTabState.UNRESOLVED,
     });
     if (this.account) {
+      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+        items.push({
+          text: `Mentions (${
+            getMentionedThreads(threads, this.account).length
+          })`,
+          value: CommentTabState.MENTIONS,
+        });
+      }
       items.push({
         text: `Drafts (${threads.filter(isDraftThread).length})`,
         value: CommentTabState.DRAFTS,
@@ -488,6 +532,9 @@
       case CommentTabState.UNRESOLVED:
         this.handleOnlyUnresolved();
         break;
+      case CommentTabState.MENTIONS:
+        this.handleOnlyMentions();
+        break;
       case CommentTabState.DRAFTS:
         this.handleOnlyDrafts();
         break;
@@ -537,8 +584,10 @@
     if (el?.editing) return true;
 
     if (this.selectedAuthors.length > 0) {
-      const hasACommentFromASelectedAuthor = thread.comments.some(c =>
-        this.isASelectedAuthor(c.author)
+      const hasACommentFromASelectedAuthor = thread.comments.some(
+        c =>
+          (isDraft(c) && this.isASelectedAuthor(this.account)) ||
+          this.isASelectedAuthor(c.author)
       );
       if (!hasACommentFromASelectedAuthor) return false;
     }
@@ -548,6 +597,9 @@
       if (isRobotThread(thread) && !hasHumanReply(thread)) return false;
     }
 
+    if (this.mentionsOnly && !isMentionedThread(thread, this.account))
+      return false;
+
     if (this.draftsOnly && !isDraftThread(thread)) return false;
     if (this.unresolvedOnly && !isUnresolved(thread)) return false;
 
@@ -557,16 +609,25 @@
   private handleOnlyUnresolved() {
     this.unresolvedOnly = true;
     this.draftsOnly = false;
+    this.mentionsOnly = false;
+  }
+
+  private handleOnlyMentions() {
+    this.mentionsOnly = true;
+    this.unresolvedOnly = true;
+    this.draftsOnly = false;
   }
 
   private handleOnlyDrafts() {
     this.draftsOnly = true;
     this.unresolvedOnly = false;
+    this.mentionsOnly = false;
   }
 
   private handleAllComments() {
     this.draftsOnly = false;
     this.unresolvedOnly = false;
+    this.mentionsOnly = false;
   }
 
   private queryThreadElement(rootId: string): GrCommentThread | undefined {
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
index 33a4e21..16fa813 100644
--- 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
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-thread-list';
 import {CommentSide, SpecialFilePath} from '../../../constants/constants';
 import {CommentTabState} from '../../../types/events';
@@ -24,32 +12,38 @@
   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 {queryAll, stubFlags} from '../../../test/test-utils';
+import {getUserId} from '../../../utils/account-util';
 import {
   createAccountDetailWithId,
+  createComment,
+  createCommentThread,
+  createDraft,
   createParsedChange,
   createThread,
 } from '../../../test/test-data-generators';
 import {
   AccountId,
+  EmailAddress,
   NumericChangeId,
-  PatchSetNum,
   Timestamp,
 } from '../../../api/rest-api';
-import {RobotId, UrlEncodedCommentId} from '../../../types/common';
-import {CommentThread} from '../../../utils/comment-util';
+import {
+  RobotId,
+  UrlEncodedCommentId,
+  RevisionPatchSetNum,
+} from '../../../types/common';
+import {CommentThread, isDraft} 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');
+import {GrDropdownList} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-thread-list tests', () => {
   let element: GrThreadList;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-thread-list></gr-thread-list>`);
     element.changeNum = 123 as NumericChangeId;
     element.change = createParsedChange();
     element.account = createAccountDetailWithId();
@@ -62,8 +56,9 @@
               _account_id: 1000001 as AccountId,
               name: 'user',
               username: 'user',
+              email: 'abcd' as EmailAddress,
             },
-            patch_set: 4 as PatchSetNum,
+            patch_set: 4 as RevisionPatchSetNum,
             id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
             line: 5,
             updated: '2015-12-01 15:15:15.000000000' as Timestamp,
@@ -79,10 +74,10 @@
             message: 'draft',
             unresolved: true,
             __draft: true,
-            patch_set: '2' as PatchSetNum,
+            patch_set: '2' as RevisionPatchSetNum,
           },
         ],
-        patchNum: 4 as PatchSetNum,
+        patchNum: 4 as RevisionPatchSetNum,
         path: '/COMMIT_MSG',
         line: 5,
         rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
@@ -96,15 +91,16 @@
               _account_id: 1000002 as AccountId,
               name: 'user',
               username: 'user',
+              email: 'abcd' as EmailAddress,
             },
-            patch_set: 3 as PatchSetNum,
+            patch_set: 3 as RevisionPatchSetNum,
             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,
+        patchNum: 3 as RevisionPatchSetNum,
         path: 'test.txt',
         rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
         commentSide: CommentSide.REVISION,
@@ -117,15 +113,16 @@
               _account_id: 1000002 as AccountId,
               name: 'user',
               username: 'user',
+              email: 'abcd' as EmailAddress,
             },
-            patch_set: 2 as PatchSetNum,
+            patch_set: 2 as RevisionPatchSetNum,
             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,
+        patchNum: 2 as RevisionPatchSetNum,
         path: '/COMMIT_MSG',
         rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
         commentSide: CommentSide.REVISION,
@@ -138,8 +135,9 @@
               _account_id: 1000003 as AccountId,
               name: 'user',
               username: 'user',
+              email: 'abcd' as EmailAddress,
             },
-            patch_set: 2 as PatchSetNum,
+            patch_set: 2 as RevisionPatchSetNum,
             id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
             line: 4,
             updated: '2015-12-04 15:16:15.000000000' as Timestamp,
@@ -147,7 +145,7 @@
             unresolved: true,
           },
         ],
-        patchNum: 2 as PatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
         path: '/COMMIT_MSG',
         line: 4,
         rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
@@ -163,10 +161,10 @@
             message: 'resolved draft',
             unresolved: false,
             __draft: true,
-            patch_set: '2' as PatchSetNum,
+            patch_set: '2' as RevisionPatchSetNum,
           },
         ],
-        patchNum: 4 as PatchSetNum,
+        patchNum: 4 as RevisionPatchSetNum,
         path: '/COMMIT_MSG',
         line: 6,
         rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
@@ -180,10 +178,10 @@
             updated: '2015-12-06 15:16:15.000000000' as Timestamp,
             message: 'patchset comment 1',
             unresolved: false,
-            patch_set: '2' as PatchSetNum,
+            patch_set: '2' as RevisionPatchSetNum,
           },
         ],
-        patchNum: 2 as PatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
         path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
         rootId: 'patchset_level_1' as UrlEncodedCommentId,
         commentSide: CommentSide.REVISION,
@@ -196,10 +194,10 @@
             updated: '2015-12-07 15:16:15.000000000' as Timestamp,
             message: 'patchset comment 2',
             unresolved: false,
-            patch_set: '3' as PatchSetNum,
+            patch_set: '3' as RevisionPatchSetNum,
           },
         ],
-        patchNum: 3 as PatchSetNum,
+        patchNum: 3 as RevisionPatchSetNum,
         path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
         rootId: 'patchset_level_2' as UrlEncodedCommentId,
         commentSide: CommentSide.REVISION,
@@ -212,8 +210,9 @@
               _account_id: 1000000 as AccountId,
               name: 'user',
               username: 'user',
+              email: 'abcd' as EmailAddress,
             },
-            patch_set: 4 as PatchSetNum,
+            patch_set: 4 as RevisionPatchSetNum,
             id: 'rc1' as UrlEncodedCommentId,
             line: 5,
             updated: '2015-12-08 15:16:15.000000000' as Timestamp,
@@ -222,7 +221,7 @@
             robot_id: 'rc1' as RobotId,
           },
         ],
-        patchNum: 4 as PatchSetNum,
+        patchNum: 4 as RevisionPatchSetNum,
         path: '/COMMIT_MSG',
         line: 5,
         rootId: 'rc1' as UrlEncodedCommentId,
@@ -236,8 +235,9 @@
               _account_id: 1000000 as AccountId,
               name: 'user',
               username: 'user',
+              email: 'abcd' as EmailAddress,
             },
-            patch_set: 4 as PatchSetNum,
+            patch_set: 4 as RevisionPatchSetNum,
             id: 'rc2' as UrlEncodedCommentId,
             line: 7,
             updated: '2015-12-09 15:16:15.000000000' as Timestamp,
@@ -251,8 +251,9 @@
               _account_id: 1000000 as AccountId,
               name: 'user',
               username: 'user',
+              email: 'abcd' as EmailAddress,
             },
-            patch_set: 4 as PatchSetNum,
+            patch_set: 4 as RevisionPatchSetNum,
             id: 'c2_1' as UrlEncodedCommentId,
             line: 5,
             updated: '2015-12-10 15:16:15.000000000' as Timestamp,
@@ -260,7 +261,7 @@
             unresolved: true,
           },
         ],
-        patchNum: 4 as PatchSetNum,
+        patchNum: 4 as RevisionPatchSetNum,
         path: '/COMMIT_MSG',
         line: 7,
         rootId: 'rc2' as UrlEncodedCommentId,
@@ -310,83 +311,89 @@
 
   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>
-    `);
+    assert.shadowDom.equal(
+      element,
+      /* 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>
-    `);
+    assert.dom.equal(
+      queryAndAssert(element, 'div#threads'),
+      /* HTML */ `
+        <div id="threads" part="threads">
+          <div><span>No comments</span></div>
+        </div>
+      `
+    );
   });
 
   test('tapping single author chips', async () => {
@@ -395,7 +402,7 @@
     const chips = Array.from(
       queryAll<GrAccountLabel>(element, 'gr-account-label')
     );
-    const authors = chips.map(chip => accountOrGroupKey(chip.account!)).sort();
+    const authors = chips.map(chip => getUserId(chip.account!)).sort();
     assert.deepEqual(authors, [
       1 as AccountId,
       1000000 as AccountId,
@@ -407,7 +414,7 @@
     assert.equal(element.getDisplayedThreads().length, 9);
 
     const chip = chips.find(chip => chip.account!._account_id === 1000001);
-    tap(chip!);
+    chip!.click();
     await element.updateComplete;
 
     assert.equal(element.threads.length, 9);
@@ -417,12 +424,38 @@
       1000001 as AccountId
     );
 
-    tap(chip!);
+    chip!.click();
     await element.updateComplete;
     assert.equal(element.threads.length, 9);
     assert.equal(element.getDisplayedThreads().length, 9);
   });
 
+  test('tapping single author with only drafts', async () => {
+    element.account = createAccountDetailWithId(1);
+    element.threads = [createThread(createDraft())];
+    await element.updateComplete;
+    const chips = Array.from(
+      queryAll<GrAccountLabel>(element, 'gr-account-label')
+    );
+    const authors = chips.map(chip => getUserId(chip.account!)).sort();
+    assert.deepEqual(authors, [1 as AccountId]);
+    assert.equal(element.threads.length, 1);
+    assert.equal(element.getDisplayedThreads().length, 1);
+
+    const chip = chips.find(chip => chip.account!._account_id === 1);
+    chip!.click();
+    await element.updateComplete;
+
+    assert.equal(element.threads.length, 1);
+    assert.equal(element.getDisplayedThreads().length, 1);
+    assert.isTrue(isDraft(element.getDisplayedThreads()[0].comments[0]));
+
+    chip!.click();
+    await element.updateComplete;
+    assert.equal(element.threads.length, 1);
+    assert.equal(element.getDisplayedThreads().length, 1);
+  });
+
   test('tapping multiple author chips', async () => {
     element.account = createAccountDetailWithId(1);
     await element.updateComplete;
@@ -430,8 +463,8 @@
       queryAll<GrAccountLabel>(element, 'gr-account-label')
     );
 
-    tap(chips.find(chip => chip.account?._account_id === 1000001)!);
-    tap(chips.find(chip => chip.account?._account_id === 1000002)!);
+    chips.find(chip => chip.account?._account_id === 1000001)!.click();
+    chips.find(chip => chip.account?._account_id === 1000002)!.click();
     await element.updateComplete;
 
     assert.equal(element.threads.length, 9);
@@ -451,32 +484,86 @@
   });
 
   test('show all comments', async () => {
-    const event = new CustomEvent('value-changed', {
-      detail: {value: CommentTabState.SHOW_ALL},
-    });
-    element.handleCommentsDropdownValueChange(event);
+    const filterDropdown = queryAndAssert<GrDropdownList>(
+      element,
+      '#filterDropdown'
+    );
+    filterDropdown.value = CommentTabState.SHOW_ALL;
+    await filterDropdown.updateComplete;
     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);
+    const filterDropdown = queryAndAssert<GrDropdownList>(
+      element,
+      '#filterDropdown'
+    );
+    filterDropdown.value = CommentTabState.UNRESOLVED;
+    await filterDropdown.updateComplete;
     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);
+    const filterDropdown = queryAndAssert<GrDropdownList>(
+      element,
+      '#filterDropdown'
+    );
+    filterDropdown.value = CommentTabState.DRAFTS;
+    await filterDropdown.updateComplete;
     await element.updateComplete;
     assert.equal(element.getDisplayedThreads().length, 2);
   });
 
+  suite('mention threads', () => {
+    let mentionedThreads: CommentThread[];
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      mentionedThreads = [
+        createCommentThread([
+          {
+            ...createComment(),
+            message: 'random text with no emails',
+          },
+        ]),
+        // Resolved thread does not contribute to the count
+        createCommentThread([
+          {
+            ...createComment(),
+            message: '@abcd@def.com please take a look',
+          },
+          {
+            ...createComment(),
+            message: '@abcd@def.com please take a look again at this',
+          },
+        ]),
+        createCommentThread([
+          {
+            ...createComment(),
+            message: '@abcd@def.com this is important',
+            unresolved: true,
+          },
+        ]),
+      ];
+      element.account!.email = 'abcd@def.com' as EmailAddress;
+      element.threads.push(...mentionedThreads);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('mentions filter', async () => {
+      const filterDropdown = queryAndAssert<GrDropdownList>(
+        element,
+        '#filterDropdown'
+      );
+      filterDropdown.value = CommentTabState.MENTIONS;
+      await filterDropdown.updateComplete;
+      await element.updateComplete;
+      assert.deepEqual(element.getDisplayedThreads(), [mentionedThreads[2]]);
+    });
+  });
+
   suite('hideDropdown', () => {
     test('header hidden for hideDropdown=true', async () => {
       element.hideDropdown = true;
@@ -534,8 +621,8 @@
   });
 
   test('patchsets in reverse order', () => {
-    t1.patchNum = 2 as PatchSetNum;
-    t2.patchNum = 3 as PatchSetNum;
+    t1.patchNum = 2 as RevisionPatchSetNum;
+    t2.patchNum = 3 as RevisionPatchSetNum;
     checkOrder([t2, t1]);
   });
 
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 33c2eac..db49e8d 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
@@ -1,20 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {customElement, property} from 'lit/decorators';
+import '../../shared/gr-icon/gr-icon';
+import {customElement, property} from 'lit/decorators.js';
 import {css, html, LitElement} from 'lit';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
 import {fontStyles} from '../../../styles/gr-font-styles';
@@ -56,10 +46,9 @@
         div.sectionIcon {
           flex: 0 0 30px;
         }
-        div.sectionIcon iron-icon {
+        div.sectionIcon gr-icon {
           position: relative;
-          width: 20px;
-          height: 20px;
+          font-size: 20px;
         }
       `,
     ];
@@ -76,7 +65,7 @@
       </div>
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="small" icon="gr-icons:info-outline"></iron-icon>
+          <gr-icon icon="info" class="small"></gr-icon></span>
         </div>
         <div class="sectionContent">
           <div class="row">
@@ -96,9 +85,14 @@
     if (!description) return;
     return html`<div class="section description">
       <div class="sectionIcon">
-        <iron-icon icon="gr-icons:description"></iron-icon>
+        <gr-icon icon="description"></gr-icon>
       </div>
-      <div class="sectionContent">${description}</div>
+      <div class="sectionContent">
+        <gr-formatted-text
+          .markdown=${true}
+          .content=${description}
+        ></gr-formatted-text>
+      </div>
     </div>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard_test.ts
new file mode 100644
index 0000000..305ddd3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard_test.ts
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-trigger-vote-hovercard';
+import {GrTriggerVoteHovercard} from './gr-trigger-vote-hovercard';
+import {createLabelInfo} from '../../../test/test-data-generators';
+
+suite('gr-trigger-vote-hovercard tests', () => {
+  let element: GrTriggerVoteHovercard;
+  setup(async () => {
+    element = await fixture<GrTriggerVoteHovercard>(
+      html`<gr-trigger-vote-hovercard
+        .labelInfo=${createLabelInfo()}
+        .labelName=${'Foo'}
+      ></gr-trigger-vote-hovercard>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <div class="section">
+            <div class="sectionContent">
+              <h3 class="heading-3 name">
+                <span> Foo </span>
+              </h3>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon icon="info" class=" small"></gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Status</div>
+                <div>
+                  <slot name="label-info"> </slot>
+                </div>
+              </div>
+            </div>
+          </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
index f2bd350..0e4410c 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
@@ -1,24 +1,13 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 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 {customElement, property} from 'lit/decorators.js';
 import {ParsedChangeInfo} from '../../../types/types';
 import {
   AccountInfo,
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
index 8497ff4..6247472 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
@@ -1,22 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {fixture} from '@open-wc/testing-helpers';
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import './gr-trigger-vote';
 import {GrTriggerVote} from './gr-trigger-vote';
@@ -73,12 +61,15 @@
   });
 
   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>`);
+    assert.shadowDom.equal(
+      element,
+      /* 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 6097cde..1c494fa 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -1,23 +1,12 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {Action} from '../../api/checks';
-import {checkRequiredProperty} from '../../utils/common-util';
+import {assertIsDefined} from '../../utils/common-util';
 import {resolve} from '../../models/dependency';
 import {checksModelToken} from '../../models/checks/checks-model';
 @customElement('gr-checks-action')
@@ -36,7 +25,7 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    checkRequiredProperty(this.action, 'action');
+    assertIsDefined(this.action, 'action');
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
index a8ce40f..8c0143c 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {CheckRun} from '../../models/checks/checks-model';
 import {ordinal} from '../../utils/string-util';
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 9ea29b0..848948f 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -1,28 +1,24 @@
 /**
  * @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 {classMap} from 'lit/directives/class-map';
-import {repeat} from 'lit/directives/repeat';
-import {ifDefined} from 'lit/directives/if-defined';
-import {LitElement, css, html, PropertyValues, TemplateResult} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import '../shared/gr-icon/gr-icon';
+import {classMap} from 'lit/directives/class-map.js';
+import {repeat} from 'lit/directives/repeat.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {
+  LitElement,
+  css,
+  html,
+  PropertyValues,
+  TemplateResult,
+  nothing,
+} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import './gr-checks-action';
 import './gr-hovercard-run';
 import '@polymer/paper-tooltip/paper-tooltip';
-import '@polymer/iron-icon/iron-icon';
 import {
   Action,
   Category,
@@ -34,7 +30,15 @@
 import {sharedStyles} from '../../styles/shared-styles';
 import {CheckRun, RunResult} from '../../models/checks/checks-model';
 import {
+  ALL_ATTEMPTS,
+  AttemptChoice,
+  attemptChoiceLabel,
+  isAttemptChoice,
+  LATEST_ATTEMPT,
+  sortAttemptChoices,
+  stringToAttemptChoice,
   allResults,
+  createFixAction,
   firstPrimaryLink,
   hasCompletedWithoutResults,
   iconFor,
@@ -44,35 +48,36 @@
   secondaryLinks,
   tooltipForLink,
 } from '../../models/checks/checks-util';
-import {assertIsDefined, check} from '../../utils/common-util';
+import {assertIsDefined, assert, unique} from '../../utils/common-util';
 import {modifierPressed, toggleClass, whenVisible} from '../../utils/dom-util';
 import {durationString} from '../../utils/date-util';
 import {charsOnly} from '../../utils/string-util';
 import {isAttemptSelected, matches} from './gr-checks-util';
-import {ChecksTabState} from '../../types/events';
-import {
-  ConfigInfo,
-  LabelNameToInfoMap,
-  PatchSetNumber,
-} from '../../types/common';
+import {ChecksTabState, ValueChangedEvent} from '../../types/events';
+import {LabelNameToInfoMap, PatchSetNumber} from '../../types/common';
 import {spinnerStyles} from '../../styles/gr-spinner-styles';
 import {
   getLabelStatus,
   getRepresentativeValue,
   valueString,
 } from '../../utils/label-util';
-import {GerritNav} from '../core/gr-navigation/gr-navigation';
 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';
+import {when} from 'lit/directives/when.js';
+import {KnownExperimentId} from '../../services/flags/flags';
+import {HtmlPatched} from '../../utils/lit-util';
+import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
+import './gr-checks-attempt';
+import {createDiffUrl} from '../../models/views/diff';
+import {changeViewModelToken} from '../../models/views/change';
 
 /**
  * Firing this event sets the regular expression of the results filter.
@@ -89,7 +94,7 @@
 }
 
 @customElement('gr-result-row')
-class GrResultRow extends LitElement {
+export class GrResultRow extends LitElement {
   @query('td.nameCol div.name')
   nameEl?: HTMLElement;
 
@@ -108,15 +113,37 @@
   @state()
   labels?: LabelNameToInfoMap;
 
+  @state()
+  latestPatchNum?: PatchSetNumber;
+
+  @state()
+  selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
+
   private getChangeModel = resolve(this, changeModelToken);
 
   private getChecksModel = resolve(this, checksModelToken);
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.getChangeModel().labels$, x => (this.labels = x));
+  private readonly flags = getAppContext().flagsService;
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().labels$,
+      x => (this.labels = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().checksSelectedAttemptNumber$,
+      x => (this.selectedAttempt = x)
+    );
   }
 
   static override get styles() {
@@ -138,7 +165,7 @@
         a.link {
           margin-right: var(--spacing-s);
         }
-        iron-icon.link {
+        gr-icon.link {
           color: var(--link-color);
         }
         td.nameCol div.flex {
@@ -257,7 +284,7 @@
           margin: -4px 0;
           vertical-align: top;
         }
-        #moreActions iron-icon {
+        #moreActions gr-icon {
           color: var(--link-color);
         }
         #moreMessage {
@@ -346,6 +373,7 @@
             >
               ${this.result.checkName}
             </div>
+            ${this.renderAttempt()}
             <div class="space"></div>
           </div>
         </td>
@@ -375,11 +403,9 @@
               : 'Expand result row'}
             @keydown=${this.toggleExpandedPress}
           >
-            <iron-icon
-              icon=${this.isExpanded
-                ? 'gr-icons:expand-less'
-                : 'gr-icons:expand-more'}
-            ></iron-icon>
+            <gr-icon
+              icon=${this.isExpanded ? 'expand_less' : 'expand_more'}
+            ></gr-icon>
           </div>
         </td>
       </tr>
@@ -389,6 +415,11 @@
     `;
   }
 
+  private renderAttempt() {
+    if (this.selectedAttempt !== ALL_ATTEMPTS) return nothing;
+    return html`<gr-checks-attempt .run=${this.result}></gr-checks-attempt>`;
+  }
+
   private renderExpanded() {
     if (!this.isExpanded) return;
     return html`<gr-result-expanded
@@ -416,8 +447,7 @@
   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;
+    if (e.key !== 'Enter' && e.key !== ' ') return;
     e.preventDefault();
     e.stopPropagation();
     this.toggleExpanded();
@@ -437,7 +467,9 @@
     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} title=${text}>
+        ${text}&nbsp;
+      </div>
     `;
   }
 
@@ -447,6 +479,12 @@
     const label = this.result?.labelName;
     if (!label) return;
     if (!this.result?.isLatestAttempt) return;
+    // For check results on older patchsets it is impossible to decide whether
+    // the current label score is still influenced by them. But typically it
+    // is really confusing for the user, if we claim that an old (error) result
+    // influences the current (positive) score. So we prefer to be conservative
+    // and only display the label chip for checks results on the latest ps.
+    if (this.result.patchset !== this.latestPatchNum) return;
     const info = this.labels?.[label];
     const status = getLabelStatus(info).toLowerCase();
     const value = getRepresentativeValue(info);
@@ -484,18 +522,24 @@
     if (this.isExpanded) return;
     if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
+    const icon = iconForLink(link.icon);
     return html`<a href=${link.url} class="link" target="_blank"
-      ><iron-icon
+      ><gr-icon
+        icon=${icon.name}
+        ?filled=${icon.filled}
         aria-label="external link to details"
         class="link"
-        icon="gr-icons:${iconForLink(link.icon)}"
-      ></iron-icon
+      ></gr-icon
       ><paper-tooltip offset="5">${tooltipText}</paper-tooltip></a
     >`;
   }
 
   private renderActions() {
-    const actions = this.result?.actions ?? [];
+    const actions = [...(this.result?.actions ?? [])];
+    if (this.flags.isEnabled(KnownExperimentId.CHECKS_FIXES)) {
+      const fixAction = createFixAction(this, this.result);
+      if (fixAction) actions.unshift(fixAction);
+    }
     if (actions.length === 0) return;
     const overflowItems = actions.slice(2).map(action => {
       return {...action, id: action.name};
@@ -511,14 +555,13 @@
         vertical-offset="32"
         horizontal-align="right"
         @tap-item=${this.handleAction}
-        @opened-changed=${(e: CustomEvent) =>
+        @opened-changed=${(e: ValueChangedEvent<boolean>) =>
           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>
+        <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
         <span id="moreMessage">More</span>
       </gr-dropdown>
     </div>`;
@@ -580,13 +623,8 @@
   @property({type: Boolean})
   hideCodePointers = false;
 
-  @state()
-  repoConfig?: ConfigInfo;
-
   private getChangeModel = resolve(this, changeModelToken);
 
-  private getConfigModel = resolve(this, configModelToken);
-
   static override get styles() {
     return [
       sharedStyles,
@@ -598,7 +636,7 @@
           display: inline-block;
           margin-right: var(--spacing-xl);
         }
-        .links a iron-icon {
+        .links a gr-icon {
           margin-right: var(--spacing-xs);
         }
         .message {
@@ -608,15 +646,6 @@
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(
-      this,
-      this.getConfigModel().repoConfig$,
-      x => (this.repoConfig = x)
-    );
-  }
-
   override render() {
     if (!this.result) return '';
     return html`
@@ -632,10 +661,9 @@
           .value=${this.result}
         ></gr-endpoint-param>
         <gr-formatted-text
-          noTrailingMargin
           class="message"
-          .content=${this.result.message}
-          .config=${this.repoConfig?.commentlinks}
+          .markdown=${true}
+          .content=${this.result.message ?? ''}
         ></gr-formatted-text>
       </gr-endpoint-decorator>
     `;
@@ -681,7 +709,13 @@
       return {
         icon: LinkIcon.CODE,
         tooltip: `${path}${rangeText}`,
-        url: GerritNav.getUrlForDiff(change, path, patchset, undefined, line),
+        url: createDiffUrl({
+          changeNum: change._number,
+          project: change.project,
+          path,
+          patchNum: patchset,
+          lineNum: line,
+        }),
         primary: true,
       };
     });
@@ -694,12 +728,10 @@
     if (!link) return;
     const text = link.tooltip ?? tooltipForLink(link.icon);
     const target = targetBlank ? '_blank' : undefined;
+    const icon = iconForLink(link.icon);
     return html`<a href=${link.url} target=${ifDefined(target)}>
-      <iron-icon
-        class="link"
-        icon="gr-icons:${iconForLink(link.icon)}"
-      ></iron-icon
-      ><span>${text}</span>
+      <gr-icon icon=${icon.name} class="link" ?filled=${icon.filled}></gr-icon>
+      <span>${text}</span>
     </a>`;
   }
 }
@@ -725,7 +757,7 @@
   filterInput?: HTMLInputElement;
 
   @state()
-  filterRegExp = new RegExp('');
+  filterRegExp = '';
 
   /** All runs. Shown should only the selected/filtered ones. */
   @property({attribute: false})
@@ -735,8 +767,8 @@
    * Check names of runs that are selected in the runs panel. When this array
    * is empty, then no run is selected and all runs should be shown.
    */
-  @property({attribute: false})
-  selectedRuns: string[] = [];
+  @state()
+  selectedRuns: Set<string> = new Set();
 
   @state()
   actions: Action[] = [];
@@ -756,12 +788,8 @@
   @state()
   latestPatchsetNumber: PatchSetNumber | undefined = undefined;
 
-  /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @property({attribute: false})
-  selectedAttempts: Map<string, number | undefined> = new Map<
-    string,
-    number | undefined
-  >();
+  @state()
+  selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
 
   /** Maintains the state of which result sections should show all results. */
   @state()
@@ -781,39 +809,63 @@
    */
   private isSectionExpandedByUser = new Map<Category, boolean>();
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
+  private readonly patched = new HtmlPatched(key => {
+    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
+      component: this.tagName,
+      key: key.substring(0, 300),
+    });
+  });
+
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().topLevelActionsSelected$,
+      () => this.getChecksModel().topLevelActionsSelected$,
       x => (this.actions = x)
     );
     subscribe(
       this,
-      this.getChecksModel().topLevelLinksSelected$,
+      () => this.getChecksModel().topLevelLinksSelected$,
       x => (this.links = x)
     );
     subscribe(
       this,
-      this.getChecksModel().checksSelectedPatchsetNumber$,
+      () => this.getChecksModel().checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChangeModel().latestPatchNum$,
+      () => this.getChecksModel().checksSelectedAttemptNumber$,
+      x => (this.selectedAttempt = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChecksModel().someProvidersAreLoadingSelected$,
+      () => this.getChecksModel().someProvidersAreLoadingSelected$,
       x => (this.someProvidersAreLoading = x)
     );
+    subscribe(
+      this,
+      () => this.getViewModel().checksRunsSelected$,
+      x => (this.selectedRuns = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().checksResultsFilter$,
+      x => (this.filterRegExp = x)
+    );
   }
 
   static override get styles() {
@@ -838,7 +890,6 @@
         }
         .headerTopRow,
         .headerBottomRow {
-          max-width: 1600px;
           display: flex;
           justify-content: space-between;
           align-items: flex-end;
@@ -878,11 +929,13 @@
         .notLatest .headerTopRow .right .goToLatest {
           display: block;
         }
+        .headerTopRow .right > * {
+          margin-left: var(--spacing-m);
+        }
         .headerTopRow .right .goToLatest gr-button {
-          margin-right: var(--spacing-m);
           --gr-button-padding: var(--spacing-s) var(--spacing-m);
         }
-        .headerBottomRow iron-icon {
+        .headerBottomRow gr-icon {
           color: var(--link-color);
         }
         .headerBottomRow .space {
@@ -893,7 +946,7 @@
         .headerBottomRow a {
           margin-right: var(--spacing-l);
         }
-        #moreActions iron-icon {
+        #moreActions gr-icon {
           color: var(--link-color);
         }
         #moreMessage {
@@ -948,7 +1001,7 @@
         .categoryHeader .statusIcon.success {
           color: var(--success-foreground);
         }
-        .categoryHeader.empty iron-icon.statusIcon {
+        .categoryHeader.empty gr-icon.statusIcon {
           color: var(--deemphasized-text-color);
         }
         .categoryHeader .filtered {
@@ -964,7 +1017,6 @@
         }
         .noResultsMessage {
           width: 100%;
-          max-width: 1600px;
           margin-top: var(--spacing-m);
           background-color: var(--background-color-primary);
           box-shadow: var(--elevation-level-1);
@@ -973,7 +1025,6 @@
         }
         table.resultsTable {
           width: 100%;
-          max-width: 1600px;
           table-layout: fixed;
           margin-top: var(--spacing-m);
           background-color: var(--background-color-primary);
@@ -1013,6 +1064,9 @@
 
   protected override updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
+    if (changedProperties.has('filterRegExp') && this.filterInput) {
+      this.filterInput.value = this.filterRegExp;
+    }
     if (changedProperties.has('tabState') && this.tabState) {
       const {statusOrCategory, checkName} = this.tabState;
       if (isCategory(statusOrCategory)) {
@@ -1052,6 +1106,7 @@
       header: true,
       notLatest: !!this.checksPatchsetNumber,
     };
+    const attemptItems = this.createAttemptDropdownItems();
     return html`
       <div class=${classMap(headerClasses)}>
         <div class="headerTopRow">
@@ -1068,6 +1123,14 @@
                 >Go to latest patchset</gr-button
               >
             </div>
+            ${when(
+              attemptItems.length > 0,
+              () => html` <gr-dropdown-list
+                value=${this.selectedAttempt ?? 0}
+                .items=${attemptItems}
+                @value-change=${this.onAttemptSelected}
+              ></gr-dropdown-list>`
+            )}
             <gr-dropdown-list
               value=${this.checksPatchsetNumber ??
               this.latestPatchsetNumber ??
@@ -1138,13 +1201,15 @@
   private renderLink(link?: Link) {
     if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
+    const icon = iconForLink(link.icon);
     return html`<a href=${link.url} target="_blank"
-      ><iron-icon
+      ><gr-icon
+        icon=${icon.name}
         aria-label=${tooltipText}
         class="link"
-        icon="gr-icons:${iconForLink(link.icon)}"
-      ></iron-icon
-      ><paper-tooltip offset="5">${tooltipText}</paper-tooltip></a
+        ?filled=${icon.filled}
+      ></gr-icon>
+      <paper-tooltip offset="5">${tooltipText}</paper-tooltip></a
     >`;
   }
 
@@ -1160,8 +1225,7 @@
         .items=${items}
         .disabledIds=${disabledIds}
       >
-        <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
-        </iron-icon>
+        <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
         <span id="moreMessage">More</span>
       </gr-dropdown>
     `;
@@ -1176,11 +1240,10 @@
   }
 
   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();
+    this.getViewModel().updateState({
+      checksResultsFilter: this.filterRegExp === newValue ? '' : newValue,
+    });
   }
 
   private renderAction(action?: Action) {
@@ -1191,14 +1254,40 @@
     ></gr-checks-action>`;
   }
 
+  private onAttemptSelected(e: CustomEvent<{value: string | undefined}>) {
+    const attempt = stringToAttemptChoice(e.detail.value);
+    assertIsDefined(attempt, `unexpected attempt choice ${e.detail.value}`);
+    this.getChecksModel().updateStateSetAttempt(attempt);
+  }
+
   private onPatchsetSelected(e: CustomEvent<{value: string}>) {
-    const patchset = Number(e.detail.value);
-    check(!isNaN(patchset), 'selected patchset must be a number');
-    this.getChecksModel().setPatchset(patchset as PatchSetNumber);
+    let patchset: number | undefined = Number(e.detail.value);
+    assert(Number.isInteger(patchset), `patchset must be integer: ${patchset}`);
+    if (patchset === this.latestPatchsetNumber) patchset = undefined;
+    this.getChecksModel().updateStateSetPatchset(
+      patchset as PatchSetNumber | undefined
+    );
   }
 
   private goToLatestPatchset() {
-    this.getChecksModel().setPatchset(undefined);
+    this.getChecksModel().updateStateSetPatchset(undefined);
+  }
+
+  private createAttemptDropdownItems() {
+    if (this.runs.every(run => run.isSingleAttempt)) return [];
+    const attempts: AttemptChoice[] = this.runs
+      .map(run => run.attempt ?? 0)
+      .filter(isAttemptChoice)
+      .filter(unique);
+    attempts.push(LATEST_ATTEMPT);
+    attempts.push(ALL_ATTEMPTS);
+    const items: DropdownItem[] = attempts.sort(sortAttemptChoices).map(a => {
+      return {
+        value: a,
+        text: attemptChoiceLabel(a),
+      };
+    });
+    return items;
   }
 
   private createPatchsetDropdownItems() {
@@ -1215,21 +1304,19 @@
   }
 
   isRunSelected(run: {checkName: string}) {
-    return (
-      this.selectedRuns.length === 0 ||
-      this.selectedRuns.includes(run.checkName)
-    );
+    return this.selectedRuns.size === 0 || this.selectedRuns.has(run.checkName);
   }
 
   renderFilter() {
     const runs = this.runs.filter(
       run =>
-        this.isRunSelected(run) && isAttemptSelected(this.selectedAttempts, run)
+        this.isRunSelected(run) && isAttemptSelected(this.selectedAttempt, run)
     );
-    if (this.selectedRuns.length === 0 && allResults(runs).length <= 3) {
-      if (this.filterRegExp.source.length > 0) {
-        this.filterRegExp = new RegExp('');
-      }
+    if (
+      this.selectedRuns.size === 0 &&
+      allResults(runs).length <= 3 &&
+      this.filterRegExp === ''
+    ) {
       return;
     }
     return html`
@@ -1251,7 +1338,9 @@
       {},
       {deduping: Deduping.EVENT_ONCE_PER_CHANGE}
     );
-    this.filterRegExp = new RegExp(this.filterInput.value, 'i');
+    this.getViewModel().updateState({
+      checksResultsFilter: this.filterInput.value,
+    });
   }
 
   renderSection(category: Category) {
@@ -1259,7 +1348,7 @@
     const isWarningOrError =
       category === Category.WARNING || category === Category.ERROR;
     const allRuns = this.runs.filter(run =>
-      isAttemptSelected(this.selectedAttempts, run)
+      isAttemptSelected(this.selectedAttempt, run)
     );
     const all = allRuns.reduce(
       (results: RunResult[], run) => [
@@ -1268,19 +1357,32 @@
       ],
       []
     );
-    const isSelection = this.selectedRuns.length > 0;
+    const isSelectionActive = this.selectedRuns.size > 0;
     const selected = all.filter(result => this.isRunSelected(result));
-    const filtered = selected.filter(result =>
-      matches(result, this.filterRegExp)
-    );
+    const re = new RegExp(this.filterRegExp, 'i');
+    const filtered = selected.filter(result => matches(result, re));
+    const isFilterActiveWithResults =
+      this.filterRegExp !== '' && filtered.length > 0;
+
+    // The logic for deciding whether to expand a section by default is a bit
+    // complicated, but we want to collapse empty and info/success sections by
+    // default for a clean and focused user experience. However, as soon as the
+    // user starts selecting or filtering we must take this into account and
+    // prefer to expand the sections.
     let expanded = this.isSectionExpanded.get(category);
     const expandedByUser = this.isSectionExpandedByUser.get(category) ?? false;
     if (!expandedByUser || expanded === undefined) {
-      expanded = selected.length > 0 && (isWarningOrError || isSelection);
+      // Note that we are using `selected` for `isEmpty` and not `filtered`,
+      // because if the filter is what makes a section empty, then we want to
+      // show an expanded section, which contains a message about this.
+      const isEmpty = selected.length === 0;
+      expanded =
+        !isEmpty &&
+        (isWarningOrError || isSelectionActive || isFilterActiveWithResults);
       this.isSectionExpanded.set(category, expanded);
     }
     const expandedClass = expanded ? 'expanded' : 'collapsed';
-    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' : '';
@@ -1291,18 +1393,23 @@
       resultLimit,
       resultCount
     );
+    const icon = iconFor(category);
     return html`
       <div class=${expandedClass}>
         <h3
           class="categoryHeader ${catString} ${empty} heading-3"
           @click=${() => this.toggleExpanded(category)}
         >
-          <iron-icon class="expandIcon" icon=${icon}></iron-icon>
+          <gr-icon
+            class="expandIcon"
+            icon=${expanded ? 'expand_less' : 'expand_more'}
+          ></gr-icon>
           <div class="statusIconWrapper">
-            <iron-icon
-              icon="gr-icons:${iconFor(category)}"
+            <gr-icon
+              icon=${icon.name}
+              ?filled=${icon.filled}
               class="statusIcon ${catString}"
-            ></iron-icon>
+            ></gr-icon>
             <span class="title">${catString}</span>
             <span class="count">${this.renderCount(all, filtered)}</span>
             <paper-tooltip offset="5"
@@ -1310,12 +1417,14 @@
             >
           </div>
         </h3>
-        ${this.renderResults(
-          all,
-          selected,
-          filtered,
-          resultLimit,
-          showAllButton
+        ${when(expanded, () =>
+          this.renderResults(
+            all,
+            selected,
+            filtered,
+            resultLimit,
+            showAllButton
+          )
         )}
       </div>
     `;
@@ -1392,7 +1501,7 @@
           ${repeat(
             filtered,
             result => result.internalResultId,
-            (result?: RunResult) => html`
+            (result?: RunResult) => this.patched.html`
               <gr-result-row
                 class=${charsOnly(result!.checkName)}
                 .result=${result}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index babfd42..934e958 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -1,26 +1,338 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import '../../test/common-test-setup';
+import './gr-checks-results';
+import {GrChecksResults, GrResultRow} from './gr-checks-results';
+import {html} from 'lit';
+import {fixture, assert} from '@open-wc/testing';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {fakeRun0, setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {resolve} from '../../models/dependency';
+import {createLabelInfo} from '../../test/test-data-generators';
+import {queryAndAssert, query, assertIsDefined} from '../../utils/common-util';
+import {PatchSetNumber} from '../../api/rest-api';
+import {GrDropdownList} from '../shared/gr-dropdown-list/gr-dropdown-list';
 
-import '../../test/common-test-setup-karma';
-import {GrChecksResults} from './gr-checks-results';
+suite('gr-result-row test', () => {
+  let element: GrResultRow;
+
+  setup(async () => {
+    const result = {...fakeRun0, ...fakeRun0.results![0]};
+    element = await fixture<GrResultRow>(
+      html`<gr-result-row .result=${result}></gr-result-row>`
+    );
+    element.shouldRender = true;
+  });
+
+  test('renders label association', async () => {
+    element.result = {...element.result!, labelName: 'test-label', patchset: 1};
+    element.labels = {'test-label': createLabelInfo()};
+
+    // don't show when patchset does not match latest
+    element.latestPatchNum = 2 as PatchSetNumber;
+    await element.updateComplete;
+    let labelDiv = query(element, '.label');
+    assert.isNotOk(labelDiv);
+
+    element.latestPatchNum = 1 as PatchSetNumber;
+    await element.updateComplete;
+    labelDiv = queryAndAssert(element, '.label');
+    assert.dom.equal(
+      labelDiv,
+      /* HTML */ `
+        <div class="approved label">
+          <span> test-label +1 </span>
+          <paper-tooltip
+            fittovisiblebounds=""
+            offset="5"
+            role="tooltip"
+            tabindex="-1"
+          >
+            The check result has (probably) influenced this label vote.
+          </paper-tooltip>
+        </div>
+      `
+    );
+  });
+
+  test('renders', async () => {
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+      <div class="flex">
+        <gr-hovercard-run> </gr-hovercard-run>
+        <div class="name" role="button" tabindex="0">
+          FAKE Error Finder Finder Finder Finder Finder Finder Finder
+        </div>
+        <div class="space"></div>
+      </div>
+        <div class="summary-cell">
+          <a class="link" href="https://www.google.com" target="_blank">
+            <gr-icon
+              icon="open_in_new"
+              aria-label="external link to details"
+              class="link"
+            ></gr-icon>
+            <paper-tooltip offset="5" role="tooltip" tabindex="-1">
+              Link to details
+            </paper-tooltip>
+          </a>
+          <div
+            class="summary"
+            title="I would like to point out this error: 1 is not equal to 2!"
+          >
+            I would like to point out this error: 1 is not equal to 2!
+          </div>
+          <div class="message"></div>
+          <div class="tags">
+            <button class="tag">
+              <span> OBSOLETE </span>
+              <paper-tooltip
+                fittovisiblebounds=""
+                offset="5"
+                role="tooltip"
+                tabindex="-1"
+              >
+                A category tag for this check result. Click to filter.
+              </paper-tooltip>
+            </button>
+            <button class="tag">
+              <span> E2E </span>
+              <paper-tooltip
+                fittovisiblebounds=""
+                offset="5"
+                role="tooltip"
+                tabindex="-1"
+              >
+                A category tag for this check result. Click to filter.
+              </paper-tooltip>
+            </button>
+          </div>
+        </div>
+        <div
+          aria-checked="false"
+          aria-label="Expand result row"
+          class="show-hide"
+          hidden=""
+          role="switch"
+          tabindex="0"
+        >
+          <gr-icon icon="expand_more"></gr-icon>
+        </div>
+      </div>
+    `
+    );
+  });
+});
 
 suite('gr-checks-results test', () => {
-  test('is defined', () => {
-    const el = document.createElement('gr-checks-results');
-    assert.instanceOf(el, GrChecksResults);
+  let element: GrChecksResults;
+
+  setup(async () => {
+    element = await fixture<GrChecksResults>(
+      html`<gr-checks-results></gr-checks-results>`
+    );
+    const getChecksModel = resolve(element, checksModelToken);
+    getChecksModel().allRunsSelectedPatchset$.subscribe(
+      runs => (element.runs = runs)
+    );
+    setAllFakeRuns(getChecksModel());
+    await element.updateComplete;
+  });
+
+  test('attempt dropdown items', async () => {
+    const attemptDropdown = queryAndAssert<GrDropdownList>(
+      element,
+      'gr-dropdown-list'
+    );
+    assertIsDefined(attemptDropdown.items);
+    assert.equal(attemptDropdown.items.length, 42);
+    assert.deepEqual(attemptDropdown.items[0], {
+      text: 'Latest Attempt',
+      value: 'latest',
+    });
+    assert.deepEqual(attemptDropdown.items[1], {
+      text: 'All Attempts',
+      value: 'all',
+    });
+    assert.deepEqual(attemptDropdown.items[2], {
+      text: 'Attempt 0',
+      value: 0,
+    });
+    assert.deepEqual(attemptDropdown.items[41], {
+      text: 'Attempt 40',
+      value: 40,
+    });
+  });
+
+  test('renders', async () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="header">
+          <div class="headerTopRow">
+            <div class="left">
+              <h2 class="heading-2">Results</h2>
+              <div class="loading" hidden="">
+                <span> Loading results </span>
+                <span class="loadingSpin"> </span>
+              </div>
+            </div>
+            <div class="right">
+              <div class="goToLatest">
+                <gr-button link=""> Go to latest patchset </gr-button>
+              </div>
+              <gr-dropdown-list value="latest"> </gr-dropdown-list>
+              <gr-dropdown-list value="0"> </gr-dropdown-list>
+            </div>
+          </div>
+          <div class="headerBottomRow">
+            <div class="left">
+              <div class="filterDiv">
+                <input
+                  id="filterInput"
+                  placeholder="Filter results by tag or regular expression"
+                  type="text"
+                />
+              </div>
+            </div>
+            <div class="right">
+              <a href="https://www.google.com" target="_blank">
+                <gr-icon
+                  icon="bug_report"
+                  filled
+                  aria-label="Fake Bug Report 1"
+                  class="link"
+                ></gr-icon>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </a>
+              <a href="https://www.google.com" target="_blank">
+                <gr-icon
+                  icon="open_in_new"
+                  aria-label="Fake Link 1"
+                  class="link"
+                ></gr-icon>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </a>
+              <a href="https://www.google.com" target="_blank">
+                <gr-icon icon="code" aria-label="Fake Code Link" class="link">
+                </gr-icon>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </a>
+              <a href="https://www.google.com" target="_blank">
+                <gr-icon
+                  icon="image"
+                  filled
+                  aria-label="Fake Image Link"
+                  class="link"
+                ></gr-icon>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </a>
+              <div class="space"></div>
+              <gr-checks-action context="results"> </gr-checks-action>
+              <gr-dropdown
+                horizontal-align="right"
+                id="moreActions"
+                link=""
+                vertical-offset="32"
+              >
+                <gr-icon
+                  icon="more_vert"
+                  aria-labelledby="moreMessage"
+                ></gr-icon>
+                <span id="moreMessage"> More </span>
+              </gr-dropdown>
+            </div>
+          </div>
+        </div>
+        <div class="body">
+          <div class="expanded">
+            <h3 class="categoryHeader error heading-3">
+              <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+              <div class="statusIconWrapper">
+                <gr-icon icon="error" filled class="error statusIcon"></gr-icon>
+                <span class="title"> error </span>
+                <span class="count"> (3) </span>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </div>
+            </h3>
+            <gr-result-row
+              class="FAKEErrorFinderFinderFinderFinderFinderFinderFinder"
+            >
+            </gr-result-row>
+            <gr-result-row
+              isexpandable
+              class="FAKEErrorFinderFinderFinderFinderFinderFinderFinder"
+            >
+            </gr-result-row>
+            <gr-result-row isexpandable class="FAKESuperCheck"> </gr-result-row>
+            <table class="resultsTable">
+              <thead>
+                <tr class="headerRow">
+                  <th class="longNames nameCol">Run</th>
+                  <th class="summaryCol">Summary</th>
+                  <th class="expanderCol"></th>
+                </tr>
+              </thead>
+              <tbody></tbody>
+            </table>
+          </div>
+          <div class="expanded">
+            <h3 class="categoryHeader heading-3 warning">
+              <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+              <div class="statusIconWrapper">
+                <gr-icon icon="warning" filled class="warning statusIcon">
+                </gr-icon>
+                <span class="title"> warning </span>
+                <span class="count"> (1) </span>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </div>
+            </h3>
+            <gr-result-row class="FAKESuperCheck" isexpandable> </gr-result-row>
+            <table class="resultsTable">
+              <thead>
+                <tr class="headerRow">
+                  <th class="nameCol">Run</th>
+                  <th class="summaryCol">Summary</th>
+                  <th class="expanderCol"></th>
+                </tr>
+              </thead>
+              <tbody></tbody>
+            </table>
+          </div>
+          <div class="collapsed">
+            <h3 class="categoryHeader heading-3 info">
+              <gr-icon icon="expand_more" class="expandIcon"></gr-icon>
+              <div class="statusIconWrapper">
+                <gr-icon icon="info" class="info statusIcon"></gr-icon>
+                <span class="title"> info </span>
+                <span class="count"> (3) </span>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </div>
+            </h3>
+          </div>
+          <div class="collapsed">
+            <h3 class="categoryHeader empty heading-3 success">
+              <gr-icon icon="expand_more" class="expandIcon"></gr-icon>
+              <div class="statusIconWrapper">
+                <gr-icon icon="check_circle" class="statusIcon success">
+                </gr-icon>
+                <span class="title"> success </span>
+                <span class="count"> (0) </span>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </div>
+            </h3>
+          </div>
+        </div>
+      `,
+      {
+        ignoreChildren: ['paper-tooltip'],
+        ignoreAttributes: ['tabindex', 'aria-disabled', 'role'],
+      }
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index b18a5ff..128a9b0a 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -1,28 +1,21 @@
 /**
  * @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 '@polymer/iron-icon/iron-icon';
-import {classMap} from 'lit/directives/class-map';
+import '../shared/gr-icon/gr-icon';
+import {classMap} from 'lit/directives/class-map.js';
 import './gr-hovercard-run';
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import './gr-checks-attempt';
 import {Action, Link, RunStatus} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
+  ALL_ATTEMPTS,
+  AttemptChoice,
+  attemptChoiceLabel,
+  LATEST_ATTEMPT,
   AttemptDetail,
   compareByWorstCategory,
   headerForStatus,
@@ -51,11 +44,7 @@
 } from '../../models/checks/checks-fakes';
 import {assertIsDefined} from '../../utils/common-util';
 import {modifierPressed, whenVisible} from '../../utils/dom-util';
-import {
-  fireAttemptSelected,
-  fireRunSelected,
-  fireRunSelectionReset,
-} from './gr-checks-util';
+import {fireRunSelected, RunSelectedEvent} from './gr-checks-util';
 import {ChecksTabState} from '../../types/events';
 import {charsOnly} from '../../utils/string-util';
 import {getAppContext} from '../../services/app-context';
@@ -67,6 +56,8 @@
 import {checksModelToken} from '../../models/checks/checks-model';
 import {Interaction} from '../../constants/reporting';
 import {Deduping} from '../../api/reporting';
+import {when} from 'lit/directives/when.js';
+import {changeViewModelToken} from '../../models/views/change';
 
 @customElement('gr-checks-run')
 export class GrChecksRun extends LitElement {
@@ -78,6 +69,14 @@
           display: block;
           --thick-border: 6px;
         }
+        :host([condensed]) .eta,
+        :host([condensed]) .middle,
+        :host([condensed]) .right {
+          display: none;
+        }
+        :host([condensed]) * {
+          pointer-events: none;
+        }
         .chip {
           display: flex;
           justify-content: space-between;
@@ -114,35 +113,35 @@
         .chip.warning {
           border-left: var(--thick-border) solid var(--warning-foreground);
         }
-        .chip.info-outline {
+        .chip.info {
           border-left: var(--thick-border) solid var(--info-foreground);
         }
-        .chip.check-circle-outline {
+        .chip.check_circle {
           border-left: var(--thick-border) solid var(--success-foreground);
         }
         .chip.timelapse,
-        .chip.scheduled {
+        .chip.pending_actions {
           border-left: var(--thick-border) solid var(--border-color);
         }
         .chip.placeholder {
           border-left: var(--thick-border) solid var(--border-color);
         }
-        .chip.placeholder iron-icon {
+        .chip.placeholder gr-icon {
           display: none;
         }
-        iron-icon.error {
+        gr-icon.error {
           color: var(--error-foreground);
         }
-        iron-icon.warning {
+        gr-icon.warning {
           color: var(--warning-foreground);
         }
-        iron-icon.info-outline {
+        gr-icon.info {
           color: var(--info-foreground);
         }
-        iron-icon.check-circle-outline {
+        gr-icon.check_circle {
           color: var(--success-foreground);
         }
-        div.chip:hover {
+        :host(:not([condensed])) div.chip:hover {
           background-color: var(--hover-background-color);
         }
         div.chip:focus-within {
@@ -155,7 +154,7 @@
           padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
         }
         div.chip.selected .name,
-        div.chip.selected iron-icon.filter {
+        div.chip.selected gr-icon.filter {
           color: var(--selected-foreground);
         }
         gr-checks-action {
@@ -200,44 +199,43 @@
   @property({attribute: false})
   selected = false;
 
-  @property({attribute: false})
-  selectedAttempt?: number;
+  @state()
+  selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
 
   @property({attribute: false})
   deselected = false;
 
+  @property({type: Boolean})
+  condensed = false;
+
   @state()
   shouldRender = false;
 
   private readonly reporting = getAppContext().reportingService;
 
+  private getChecksModel = resolve(this, checksModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChecksModel().checksSelectedAttemptNumber$,
+      x => (this.selectedAttempt = x)
+    );
+  }
+
   override firstUpdated() {
     assertIsDefined(this.chipElement, 'chip element');
     whenVisible(this.chipElement, () => (this.shouldRender = true), 200);
   }
 
-  protected override updated(changedProperties: PropertyValues) {
-    super.updated(changedProperties);
-
-    // For some reason the browser does not pick up the correct `checked` state
-    // that is set in renderAttempt(). So we have to set it programmatically
-    // here.
-    const selectedAttempt = this.selectedAttempt ?? this.run.attempt;
-    const inputToBeSelected = this.shadowRoot?.querySelector(
-      `.attemptDetails input#attempt-${selectedAttempt}`
-    ) as HTMLInputElement | undefined;
-    if (inputToBeSelected) {
-      inputToBeSelected.checked = true;
-    }
-  }
-
   override render() {
     if (!this.shouldRender) return html`<div class="chip">Loading ...</div>`;
 
     const icon = iconForRun(this.run);
     const classes = {
       chip: true,
-      [icon]: true,
+      [icon.name]: true,
       selected: this.selected,
       deselected: this.deselected,
     };
@@ -250,10 +248,14 @@
         class=${classMap(classes)}
         tabindex="0"
       >
-        <div class="left">
+        <div class="left" tabindex="0">
           <gr-hovercard-run .run=${this.run}></gr-hovercard-run>
           ${this.renderFilterIcon()}
-          <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
+          <gr-icon
+            class=${icon.name}
+            icon=${icon.name}
+            ?filled=${icon.filled}
+          ></gr-icon>
           ${this.renderAdditionalIcon()}
           <span class="name">${this.run.checkName}</span>
           ${this.renderETA()}
@@ -275,43 +277,45 @@
         class="attemptDetails"
         ?hidden=${this.run.isSingleAttempt || !this.selected}
       >
+        ${this.renderAttempt({attempt: LATEST_ATTEMPT})}
+        ${this.renderAttempt({attempt: ALL_ATTEMPTS})}
         ${this.run.attemptDetails.map(a => this.renderAttempt(a))}
       </div>
     `;
   }
 
-  isSelected(detail: AttemptDetail) {
-    // this.selectedAttempt may be undefined, then choose the latest attempt,
-    // which is what this.run has.
-    const selectedAttempt = this.selectedAttempt ?? this.run.attempt;
-    return detail.attempt === selectedAttempt;
-  }
-
   renderAttempt(detail: AttemptDetail) {
+    const attempt = detail.attempt ?? 0;
     const checkNameId = charsOnly(this.run.checkName).toLowerCase();
     const id = `attempt-${detail.attempt}`;
-    const icon = detail.icon;
-    const wasNotRun = icon === iconFor(RunStatus.RUNNABLE);
+    const icon = detail.icon ?? {name: ''};
+    const wasNotRun =
+      icon?.name === iconFor(RunStatus.RUNNABLE)?.name &&
+      attempt !== LATEST_ATTEMPT &&
+      attempt !== ALL_ATTEMPTS;
+    const selected = this.selectedAttempt === attempt;
     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)}
+        .checked=${selected}
+        ?disabled=${!selected && wasNotRun}
+        @change=${() => this.handleAttemptChange(attempt)}
       />
-      <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
+      <gr-icon
+        icon=${icon.name}
+        class=${icon.name}
+        ?filled=${icon.filled}
+      ></gr-icon>
       <label for=${id}>
-        Attempt ${detail.attempt}${wasNotRun ? ' (not run)' : ''}
+        ${attemptChoiceLabel(attempt)}${wasNotRun ? ' (not run)' : ''}
       </label>
     </div>`;
   }
 
-  handleAttemptChange(detail: AttemptDetail) {
-    if (!this.isSelected(detail)) {
-      fireAttemptSelected(this, this.run.checkName, detail.attempt);
-    }
+  handleAttemptChange(attempt: AttemptChoice) {
+    this.getChecksModel().updateStateSetAttempt(attempt);
   }
 
   renderETA() {
@@ -328,11 +332,11 @@
     if (!link) return;
     return html`
       <a href=${link} target="_blank" @click=${this.onLinkClick}
-        ><iron-icon
+        ><gr-icon
+          icon="open_in_new"
           class="statusLinkIcon"
-          icon="gr-icons:launch"
           aria-label="external link to run status details"
-        ></iron-icon>
+        ></gr-icon>
         <paper-tooltip offset="5">Link to run status details</paper-tooltip>
       </a>
     `;
@@ -349,9 +353,7 @@
 
   renderFilterIcon() {
     if (!this.selected) return;
-    return html`
-      <iron-icon class="filter" icon="gr-icons:filter"></iron-icon>
-    `;
+    return html`<gr-icon icon="filter_alt" filled class="filter"></gr-icon>`;
   }
 
   /**
@@ -364,7 +366,11 @@
     if (!category) return nothing;
     const icon = iconFor(category);
     return html`
-      <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
+      <gr-icon
+        icon=${icon.name}
+        class=${icon.name}
+        ?filled=${icon.filled}
+      ></gr-icon>
     `;
   }
 
@@ -377,7 +383,7 @@
   private handleChipKey(e: KeyboardEvent) {
     if (modifierPressed(e)) return;
     // Only react to `return` and `space`.
-    if (e.keyCode !== 13 && e.keyCode !== 32) return;
+    if (e.key !== 'Enter' && e.key !== ' ') return;
     e.preventDefault();
     e.stopPropagation();
     fireRunSelected(this, this.run.checkName);
@@ -389,12 +395,8 @@
   @query('#filterInput')
   filterInput?: HTMLInputElement;
 
-  /**
-   * We prefer `undefined` over a RegExp with '', because `.source` yields
-   * a strange '(?:)' for ''.
-   */
   @state()
-  filterRegExp?: RegExp;
+  filterRegExp = '';
 
   @property({attribute: false})
   runs: CheckRun[] = [];
@@ -402,15 +404,11 @@
   @property({type: Boolean, reflect: true})
   collapsed = false;
 
-  @property({attribute: false})
-  selectedRuns: string[] = [];
+  @state()
+  selectedRuns: Set<string> = new Set();
 
-  /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @property({attribute: false})
-  selectedAttempts: Map<string, number | undefined> = new Map<
-    string,
-    number | undefined
-  >();
+  @state()
+  selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
 
   @property({attribute: false})
   tabState?: ChecksTabState;
@@ -427,25 +425,45 @@
 
   private getChecksModel = resolve(this, checksModelToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsSelectedPatchset$,
+      () => this.getChecksModel().allRunsSelectedPatchset$,
       x => (this.runs = x)
     );
     subscribe(
       this,
-      this.getChecksModel().errorMessagesLatest$,
+      () => this.getChecksModel().errorMessagesLatest$,
       x => (this.errorMessages = x)
     );
     subscribe(
       this,
-      this.getChecksModel().loginCallbackLatest$,
+      () => this.getChecksModel().loginCallbackLatest$,
       x => (this.loginCallback = x)
     );
+    subscribe(
+      this,
+      () => this.getChecksModel().checksSelectedAttemptNumber$,
+      x => (this.selectedAttempt = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().runFilterRegexp$,
+      x => (this.filterRegExp = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().checksRunsSelected$,
+      x => (this.selectedRuns = x)
+    );
+    this.addEventListener('click', () => {
+      if (this.collapsed) this.toggleCollapsed();
+    });
   }
 
   static override get styles() {
@@ -457,12 +475,22 @@
           display: block;
         }
         :host(:not([collapsed])) {
-          min-width: 320px;
+          width: 20%;
           padding: var(--spacing-l) var(--spacing-xl) var(--spacing-xl)
             var(--spacing-xl);
         }
         :host([collapsed]) {
-          padding: var(--spacing-l) 0;
+          width: 90px;
+          padding: var(--spacing-l) var(--spacing-l) var(--spacing-xl)
+            var(--spacing-l);
+          max-height: 600px;
+          overflow: hidden;
+        }
+        :host([collapsed]) * {
+          pointer-events: none;
+        }
+        :host([collapsed]:hover) {
+          cursor: pointer;
         }
         .title {
           display: flex;
@@ -477,25 +505,28 @@
         .title gr-button.expandButton {
           --gr-button-padding: var(--spacing-xs) var(--spacing-s);
         }
-        :host(:not([collapsed])) .expandButton {
+        :host .expandButton {
           margin-right: calc(0px - var(--spacing-m));
         }
-        .expandIcon {
-          width: var(--line-height-h3);
-          height: var(--line-height-h3);
+        :host([collapsed]:hover) .expandButton {
+          background: var(--gray-background-hover);
+          border-radius: var(--border-radius);
         }
         .sectionHeader {
           padding-top: var(--spacing-l);
           text-transform: capitalize;
           cursor: default;
         }
+        :host([collapsed]) .sectionHeader {
+          cursor: pointer;
+        }
         .sectionHeader h3 {
           display: inline-block;
         }
-        .collapsed .sectionRuns {
+        :host(:not([collapsed])) .collapsed .sectionRuns {
           display: none;
         }
-        .collapsed {
+        :host(:not([collapsed])) .collapsed {
           border-bottom: 1px solid var(--border-color);
           padding-bottom: var(--spacing-m);
         }
@@ -533,14 +564,14 @@
           display: flex;
           background-color: var(--error-background);
         }
-        .error iron-icon {
+        .error gr-icon {
           color: var(--error-foreground);
           margin-right: var(--spacing-m);
         }
         .login {
           background: var(--info-background);
         }
-        .login iron-icon {
+        .login gr-icon {
           color: var(--info-foreground);
         }
         .login .buttonRow {
@@ -556,20 +587,7 @@
 
   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 ||
@@ -585,9 +603,6 @@
   }
 
   override render() {
-    if (this.collapsed) {
-      return html`${this.renderCollapseButton()}`;
-    }
     return html`
       <h2 class="title">
         <div class="heading-2">Runs</div>
@@ -600,6 +615,7 @@
         type="text"
         placeholder="Filter runs by regular expression"
         ?hidden=${!this.showFilter()}
+        .value=${this.filterRegExp}
         @input=${this.onInput}
       />
       ${this.renderSection(RunStatus.RUNNING)}
@@ -609,38 +625,39 @@
   }
 
   private renderZeroState() {
+    if (this.collapsed) return;
     if (this.runs.length > 0) return;
     return html`<div class="zero">No Check Run to show</div>`;
   }
 
   private renderErrors() {
-    return Object.entries(this.errorMessages).map(
-      ([plugin, message]) =>
-        html`
-          <div class="error">
-            <div class="left">
-              <iron-icon icon="gr-icons:error"></iron-icon>
-            </div>
-            <div class="right">
-              <div class="message">
-                Error while fetching results for ${plugin}:<br />${message}
-              </div>
-            </div>
+    return Object.entries(this.errorMessages).map(([plugin, message]) => {
+      const msg = this.collapsed
+        ? 'Error'
+        : `Error while fetching results for ${plugin}:<br />${message}`;
+      return html`
+        <div class="error">
+          <div class="left">
+            <gr-icon icon="error" filled></gr-icon>
           </div>
-        `
-    );
+          <div class="right">
+            <div class="message">${msg}</div>
+          </div>
+        </div>
+      `;
+    });
   }
 
   private renderSignIn() {
     if (!this.loginCallback) return;
+    const message = this.collapsed
+      ? 'Sign in'
+      : 'Sign in to Checks Plugin to see runs and results';
     return html`
       <div class="login">
         <div>
-          <iron-icon
-            class="info-outline"
-            icon="gr-icons:info-outline"
-          ></iron-icon>
-          Sign in to Checks Plugin to see runs and results
+          <gr-icon icon="info"></gr-icon>
+          ${message}
         </div>
         <div class="buttonRow">
           <gr-button @click=${this.loginCallback} link>Sign in</gr-button>
@@ -650,8 +667,9 @@
   }
 
   private renderTitleButtons() {
-    if (this.selectedRuns.length < 2) return;
-    const actions = this.selectedRuns.map(selected => {
+    if (this.collapsed) return;
+    if (this.selectedRuns.size < 2) return;
+    const actions = [...this.selectedRuns].map(selected => {
       const run = this.runs.find(
         run => run.isLatestAttempt && run.checkName === selected
       );
@@ -666,7 +684,8 @@
       <gr-button
         class="font-normal"
         link
-        @click=${() => fireRunSelectionReset(this)}
+        @click=${() =>
+          this.getViewModel().updateState({checksRunsSelected: undefined})}
         >Unselect All</gr-button
       >
       <gr-tooltip-content
@@ -706,25 +725,28 @@
       >
         <gr-button
           link
-          class="expandButton"
+          class="expandButton font-normal"
           role="switch"
           aria-checked=${this.collapsed ? 'true' : 'false'}
           aria-label=${this.collapsed
             ? 'Expand runs panel'
             : 'Collapse runs panel'}
           @click=${this.toggleCollapsed}
-          ><iron-icon
-            class="expandIcon"
-            icon=${this.collapsed
-              ? 'gr-icons:chevron-right'
-              : 'gr-icons:chevron-left'}
-          ></iron-icon>
+        >
+          <div>
+            <gr-icon
+              icon=${this.collapsed ? 'chevron_right' : 'chevron_left'}
+              class="expandIcon"
+            >
+            </gr-icon>
+          </div>
         </gr-button>
       </gr-tooltip-content>
     `;
   }
 
-  private toggleCollapsed() {
+  private toggleCollapsed(event?: Event) {
+    if (event) event.stopPropagation();
     this.collapsed = !this.collapsed;
     this.reporting.reportInteraction(Interaction.CHECKS_RUNS_PANEL_TOGGLE, {
       collapsed: this.collapsed,
@@ -738,11 +760,8 @@
       {},
       {deduping: Deduping.EVENT_ONCE_PER_CHANGE}
     );
-    if (this.filterInput.value) {
-      this.filterRegExp = new RegExp(this.filterInput.value, 'i');
-    } else {
-      this.filterRegExp = undefined;
-    }
+    const value = this.filterInput.value;
+    this.getChecksModel().updateStateSetRunFilter(value ?? '');
   }
 
   toggle(
@@ -764,6 +783,7 @@
   }
 
   renderSection(status: RunStatus) {
+    const regExp = new RegExp(this.filterRegExp, 'i');
     const runs = this.runs
       .filter(r => r.isLatestAttempt)
       .filter(
@@ -771,21 +791,26 @@
           r.status === status ||
           (status === RunStatus.RUNNING && r.status === RunStatus.SCHEDULED)
       )
-      .filter(r => !this.filterRegExp || this.filterRegExp.test(r.checkName))
+      .filter(r => regExp.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';
+    const icon = expanded ? 'expand_less' : 'expand_more';
     let header = headerForStatus(status);
     if (runs.some(r => r.status === RunStatus.SCHEDULED)) {
       header = `${header} / ${headerForStatus(RunStatus.SCHEDULED)}`;
     }
+    const count = when(!this.collapsed, () => html` (${runs.length})`);
+    const grIcon = when(
+      !this.collapsed,
+      () => html`<gr-icon icon=${icon} class="expandIcon"></gr-icon>`
+    );
     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">${header}</h3>
+          ${grIcon}
+          <h3 class="heading-3">${header}${count}</h3>
         </div>
         <div class="sectionRuns">${runs.map(run => this.renderRun(run))}</div>
       </div>
@@ -793,6 +818,7 @@
   }
 
   toggleExpanded(status: RunStatus) {
+    if (this.collapsed) return;
     const expanded = this.isSectionExpanded.get(status) ?? true;
     this.isSectionExpanded.set(status, !expanded);
     this.reporting.reportInteraction(Interaction.CHECKS_RUN_SECTION_TOGGLE, {
@@ -803,23 +829,31 @@
   }
 
   renderRun(run: CheckRun) {
-    const selectedRun = this.selectedRuns.includes(run.checkName);
-    const selectedAttempt = this.selectedAttempts.get(run.checkName);
-    const deselected = !selectedRun && this.selectedRuns.length > 0;
+    const selectedRun = this.selectedRuns.has(run.checkName);
+    const deselected = !selectedRun && this.selectedRuns.size > 0;
     return html`<gr-checks-run
       .run=${run}
+      ?condensed=${this.collapsed}
       .selected=${selectedRun}
-      .selectedAttempt=${selectedAttempt}
       .deselected=${deselected}
+      @run-selected=${this.handleRunSelected}
     ></gr-checks-run>`;
   }
 
+  handleRunSelected(e: RunSelectedEvent) {
+    if (e.detail.checkName) {
+      this.getViewModel().toggleSelectedCheckRun(e.detail.checkName);
+    }
+  }
+
   showFilter(): boolean {
+    if (this.collapsed) return false;
     return this.runs.length > 10 || !!this.filterRegExp;
   }
 
   renderFakeControls() {
     if (!this.flagService.isEnabled(KnownExperimentId.CHECKS_DEVELOPER)) return;
+    if (this.collapsed) return;
     return html`
       <div class="testing">
         <div>Toggle fake runs by clicking buttons:</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 a6d4585..4bd3446 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -1,27 +1,17 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import './gr-checks-runs';
-import {GrChecksRuns} from './gr-checks-runs';
+import {GrChecksRun, GrChecksRuns} from './gr-checks-runs';
 import {html} from 'lit';
-import {fixture} from '@open-wc/testing-helpers';
+import {fixture, assert} from '@open-wc/testing';
 import {checksModelToken} from '../../models/checks/checks-model';
-import {setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {fakeRun0, setAllFakeRuns} from '../../models/checks/checks-fakes';
 import {resolve} from '../../models/dependency';
+import {queryAll} from '../../utils/common-util';
 
 suite('gr-checks-runs test', () => {
   let element: GrChecksRuns;
@@ -32,18 +22,23 @@
     );
     const getChecksModel = resolve(element, checksModelToken);
     setAllFakeRuns(getChecksModel());
+    await element.updateComplete;
   });
 
-  test('tabState filter', async () => {
-    element.tabState = {filter: 'fff'};
+  test('filterRegExp', async () => {
+    // Without a filter all 6 fake runs (0-5) will be rendered.
+    assert.equal(queryAll(element, 'gr-checks-run').length, 6);
+
+    // This filter will only match fakeRun2 (checkName: 'FAKE Mega Analysis').
+    element.filterRegExp = 'Mega';
     await element.updateComplete;
-    assert.equal(element.filterRegExp?.source, 'fff');
+    assert.equal(queryAll(element, 'gr-checks-run').length, 1);
   });
 
   test('renders', async () => {
-    await element.updateComplete;
     assert.equal(element.runs.length, 44);
-    expect(element).shadowDom.to.equal(
+    assert.shadowDom.equal(
+      element,
       /* HTML */ `
         <h2 class="title">
           <div class="heading-2">Runs</div>
@@ -52,14 +47,13 @@
             <gr-button
               aria-checked="false"
               aria-label="Collapse runs panel"
-              class="expandButton"
+              class="expandButton font-normal"
               link=""
               role="switch"
             >
-              <iron-icon
-                class="expandIcon"
-                icon="gr-icons:chevron-left"
-              ></iron-icon>
+              <div>
+                <gr-icon icon="chevron_left" class="expandIcon"></gr-icon>
+              </div>
             </gr-button>
           </gr-tooltip-content>
         </h2>
@@ -70,11 +64,8 @@
         />
         <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>
+            <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+            <h3 class="heading-3">Running / Scheduled (2)</h3>
           </div>
           <div class="sectionRuns">
             <gr-checks-run></gr-checks-run>
@@ -83,11 +74,8 @@
         </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>
+            <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+            <h3 class="heading-3">Completed (3)</h3>
           </div>
           <div class="sectionRuns">
             <gr-checks-run></gr-checks-run>
@@ -97,11 +85,8 @@
         </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>
+            <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+            <h3 class="heading-3">Not run (1)</h3>
           </div>
           <div class="sectionRuns">
             <gr-checks-run></gr-checks-run>
@@ -111,4 +96,129 @@
       {ignoreAttributes: ['tabindex', 'aria-disabled']}
     );
   });
+
+  test('renders collapsed', async () => {
+    element.collapsed = true;
+    await element.updateComplete;
+    assert.equal(element.runs.length, 44);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <h2 class="title">
+          <div class="heading-2">Runs</div>
+          <div class="flex-space"></div>
+          <gr-tooltip-content has-tooltip="" title="Expand runs panel">
+            <gr-button
+              aria-checked="true"
+              aria-label="Expand runs panel"
+              class="expandButton font-normal"
+              link=""
+              role="switch"
+            >
+              <div>
+                <gr-icon icon="chevron_right" class="expandIcon"></gr-icon>
+              </div>
+            </gr-button>
+          </gr-tooltip-content>
+        </h2>
+        <input
+          hidden
+          id="filterInput"
+          placeholder="Filter runs by regular expression"
+          type="text"
+        />
+        <div class="expanded running">
+          <div class="sectionHeader">
+            <h3 class="heading-3">Running / Scheduled</h3>
+          </div>
+          <div class="sectionRuns">
+            <gr-checks-run condensed></gr-checks-run>
+            <gr-checks-run condensed></gr-checks-run>
+          </div>
+        </div>
+        <div class="completed expanded">
+          <div class="sectionHeader">
+            <h3 class="heading-3">Completed</h3>
+          </div>
+          <div class="sectionRuns">
+            <gr-checks-run condensed></gr-checks-run>
+            <gr-checks-run condensed></gr-checks-run>
+            <gr-checks-run condensed></gr-checks-run>
+          </div>
+        </div>
+        <div class="expanded runnable">
+          <div class="sectionHeader">
+            <h3 class="heading-3">Not run</h3>
+          </div>
+          <div class="sectionRuns">
+            <gr-checks-run condensed></gr-checks-run>
+          </div>
+        </div>
+      `,
+      {ignoreAttributes: ['tabindex', 'aria-disabled']}
+    );
+  });
+});
+
+suite('gr-checks-run test', () => {
+  let element: GrChecksRun;
+
+  setup(async () => {
+    element = await fixture<GrChecksRun>(html`<gr-checks-run></gr-checks-run>`);
+    const getChecksModel = resolve(element, checksModelToken);
+    setAllFakeRuns(getChecksModel());
+    await element.updateComplete;
+  });
+
+  test('renders loading', async () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ' <div class="chip">Loading ...</div> '
+    );
+  });
+
+  test('renders fakeRun0', async () => {
+    element.shouldRender = true;
+    element.run = fakeRun0;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="chip error" tabindex="0">
+          <div class="left" tabindex="0">
+            <gr-hovercard-run> </gr-hovercard-run>
+            <gr-icon class="error" filled="" icon="error"> </gr-icon>
+            <span class="name">
+              FAKE Error Finder Finder Finder Finder Finder Finder Finder
+            </span>
+          </div>
+          <div class="middle">
+            <gr-checks-attempt> </gr-checks-attempt>
+          </div>
+          <div class="right"></div>
+          </div>
+          <div class="attemptDetails" hidden="">
+            <div class="attemptDetail">
+              <input
+                id="attempt-latest"
+                name="fakeerrorfinderfinderfinderfinderfinderfinderfinder-attempt-choice"
+                type="radio"
+              />
+              <gr-icon icon=""> </gr-icon>
+              <label for="attempt-latest"> Latest Attempt </label>
+            </div>
+            <div class="attemptDetail">
+              <input
+                id="attempt-all"
+                name="fakeerrorfinderfinderfinderfinderfinderfinderfinder-attempt-choice"
+                type="radio"
+              />
+              <gr-icon icon=""> </gr-icon>
+              <label for="attempt-all"> All Attempts </label>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-styles.ts b/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
index 65e5aae..6284892 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
@@ -1,34 +1,23 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
 const $_documentContainer = document.createElement('template');
 
 export const checksStyles = css`
-  iron-icon.error {
+  gr-icon.error {
     color: var(--error-foreground);
   }
-  iron-icon.warning {
+  gr-icon.warning {
     color: var(--warning-foreground);
   }
-  iron-icon.info-outline {
+  gr-icon.info {
     color: var(--info-foreground);
   }
-  iron-icon.check-circle-outline {
+  gr-icon.check_circle {
     color: var(--success-foreground);
   }
 `;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index d808d11..c1bcdc1 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -1,21 +1,10 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the 'License');
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an 'AS IS' BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {
   CheckResult,
   CheckRun,
@@ -25,13 +14,13 @@
 import './gr-checks-runs';
 import './gr-checks-results';
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
-import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
 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';
+import {GrChecksRuns} from './gr-checks-runs';
 
 /**
  * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
@@ -39,6 +28,9 @@
  */
 @customElement('gr-checks-tab')
 export class GrChecksTab extends LitElement {
+  @query('.runs')
+  checksRuns?: GrChecksRuns;
+
   @state()
   runs: CheckRun[] = [];
 
@@ -57,49 +49,49 @@
   @state()
   changeNum: NumericChangeId | undefined = undefined;
 
-  @state()
-  selectedRuns: string[] = [];
-
-  /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @state()
-  selectedAttempts: Map<string, number | undefined> = new Map<
-    string,
-    number | undefined
-  >();
-
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  private offsetWidthBefore = 0;
+
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsSelectedPatchset$,
+      () => this.getChecksModel().allRunsSelectedPatchset$,
       x => (this.runs = x)
     );
     subscribe(
       this,
-      this.getChecksModel().allResultsSelected$,
+      () => this.getChecksModel().allResultsSelected$,
       x => (this.results = x)
     );
     subscribe(
       this,
-      this.getChecksModel().checksSelectedPatchsetNumber$,
+      () => this.getChecksModel().checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChangeModel().latestPatchNum$,
+      () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
+    const observer = new ResizeObserver(() => {
+      if (!this.checksRuns) return;
+      // The appearance of a scroll bar (<40px width) should not trigger.
+      if (Math.abs(this.offsetWidth - this.offsetWidthBefore) < 40) return;
+      this.offsetWidthBefore = this.offsetWidth;
+      this.checksRuns.collapsed = this.offsetWidth < 1200;
+    });
+    observer.observe(this);
   }
 
   static override get styles() {
@@ -113,9 +105,10 @@
       .runs {
         min-height: 400px;
         border-right: 1px solid var(--border-color);
+        flex: 0 0 auto;
       }
       .results {
-        flex-grow: 1;
+        flex: 1 1 auto;
       }
     `;
   }
@@ -135,112 +128,16 @@
           class="runs"
           ?collapsed=${this.offsetWidth < 1000}
           .runs=${this.runs}
-          .selectedRuns=${this.selectedRuns}
-          .selectedAttempts=${this.selectedAttempts}
           .tabState=${this.tabState?.checksTab}
-          @run-selected=${this.handleRunSelected}
-          @attempt-selected=${this.handleAttemptSelected}
         ></gr-checks-runs>
         <gr-checks-results
           class="results"
           .tabState=${this.tabState?.checksTab}
           .runs=${this.runs}
-          .selectedRuns=${this.selectedRuns}
-          .selectedAttempts=${this.selectedAttempts}
-          @run-selected=${this.handleRunSelected}
         ></gr-checks-results>
       </div>
     `;
   }
-
-  protected override updated(changedProperties: PropertyValues) {
-    super.updated(changedProperties);
-    if (changedProperties.has('tabState')) this.applyTabState();
-    if (changedProperties.has('runs')) this.applyTabState();
-  }
-
-  /**
-   * 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();
-      return;
-    }
-    if (e.detail.checkName) {
-      this.toggleSelected(e.detail.checkName);
-    }
-  }
-
-  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.
-    this.selectedAttempts = new Map(this.selectedAttempts);
-  }
-
-  toggleSelected(checkName: string) {
-    this.clearTabState();
-    if (this.selectedRuns.includes(checkName)) {
-      this.selectedRuns = this.selectedRuns.filter(r => r !== checkName);
-      this.selectedAttempts.set(checkName, undefined);
-      this.selectedAttempts = new Map(this.selectedAttempts);
-    } else {
-      this.selectedRuns = [...this.selectedRuns, checkName];
-    }
-  }
 }
 
 declare global {
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 9092f60..f8e4e69 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
@@ -1,28 +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
  */
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import {html} from 'lit';
 import './gr-checks-tab';
 import {GrChecksTab} from './gr-checks-tab';
-import {fixture} from '@open-wc/testing-helpers';
+import {fixture, assert} from '@open-wc/testing';
 import {checksModelToken} from '../../models/checks/checks-model';
-import {fakeRun4_3, setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {setAllFakeRuns} from '../../models/checks/checks-fakes';
 import {resolve} from '../../models/dependency';
-import {Category} from '../../api/checks';
 
 suite('gr-checks-tab test', () => {
   let element: GrChecksTab;
@@ -36,25 +24,14 @@
   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);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <gr-checks-runs class="runs" collapsed=""> </gr-checks-runs>
+          <gr-checks-results class="results"> </gr-checks-results>
+        </div>
+      `
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
index acbf8d0..c7477c4 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -1,50 +1,16 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {CheckRun, RunResult} from '../../models/checks/checks-model';
-
-export interface AttemptSelectedEventDetail {
-  checkName: string;
-  attempt: number | undefined;
-}
-
-export type AttemptSelectedEvent = CustomEvent<AttemptSelectedEventDetail>;
-
-declare global {
-  interface HTMLElementEventMap {
-    'attempt-selected': AttemptSelectedEvent;
-  }
-}
-
-export function fireAttemptSelected(
-  target: EventTarget,
-  checkName: string,
-  attempt: number | undefined
-) {
-  target.dispatchEvent(
-    new CustomEvent('attempt-selected', {
-      detail: {checkName, attempt},
-      composed: true,
-      bubbles: true,
-    })
-  );
-}
+import {
+  ALL_ATTEMPTS,
+  AttemptChoice,
+  LATEST_ATTEMPT,
+} from '../../models/checks/checks-util';
 
 export interface RunSelectedEventDetail {
-  reset: boolean;
   checkName?: string;
 }
 
@@ -66,24 +32,13 @@
   );
 }
 
-export function fireRunSelectionReset(target: EventTarget) {
-  target.dispatchEvent(
-    new CustomEvent('run-selected', {
-      detail: {reset: true},
-      composed: true,
-      bubbles: true,
-    })
-  );
-}
-
 export function isAttemptSelected(
-  selectedAttempts: Map<string, number | undefined>,
+  selectedAttempt: AttemptChoice,
   run: CheckRun
 ) {
-  const selected = selectedAttempts.get(run.checkName);
-  return (
-    (selected === undefined && run.isLatestAttempt) || selected === run.attempt
-  );
+  if (selectedAttempt === LATEST_ATTEMPT) return run.isLatestAttempt;
+  if (selectedAttempt === ALL_ATTEMPTS) return true;
+  return selectedAttempt === (run.attempt ?? 0);
 }
 
 export function matches(result: RunResult, regExp: RegExp) {
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 6e23ae7..a09f0ec 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts
@@ -1,23 +1,13 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import {createRunResult} from '../../test/test-data-generators';
 import {matches} from './gr-checks-util';
 import {RunResult} from '../../models/checks/checks-model';
+import {assert} from '@open-wc/testing';
 
 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
index a602c72..0aca71f 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -1,29 +1,20 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/paper-tooltip/paper-tooltip';
-import '@polymer/iron-icon/iron-icon';
+import '../shared/gr-icon/gr-icon';
 import {LitElement, css, html, PropertyValues, nothing} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {RunResult} from '../../models/checks/checks-model';
-import {iconFor} from '../../models/checks/checks-util';
+import {createFixAction, 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';
+import {KnownExperimentId} from '../../services/flags/flags';
+import {getAppContext} from '../../services/app-context';
 
 @customElement('gr-diff-check-result')
 export class GrDiffCheckResult extends LitElement {
@@ -43,6 +34,8 @@
   @state()
   isExpandable = false;
 
+  private readonly flags = getAppContext().flagsService;
+
   static override get styles() {
     return [
       fontStyles,
@@ -60,21 +53,21 @@
           border-color: var(--info-foreground);
           background-color: var(--info-background);
         }
-        .container.info .icon {
+        .container.info gr-icon {
           color: var(--info-foreground);
         }
         .container.warning {
           border-color: var(--warning-foreground);
           background-color: var(--warning-background);
         }
-        .container.warning .icon {
+        .container.warning gr-icon {
           color: var(--warning-foreground);
         }
         .container.error {
           border-color: var(--error-foreground);
           background-color: var(--error-background);
         }
-        .container.error .icon {
+        .container.error gr-icon {
           color: var(--error-foreground);
         }
         .header {
@@ -109,17 +102,18 @@
           display: block;
           margin-top: var(--spacing-m);
         }
-        iron-icon {
-          width: var(--line-height-normal);
-          height: var(--line-height-normal);
-          vertical-align: top;
+        gr-icon {
+          font-size: var(--line-height-normal);
         }
-        .icon iron-icon {
-          width: calc(var(--line-height-normal) - 4px);
-          height: calc(var(--line-height-normal) - 4px);
+        .icon gr-icon {
+          font-size: calc(var(--line-height-normal) - 4px);
           position: relative;
           top: 2px;
         }
+        div.actions {
+          display: flex;
+          justify-content: flex-end;
+        }
       `,
     ];
   }
@@ -127,13 +121,12 @@
   override render() {
     if (!this.result) return;
     const cat = this.result.category.toLowerCase();
+    const icon = iconFor(this.result.category);
     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>
+            <gr-icon icon=${icon.name} ?filled=${icon.filled}></gr-icon>
           </div>
           <div class="name">
             <gr-hovercard-run .run=${this.result}></gr-hovercard-run>
@@ -154,7 +147,9 @@
           </div>
           ${this.renderToggle()}
         </div>
-        <div class="details">${this.renderExpanded()}</div>
+        <div class="details">
+          ${this.renderExpanded()}${this.renderActions()}
+        </div>
       </div>
     `;
   }
@@ -172,11 +167,9 @@
           : 'Expand result row'}
         @keydown=${this.toggleExpandedPress}
       >
-        <iron-icon
-          icon=${this.isExpanded
-            ? 'gr-icons:expand-less'
-            : 'gr-icons:expand-more'}
-        ></iron-icon>
+        <gr-icon
+          icon=${this.isExpanded ? 'expand_less' : 'expand_more'}
+        ></gr-icon>
       </div>
     `;
   }
@@ -191,6 +184,20 @@
     `;
   }
 
+  private renderActions() {
+    if (!this.isExpanded) return nothing;
+    return html`<div class="actions">${this.renderFixButton()}</div>`;
+  }
+
+  private renderFixButton() {
+    if (!this.flags.isEnabled(KnownExperimentId.CHECKS_FIXES)) return nothing;
+    const action = createFixAction(this, this.result);
+    if (!action) return nothing;
+    return html`
+      <gr-checks-action context="diff-fix" .action=${action}></gr-checks-action>
+    `;
+  }
+
   override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('result')) {
       this.isExpandable = !!this.result?.summary && !!this.result?.message;
@@ -208,7 +215,7 @@
     if (!this.isExpandable) return;
     if (modifierPressed(e)) return;
     // Only react to `return` and `space`.
-    if (e.keyCode !== 13 && e.keyCode !== 32) return;
+    if (e.key !== 'Enter' && e.key !== ' ') return;
     e.preventDefault();
     e.stopPropagation();
     this.toggleExpanded();
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index 1be249d..3892c9a 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -1,22 +1,12 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {assert} from '@open-wc/testing';
 import {fakeRun1} from '../../models/checks/checks-fakes';
 import {RunResult} from '../../models/checks/checks-model';
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import './gr-diff-check-result';
 import {GrDiffCheckResult} from './gr-diff-check-result';
 
@@ -37,11 +27,13 @@
     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(`
+    assert.shadowDom.equal(
+      element,
+      `
       <div class="container font-normal warning">
         <div class="header">
           <div class="icon">
-            <iron-icon icon="gr-icons:warning"> </iron-icon>
+            <gr-icon icon="warning" filled></gr-icon>
           </div>
           <div class="name">
             <gr-hovercard-run> </gr-hovercard-run>
@@ -55,6 +47,7 @@
         </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 cc38693..9aa837d 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -1,25 +1,16 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import '../shared/gr-icon/gr-icon';
 import {fontStyles} from '../../styles/gr-font-styles';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import './gr-checks-action';
 import {CheckRun} from '../../models/checks/checks-model';
 import {
   AttemptDetail,
+  ChecksIcon,
   iconFor,
   runActions,
   worstCategory,
@@ -79,38 +70,34 @@
         div.sectionIcon {
           flex: 0 0 30px;
         }
-        div.chip iron-icon {
-          width: 16px;
-          height: 16px;
+        div.chip gr-icon {
+          font-size: 16px;
           /* Positioning of a 16px icon in the middle of a 20px line. */
           position: relative;
           top: 2px;
         }
-        div.sectionIcon iron-icon {
+        div.sectionIcon gr-icon {
           position: relative;
           top: 2px;
-          width: 20px;
-          height: 20px;
+          font-size: 20px;
         }
-        div.sectionIcon iron-icon.small {
+        div.sectionIcon gr-icon.small {
           position: relative;
           top: 6px;
-          width: 16px;
-          height: 16px;
+          font-size: 16px;
         }
-        div.sectionContent iron-icon.link {
+        div.sectionContent gr-icon.link {
           color: var(--link-color);
         }
-        div.sectionContent .attemptIcon iron-icon,
-        div.sectionContent iron-icon.small {
-          width: 16px;
-          height: 16px;
+        div.sectionContent .attemptIcon gr-icon,
+        div.sectionContent gr-icon.small {
+          font-size: 16px;
           margin-right: var(--spacing-s);
           /* Positioning of a 16px icon in the middle of a 20px line. */
           position: relative;
           top: 2px;
         }
-        div.sectionContent .attemptIcon iron-icon {
+        div.sectionContent .attemptIcon gr-icon {
           margin-right: 0;
         }
         .attemptIcon,
@@ -133,6 +120,7 @@
   override render() {
     if (!this.run) return '';
     const icon = this.computeIcon();
+    const chipIcon = this.computeChipIcon();
     return html`
       <div id="container" role="tooltip" tabindex="-1">
         <div class="section">
@@ -141,14 +129,21 @@
             class="chipRow"
           >
             <div class="chip">
-              <iron-icon icon="gr-icons:${this.computeChipIcon()}"></iron-icon>
+              <gr-icon
+                icon=${chipIcon.name}
+                ?filled=${chipIcon.filled}
+              ></gr-icon>
               <span>${this.run.status}</span>
             </div>
           </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.name.length === 0}>
+            <gr-icon
+              icon=${icon.name}
+              class=${icon.name}
+              ?filled=${icon.filled}
+            ></gr-icon>
           </div>
           <div class="sectionContent">
             <h3 class="name heading-3">
@@ -170,7 +165,7 @@
     return html`
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="small" icon="gr-icons:info-outline"></iron-icon>
+          <gr-icon icon="info" class="small"></gr-icon>
         </div>
         <div class="sectionContent">
           ${this.run.statusLink
@@ -178,11 +173,11 @@
                 <div class="title">Status</div>
                 <div>
                   <a href=${this.run.statusLink} target="_blank"
-                    ><iron-icon
+                    ><gr-icon
+                      icon="open_in_new"
                       aria-label="external link to check status"
                       class="small link"
-                      icon="gr-icons:launch"
-                    ></iron-icon
+                    ></gr-icon
                     >${this.computeHostName(this.run.statusLink)}
                   </a>
                 </div>
@@ -205,7 +200,7 @@
     return html`
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="small" icon="gr-icons:arrow-forward"></iron-icon>
+          <gr-icon icon="arrow_forward" class="small"></gr-icon>
         </div>
         <div class="sectionContent">
           <div class="attempts row">
@@ -218,15 +213,18 @@
   }
 
   private renderAttempt(attempt: AttemptDetail) {
+    const attemptNumber = attempt.attempt;
+    const icon = attempt.icon ?? {name: ''};
+    if (attemptNumber !== undefined && typeof attemptNumber !== 'number') {
+      return;
+    }
     return html`
       <div>
         <div class="attemptIcon">
-          <iron-icon
-            class=${attempt.icon}
-            icon="gr-icons:${attempt.icon}"
-          ></iron-icon>
+          <gr-icon class=${icon.name} icon=${icon.name} ?filled=${icon.filled}>
+          </gr-icon>
         </div>
-        <div class="attemptNumber">${ordinal(attempt.attempt)}</div>
+        <div class="attemptNumber">${ordinal(attemptNumber)}</div>
       </div>
     `;
   }
@@ -292,7 +290,7 @@
     return html`
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="small" icon="gr-icons:schedule"></iron-icon>
+          <gr-icon icon="schedule" class="small"></gr-icon>
         </div>
         <div class="sectionContent">
           ${scheduled} ${started} ${finished} ${completed} ${eta}
@@ -307,7 +305,7 @@
     return html`
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="small" icon="gr-icons:link"></iron-icon>
+          <gr-icon icon="link" class="small"></gr-icon>
         </div>
         <div class="sectionContent">
           ${this.run.checkDescription
@@ -321,11 +319,11 @@
                 <div class="title">Documentation</div>
                 <div>
                   <a href=${this.run.checkLink} target="_blank"
-                    ><iron-icon
+                    ><gr-icon
+                      icon="open_in_new"
                       aria-label="external link to check documentation"
                       class="small link"
-                      icon="gr-icons:launch"
-                    ></iron-icon
+                    ></gr-icon
                     >${this.computeHostName(this.run.checkLink)}
                   </a>
                 </div>
@@ -352,25 +350,27 @@
     );
   }
 
-  computeIcon() {
-    if (!this.run) return '';
+  computeIcon(): ChecksIcon {
+    if (!this.run) return {name: ''};
     const category = worstCategory(this.run);
     if (category) return iconFor(category);
     return this.run.status === RunStatus.COMPLETED
       ? iconFor(RunStatus.COMPLETED)
-      : '';
+      : {name: ''};
   }
 
   computeAttempts(): AttemptDetail[] {
-    const details = this.run?.attemptDetails ?? [];
-    const more =
-      details.length > 7 ? [{icon: 'more-horiz', attempt: undefined}] : [];
+    const details: AttemptDetail[] = this.run?.attemptDetails ?? [];
+    const more: AttemptDetail[] =
+      details.length > 7
+        ? [{icon: {name: 'more_horiz'}, attempt: undefined}]
+        : [];
     return [...more, ...details.slice(-7)];
   }
 
-  private computeChipIcon() {
+  private computeChipIcon(): ChecksIcon {
     if (this.run?.status === RunStatus.COMPLETED) {
-      return 'check';
+      return {name: 'check'};
     }
     if (this.run?.status === RunStatus.RUNNING) {
       return iconFor(RunStatus.RUNNING);
@@ -378,7 +378,7 @@
     if (this.run?.status === RunStatus.SCHEDULED) {
       return iconFor(RunStatus.SCHEDULED);
     }
-    return '';
+    return {name: ''};
   }
 
   private computeHostName(link?: string) {
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 bec2b92..4ae2a46 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
@@ -1,40 +1,194 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import './gr-hovercard-run';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrHovercardRun} from './gr-hovercard-run';
+import {fakeRun4Att, fakeRun4_4} from '../../models/checks/checks-fakes';
+import {createAttemptMap} from '../../models/checks/checks-util';
+import {CheckRun} from '../../models/checks/checks-model';
 
 suite('gr-hovercard-run tests', () => {
   let element: GrHovercardRun;
 
   setup(async () => {
+    const fakeNow = new Date('Sep 26 2022 12:00:00');
+    sinon.useFakeTimers(fakeNow);
     element = await fixture<GrHovercardRun>(html`
       <gr-hovercard-run class="hovered"></gr-hovercard-run>
     `);
-    await flush();
   });
 
   teardown(() => {
     element.mouseHide(new MouseEvent('click'));
   });
 
-  test('hovercard is shown', () => {
-    assert.equal(element.computeIcon(), '');
+  test('render fakeRun4', async () => {
+    const attemptMap = createAttemptMap(fakeRun4Att);
+    const attemptDetails = attemptMap.get(fakeRun4_4.checkName)!.attempts;
+    const run: CheckRun = {...fakeRun4_4, attemptDetails};
+    element.run = run;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <div class="section">
+            <div class="chipRow">
+              <div class="chip">
+                <gr-icon icon="check"></gr-icon>
+                <span> COMPLETED </span>
+              </div>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="info" icon="info"></gr-icon>
+            </div>
+            <div class="sectionContent">
+              <h3 class="heading-3 name">
+                <span> FAKE Elimination Long Long Long Long Long </span>
+              </h3>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="info"> </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Status</div>
+                <div>
+                  <a href="https://www.google.com" target="_blank">
+                    <gr-icon
+                      aria-label="external link to check status"
+                      class="link small"
+                      icon="open_in_new"
+                    >
+                    </gr-icon>
+                    www.google.com
+                  </a>
+                </div>
+              </div>
+              <div class="row">
+                <div class="title">Message</div>
+                <div>Everything was eliminated already.</div>
+              </div>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="arrow_forward"> </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="attempts row">
+                <div class="title">Attempt</div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="more_horiz" icon="more_horiz"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber"></div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="error" filled="" icon="error"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber">34th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="check_circle" icon="check_circle">
+                    </gr-icon>
+                  </div>
+                  <div class="attemptNumber">35th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="error" filled="" icon="error"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber">36th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="check_circle" icon="check_circle">
+                    </gr-icon>
+                  </div>
+                  <div class="attemptNumber">37th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="error" filled="" icon="error"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber">38th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="check_circle" icon="check_circle">
+                    </gr-icon>
+                  </div>
+                  <div class="attemptNumber">39th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="info" icon="info"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber">40th</div>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="schedule"> </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Started</div>
+                <div>1 year 6 m ago</div>
+              </div>
+              <div class="row">
+                <div class="title">Ended</div>
+                <div>1 year 6 m ago</div>
+              </div>
+              <div class="row">
+                <div class="title">Completion</div>
+                <div>1 minute</div>
+              </div>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="link"> </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Description</div>
+                <div>Shows you the possible eliminations.</div>
+              </div>
+              <div class="row">
+                <div class="title">Documentation</div>
+                <div>
+                  <a href="https://www.google.com" target="_blank">
+                    <gr-icon
+                      aria-label="external link to check documentation"
+                      class="link small"
+                      icon="open_in_new"
+                    >
+                    </gr-icon>
+                    www.google.com
+                  </a>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="action">
+            <gr-checks-action context="hovercard"> </gr-checks-action>
+          </div>
+        </div>
+      `
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index fbe1c60..b46d2b9 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
@@ -1,18 +1,7 @@
 /**
  * @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 '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-avatar/gr-avatar';
@@ -26,7 +15,7 @@
 } from '../../shared/gr-dropdown/gr-dropdown';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
 
@@ -58,7 +47,7 @@
   override connectedCallback() {
     super.connectedCallback();
     this.handleLocationChange();
-    window.addEventListener('location-change', this.handleLocationChange);
+    document.addEventListener('location-change', this.handleLocationChange);
     this.restApiService.getConfig().then(cfg => {
       this.config = cfg;
 
@@ -72,7 +61,7 @@
   }
 
   override disconnectedCallback() {
-    window.removeEventListener('location-change', this.handleLocationChange);
+    document.removeEventListener('location-change', this.handleLocationChange);
     super.disconnectedCallback();
   }
 
@@ -123,7 +112,6 @@
   }
 
   _getLinks(switchAccountUrl?: string, path?: string) {
-    // Polymer 2: check for undefined
     if (switchAccountUrl === undefined || path === undefined) {
       return undefined;
     }
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
index 88dccad..c224a6b 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
@@ -1,33 +1,35 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-account-dropdown';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrAccountDropdown} from './gr-account-dropdown';
 import {AccountInfo} from '../../../types/common';
 import {createServerInfo} from '../../../test/test-data-generators';
 
-const basicFixture = fixtureFromElement('gr-account-dropdown');
-
 suite('gr-account-dropdown tests', () => {
   let element: GrAccountDropdown;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-account-dropdown></gr-account-dropdown>`);
+  });
+
+  test('renders', async () => {
+    element.account = {name: 'John Doe', email: 'john@doe.com'} as AccountInfo;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dropdown link="">
+          <span>John Doe</span>
+          <gr-avatar aria-label="Account avatar" hidden=""> </gr-avatar>
+        </gr-dropdown>
+      `
+    );
   });
 
   test('account information', () => {
@@ -41,7 +43,7 @@
   test('test for account without a name', () => {
     element.account = {id: '0001'} as AccountInfo;
     assert.deepEqual(element.topContent, [
-      {text: 'Anonymous', bold: true},
+      {text: 'Name of user not set', bold: true},
       {text: ''},
     ]);
   });
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 a0f286f..461781e 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
@@ -1,23 +1,12 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-dialog/gr-dialog';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html, css} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
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 b42b20a..148b5c4 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
@@ -1,41 +1,44 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import {mockPromise, queryAndAssert} from '../../../test/test-utils';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrErrorDialog} from './gr-error-dialog';
-
-const basicFixture = fixtureFromElement('gr-error-dialog');
+import './gr-error-dialog';
 
 suite('gr-error-dialog tests', () => {
   let element: GrErrorDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-error-dialog></gr-error-dialog>`);
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog
+          cancel-label=""
+          confirm-label="Dismiss"
+          confirm-on-enter=""
+          id="dialog"
+          role="dialog"
+        >
+          <div class="header" slot="header">An error occurred</div>
+          <div class="main" slot="main"></div>
+        </gr-dialog>
+      `
+    );
   });
 
   test('dismiss tap fires event', async () => {
     const dismissCalled = mockPromise();
     element.addEventListener('dismiss', () => dismissCalled.resolve());
-    MockInteractions.tap(
-      queryAndAssert<GrDialog>(element, '#dialog').confirmButton!
-    );
+    queryAndAssert<GrDialog>(element, '#dialog').confirmButton!.click();
     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 d777c16..e663ae1 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
@@ -1,18 +1,7 @@
 /**
  * @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 to get Gerrit interface */
 /* TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface */
@@ -38,7 +27,7 @@
 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';
+import {customElement, property, query, state} from 'lit/decorators.js';
 
 const HIDE_ALERT_TIMEOUT_MS = 10 * 1000;
 const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
@@ -270,7 +259,7 @@
           );
         }
       }
-      this.reporting.error(new Error(`Server error: ${errorText}`));
+      this.reporting.error('Server error', new Error(errorText));
     });
   };
 
@@ -327,10 +316,10 @@
 
   private readonly handleNetworkError = (e: NetworkErrorEvent) => {
     this._showAlert('Server unavailable');
-    this.reporting.error(new Error(`network error: ${e.detail.error.message}`));
+    this.reporting.error('Network error', new Error(e.detail.error.message));
   };
 
-  // TODO(dhruvsr): allow less priority alerts to override high priority alerts
+  // TODO(dhruvsri): allow less priority alerts to override high priority alerts
   // In some use cases we may want generic alerts to show along/over errors
   // private but used in tests
   canOverride(incoming = ErrorType.GENERIC, existing = ErrorType.GENERIC) {
@@ -365,7 +354,7 @@
     el.show(text, actionText, actionCallback);
     this.alertElement = el;
     fireIronAnnounce(this, `Alert: ${text}`);
-    this.reporting.reportInteraction('show-alert', {text});
+    this.reporting.reportInteraction(EventType.SHOW_ALERT, {text});
   }
 
   private readonly hideAlert = () => {
@@ -412,6 +401,7 @@
   // private but used in tests
   createToastAlert(showDismiss?: boolean) {
     const el = document.createElement('gr-alert');
+    el.owner = this;
     el.toast = true;
     el.showDismiss = !!showDismiss;
     return el;
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 79321e1..e0de507 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
@@ -1,38 +1,31 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-error-manager';
 import {
   constructServerErrorMsg,
   GrErrorManager,
   __testOnly_ErrorType,
 } from './gr-error-manager';
-import {stubAuth, stubReporting, stubRestApi} from '../../../test/test-utils';
+import {
+  stubAuth,
+  stubReporting,
+  stubRestApi,
+  waitEventLoop,
+} from '../../../test/test-utils';
 import {AppContext, getAppContext} from '../../../services/app-context';
 import {
   createAccountDetailWithId,
   createPreferences,
 } from '../../../test/test-data-generators';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {AccountId} from '../../../types/common';
 import {waitUntil} from '../../../test/test-utils';
-import {fixture} from '@open-wc/testing-helpers';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
+import {EventType} from '../../../types/events';
 
 suite('gr-error-manager tests', () => {
   let element: GrErrorManager;
@@ -68,6 +61,34 @@
       });
     });
 
+    test('renders', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-overlay
+            aria-hidden="true"
+            id="errorOverlay"
+            style="outline: none; display: none;"
+            tabindex="-1"
+            with-backdrop=""
+          >
+            <gr-error-dialog id="errorDialog"> </gr-error-dialog>
+          </gr-overlay>
+          <gr-overlay
+            always-on-top=""
+            aria-hidden="true"
+            id="noInteractionOverlay"
+            no-cancel-on-esc-key=""
+            no-cancel-on-outside-click=""
+            style="outline: none; display: none;"
+            tabindex="-1"
+            with-backdrop=""
+          >
+          </gr-overlay>
+        `
+      );
+    });
+
     test('does not show auth error on 403 by default', async () => {
       const showAuthErrorStub = sinon.stub(element, 'showAuthErrorAlert');
       const responseText = Promise.resolve('server says no.');
@@ -85,7 +106,7 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       assert.isFalse(showAuthErrorStub.calledOnce);
     });
 
@@ -107,7 +128,7 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       assert.isTrue(showAuthErrorStub.calledOnce);
     });
 
@@ -130,7 +151,7 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       assert.isTrue(getLoggedInStub.calledOnce);
     });
 
@@ -162,7 +183,7 @@
       );
 
       assert.isTrue(textSpy.called);
-      await flush();
+      await waitEventLoop();
       assert.isTrue(showErrorSpy.calledOnce);
       assert.isTrue(showErrorSpy.lastCall.calledWithExactly('Error 500: ZOMG'));
     });
@@ -221,7 +242,7 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       assert.equal(element.errorDialog.text, 'Error 500: 500\nTrace Id: xxxx');
     });
 
@@ -239,7 +260,7 @@
       );
 
       assert.isTrue(textSpy.called);
-      await flush();
+      await waitEventLoop();
       assert.isFalse(showAlertStub.called);
     });
 
@@ -252,7 +273,7 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       assert.isTrue(showAlertStub.calledOnce);
       assert.isTrue(
         showAlertStub.lastCall.calledWithExactly('Server unavailable')
@@ -315,15 +336,15 @@
         })
       );
       assert.equal(fetchStub.callCount, 1);
-      await flush();
+      await waitEventLoop();
 
-      // here needs two flush as there are two chanined
-      // promises on server-error handler and flush only flushes one
+      // here needs two waitEventLoop() as there are two chained promises on
+      // server-error handler and waitEventLoop() only flushes one
       assert.equal(fetchStub.callCount, 2);
-      await flush();
+      await waitEventLoop();
       // Sometime overlay opens with delay, waiting while open is complete
       clock.tick(1000);
-      await flush();
+      await waitEventLoop();
       // auth-error fired
       assert.isTrue(toastSpy.called);
 
@@ -345,7 +366,7 @@
         ''
       );
       assert.isFalse(windowOpen.called);
-      tap(toast.shadowRoot.querySelector('gr-button.action'));
+      toast.shadowRoot.querySelector('gr-button.action')!.click();
       assert.isTrue(windowOpen.called);
 
       // @see Issue 5822: noopener breaks closeAfterLogin
@@ -359,7 +380,7 @@
       clock.tick(1000);
       element.knownAccountId = 5 as AccountId;
       element.checkSignedIn();
-      await flush();
+      await waitEventLoop();
 
       assert.isTrue(refreshStub.called);
       assert.isTrue(hideToastSpy.called);
@@ -382,13 +403,13 @@
 
       // fake an alert
       element.dispatchEvent(
-        new CustomEvent('show-alert', {
+        new CustomEvent(EventType.SHOW_ALERT, {
           detail: {message: 'test reload', action: 'reload'},
           composed: true,
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       let toast = toastSpy.lastCall.returnValue;
       assert.isOk(toast);
       assert.include(toast.shadowRoot.textContent, 'test reload');
@@ -409,15 +430,15 @@
           bubbles: true,
         })
       );
-      await flush();
-      await flush();
-      // here needs two flush as there are two chained
-      // promises on server-error handler and flush only flushes one
+      await waitEventLoop();
+      await waitEventLoop();
+      // here needs two waitEventLoop() as there are two chained promises on
+      // server-error handler and waitEventLoop() only flushes one
       assert.equal(fetchStub.callCount, 2);
-      await flush();
+      await waitEventLoop();
       // Sometime overlay opens with delay, waiting while open is complete
       clock.tick(1000);
-      await flush();
+      await waitEventLoop();
       // toast
       toast = toastSpy.lastCall.returnValue;
       assert.include(toast.shadowRoot.textContent, 'Credentials expired.');
@@ -430,26 +451,26 @@
 
       // fake an alert
       element.dispatchEvent(
-        new CustomEvent('show-alert', {
+        new CustomEvent(EventType.SHOW_ALERT, {
           detail: {message: 'test reload', action: 'reload'},
           composed: true,
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       let toast = toastSpy.lastCall.returnValue;
       assert.isOk(toast);
       assert.include(toast.shadowRoot.textContent, 'test reload');
 
       // new alert
       element.dispatchEvent(
-        new CustomEvent('show-alert', {
+        new CustomEvent(EventType.SHOW_ALERT, {
           detail: {message: 'second-test', action: 'reload'},
           composed: true,
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       toast = toastSpy.lastCall.returnValue;
       assert.include(toast.shadowRoot.textContent, 'second-test');
     });
@@ -476,12 +497,12 @@
         })
       );
       assert.equal(fetchStub.callCount, 1);
-      await flush();
+      await waitEventLoop();
 
-      // here needs two flush as there are two chained
-      // promises on server-error handler and flush only flushes one
+      // here needs two waitEventLoop() as there are two chained promises on
+      // server-error handler and waitEventLoop() only flushes one
       assert.equal(fetchStub.callCount, 2);
-      await flush();
+      await waitEventLoop();
       await waitUntil(() => toastSpy.calledOnce);
       let toast = toastSpy.lastCall.returnValue;
       assert.include(toast.shadowRoot.textContent, 'Credentials expired.');
@@ -489,7 +510,7 @@
 
       // fake an alert
       element.dispatchEvent(
-        new CustomEvent('show-alert', {
+        new CustomEvent(EventType.SHOW_ALERT, {
           detail: {
             message: 'test-alert',
             action: 'reload',
@@ -499,7 +520,7 @@
         })
       );
 
-      await flush();
+      await waitEventLoop();
       assert.isTrue(toastSpy.calledOnce);
       toast = toastSpy.lastCall.returnValue;
       assert.isOk(toast);
@@ -510,7 +531,7 @@
       const alertObj = {message: 'foo'};
       const showAlertStub = sinon.stub(element, '_showAlert');
       element.dispatchEvent(
-        new CustomEvent('show-alert', {
+        new CustomEvent(EventType.SHOW_ALERT, {
           detail: alertObj,
           composed: true,
           bubbles: true,
@@ -558,7 +579,7 @@
       element.refreshingCredentials = true;
       element.checkSignedIn();
 
-      await flush();
+      await waitEventLoop();
       assert.isFalse(requestCheckStub.called);
       assert.isTrue(handleRefreshStub.called);
       assert.isFalse(reloadStub.called);
@@ -584,7 +605,7 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
 
       assert.isTrue(openStub.called);
       assert.isTrue(reportStub.called);
@@ -596,7 +617,7 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
 
       assert.isTrue(closeStub.called);
     });
@@ -617,7 +638,7 @@
       element.refreshingCredentials = true;
       element.checkSignedIn();
 
-      await flush();
+      await waitEventLoop();
 
       assert.isFalse(requestCheckStub.called);
       assert.isFalse(handleRefreshStub.called);
@@ -653,7 +674,7 @@
       element.refreshingCredentials = true;
       element.checkSignedIn();
 
-      await flush();
+      await waitEventLoop();
       assert.isTrue(requestCheckStub.called);
       assert.isFalse(handleRefreshStub.called);
       assert.isFalse(reloadStub.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 2a3936f..6b64aec 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
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
index dd023cb..31256f8 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
@@ -1,52 +1,63 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import './gr-key-binding-display';
 import {GrKeyBindingDisplay} from './gr-key-binding-display';
 
-const basicFixture = fixtureFromElement('gr-key-binding-display');
+const x = ['x'];
+const ctrlX = ['Ctrl', 'x'];
+const shiftMetaX = ['Shift', 'Meta', 'x'];
 
 suite('gr-key-binding-display tests', () => {
   let element: GrKeyBindingDisplay;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-key-binding-display
+        .binding=${[x, ctrlX, shiftMetaX]}
+      ></gr-key-binding-display>`
+    );
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <span class="key"> x </span>
+        or
+        <span class="key modifier"> Ctrl </span>
+        <span class="key"> x </span>
+        or
+        <span class="key modifier"> Shift </span>
+        <span class="key modifier"> Meta </span>
+        <span class="key"> x </span>
+      `
+    );
   });
 
   suite('_computeKey', () => {
     test('unmodified key', () => {
-      assert.strictEqual(element._computeKey(['x']), 'x');
+      assert.strictEqual(element._computeKey(x), 'x');
     });
 
     test('key with modifiers', () => {
-      assert.strictEqual(element._computeKey(['Ctrl', 'x']), 'x');
-      assert.strictEqual(element._computeKey(['Shift', 'Meta', 'x']), 'x');
+      assert.strictEqual(element._computeKey(ctrlX), 'x');
+      assert.strictEqual(element._computeKey(shiftMetaX), 'x');
     });
   });
 
   suite('_computeModifiers', () => {
     test('single unmodified key', () => {
-      assert.deepEqual(element._computeModifiers(['x']), []);
+      assert.deepEqual(element._computeModifiers(x), []);
     });
 
     test('key with modifiers', () => {
-      assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
-      assert.deepEqual(element._computeModifiers(['Shift', 'Meta', 'x']), [
+      assert.deepEqual(element._computeModifiers(ctrlX), ['Ctrl']);
+      assert.deepEqual(element._computeModifiers(shiftMetaX), [
         'Shift',
         'Meta',
       ]);
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 e20d73a..25173b4 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
@@ -1,31 +1,21 @@
 /**
  * @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 '../../shared/gr-button/gr-button';
 import '../gr-key-binding-display/gr-key-binding-display';
 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 {customElement, state} from 'lit/decorators.js';
 import {
   ShortcutSection,
   SectionView,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {getAppContext} from '../../../services/app-context';
-import {ShortcutViewListener} from '../../../services/shortcuts/shortcuts-service';
+  shortcutsServiceToken,
+  ShortcutViewListener,
+} from '../../../services/shortcuts/shortcuts-service';
+import {resolve} from '../../../models/dependency';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -46,20 +36,20 @@
    * @event close
    */
 
-  @property({type: Array})
-  _left?: SectionShortcut[];
+  // private but used in tests
+  @state() left?: SectionShortcut[];
 
-  @property({type: Array})
-  _right?: SectionShortcut[];
+  // private but used in tests
+  @state() right?: SectionShortcut[];
 
   private readonly shortcutListener: ShortcutViewListener;
 
-  private readonly shortcuts = getAppContext().shortcutsService;
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   constructor() {
     super();
     this.shortcutListener = (d?: Map<ShortcutSection, SectionView>) =>
-      this._onDirectoryUpdated(d);
+      this.onDirectoryUpdated(d);
   }
 
   static override get styles() {
@@ -88,8 +78,7 @@
           justify-content: space-between;
         }
         table caption {
-          padding-top: var(--spacing-l);
-          text-align: left;
+          padding: var(--spacing-l) var(--spacing-s);
         }
         td {
           padding: var(--spacing-xs) 0;
@@ -126,10 +115,10 @@
       </header>
       <main>
         <div class="column">
-          ${this._left?.map(section => this.renderSection(section))}
+          ${this.left?.map(section => this.renderSection(section))}
         </div>
         <div class="column">
-          ${this._right?.map(section => this.renderSection(section))}
+          ${this.right?.map(section => this.renderSection(section))}
         </div>
       </main>
       <footer></footer>
@@ -163,11 +152,11 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.shortcuts.addListener(this.shortcutListener);
+    this.getShortcutsService().addListener(this.shortcutListener);
   }
 
   override disconnectedCallback() {
-    this.shortcuts.removeListener(this.shortcutListener);
+    this.getShortcutsService().removeListener(this.shortcutListener);
     super.disconnectedCallback();
   }
 
@@ -182,7 +171,7 @@
     );
   }
 
-  _onDirectoryUpdated(directory?: Map<ShortcutSection, SectionView>) {
+  onDirectoryUpdated(directory?: Map<ShortcutSection, SectionView>) {
     if (!directory) {
       return;
     }
@@ -231,7 +220,7 @@
       });
     }
 
-    this._right = right;
-    this._left = left;
+    this.right = right;
+    this.left = left;
   }
 }
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 8ec43ca..5208359 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
@@ -1,122 +1,196 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-keyboard-shortcuts-dialog';
 import {GrKeyboardShortcutsDialog} from './gr-keyboard-shortcuts-dialog';
 import {
   SectionView,
   ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+} from '../../../services/shortcuts/shortcuts-service';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitEventLoop} from '../../../test/test-utils';
 
-const basicFixture = fixtureFromElement('gr-keyboard-shortcuts-dialog');
+const x = ['x'];
+const ctrlX = ['Ctrl', 'x'];
+const shiftMetaX = ['Shift', 'Meta', 'x'];
 
 suite('gr-keyboard-shortcuts-dialog tests', () => {
   let element: GrKeyboardShortcutsDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(
+      html`<gr-keyboard-shortcuts-dialog></gr-keyboard-shortcuts-dialog>`
+    );
+    await waitEventLoop();
   });
 
-  function update(directory: Map<ShortcutSection, SectionView>) {
-    element._onDirectoryUpdated(directory);
-    flush();
+  async function update(directory: Map<ShortcutSection, SectionView>) {
+    element.onDirectoryUpdated(directory);
+    await waitEventLoop();
   }
 
-  suite('_left and _right contents', () => {
+  test('renders left and right contents', async () => {
+    const directory = new Map([
+      [
+        ShortcutSection.NAVIGATION,
+        [{binding: [x, ctrlX], text: 'navigation shortcuts'}],
+      ],
+      [
+        ShortcutSection.ACTIONS,
+        [{binding: [shiftMetaX], text: 'navigation shortcuts'}],
+      ],
+    ]);
+    await update(directory);
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <header>
+          <h3 class="heading-2">Keyboard shortcuts</h3>
+          <gr-button aria-disabled="false" link="" role="button" tabindex="0">
+            Close
+          </gr-button>
+        </header>
+        <main>
+          <div class="column">
+            <table>
+              <caption class="heading-3">
+                Navigation
+              </caption>
+              <thead>
+                <tr>
+                  <th>
+                    <strong> Action </strong>
+                  </th>
+                  <th>
+                    <strong> Key </strong>
+                  </th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <td>navigation shortcuts</td>
+                  <td>
+                    <gr-key-binding-display> </gr-key-binding-display>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+          <div class="column">
+            <table>
+              <caption class="heading-3">
+                Actions
+              </caption>
+              <thead>
+                <tr>
+                  <th>
+                    <strong> Action </strong>
+                  </th>
+                  <th>
+                    <strong> Key </strong>
+                  </th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <td>navigation shortcuts</td>
+                  <td>
+                    <gr-key-binding-display> </gr-key-binding-display>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </main>
+        <footer></footer>
+      `
+    );
+  });
+
+  suite('left and right contents', () => {
     test('empty dialog', () => {
-      assert.isEmpty(element._left);
-      assert.isEmpty(element._right);
+      assert.isEmpty(element.left);
+      assert.isEmpty(element.right);
     });
 
-    test('everywhere goes on left', () => {
+    test('everywhere goes on left', async () => {
       const sectionView = [{binding: [], text: 'everywhere shortcuts'}];
-      update(new Map([[ShortcutSection.EVERYWHERE, sectionView]]));
-      assert.deepEqual(element._left, [
+      await update(new Map([[ShortcutSection.EVERYWHERE, sectionView]]));
+      assert.deepEqual(element.left, [
         {
           section: ShortcutSection.EVERYWHERE,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._right);
+      assert.isEmpty(element.right);
     });
 
-    test('navigation goes on left', () => {
+    test('navigation goes on left', async () => {
       const sectionView = [{binding: [], text: 'navigation shortcuts'}];
-      update(new Map([[ShortcutSection.NAVIGATION, sectionView]]));
-      assert.deepEqual(element._left, [
+      await update(new Map([[ShortcutSection.NAVIGATION, sectionView]]));
+      assert.deepEqual(element.left, [
         {
           section: ShortcutSection.NAVIGATION,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._right);
+      assert.isEmpty(element.right);
     });
 
-    test('actions go on right', () => {
+    test('actions go on right', async () => {
       const sectionView = [{binding: [], text: 'actions shortcuts'}];
-      update(new Map([[ShortcutSection.ACTIONS, sectionView]]));
-      assert.deepEqual(element._right, [
+      await update(new Map([[ShortcutSection.ACTIONS, sectionView]]));
+      assert.deepEqual(element.right, [
         {
           section: ShortcutSection.ACTIONS,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._left);
+      assert.isEmpty(element.left);
     });
 
-    test('reply dialog goes on left', () => {
+    test('reply dialog goes on left', async () => {
       const sectionView = [{binding: [], text: 'reply dialog shortcuts'}];
-      update(new Map([[ShortcutSection.REPLY_DIALOG, sectionView]]));
-      assert.deepEqual(element._left, [
+      await update(new Map([[ShortcutSection.REPLY_DIALOG, sectionView]]));
+      assert.deepEqual(element.left, [
         {
           section: ShortcutSection.REPLY_DIALOG,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._right);
+      assert.isEmpty(element.right);
     });
 
-    test('file list goes on left', () => {
+    test('file list goes on left', async () => {
       const sectionView = [{binding: [], text: 'file list shortcuts'}];
-      update(new Map([[ShortcutSection.FILE_LIST, sectionView]]));
-      assert.deepEqual(element._left, [
+      await update(new Map([[ShortcutSection.FILE_LIST, sectionView]]));
+      assert.deepEqual(element.left, [
         {
           section: ShortcutSection.FILE_LIST,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._right);
+      assert.isEmpty(element.right);
     });
 
-    test('diffs go on right', () => {
+    test('diffs go on right', async () => {
       const sectionView = [{binding: [], text: 'diffs shortcuts'}];
-      update(new Map([[ShortcutSection.DIFFS, sectionView]]));
-      assert.deepEqual(element._right, [
+      await update(new Map([[ShortcutSection.DIFFS, sectionView]]));
+      assert.deepEqual(element.right, [
         {
           section: ShortcutSection.DIFFS,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._left);
+      assert.isEmpty(element.left);
     });
 
-    test('multiple sections on each side', () => {
+    test('multiple sections on each side', async () => {
       const actionsSectionView = [{binding: [], text: 'actions shortcuts'}];
       const diffsSectionView = [{binding: [], text: 'diffs shortcuts'}];
       const everywhereSectionView = [
@@ -125,7 +199,7 @@
       const navigationSectionView = [
         {binding: [], text: 'navigation shortcuts'},
       ];
-      update(
+      await update(
         new Map([
           [ShortcutSection.ACTIONS, actionsSectionView],
           [ShortcutSection.DIFFS, diffsSectionView],
@@ -133,7 +207,7 @@
           [ShortcutSection.NAVIGATION, navigationSectionView],
         ])
       );
-      assert.deepEqual(element._left, [
+      assert.deepEqual(element.left, [
         {
           section: ShortcutSection.EVERYWHERE,
           shortcuts: everywhereSectionView,
@@ -143,7 +217,7 @@
           shortcuts: navigationSectionView,
         },
       ]);
-      assert.deepEqual(element._right, [
+      assert.deepEqual(element.right, [
         {
           section: ShortcutSection.ACTIONS,
           shortcuts: actionsSectionView,
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 e736928..dcc9a99 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
@@ -1,24 +1,13 @@
 /**
  * @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 {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 '../../shared/gr-icon/gr-icon';
 import '../gr-account-dropdown/gr-account-dropdown';
 import '../gr-smart-search/gr-smart-search';
 import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
@@ -36,7 +25,7 @@
 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 {customElement, property, state} from 'lit/decorators.js';
 import {fireEvent} from '../../../utils/event-util';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
@@ -321,8 +310,10 @@
         .bigTitle,
         .loginButton,
         .registerButton,
-        iron-icon,
+        gr-icon,
+        gr-dropdown,
         gr-account-dropdown {
+          --gr-button-text-color: var(--header-text-color);
           color: var(--header-text-color);
         }
         #mobileSearch {
@@ -424,7 +415,7 @@
         target="_blank"
         role="button"
       >
-        <iron-icon icon="gr-icons:bug"></iron-icon>
+        <gr-icon icon="bug_report" filled></gr-icon>
       </a>
     `;
   }
@@ -432,9 +423,9 @@
   private renderAccount() {
     return html`
       <div class="accountContainer" id="accountContainer">
-        <iron-icon
+        <gr-icon
           id="mobileSearch"
-          icon="gr-icons:search"
+          icon="search"
           @click=${(e: Event) => {
             this.onMobileSearchTap(e);
           }}
@@ -442,7 +433,7 @@
           aria-label=${this.mobileSearchHidden
             ? 'Show Searchbar'
             : 'Hide Searchbar'}
-        ></iron-icon>
+        ></gr-icon>
         ${this.renderRegister()}
         <a class="loginButton" href=${this.loginUrl}>Sign in</a>
         <a
@@ -452,7 +443,7 @@
           aria-label="Settings"
           role="button"
         >
-          <iron-icon icon="gr-icons:settings"></iron-icon>
+          <gr-icon icon="settings" filled></gr-icon>
         </a>
         ${this.renderAccountDropdown()}
       </div>
@@ -497,7 +488,6 @@
     // defaultLinks parameter is used in tests only
     defaultLinks = DEFAULT_LINKS
   ) {
-    // Polymer 2: check for undefined
     if (
       userLinks === undefined ||
       adminLinks === undefined ||
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 4dcb755..bca2634 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
@@ -1,22 +1,15 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {isHidden, query, stubRestApi} from '../../../test/test-utils';
+import '../../../test/common-test-setup';
+import {
+  isHidden,
+  query,
+  stubElement,
+  stubRestApi,
+} from '../../../test/test-utils';
 import './gr-main-header';
 import {GrMainHeader} from './gr-main-header';
 import {
@@ -27,17 +20,82 @@
 import {NavLink} from '../../../utils/admin-nav-util';
 import {ServerInfo, TopMenuItemInfo} from '../../../types/common';
 import {AuthType} from '../../../constants/constants';
-
-const basicFixture = fixtureFromElement('gr-main-header');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-main-header tests', () => {
   let element: GrMainHeader;
 
   setup(async () => {
     stubRestApi('probePath').returns(Promise.resolve(false));
-    stub('gr-main-header', 'loadAccount').callsFake(() => Promise.resolve());
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    stubElement('gr-main-header', 'loadAccount').callsFake(() =>
+      Promise.resolve()
+    );
+    element = await fixture(html`<gr-main-header></gr-main-header>`);
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <nav>
+          <a class="bigTitle" href="//localhost:9876/">
+            <gr-endpoint-decorator name="header-title">
+              <span class="titleText"> </span>
+            </gr-endpoint-decorator>
+          </a>
+          <ul class="links">
+            <li>
+              <gr-dropdown down-arrow="" horizontal-align="left" link="">
+                <span class="linksTitle" id="Changes"> Changes </span>
+              </gr-dropdown>
+            </li>
+            <li>
+              <gr-dropdown down-arrow="" horizontal-align="left" link="">
+                <span class="linksTitle" id="Browse"> Browse </span>
+              </gr-dropdown>
+            </li>
+          </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">
+            </gr-smart-search>
+            <gr-endpoint-decorator
+              class="hideOnMobile"
+              name="header-browse-source"
+            >
+            </gr-endpoint-decorator>
+            <gr-endpoint-decorator
+              class="feedbackButton"
+              name="header-feedback"
+            >
+            </gr-endpoint-decorator>
+          </div>
+          <div class="accountContainer" id="accountContainer">
+            <gr-icon
+              aria-label="Hide Searchbar"
+              icon="search"
+              id="mobileSearch"
+              role="button"
+            >
+            </gr-icon>
+            <a class="loginButton" href="/login"> Sign in </a>
+            <a
+              aria-label="Settings"
+              class="settingsButton"
+              href="/settings/"
+              role="button"
+              title="Settings"
+            >
+              <gr-icon icon="settings" filled></gr-icon>
+            </a>
+          </div>
+        </nav>
+      `
+    );
   });
 
   test('link visibility', async () => {
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 4453a7e..4af24cc 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -1,977 +1,29 @@
 /**
  * @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 {
-  BasePatchSetNum,
-  BranchName,
-  ChangeConfigInfo,
-  ChangeInfo,
-  CommentLinks,
-  CommitId,
-  DashboardId,
-  EditPatchSetNum,
-  GroupId,
-  Hashtag,
-  NumericChangeId,
-  ParentPatchSetNum,
-  PatchSetNum,
-  RepoName,
-  ServerInfo,
-  TopicName,
-  UrlEncodedCommentId,
-} from '../../../types/common';
-import {GerritView} from '../../../services/router/router-model';
-import {ParsedChangeInfo} from '../../../types/types';
+import {define} from '../../../models/dependency';
 
-// Navigation parameters object format:
-//
-// Each object has a `view` property with a value from GerritNav.View. The
-// remaining properties depend on the value used for view.
-// GenerateUrlParameters lists all the possible view parameters.
+export const navigationToken = define<NavigationService>('navigation');
 
-const uninitialized = () => {
-  console.warn('Use of uninitialized routing');
-};
-
-const uninitializedNavigate: NavigateCallback = () => {
-  uninitialized();
-  return '';
-};
-
-const uninitializedGenerateUrl: GenerateUrlCallback = () => {
-  uninitialized();
-  return '';
-};
-
-const uninitializedGenerateWebLinks: GenerateWebLinksCallback = () => {
-  uninitialized();
-  return [];
-};
-
-const uninitializedMapCommentLinks: MapCommentLinksCallback = () => {
-  uninitialized();
-  return {};
-};
-
-const USER_PLACEHOLDER_PATTERN = /\${user}/g;
-
-export interface DashboardSection {
-  name: string;
-  query: string;
-  suffixForDashboard?: string;
-  selfOnly?: boolean;
-  hideIfEmpty?: boolean;
-  results?: ChangeInfo[];
-}
-
-export interface UserDashboardConfig {
-  change?: ChangeConfigInfo;
-}
-
-export interface UserDashboard {
-  title?: string;
-  sections: DashboardSection[];
-}
-
-// NOTE: These queries are tested in Java. Any changes made to definitions
-// here require corresponding changes to:
-// java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
-const HAS_DRAFTS: DashboardSection = {
-  // Changes with unpublished draft comments. This section is omitted when
-  // viewing other users, so we don't need to filter anything out.
-  name: 'Has draft comments',
-  query: 'has:draft',
-  selfOnly: true,
-  hideIfEmpty: true,
-  suffixForDashboard: 'limit:10',
-};
-export const YOUR_TURN: DashboardSection = {
-  // Changes where the user is in the attention set.
-  name: 'Your Turn',
-  query: 'attention:${user}',
-  hideIfEmpty: false,
-  suffixForDashboard: 'limit:25',
-};
-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.
-  name: 'Work in progress',
-  query: 'is:open owner:${user} is:wip',
-  selfOnly: true,
-  hideIfEmpty: true,
-  suffixForDashboard: 'limit:25',
-};
-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',
-  suffixForDashboard: 'limit:25',
-};
-const INCOMING: DashboardSection = {
-  // Non-WIP open changes not owned by the viewed user, that the viewed user
-  // 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}',
-  suffixForDashboard: 'limit:25',
-};
-const CCED: DashboardSection = {
-  // Open changes the viewed user is CCed on. Changes ignored by the viewing
-  // user are filtered out.
-  name: 'CCed on',
-  query: 'is:open -is:ignored -is:wip cc:${user}',
-  suffixForDashboard: 'limit:10',
-};
-export const CLOSED: DashboardSection = {
-  name: 'Recently closed',
-  // 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 cc:${user})',
-  suffixForDashboard: '-age:4w limit:10',
-};
-const DEFAULT_SECTIONS: DashboardSection[] = [
-  HAS_DRAFTS,
-  YOUR_TURN,
-  WIP,
-  OUTGOING,
-  INCOMING,
-  CCED,
-  CLOSED,
-];
-
-export interface GenerateUrlSearchViewParameters {
-  view: GerritView.SEARCH;
-  query?: string;
-  offset?: number;
-  project?: RepoName;
-  branch?: BranchName;
-  topic?: TopicName;
-  // TODO(TS): Define more precise type (enum?)
-  statuses?: string[];
-  hashtag?: string;
-  host?: string;
-  owner?: string;
-}
-
-export interface GenerateUrlChangeViewParameters {
-  view: GerritView.CHANGE;
-  // TODO(TS): NumericChangeId - not sure about it, may be it can be removed
-  changeNum: NumericChangeId;
-  project: RepoName;
-  patchNum?: PatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-  edit?: boolean;
-  host?: string;
-  messageHash?: string;
-  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 GenerateUrlRepoViewParameters {
-  view: GerritView.REPO;
-  repoName: RepoName;
-  detail?: RepoDetailView;
-}
-
-export interface GenerateUrlDashboardViewParameters {
-  view: GerritView.DASHBOARD;
-  user?: string;
-  repo?: RepoName;
-  dashboard?: DashboardId;
-
-  // TODO(TS): properties bellow aren't set anywhere, try to remove
-  project?: RepoName;
-  sections?: DashboardSection[];
-  title?: string;
-}
-
-export interface GenerateUrlGroupViewParameters {
-  view: GerritView.GROUP;
-  groupId: GroupId;
-  detail?: GroupDetailView;
-}
-
-export interface GenerateUrlEditViewParameters {
-  view: GerritView.EDIT;
-  changeNum: NumericChangeId;
-  project: RepoName;
-  path: string;
-  patchNum: PatchSetNum;
-  lineNum?: number | string;
-}
-
-export interface GenerateUrlRootViewParameters {
-  view: GerritView.ROOT;
-}
-
-export interface GenerateUrlSettingsViewParameters {
-  view: GerritView.SETTINGS;
-}
-
-export interface GenerateUrlDiffViewParameters {
-  view: GerritView.DIFF;
-  changeNum: NumericChangeId;
-  project: RepoName;
-  path?: string;
-  patchNum?: PatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-  lineNum?: number | string;
-  leftSide?: boolean;
-  commentId?: UrlEncodedCommentId;
-  // TODO(TS): remove - property is set but never used
-  commentLink?: boolean;
-}
-
-export type GenerateUrlParameters =
-  | GenerateUrlSearchViewParameters
-  | GenerateUrlChangeViewParameters
-  | GenerateUrlRepoViewParameters
-  | GenerateUrlDashboardViewParameters
-  | GenerateUrlGroupViewParameters
-  | GenerateUrlEditViewParameters
-  | GenerateUrlRootViewParameters
-  | GenerateUrlSettingsViewParameters
-  | GenerateUrlDiffViewParameters;
-
-export function isGenerateUrlChangeViewParameters(
-  x: GenerateUrlParameters
-): x is GenerateUrlChangeViewParameters {
-  return x.view === GerritView.CHANGE;
-}
-
-export function isGenerateUrlEditViewParameters(
-  x: GenerateUrlParameters
-): x is GenerateUrlEditViewParameters {
-  return x.view === GerritView.EDIT;
-}
-
-export function isGenerateUrlDiffViewParameters(
-  x: GenerateUrlParameters
-): x is GenerateUrlDiffViewParameters {
-  return x.view === GerritView.DIFF;
-}
-
-export interface GenerateWebLinksOptions {
-  weblinks?: GeneratedWebLink[];
-  config?: ServerInfo;
-}
-
-export interface GenerateWebLinksPatchsetParameters {
-  type: WeblinkType.PATCHSET;
-  repo: RepoName;
-  commit?: CommitId;
-  options?: GenerateWebLinksOptions;
-}
-export interface GenerateWebLinksResolveConflictsParameters {
-  type: WeblinkType.RESOLVE_CONFLICTS;
-  repo: RepoName;
-  commit?: CommitId;
-  options?: GenerateWebLinksOptions;
-}
-export interface GenerateWebLinksEditParameters {
-  type: WeblinkType.EDIT;
-  repo: RepoName;
-  commit: CommitId;
-  file: string;
-  options?: GenerateWebLinksOptions;
-}
-export interface GenerateWebLinksFileParameters {
-  type: WeblinkType.FILE;
-  repo: RepoName;
-  commit: CommitId;
-  file: string;
-  options?: GenerateWebLinksOptions;
-}
-export interface GenerateWebLinksChangeParameters {
-  type: WeblinkType.CHANGE;
-  repo: RepoName;
-  commit: CommitId;
-  options?: GenerateWebLinksOptions;
-}
-
-export type GenerateWebLinksParameters =
-  | GenerateWebLinksPatchsetParameters
-  | GenerateWebLinksResolveConflictsParameters
-  | GenerateWebLinksEditParameters
-  | GenerateWebLinksFileParameters
-  | GenerateWebLinksChangeParameters;
-
-export type NavigateCallback = (target: string, redirect?: boolean) => void;
-export type GenerateUrlCallback = (params: GenerateUrlParameters) => string;
-// TODO: Refactor to return only GeneratedWebLink[]
-export type GenerateWebLinksCallback = (
-  params: GenerateWebLinksParameters
-) => GeneratedWebLink[] | GeneratedWebLink;
-
-export type MapCommentLinksCallback = (patterns: CommentLinks) => CommentLinks;
-
-export interface WebLink {
-  name?: string;
-  label: string;
-  url: string;
-}
-
-export interface GeneratedWebLink {
-  name?: string;
-  label?: string;
-  url?: string;
-}
-
-export enum GroupDetailView {
-  MEMBERS = 'members',
-  LOG = 'log',
-}
-
-export enum RepoDetailView {
-  GENERAL = 'general',
-  ACCESS = 'access',
-  BRANCHES = 'branches',
-  COMMANDS = 'commands',
-  DASHBOARDS = 'dashboards',
-  TAGS = 'tags',
-}
-
-export enum WeblinkType {
-  CHANGE = 'change',
-  EDIT = 'edit',
-  FILE = 'file',
-  PATCHSET = 'patchset',
-  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 = {
-  View: GerritView,
-
-  GroupDetailView,
-
-  RepoDetailView,
-
-  WeblinkType,
-
-  _navigate: uninitializedNavigate,
-
-  _generateUrl: uninitializedGenerateUrl,
-
-  _generateWeblinks: uninitializedGenerateWebLinks,
-
-  mapCommentlinks: uninitializedMapCommentLinks,
-
-  _checkPatchRange(patchNum?: PatchSetNum, basePatchNum?: BasePatchSetNum) {
-    if (basePatchNum && !patchNum) {
-      throw new Error('Cannot use base patch number without patch number.');
-    }
-  },
-
+export interface NavigationService {
   /**
-   * Setup router implementation.
+   * This is similar to letting the browser navigate to this URL when the user
+   * clicks it, or to just setting `window.location.href` directly.
    *
-   * @param navigate the router-abstracted equivalent of
-   *     `window.location.href = ...` or window.location.replace(...). The
-   *     string is a new location and boolean defines is it redirect or not
-   *     (true means redirect, i.e. equivalent of window.location.replace).
-   * @param generateUrl generates a URL given
-   *     navigation parameters, detailed in the file header.
-   * @param generateWeblinks weblinks generator
-   *     function takes single payload parameter with type property that
-   *  determines which
-   *     part of the UI is the consumer of the weblinks. type property can
-   *     be one of file, change, or patchset.
-   *     - For file type, payload will also contain string properties: repo,
-   *         commit, file.
-   *     - For patchset type, payload will also contain string properties:
-   *         repo, commit.
-   *     - For change type, payload will also contain string properties:
-   *         repo, commit. If server provides weblinks, those will be passed
-   *         as options.weblinks property on the main payload object.
-   * @param mapCommentlinks provides an escape
-   *     hatch to modify the commentlinks object, e.g. if it contains any
-   *     relative URLs.
+   * This adds a new entry to the browser location history. Consier using
+   * `replaceUrl()`, if you want to avoid that.
+   *
+   * page.show() eventually just calls `window.history.pushState()`.
    */
-  setup(
-    navigate: NavigateCallback,
-    generateUrl: GenerateUrlCallback,
-    generateWeblinks: GenerateWebLinksCallback,
-    mapCommentlinks: MapCommentLinksCallback
-  ) {
-    this._navigate = navigate;
-    this._generateUrl = generateUrl;
-    this._generateWeblinks = generateWeblinks;
-    this.mapCommentlinks = mapCommentlinks;
-  },
-
-  destroy() {
-    this._navigate = uninitializedNavigate;
-    this._generateUrl = uninitializedGenerateUrl;
-    this._generateWeblinks = uninitializedGenerateWebLinks;
-    this.mapCommentlinks = uninitializedMapCommentLinks;
-  },
+  setUrl(url: string): void;
 
   /**
-   * Generate a URL for the given route parameters.
+   * Navigate to this URL, but replace the current URL in the history instead of
+   * adding a new one (which is what `setUrl()` would do).
+   *
+   * page.redirect() eventually just calls `window.history.replaceState()`.
    */
-  _getUrlFor(params: GenerateUrlParameters) {
-    return this._generateUrl(params);
-  },
-
-  getUrlForSearchQuery(query: string, offset?: number) {
-    return this._getUrlFor({
-      view: GerritView.SEARCH,
-      query,
-      offset,
-    });
-  },
-
-  /**
-   * @param openOnly When true, only search open changes in the project.
-   * @param host The host in which to search.
-   */
-  getUrlForProjectChanges(
-    project: RepoName,
-    openOnly?: boolean,
-    host?: string
-  ) {
-    return this._getUrlFor({
-      view: GerritView.SEARCH,
-      project,
-      statuses: openOnly ? ['open'] : [],
-      host,
-    });
-  },
-
-  /**
-   * @param status The status to search.
-   * @param host The host in which to search.
-   */
-  getUrlForBranch(
-    branch: BranchName,
-    project: RepoName,
-    status?: string,
-    host?: string
-  ) {
-    return this._getUrlFor({
-      view: GerritView.SEARCH,
-      branch,
-      project,
-      statuses: status ? [status] : undefined,
-      host,
-    });
-  },
-
-  /**
-   * @param topic The name of the topic.
-   * @param host The host in which to search.
-   */
-  getUrlForTopic(topic: TopicName, host?: string) {
-    return this._getUrlFor({
-      view: GerritView.SEARCH,
-      topic,
-      host,
-    });
-  },
-
-  /**
-   * @param hashtag The name of the hashtag.
-   */
-  getUrlForHashtag(hashtag: Hashtag) {
-    return this._getUrlFor({
-      view: GerritView.SEARCH,
-      hashtag,
-      statuses: ['open', 'merged'],
-    });
-  },
-
-  /**
-   * Navigate to a search for changes with the given status.
-   */
-  navigateToStatusSearch(status: string) {
-    this._navigate(
-      this._getUrlFor({
-        view: GerritView.SEARCH,
-        statuses: [status],
-      })
-    );
-  },
-
-  /**
-   * Navigate to a search query
-   */
-  navigateToSearchQuery(query: string, offset?: number) {
-    this._navigate(this.getUrlForSearchQuery(query, offset));
-  },
-
-  /**
-   * Navigate to the user's dashboard
-   */
-  navigateToUserDashboard() {
-    this._navigate(this.getUrlForUserDashboard('self'));
-  },
-
-  /**
-   * @param basePatchNum The string 'PARENT' can be used for none.
-   */
-  getUrlForChange(
-    change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
-    options: ChangeUrlParams = {}
-  ) {
-    let {patchNum, basePatchNum, isEdit, messageHash, forceReload} = options;
-    if (basePatchNum === ParentPatchSetNum) {
-      basePatchNum = undefined;
-    }
-
-    this._checkPatchRange(patchNum, basePatchNum);
-    return this._getUrlFor({
-      view: GerritView.CHANGE,
-      changeNum: change._number,
-      project: change.project,
-      patchNum,
-      basePatchNum,
-      edit: isEdit,
-      host: change.internalHost || undefined,
-      messageHash,
-      forceReload,
-    });
-  },
-
-  getUrlForChangeById(
-    changeNum: NumericChangeId,
-    project: RepoName,
-    patchNum?: PatchSetNum
-  ) {
-    return this._getUrlFor({
-      view: GerritView.CHANGE,
-      changeNum,
-      project,
-      patchNum,
-    });
-  },
-
-  /**
-   * @param basePatchNum The string 'PARENT' can be used for none.
-   * @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'>,
-    options: NavigateToChangeParams = {}
-  ) {
-    const {patchNum, basePatchNum, isEdit, forceReload, redirect} = options;
-    this._navigate(
-      this.getUrlForChange(change, {
-        patchNum,
-        basePatchNum,
-        isEdit,
-        forceReload,
-      }),
-      redirect
-    );
-  },
-
-  /**
-   * @param basePatchNum The string 'PARENT' can be used for none.
-   */
-  getUrlForDiff(
-    change: ChangeInfo | ParsedChangeInfo,
-    filePath: string,
-    patchNum?: PatchSetNum,
-    basePatchNum?: BasePatchSetNum,
-    lineNum?: number
-  ) {
-    return this.getUrlForDiffById(
-      change._number,
-      change.project,
-      filePath,
-      patchNum,
-      basePatchNum,
-      lineNum
-    );
-  },
-
-  getUrlForComment(
-    changeNum: NumericChangeId,
-    project: RepoName,
-    commentId: UrlEncodedCommentId
-  ) {
-    return this._getUrlFor({
-      view: GerritView.DIFF,
-      changeNum,
-      project,
-      commentId,
-    });
-  },
-
-  getUrlForCommentsTab(
-    changeNum: NumericChangeId,
-    project: RepoName,
-    commentId: UrlEncodedCommentId
-  ) {
-    return this._getUrlFor({
-      view: GerritView.CHANGE,
-      changeNum,
-      project,
-      commentId,
-    });
-  },
-
-  /**
-   * @param basePatchNum The string 'PARENT' can be used for none.
-   */
-  getUrlForDiffById(
-    changeNum: NumericChangeId,
-    project: RepoName,
-    filePath: string,
-    patchNum?: PatchSetNum,
-    basePatchNum?: BasePatchSetNum,
-    lineNum?: number,
-    leftSide?: boolean
-  ) {
-    if (basePatchNum === ParentPatchSetNum) {
-      basePatchNum = undefined;
-    }
-
-    this._checkPatchRange(patchNum, basePatchNum);
-    return this._getUrlFor({
-      view: GerritView.DIFF,
-      changeNum,
-      project,
-      path: filePath,
-      patchNum,
-      basePatchNum,
-      lineNum,
-      leftSide,
-    });
-  },
-
-  getEditUrlForDiff(
-    change: ChangeInfo | ParsedChangeInfo,
-    filePath: string,
-    patchNum?: PatchSetNum,
-    lineNum?: number
-  ) {
-    return this.getEditUrlForDiffById(
-      change._number,
-      change.project,
-      filePath,
-      patchNum,
-      lineNum
-    );
-  },
-
-  /**
-   * @param patchNum The patchNum the file content should be based on, or
-   *   ${EditPatchSetNum} if left undefined.
-   * @param lineNum The line number to pass to the inline editor.
-   */
-  getEditUrlForDiffById(
-    changeNum: NumericChangeId,
-    project: RepoName,
-    filePath: string,
-    patchNum?: PatchSetNum,
-    lineNum?: number
-  ) {
-    return this._getUrlFor({
-      view: GerritView.EDIT,
-      changeNum,
-      project,
-      path: filePath,
-      patchNum: patchNum || EditPatchSetNum,
-      lineNum,
-    });
-  },
-
-  /**
-   * @param basePatchNum The string 'PARENT' can be used for none.
-   */
-  navigateToDiff(
-    change: ChangeInfo | ParsedChangeInfo,
-    filePath: string,
-    patchNum?: PatchSetNum,
-    basePatchNum?: BasePatchSetNum,
-    lineNum?: number
-  ) {
-    this._navigate(
-      this.getUrlForDiff(change, filePath, patchNum, basePatchNum, lineNum)
-    );
-  },
-
-  /**
-   * @param owner The name of the owner.
-   */
-  getUrlForOwner(owner: string) {
-    return this._getUrlFor({
-      view: GerritView.SEARCH,
-      owner,
-    });
-  },
-
-  /**
-   * @param user The name of the user.
-   */
-  getUrlForUserDashboard(user: string) {
-    return this._getUrlFor({
-      view: GerritView.DASHBOARD,
-      user,
-    });
-  },
-
-  getUrlForRoot() {
-    return this._getUrlFor({
-      view: GerritView.ROOT,
-    });
-  },
-
-  /**
-   * @param repo The name of the repo.
-   * @param dashboard The ID of the dashboard, in the form of '<ref>:<path>'.
-   */
-  getUrlForRepoDashboard(repo: RepoName, dashboard: DashboardId) {
-    return this._getUrlFor({
-      view: GerritView.DASHBOARD,
-      repo,
-      dashboard,
-    });
-  },
-
-  /**
-   * Navigate to an arbitrary relative URL.
-   */
-  navigateToRelativeUrl(relativeUrl: string) {
-    if (!relativeUrl.startsWith('/')) {
-      throw new Error('navigateToRelativeUrl with non-relative URL');
-    }
-    this._navigate(relativeUrl);
-  },
-
-  getUrlForRepo(repoName: RepoName) {
-    return this._getUrlFor({
-      view: GerritView.REPO,
-      detail: RepoDetailView.GENERAL,
-      repoName,
-    });
-  },
-
-  /**
-   * Navigate to a repo settings page.
-   */
-  navigateToRepo(repoName: RepoName) {
-    this._navigate(this.getUrlForRepo(repoName));
-  },
-
-  getUrlForRepoTags(repoName: RepoName) {
-    return this._getUrlFor({
-      view: GerritView.REPO,
-      repoName,
-      detail: RepoDetailView.TAGS,
-    });
-  },
-
-  getUrlForRepoBranches(repoName: RepoName) {
-    return this._getUrlFor({
-      view: GerritView.REPO,
-      repoName,
-      detail: GerritNav.RepoDetailView.BRANCHES,
-    });
-  },
-
-  getUrlForRepoAccess(repoName: RepoName) {
-    return this._getUrlFor({
-      view: GerritView.REPO,
-      repoName,
-      detail: GerritNav.RepoDetailView.ACCESS,
-    });
-  },
-
-  getUrlForRepoCommands(repoName: RepoName) {
-    return this._getUrlFor({
-      view: GerritView.REPO,
-      repoName,
-      detail: GerritNav.RepoDetailView.COMMANDS,
-    });
-  },
-
-  getUrlForRepoDashboards(repoName: RepoName) {
-    return this._getUrlFor({
-      view: GerritView.REPO,
-      repoName,
-      detail: GerritNav.RepoDetailView.DASHBOARDS,
-    });
-  },
-
-  getUrlForGroup(groupId: GroupId) {
-    return this._getUrlFor({
-      view: GerritView.GROUP,
-      groupId,
-    });
-  },
-
-  getUrlForGroupLog(groupId: GroupId) {
-    return this._getUrlFor({
-      view: GerritView.GROUP,
-      groupId,
-      detail: GerritNav.GroupDetailView.LOG,
-    });
-  },
-
-  getUrlForGroupMembers(groupId: GroupId) {
-    return this._getUrlFor({
-      view: GerritView.GROUP,
-      groupId,
-      detail: GroupDetailView.MEMBERS,
-    });
-  },
-
-  getUrlForSettings() {
-    return this._getUrlFor({view: GerritView.SETTINGS});
-  },
-
-  getEditWebLinks(
-    repo: RepoName,
-    commit: CommitId,
-    file: string,
-    options?: GenerateWebLinksOptions
-  ): GeneratedWebLink[] {
-    const params: GenerateWebLinksEditParameters = {
-      type: WeblinkType.EDIT,
-      repo,
-      commit,
-      file,
-    };
-    if (options) {
-      params.options = options;
-    }
-    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
-  },
-
-  getFileWebLinks(
-    repo: RepoName,
-    commit: CommitId,
-    file: string,
-    options?: GenerateWebLinksOptions
-  ): GeneratedWebLink[] {
-    const params: GenerateWebLinksFileParameters = {
-      type: WeblinkType.FILE,
-      repo,
-      commit,
-      file,
-    };
-    if (options) {
-      params.options = options;
-    }
-    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
-  },
-
-  getPatchSetWeblink(
-    repo: RepoName,
-    commit?: CommitId,
-    options?: GenerateWebLinksOptions
-  ): GeneratedWebLink {
-    const params: GenerateWebLinksPatchsetParameters = {
-      type: WeblinkType.PATCHSET,
-      repo,
-      commit,
-    };
-    if (options) {
-      params.options = options;
-    }
-    const result = this._generateWeblinks(params);
-    if (Array.isArray(result)) {
-      // TODO(TS): Unclear what to do with empty array.
-      // Either write a comment why result can't be empty or change the return
-      // type or add a check.
-      return result.pop()!;
-    } else {
-      return result;
-    }
-  },
-
-  getResolveConflictsWeblinks(
-    repo: RepoName,
-    commit?: CommitId,
-    options?: GenerateWebLinksOptions
-  ): GeneratedWebLink[] {
-    const params: GenerateWebLinksResolveConflictsParameters = {
-      type: WeblinkType.RESOLVE_CONFLICTS,
-      repo,
-      commit,
-    };
-    if (options) {
-      params.options = options;
-    }
-    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
-  },
-
-  getChangeWeblinks(
-    repo: RepoName,
-    commit: CommitId,
-    options?: GenerateWebLinksOptions
-  ): GeneratedWebLink[] {
-    const params: GenerateWebLinksChangeParameters = {
-      type: WeblinkType.CHANGE,
-      repo,
-      commit,
-    };
-    if (options) {
-      params.options = options;
-    }
-    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
-  },
-
-  getUserDashboard(
-    user = 'self',
-    sections = DEFAULT_SECTIONS,
-    title = ''
-  ): UserDashboard {
-    sections = sections
-      .filter(section => user === 'self' || !section.selfOnly)
-      .map(section => {
-        return {
-          ...section,
-          name: section.name,
-          query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
-        };
-      });
-    return {title, sections};
-  },
-};
+  replaceUrl(url: string): void;
+}
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
deleted file mode 100644
index 03266486..0000000
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.ts
+++ /dev/null
@@ -1,86 +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';
-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 0c3a637..1102895 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -1,79 +1,91 @@
 /**
  * @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 {
   page,
   PageContext,
   PageNextCallback,
 } from '../../../utils/page-wrapper-utils';
-import {
-  DashboardSection,
-  GeneratedWebLink,
-  GenerateUrlChangeViewParameters,
-  GenerateUrlDashboardViewParameters,
-  GenerateUrlDiffViewParameters,
-  GenerateUrlEditViewParameters,
-  GenerateUrlGroupViewParameters,
-  GenerateUrlParameters,
-  GenerateUrlRepoViewParameters,
-  GenerateUrlSearchViewParameters,
-  GenerateWebLinksChangeParameters,
-  GenerateWebLinksEditParameters,
-  GenerateWebLinksFileParameters,
-  GenerateWebLinksParameters,
-  GenerateWebLinksPatchsetParameters,
-  GenerateWebLinksResolveConflictsParameters,
-  GerritNav,
-  GroupDetailView,
-  isGenerateUrlDiffViewParameters,
-  RepoDetailView,
-  WeblinkType,
-} from '../gr-navigation/gr-navigation';
+import {NavigationService} from '../gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
 import {convertToPatchSetNum} from '../../../utils/patch-set-util';
-import {assertNever} from '../../../utils/common-util';
+import {assertIsDefined} from '../../../utils/common-util';
 import {
   BasePatchSetNum,
   DashboardId,
   GroupId,
   NumericChangeId,
-  PatchSetNum,
+  RevisionPatchSetNum,
   RepoName,
-  ServerInfo,
   UrlEncodedCommentId,
-  ParentPatchSetNum,
+  PARENT,
+  PatchSetNumber,
 } from '../../../types/common';
-import {
-  AppElement,
-  AppElementAgreementParam,
-  AppElementParams,
-} from '../../gr-app-types';
+import {AppElement, AppElementParams} from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
-import {GerritView} from '../../../services/router/router-model';
+import {GerritView, RouterModel} from '../../../services/router/router-model';
 import {firePageError} from '../../../utils/event-util';
-import {addQuotesWhen} from '../../../utils/string-util';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {
-  encodeURL,
   getBaseUrl,
+  PatchRangeParams,
   toPath,
   toPathname,
   toSearchParams,
 } from '../../../utils/url-util';
-import {Execution, LifeCycle, Timing} from '../../../constants/reporting';
+import {LifeCycle, Timing} from '../../../constants/reporting';
+import {
+  LATEST_ATTEMPT,
+  stringToAttemptChoice,
+} from '../../../models/checks/checks-util';
+import {
+  AdminChildView,
+  AdminViewModel,
+  AdminViewState,
+} from '../../../models/views/admin';
+import {
+  AgreementViewModel,
+  AgreementViewState,
+} from '../../../models/views/agreement';
+import {
+  RepoDetailView,
+  RepoViewModel,
+  RepoViewState,
+} from '../../../models/views/repo';
+import {
+  GroupDetailView,
+  GroupViewModel,
+  GroupViewState,
+} from '../../../models/views/group';
+import {DiffViewModel, DiffViewState} from '../../../models/views/diff';
+import {
+  ChangeViewModel,
+  ChangeViewState,
+  createChangeUrl,
+} from '../../../models/views/change';
+import {EditViewModel, EditViewState} from '../../../models/views/edit';
+import {
+  DashboardViewModel,
+  DashboardViewState,
+} from '../../../models/views/dashboard';
+import {
+  SettingsViewModel,
+  SettingsViewState,
+} from '../../../models/views/settings';
+import {define} from '../../../models/dependency';
+import {Finalizable} from '../../../services/registry';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {
+  DocumentationViewModel,
+  DocumentationViewState,
+} from '../../../models/views/documentation';
+import {PluginViewModel, PluginViewState} from '../../../models/views/plugin';
+import {SearchViewModel, SearchViewState} from '../../../models/views/search';
+import {DashboardSection} from '../../../utils/dashboard-util';
+import {Subscription} from 'rxjs';
 
 const RoutePattern = {
   ROOT: '/',
@@ -240,24 +252,12 @@
 const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
 
 /**
- * Pattern to recognize '+' in url-encoded strings for replacement with ' '.
- */
-const PLUS_PATTERN = /\+/g;
-
-/**
- * Pattern to recognize leading '?' in window.location.search, for stripping.
- */
-const QUESTION_PATTERN = /^\?*/;
-
-/**
  * GWT UI would use @\d+ at the end of a path to indicate linenum.
  */
 const LEGACY_LINENUM_PATTERN = /@([ab]?\d+)$/;
 
 const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
 
-const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;
-
 // Polymer makes `app` intrinsically defined on the window by virtue of the
 // custom element having the id "pg-app", but it is made explicit here.
 // If you move this code to other place, please update comment about
@@ -274,18 +274,9 @@
   });
 })();
 
-export interface PageContextWithQueryMap extends PageContext {
-  queryMap: Map<string, string> | URLSearchParams;
-}
+export const routerToken = define<GrRouter>('router');
 
-type QueryStringItem = [string, string]; // [key, value]
-
-export interface PatchRangeParams {
-  patchNum?: PatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-}
-
-export class GrRouter {
+export class GrRouter implements Finalizable, NavigationService {
   readonly _app = app;
 
   _isRedirecting?: boolean;
@@ -294,11 +285,57 @@
   // and for first navigation in app after loaded from server (true).
   _isInitialLoad = true;
 
-  private readonly reporting = getAppContext().reportingService;
+  private subscriptions: Subscription[] = [];
 
-  private readonly routerModel = getAppContext().routerModel;
+  private view?: GerritView;
 
-  private readonly restApiService = getAppContext().restApiService;
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly routerModel: RouterModel,
+    private readonly restApiService: RestApiService,
+    private readonly adminViewModel: AdminViewModel,
+    private readonly agreementViewModel: AgreementViewModel,
+    private readonly changeViewModel: ChangeViewModel,
+    private readonly dashboardViewModel: DashboardViewModel,
+    private readonly diffViewModel: DiffViewModel,
+    private readonly documentationViewModel: DocumentationViewModel,
+    private readonly editViewModel: EditViewModel,
+    private readonly groupViewModel: GroupViewModel,
+    private readonly pluginViewModel: PluginViewModel,
+    private readonly repoViewModel: RepoViewModel,
+    private readonly searchViewModel: SearchViewModel,
+    private readonly settingsViewModel: SettingsViewModel
+  ) {
+    this.subscriptions = [
+      // TODO: Do the same for other view models.
+      // We want to make sure that the current view model state is always
+      // reflected back into the URL bar.
+      this.changeViewModel.state$.subscribe(state => {
+        if (!state) return;
+        // Note that router model view must be updated before view model state.
+        // So this check is slightly fragile, but should work.
+        if (this.view !== GerritView.CHANGE) return;
+        const browserUrl = new URL(window.location.toString());
+        const stateUrl = new URL(createChangeUrl(state), browserUrl);
+        stateUrl.hash = browserUrl.hash;
+        if (browserUrl.toString() !== stateUrl.toString()) {
+          page.replace(
+            stateUrl.toString(),
+            null,
+            /* init: */ false,
+            /* dispatch: */ false
+          );
+        }
+      }),
+      this.routerModel.routerView$.subscribe(view => (this.view = view)),
+    ];
+  }
+
+  finalize(): void {
+    for (const subscription of this.subscriptions) {
+      subscription.unsubscribe();
+    }
+  }
 
   start() {
     if (!this._app) {
@@ -307,20 +344,22 @@
     this.startRouter();
   }
 
-  setParams(params: AppElementParams | GenerateUrlParameters) {
+  setState(state: AppElementParams) {
     if (
-      'project' in params &&
-      params.project !== undefined &&
-      'changeNum' in params
+      'project' in state &&
+      state.project !== undefined &&
+      'changeNum' in state
     )
-      this.restApiService.setInProjectLookup(params.changeNum, params.project);
+      this.restApiService.setInProjectLookup(state.changeNum, state.project);
 
-    this.routerModel.updateState({
-      view: params.view,
-      changeNum: 'changeNum' in params ? params.changeNum : undefined,
-      patchNum: 'patchNum' in params ? params.patchNum ?? undefined : undefined,
+    this.routerModel.setState({
+      view: state.view,
+      changeNum: 'changeNum' in state ? state.changeNum : undefined,
+      patchNum: 'patchNum' in state ? state.patchNum ?? undefined : undefined,
+      basePatchNum:
+        'basePatchNum' in state ? state.basePatchNum ?? undefined : undefined,
     });
-    this.appElement().params = params;
+    this.appElement().params = state;
   }
 
   private appElement(): AppElement {
@@ -343,365 +382,24 @@
     page.redirect(url);
   }
 
-  generateUrl(params: GenerateUrlParameters) {
-    const base = getBaseUrl();
-    let url = '';
-
-    if (params.view === GerritView.SEARCH) {
-      url = this.generateSearchUrl(params);
-    } else if (params.view === GerritView.CHANGE) {
-      url = this.generateChangeUrl(params);
-    } else if (params.view === GerritView.DASHBOARD) {
-      url = this.generateDashboardUrl(params);
-    } else if (
-      params.view === GerritView.DIFF ||
-      params.view === GerritView.EDIT
-    ) {
-      url = this.generateDiffOrEditUrl(params);
-    } else if (params.view === GerritView.GROUP) {
-      url = this.generateGroupUrl(params);
-    } else if (params.view === GerritView.REPO) {
-      url = this.generateRepoUrl(params);
-    } else if (params.view === GerritView.ROOT) {
-      url = '/';
-    } else if (params.view === GerritView.SETTINGS) {
-      url = this.generateSettingsUrl();
-    } else {
-      assertNever(params, "Can't generate");
-    }
-
-    return base + url;
-  }
-
-  generateWeblinks(
-    params: GenerateWebLinksParameters
-  ): GeneratedWebLink[] | GeneratedWebLink {
-    switch (params.type) {
-      case WeblinkType.EDIT:
-        return this.getEditWebLinks(params);
-      case WeblinkType.FILE:
-        return this.getFileWebLinks(params);
-      case WeblinkType.CHANGE:
-        return this.getChangeWeblinks(params);
-      case WeblinkType.PATCHSET:
-        return this.getPatchSetWeblink(params);
-      case WeblinkType.RESOLVE_CONFLICTS:
-        return this.getResolveConflictsWeblinks(params);
-      default:
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        assertNever(params, `Unsupported weblink ${(params as any).type}!`);
-    }
-  }
-
-  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);
-    if (!weblink || !weblink.url) {
-      return {name};
-    } else {
-      return {name, url: weblink.url};
-    }
-  }
-
-  private getResolveConflictsWeblinks(
-    params: GenerateWebLinksResolveConflictsParameters
-  ): GeneratedWebLink[] {
-    return params.options?.weblinks ?? [];
-  }
-
-  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'];
-    for (let i = 0; i < codeBrowserLinks.length; i++) {
-      const weblink = weblinks.find(
-        weblink => weblink.name === codeBrowserLinks[i]
-      );
-      if (weblink) {
-        return weblink;
-      }
-    }
-    return null;
-  }
-
-  getBrowseCommitWeblink(weblinks?: GeneratedWebLink[], config?: ServerInfo) {
-    if (!weblinks) {
-      return null;
-    }
-    let weblink;
-    // Use primary weblink if configured and exists.
-    if (config?.gerrit?.primary_weblink_name) {
-      const primaryWeblinkName = config.gerrit.primary_weblink_name;
-      weblink = weblinks.find(weblink => weblink.name === primaryWeblinkName);
-    }
-    if (!weblink) {
-      weblink = this.firstCodeBrowserWeblink(weblinks);
-    }
-    if (!weblink) {
-      return null;
-    }
-    return weblink;
-  }
-
-  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);
-    return weblinks.filter(
-      weblink =>
-        !commitWeblink ||
-        !commitWeblink.name ||
-        weblink.name !== commitWeblink.name
-    );
-  }
-
-  private getEditWebLinks(
-    params: GenerateWebLinksEditParameters
-  ): GeneratedWebLink[] {
-    return params.options?.weblinks ?? [];
-  }
-
-  private getFileWebLinks(
-    params: GenerateWebLinksFileParameters
-  ): GeneratedWebLink[] {
-    return params.options?.weblinks ?? [];
-  }
-
-  private generateSearchUrl(params: GenerateUrlSearchViewParameters) {
-    let offsetExpr = '';
-    if (params.offset && params.offset > 0) {
-      offsetExpr = `,${params.offset}`;
-    }
-
-    if (params.query) {
-      return '/q/' + encodeURL(params.query, true) + offsetExpr;
-    }
-
-    const operators: string[] = [];
-    if (params.owner) {
-      operators.push('owner:' + encodeURL(params.owner, false));
-    }
-    if (params.project) {
-      operators.push('project:' + encodeURL(params.project, false));
-    }
-    if (params.branch) {
-      operators.push('branch:' + encodeURL(params.branch, false));
-    }
-    if (params.topic) {
-      operators.push(
-        'topic:' +
-          addQuotesWhen(
-            encodeURL(params.topic, false),
-            /[\s:]/.test(params.topic)
-          )
-      );
-    }
-    if (params.hashtag) {
-      operators.push(
-        'hashtag:' +
-          addQuotesWhen(
-            encodeURL(params.hashtag.toLowerCase(), false),
-            /[\s:]/.test(params.hashtag)
-          )
-      );
-    }
-    if (params.statuses) {
-      if (params.statuses.length === 1) {
-        operators.push('status:' + encodeURL(params.statuses[0], false));
-      } else if (params.statuses.length > 1) {
-        operators.push(
-          '(' +
-            params.statuses
-              .map(s => `status:${encodeURL(s, false)}`)
-              .join(' OR ') +
-            ')'
-        );
-      }
-    }
-
-    return '/q/' + operators.join('+') + offsetExpr;
-  }
-
-  private generateChangeUrl(params: GenerateUrlChangeViewParameters) {
-    let range = this.getPatchRangeExpression(params);
-    if (range.length) {
-      range = '/' + range;
-    }
-    let suffix = `${range}`;
-    let queryString = '';
-    if (params.forceReload) {
-      queryString = 'forceReload=true';
-    }
-    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}`;
-    } else {
-      return `/c/${params.changeNum}${suffix}`;
-    }
-  }
-
-  private generateDashboardUrl(params: GenerateUrlDashboardViewParameters) {
-    const repoName = params.repo || params.project || undefined;
-    if (params.sections) {
-      // Custom dashboard.
-      const queryParams = this.sectionsToEncodedParams(
-        params.sections,
-        repoName
-      );
-      if (params.title) {
-        queryParams.push('title=' + encodeURIComponent(params.title));
-      }
-      const user = params.user ? params.user : '';
-      return `/dashboard/${user}?${queryParams.join('&')}`;
-    } else if (repoName) {
-      // Project dashboard.
-      const encodedRepo = encodeURL(repoName, true);
-      return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
-    } else {
-      // User dashboard.
-      return `/dashboard/${params.user || 'self'}`;
-    }
-  }
-
-  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.
-      const query = repoName
-        ? section.query.replace(REPO_TOKEN_PATTERN, repoName)
-        : section.query;
-      return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
-    });
-  }
-
-  private generateDiffOrEditUrl(
-    params: GenerateUrlDiffViewParameters | GenerateUrlEditViewParameters
-  ) {
-    let range = this.getPatchRangeExpression(params);
-    if (range.length) {
-      range = '/' + range;
-    }
-
-    let suffix = `${range}/${encodeURL(params.path || '', true)}`;
-
-    if (params.view === GerritView.EDIT) {
-      suffix += ',edit';
-    }
-
-    if (params.lineNum) {
-      suffix += '#';
-      if (isGenerateUrlDiffViewParameters(params) && params.leftSide) {
-        suffix += 'b';
-      }
-      suffix += params.lineNum;
-    }
-
-    if (isGenerateUrlDiffViewParameters(params) && params.commentId) {
-      suffix = `/comment/${params.commentId}` + suffix;
-    }
-
-    if (params.project) {
-      const encodedProject = encodeURL(params.project, true);
-      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
-    } else {
-      return `/c/${params.changeNum}${suffix}`;
-    }
-  }
-
-  private generateGroupUrl(params: GenerateUrlGroupViewParameters) {
-    let url = `/admin/groups/${encodeURL(`${params.groupId}`, true)}`;
-    if (params.detail === GroupDetailView.MEMBERS) {
-      url += ',members';
-    } else if (params.detail === GroupDetailView.LOG) {
-      url += ',audit-log';
-    }
-    return url;
-  }
-
-  private generateRepoUrl(params: GenerateUrlRepoViewParameters) {
-    let url = `/admin/repos/${encodeURL(`${params.repoName}`, true)}`;
-    if (params.detail === RepoDetailView.GENERAL) {
-      url += ',general';
-    } else if (params.detail === RepoDetailView.ACCESS) {
-      url += ',access';
-    } else if (params.detail === RepoDetailView.BRANCHES) {
-      url += ',branches';
-    } else if (params.detail === RepoDetailView.TAGS) {
-      url += ',tags';
-    } else if (params.detail === RepoDetailView.COMMANDS) {
-      url += ',commands';
-    } else if (params.detail === RepoDetailView.DASHBOARDS) {
-      url += ',dashboards';
-    }
-    return url;
-  }
-
-  private generateSettingsUrl() {
-    return '/settings';
-  }
-
   /**
-   * Given an object of parameters, potentially including a `patchNum` or a
-   * `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) {
-    let range = '';
-    if (params.patchNum) {
-      range = `${params.patchNum}`;
-    }
-    if (params.basePatchNum && params.basePatchNum !== ParentPatchSetNum) {
-      range = `${params.basePatchNum}..${range}`;
-    }
-    return range;
-  }
-
-  /**
-   * Normalizes the params object, and determines if the URL needs to be
-   * modified to fit the proper schema.
-   *
+   * Normalizes the patchset numbers of the params object.
    */
   normalizePatchRangeParams(params: PatchRangeParams) {
-    if (params.basePatchNum === undefined) {
-      return false;
-    }
-    const hasPatchNum = params.patchNum !== undefined;
-    let needsRedirect = false;
+    if (params.basePatchNum === undefined) return;
 
     // Diffing a patch against itself is invalid, so if the base and revision
     // patches are equal clear the base.
     if (params.patchNum && params.basePatchNum === params.patchNum) {
-      needsRedirect = true;
-      params.basePatchNum = ParentPatchSetNum;
-    } else if (!hasPatchNum) {
-      // Regexes set basePatchNum instead of patchNum when only one is
-      // specified. Redirect is not needed in this case.
-      params.patchNum = params.basePatchNum;
-      params.basePatchNum = ParentPatchSetNum;
+      params.basePatchNum = PARENT;
+      return;
     }
-    return needsRedirect;
+    // Regexes set basePatchNum instead of patchNum when only one is
+    // specified.
+    if (params.patchNum === undefined) {
+      params.patchNum = params.basePatchNum as RevisionPatchSetNum;
+      params.basePatchNum = PARENT;
+    }
   }
 
   /**
@@ -740,15 +438,15 @@
    * resolves if the user is logged in. If the user us not logged in, the
    * promise is rejected and the page is redirected to the login flow.
    *
-   * @return A promise yielding the original route data
+   * @return A promise yielding the original route ctx
    * (if it resolves).
    */
-  redirectIfNotLoggedIn(data: PageContext) {
+  redirectIfNotLoggedIn(ctx: PageContext) {
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         return Promise.resolve();
       } else {
-        this.redirectToLogin(data.canonicalPath);
+        this.redirectToLogin(ctx.canonicalPath);
         return Promise.reject(new Error());
       }
     });
@@ -761,27 +459,6 @@
     });
   }
 
-  /**  Page.js middleware that try parse the querystring into queryMap. */
-  private queryStringMiddleware(ctx: PageContext, next: PageNextCallback) {
-    (ctx as PageContextWithQueryMap).queryMap = this.createQueryMap(ctx);
-    next();
-  }
-
-  private createQueryMap(ctx: PageContext) {
-    if (ctx.querystring) {
-      // https://caniuse.com/#search=URLSearchParams
-      if (window.URLSearchParams) {
-        return new URLSearchParams(ctx.querystring);
-      } else {
-        this.reporting.reportExecution(Execution.REACHABLE_CODE, {
-          id: 'noURLSearchParams',
-        });
-        return new Map(this.parseQueryString(ctx.querystring));
-      }
-    }
-    return new Map<string, string>();
-  }
-
   /**
    * Map a route to a method on the router.
    *
@@ -798,44 +475,53 @@
   mapRoute(
     pattern: string | RegExp,
     handlerName: string,
-    handler: (ctx: PageContextWithQueryMap) => void,
+    handler: (ctx: PageContext) => void,
     authRedirect?: boolean
   ) {
     page(
       pattern,
       (ctx, next) => this.loadUserMiddleware(ctx, next),
-      (ctx, next) => this.queryStringMiddleware(ctx, next),
       ctx => {
         this.reporting.locationChanged(handlerName);
         const promise = authRedirect
           ? this.redirectIfNotLoggedIn(ctx)
           : Promise.resolve();
         promise.then(() => {
-          handler(ctx as PageContextWithQueryMap);
+          handler(ctx);
         });
       }
     );
   }
 
+  /**
+   * This is similar to letting the browser navigate to this URL when the user
+   * clicks it, or to just setting `window.location.href` directly.
+   *
+   * This adds a new entry to the browser location history. Consier using
+   * `replaceUrl()`, if you want to avoid that.
+   *
+   * page.show() eventually just calls `window.history.pushState()`.
+   */
+  setUrl(url: string) {
+    page.show(url);
+  }
+
+  /**
+   * Navigate to this URL, but replace the current URL in the history instead of
+   * adding a new one (which is what `setUrl()` would do).
+   *
+   * page.redirect() eventually just calls `window.history.replaceState()`.
+   */
+  replaceUrl(url: string) {
+    this.redirect(url);
+  }
+
   startRouter() {
     const base = getBaseUrl();
     if (base) {
       page.base(base);
     }
 
-    GerritNav.setup(
-      (url, redirect?) => {
-        if (redirect) {
-          page.redirect(url);
-        } else {
-          page.show(url);
-        }
-      },
-      params => this.generateUrl(params),
-      params => this.generateWeblinks(params),
-      x => x
-    );
-
     page.exit('*', (_, next) => {
       if (!this._isRedirecting) {
         this.reporting.beforeLocationChanged();
@@ -881,7 +567,7 @@
           hash: window.location.hash,
           pathname: window.location.pathname,
         };
-        window.dispatchEvent(
+        document.dispatchEvent(
           new CustomEvent('location-change', {
             detail,
             composed: true,
@@ -1175,7 +861,7 @@
     this.mapRoute(
       RoutePattern.NEW_AGREEMENTS,
       'handleNewAgreementsRoute',
-      ctx => this.handleNewAgreementsRoute(ctx),
+      () => this.handleNewAgreementsRoute(),
       true
     );
 
@@ -1244,13 +930,13 @@
    * @return if handling the route involves asynchrony, then a
    * promise is returned. Otherwise, synchronous handling returns null.
    */
-  handleRootRoute(data: PageContextWithQueryMap) {
-    if (data.querystring.match(/^closeAfterLogin/)) {
+  handleRootRoute(ctx: PageContext) {
+    if (ctx.querystring.match(/^closeAfterLogin/)) {
       // Close child window on redirect after login.
       window.close();
       return null;
     }
-    let hash = this.getHashFromCanonicalPath(data.canonicalPath);
+    let hash = this.getHashFromCanonicalPath(ctx.canonicalPath);
     // For backward compatibility with GWT links.
     if (hash) {
       // In certain login flows the server may redirect to a hash without
@@ -1258,7 +944,7 @@
       if (hash[0] !== '/') {
         hash = '/' + hash;
       }
-      if (hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
+      if (hash.includes('/ /') && ctx.canonicalPath.includes('/+/')) {
         // Path decodes all '+' to ' ' -- this breaks project-based URLs.
         // See Issue 6888.
         hash = hash.replace('/ /', '/+/');
@@ -1281,500 +967,527 @@
   }
 
   /**
-   * Decode an application/x-www-form-urlencoded string.
-   *
-   * @param qs The application/x-www-form-urlencoded string.
-   * @return The decoded string.
-   */
-  private decodeQueryString(qs: string) {
-    return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
-  }
-
-  /**
-   * Parse a query string (e.g. window.location.search) into an array of
-   * name/value pairs.
-   *
-   * @param qs The application/x-www-form-urlencoded query string.
-   * @return An array of name/value pairs, where each
-   * element is a 2-element array.
-   */
-  parseQueryString(qs: string): Array<QueryStringItem> {
-    qs = qs.replace(QUESTION_PATTERN, '');
-    if (!qs) {
-      return [];
-    }
-    const params: Array<[string, string]> = [];
-    qs.split('&').forEach(param => {
-      const idx = param.indexOf('=');
-      let name;
-      let value;
-      if (idx < 0) {
-        name = this.decodeQueryString(param);
-        value = '';
-      } else {
-        name = this.decodeQueryString(param.substring(0, idx));
-        value = this.decodeQueryString(param.substring(idx + 1));
-      }
-      if (name) {
-        params.push([name, value]);
-      }
-    });
-    return params;
-  }
-
-  /**
    * Handle dashboard routes. These may be user, or project dashboards.
    */
-  handleDashboardRoute(data: PageContextWithQueryMap) {
+  handleDashboardRoute(ctx: PageContext) {
     // 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);
+        if (ctx.params[0].toLowerCase() === 'self') {
+          this.redirectToLogin(ctx.canonicalPath);
         } else {
-          this.redirect('/q/owner:' + encodeURIComponent(data.params[0]));
+          this.redirect('/q/owner:' + encodeURIComponent(ctx.params[0]));
         }
       } else {
-        this.setParams({
+        const state: DashboardViewState = {
           view: GerritView.DASHBOARD,
-          user: data.params[0],
-        });
+          user: ctx.params[0],
+        };
+        // Note that router model view must be updated before view models.
+        this.setState(state);
+        this.dashboardViewModel.setState(state);
       }
     });
   }
 
-  /**
-   * Handle custom dashboard routes.
-   *
-   * @param qs Optional query string associated with the route.
-   * If not given, window.location.search is used. (Used by tests).
-   */
-  handleCustomDashboardRoute(
-    _: PageContextWithQueryMap,
-    qs: string = window.location.search
-  ) {
-    const queryParams = this.parseQueryString(qs);
-    let title = 'Custom Dashboard';
-    const titleParam = queryParams.find(
-      elem => elem[0].toLowerCase() === 'title'
-    );
-    if (titleParam) {
-      title = titleParam[1];
-    }
-    // Dashboards support a foreach param which adds a base query to any
-    // additional query.
-    const forEachParam = queryParams.find(
-      elem => elem[0].toLowerCase() === 'foreach'
-    );
-    let forEachQuery: string | null = null;
-    if (forEachParam) {
-      forEachQuery = forEachParam[1];
-    }
-    const sectionParams = queryParams.filter(
-      elem =>
-        elem[0] &&
-        elem[1] &&
-        elem[0].toLowerCase() !== 'title' &&
-        elem[0].toLowerCase() !== 'foreach'
-    );
-    const sections = sectionParams.map(elem => {
-      const query = forEachQuery ? `${forEachQuery} ${elem[1]}` : elem[1];
-      return {
-        name: elem[0],
-        query,
-      };
-    });
+  handleCustomDashboardRoute(ctx: PageContext) {
+    const queryParams = new URLSearchParams(ctx.querystring);
 
-    if (sections.length > 0) {
-      // Custom dashboard view.
-      this.setParams({
-        view: GerritView.DASHBOARD,
-        user: 'self',
-        sections,
-        title,
-      });
+    let title = 'Custom Dashboard';
+    const titleParam = queryParams.get('title');
+    if (titleParam) title = titleParam;
+    queryParams.delete('title');
+
+    let forEachQuery = '';
+    const forEachParam = queryParams.get('foreach');
+    if (forEachParam) forEachQuery = forEachParam + ' ';
+    queryParams.delete('foreach');
+
+    const sections: DashboardSection[] = [];
+    for (const [name, query] of queryParams) {
+      if (!name || !query) continue;
+      sections.push({name, query: `${forEachQuery}${query}`});
+    }
+
+    if (sections.length === 0) {
+      this.redirect('/dashboard/self');
       return Promise.resolve();
     }
 
-    // Redirect /dashboard/ -> /dashboard/self.
-    this.redirect('/dashboard/self');
+    const state: DashboardViewState = {
+      view: GerritView.DASHBOARD,
+      user: 'self',
+      sections,
+      title,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.dashboardViewModel.setState(state);
     return Promise.resolve();
   }
 
-  handleProjectDashboardRoute(data: PageContextWithQueryMap) {
-    const project = data.params[0] as RepoName;
-    this.setParams({
+  handleProjectDashboardRoute(ctx: PageContext) {
+    const project = ctx.params[0] as RepoName;
+    const state: DashboardViewState = {
       view: GerritView.DASHBOARD,
       project,
-      dashboard: decodeURIComponent(data.params[1]) as DashboardId,
-    });
+      dashboard: decodeURIComponent(ctx.params[1]) as DashboardId,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.dashboardViewModel.setState(state);
     this.reporting.setRepoName(project);
   }
 
-  handleLegacyProjectDashboardRoute(data: PageContextWithQueryMap) {
-    this.redirect('/p/' + data.params[0] + '/+/dashboard/' + data.params[1]);
+  handleLegacyProjectDashboardRoute(ctx: PageContext) {
+    this.redirect('/p/' + ctx.params[0] + '/+/dashboard/' + ctx.params[1]);
   }
 
-  handleGroupInfoRoute(data: PageContextWithQueryMap) {
-    this.redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
+  handleGroupInfoRoute(ctx: PageContext) {
+    this.redirect('/admin/groups/' + encodeURIComponent(ctx.params[0]));
   }
 
-  handleGroupSelfRedirectRoute(_: PageContextWithQueryMap) {
+  handleGroupSelfRedirectRoute(_: PageContext) {
     this.redirect('/settings/#Groups');
   }
 
-  handleGroupRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleGroupRoute(ctx: PageContext) {
+    const state: GroupViewState = {
       view: GerritView.GROUP,
-      groupId: data.params[0] as GroupId,
-    });
+      groupId: ctx.params[0] as GroupId,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.groupViewModel.setState(state);
   }
 
-  handleGroupAuditLogRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleGroupAuditLogRoute(ctx: PageContext) {
+    const state: GroupViewState = {
       view: GerritView.GROUP,
       detail: GroupDetailView.LOG,
-      groupId: data.params[0] as GroupId,
-    });
+      groupId: ctx.params[0] as GroupId,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.groupViewModel.setState(state);
   }
 
-  handleGroupMembersRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleGroupMembersRoute(ctx: PageContext) {
+    const state: GroupViewState = {
       view: GerritView.GROUP,
       detail: GroupDetailView.MEMBERS,
-      groupId: data.params[0] as GroupId,
-    });
+      groupId: ctx.params[0] as GroupId,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.groupViewModel.setState(state);
   }
 
-  handleGroupListOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleGroupListOffsetRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-admin-group-list',
-      offset: data.params[1] || 0,
+      adminView: AdminChildView.GROUPS,
+      offset: ctx.params[1] || 0,
       filter: null,
-      openCreateModal: data.hash === 'create',
-    });
+      openCreateModal: ctx.hash === 'create',
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  handleGroupListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleGroupListFilterOffsetRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-admin-group-list',
-      offset: data.params['offset'],
-      filter: data.params['filter'],
-    });
+      adminView: AdminChildView.GROUPS,
+      offset: ctx.params['offset'],
+      filter: ctx.params['filter'],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  handleGroupListFilterRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleGroupListFilterRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-admin-group-list',
-      filter: data.params['filter'] || null,
-    });
+      adminView: AdminChildView.GROUPS,
+      filter: ctx.params['filter'] || null,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  handleProjectsOldRoute(data: PageContextWithQueryMap) {
+  handleProjectsOldRoute(ctx: PageContext) {
     let params = '';
-    if (data.params[1]) {
-      params = encodeURIComponent(data.params[1]);
-      if (data.params[1].includes(',')) {
-        params = encodeURIComponent(data.params[1]).replace('%2C', ',');
+    if (ctx.params[1]) {
+      params = encodeURIComponent(ctx.params[1]);
+      if (ctx.params[1].includes(',')) {
+        params = encodeURIComponent(ctx.params[1]).replace('%2C', ',');
       }
     }
 
     this.redirect(`/admin/repos/${params}`);
   }
 
-  handleRepoCommandsRoute(data: PageContextWithQueryMap) {
-    const repo = data.params[0] as RepoName;
-    this.setParams({
+  handleRepoCommandsRoute(ctx: PageContext) {
+    const repo = ctx.params[0] as RepoName;
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.COMMANDS,
       repo,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
     this.reporting.setRepoName(repo);
   }
 
-  handleRepoGeneralRoute(data: PageContextWithQueryMap) {
-    const repo = data.params[0] as RepoName;
-    this.setParams({
+  handleRepoGeneralRoute(ctx: PageContext) {
+    const repo = ctx.params[0] as RepoName;
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.GENERAL,
       repo,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
     this.reporting.setRepoName(repo);
   }
 
-  handleRepoAccessRoute(data: PageContextWithQueryMap) {
-    const repo = data.params[0] as RepoName;
-    this.setParams({
+  handleRepoAccessRoute(ctx: PageContext) {
+    const repo = ctx.params[0] as RepoName;
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.ACCESS,
       repo,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
     this.reporting.setRepoName(repo);
   }
 
-  handleRepoDashboardsRoute(data: PageContextWithQueryMap) {
-    const repo = data.params[0] as RepoName;
-    this.setParams({
+  handleRepoDashboardsRoute(ctx: PageContext) {
+    const repo = ctx.params[0] as RepoName;
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.DASHBOARDS,
       repo,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
     this.reporting.setRepoName(repo);
   }
 
-  handleBranchListOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleBranchListOffsetRoute(ctx: PageContext) {
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
-      repo: data.params[0] as RepoName,
-      offset: data.params[2] || 0,
+      repo: ctx.params[0] as RepoName,
+      offset: ctx.params[2] || 0,
       filter: null,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
   }
 
-  handleBranchListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleBranchListFilterOffsetRoute(ctx: PageContext) {
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
-      repo: data.params['repo'] as RepoName,
-      offset: data.params['offset'],
-      filter: data.params['filter'],
-    });
+      repo: ctx.params['repo'] as RepoName,
+      offset: ctx.params['offset'],
+      filter: ctx.params['filter'],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
   }
 
-  handleBranchListFilterRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleBranchListFilterRoute(ctx: PageContext) {
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
-      repo: data.params['repo'] as RepoName,
-      filter: data.params['filter'] || null,
-    });
+      repo: ctx.params['repo'] as RepoName,
+      filter: ctx.params['filter'] || null,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
   }
 
-  handleTagListOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleTagListOffsetRoute(ctx: PageContext) {
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
-      repo: data.params[0] as RepoName,
-      offset: data.params[2] || 0,
+      repo: ctx.params[0] as RepoName,
+      offset: ctx.params[2] || 0,
       filter: null,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
   }
 
-  handleTagListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleTagListFilterOffsetRoute(ctx: PageContext) {
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
-      repo: data.params['repo'] as RepoName,
-      offset: data.params['offset'],
-      filter: data.params['filter'],
-    });
+      repo: ctx.params['repo'] as RepoName,
+      offset: ctx.params['offset'],
+      filter: ctx.params['filter'],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
   }
 
-  handleTagListFilterRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleTagListFilterRoute(ctx: PageContext) {
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
-      repo: data.params['repo'] as RepoName,
-      filter: data.params['filter'] || null,
-    });
+      repo: ctx.params['repo'] as RepoName,
+      filter: ctx.params['filter'] || null,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
   }
 
-  handleRepoListOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleRepoListOffsetRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-repo-list',
-      offset: data.params[1] || 0,
+      adminView: AdminChildView.REPOS,
+      offset: ctx.params[1] || 0,
       filter: null,
-      openCreateModal: data.hash === 'create',
-    });
+      openCreateModal: ctx.hash === 'create',
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  handleRepoListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleRepoListFilterOffsetRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-repo-list',
-      offset: data.params['offset'],
-      filter: data.params['filter'],
-    });
+      adminView: AdminChildView.REPOS,
+      offset: ctx.params['offset'],
+      filter: ctx.params['filter'],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  handleRepoListFilterRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleRepoListFilterRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-repo-list',
-      filter: data.params['filter'] || null,
-    });
+      adminView: AdminChildView.REPOS,
+      filter: ctx.params['filter'] || null,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  handleCreateProjectRoute(_: PageContextWithQueryMap) {
+  handleCreateProjectRoute(_: PageContext) {
     // Redirects the legacy route to the new route, which displays the project
     // list with a hash 'create'.
     this.redirect('/admin/repos#create');
   }
 
-  handleCreateGroupRoute(_: PageContextWithQueryMap) {
+  handleCreateGroupRoute(_: PageContext) {
     // Redirects the legacy route to the new route, which displays the group
     // list with a hash 'create'.
     this.redirect('/admin/groups#create');
   }
 
-  handleRepoRoute(data: PageContextWithQueryMap) {
-    this.redirect(data.path + ',general');
+  handleRepoRoute(ctx: PageContext) {
+    this.redirect(ctx.path + ',general');
   }
 
-  handlePluginListOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handlePluginListOffsetRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-plugin-list',
-      offset: data.params[1] || 0,
+      adminView: AdminChildView.PLUGINS,
+      offset: ctx.params[1] || 0,
       filter: null,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  handlePluginListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handlePluginListFilterOffsetRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-plugin-list',
-      offset: data.params['offset'],
-      filter: data.params['filter'],
-    });
+      adminView: AdminChildView.PLUGINS,
+      offset: ctx.params['offset'],
+      filter: ctx.params['filter'],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  handlePluginListFilterRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handlePluginListFilterRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-plugin-list',
-      filter: data.params['filter'] || null,
-    });
+      adminView: AdminChildView.PLUGINS,
+      filter: ctx.params['filter'] || null,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  handlePluginListRoute(_: PageContextWithQueryMap) {
-    this.setParams({
+  handlePluginListRoute(_: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-plugin-list',
-    });
+      adminView: AdminChildView.PLUGINS,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  handleQueryRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleQueryRoute(ctx: PageContext) {
+    const state: SearchViewState = {
       view: GerritView.SEARCH,
-      query: data.params[0],
-      offset: data.params[2],
-    });
+      query: ctx.params[0],
+      offset: ctx.params[2],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.searchViewModel.setState(state);
   }
 
-  handleChangeIdQueryRoute(data: PageContextWithQueryMap) {
+  handleChangeIdQueryRoute(ctx: PageContext) {
     // 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({
-      view: GerritNav.View.SEARCH,
-      query: data.params[0],
-    });
+    const state: SearchViewState = {
+      view: GerritView.SEARCH,
+      query: ctx.params[0],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.searchViewModel.setState(state);
   }
 
-  handleQueryLegacySuffixRoute(ctx: PageContextWithQueryMap) {
+  handleQueryLegacySuffixRoute(ctx: PageContext) {
     this.redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
   }
 
-  handleChangeNumberLegacyRoute(ctx: PageContextWithQueryMap) {
+  handleChangeNumberLegacyRoute(ctx: PageContext) {
     this.redirect('/c/' + encodeURIComponent(ctx.params[0]));
   }
 
-  handleChangeRoute(ctx: PageContextWithQueryMap) {
+  handleChangeRoute(ctx: PageContext) {
     // Parameter order is based on the regex group number matched.
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const params: GenerateUrlChangeViewParameters = {
+    const state: ChangeViewState = {
       project: ctx.params[0] as RepoName,
       changeNum,
       basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
-      patchNum: convertToPatchSetNum(ctx.params[6]),
+      patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
       view: GerritView.CHANGE,
     };
 
-    if (ctx.queryMap.has('forceReload')) {
-      params.forceReload = true;
-      history.replaceState(
-        null,
-        '',
-        location.href.replace(/[?&]forceReload=true/, '')
-      );
-    }
+    const queryMap = new URLSearchParams(ctx.querystring);
+    if (queryMap.has('forceReload')) state.forceReload = true;
+    if (queryMap.has('openReplyDialog')) state.openReplyDialog = 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;
-      }
+    const tab = queryMap.get('tab');
+    if (tab) state.tab = tab;
+    const checksPatchset = Number(queryMap.get('checksPatchset'));
+    if (Number.isInteger(checksPatchset) && checksPatchset > 0) {
+      state.checksPatchset = checksPatchset as PatchSetNumber;
     }
+    const filter = queryMap.get('filter');
+    if (filter) state.filter = filter;
+    const checksResultsFilter = queryMap.get('checksResultsFilter');
+    if (checksResultsFilter) state.checksResultsFilter = checksResultsFilter;
+    const attempt = stringToAttemptChoice(queryMap.get('attempt'));
+    if (attempt && attempt !== LATEST_ATTEMPT) state.attempt = attempt;
+    const selected = queryMap.get('checksRunsSelected');
+    if (selected) state.checksRunsSelected = new Set(selected.split(','));
 
-    this.reporting.setRepoName(params.project);
+    assertIsDefined(state.project, 'project');
+    this.reporting.setRepoName(state.project);
     this.reporting.setChangeId(changeNum);
-    this.redirectOrNavigate(params);
+    this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.changeViewModel.setState(state);
   }
 
-  handleCommentRoute(ctx: PageContextWithQueryMap) {
+  handleCommentRoute(ctx: PageContext) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const params: GenerateUrlDiffViewParameters = {
+    const state: DiffViewState = {
       project: ctx.params[0] as RepoName,
       changeNum,
       commentId: ctx.params[2] as UrlEncodedCommentId,
       view: GerritView.DIFF,
       commentLink: true,
     };
-    this.reporting.setRepoName(params.project);
+    this.reporting.setRepoName(state.project ?? '');
     this.reporting.setChangeId(changeNum);
-    this.redirectOrNavigate(params);
+    this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.diffViewModel.setState(state);
   }
 
-  handleCommentsRoute(ctx: PageContextWithQueryMap) {
+  handleCommentsRoute(ctx: PageContext) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const params: GenerateUrlChangeViewParameters = {
+    const state: ChangeViewState = {
       project: ctx.params[0] as RepoName,
       changeNum,
       commentId: ctx.params[2] as UrlEncodedCommentId,
       view: GerritView.CHANGE,
     };
-    this.reporting.setRepoName(params.project);
+    assertIsDefined(state.project);
+    this.reporting.setRepoName(state.project);
     this.reporting.setChangeId(changeNum);
-    this.redirectOrNavigate(params);
+    this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.changeViewModel.setState(state);
   }
 
-  handleDiffRoute(ctx: PageContextWithQueryMap) {
+  handleDiffRoute(ctx: PageContext) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
     // Parameter order is based on the regex group number matched.
-    const params: GenerateUrlDiffViewParameters = {
+    const state: DiffViewState = {
       project: ctx.params[0] as RepoName,
       changeNum,
       basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
-      patchNum: convertToPatchSetNum(ctx.params[6]),
+      patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
       path: ctx.params[8],
       view: GerritView.DIFF,
     };
     const address = this.parseLineAddress(ctx.hash);
     if (address) {
-      params.leftSide = address.leftSide;
-      params.lineNum = address.lineNum;
+      state.leftSide = address.leftSide;
+      state.lineNum = address.lineNum;
     }
-    this.reporting.setRepoName(params.project);
+    this.reporting.setRepoName(state.project ?? '');
     this.reporting.setChangeId(changeNum);
-    this.redirectOrNavigate(params);
+    this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.diffViewModel.setState(state);
   }
 
-  handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
+  handleChangeLegacyRoute(ctx: PageContext) {
     const changeNum = Number(ctx.params[0]) as NumericChangeId;
     if (!changeNum) {
       this.show404();
@@ -1791,93 +1504,97 @@
     });
   }
 
-  handleLegacyLinenum(ctx: PageContextWithQueryMap) {
+  handleLegacyLinenum(ctx: PageContext) {
     this.redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
   }
 
-  handleDiffEditRoute(ctx: PageContextWithQueryMap) {
+  handleDiffEditRoute(ctx: PageContext) {
     // 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 state: EditViewState = {
       project,
       changeNum,
       // for edit view params, patchNum cannot be undefined
-      patchNum: convertToPatchSetNum(ctx.params[2])!,
+      patchNum: convertToPatchSetNum(ctx.params[2]) as RevisionPatchSetNum,
       path: ctx.params[3],
-      lineNum: ctx.hash,
+      lineNum: Number(ctx.hash),
       view: GerritView.EDIT,
-    });
+    };
+    this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.editViewModel.setState(state);
     this.reporting.setRepoName(project);
     this.reporting.setChangeId(changeNum);
   }
 
-  handleChangeEditRoute(ctx: PageContextWithQueryMap) {
+  handleChangeEditRoute(ctx: PageContext) {
     // 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;
-    const params: GenerateUrlChangeViewParameters = {
+    const queryMap = new URLSearchParams(ctx.querystring);
+    const state: ChangeViewState = {
       project,
       changeNum,
-      patchNum: convertToPatchSetNum(ctx.params[3]),
+      patchNum: convertToPatchSetNum(ctx.params[3]) as RevisionPatchSetNum,
       view: GerritView.CHANGE,
       edit: true,
-      tab: ctx.queryMap.get('tab') ?? '',
     };
-    if (ctx.queryMap.has('forceReload')) {
-      params.forceReload = true;
+    const tab = queryMap.get('tab');
+    if (tab) state.tab = tab;
+    if (queryMap.has('forceReload')) {
+      state.forceReload = true;
       history.replaceState(
         null,
         '',
         location.href.replace(/[?&]forceReload=true/, '')
       );
     }
-    this.redirectOrNavigate(params);
-
+    this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.changeViewModel.setState(state);
     this.reporting.setRepoName(project);
     this.reporting.setChangeId(changeNum);
   }
 
-  /**
-   * Normalize the patch range params for a the change or diff view and
-   * redirect if URL upgrade is needed.
-   */
-  private redirectOrNavigate(params: GenerateUrlParameters & PatchRangeParams) {
-    const needsRedirect = this.normalizePatchRangeParams(params);
-    if (needsRedirect) {
-      this.redirect(this.generateUrl(params));
-    } else {
-      this.setParams(params);
-    }
-  }
-
   handleAgreementsRoute() {
     this.redirect('/settings/#Agreements');
   }
 
-  handleNewAgreementsRoute(data: PageContextWithQueryMap) {
-    data.params['view'] = GerritView.AGREEMENTS;
-    // TODO(TS): create valid object
-    this.setParams(data.params as unknown as AppElementAgreementParam);
+  handleNewAgreementsRoute() {
+    const state: AgreementViewState = {
+      view: GerritView.AGREEMENTS,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.agreementViewModel.setState(state);
   }
 
-  handleSettingsLegacyRoute(data: PageContextWithQueryMap) {
+  handleSettingsLegacyRoute(ctx: PageContext) {
     // 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({
+    const token = ctx.params[0].replace(/ /g, '+');
+    const state: SettingsViewState = {
       view: GerritView.SETTINGS,
       emailToken: token,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.settingsViewModel.setState(state);
   }
 
-  handleSettingsRoute(_: PageContextWithQueryMap) {
-    this.setParams({view: GerritView.SETTINGS});
+  handleSettingsRoute(_: PageContext) {
+    const state: SettingsViewState = {view: GerritView.SETTINGS};
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.settingsViewModel.setState(state);
   }
 
-  handleRegisterRoute(ctx: PageContextWithQueryMap) {
-    this.setParams({justRegistered: true});
+  handleRegisterRoute(ctx: PageContext) {
+    this.setState({justRegistered: true});
     let path = ctx.params[0] || '/';
 
     // Prevent redirect looping.
@@ -1903,7 +1620,7 @@
    * URL may sometimes have /+/ encoded to / /.
    * Context: Issue 6888, Issue 7100
    */
-  handleImproperlyEncodedPlusRoute(ctx: PageContextWithQueryMap) {
+  handleImproperlyEncodedPlusRoute(ctx: PageContext) {
     let hash = this.getHashFromCanonicalPath(ctx.canonicalPath);
     if (hash.length) {
       hash = '#' + hash;
@@ -1911,28 +1628,35 @@
     this.redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
   }
 
-  handlePluginScreen(ctx: PageContextWithQueryMap) {
-    const view = GerritView.PLUGIN_SCREEN;
-    const plugin = ctx.params[0];
-    const screen = ctx.params[1];
-    this.setParams({view, plugin, screen});
+  handlePluginScreen(ctx: PageContext) {
+    const state: PluginViewState = {
+      view: GerritView.PLUGIN_SCREEN,
+      plugin: ctx.params[0],
+      screen: ctx.params[1],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.pluginViewModel.setState(state);
   }
 
-  handleDocumentationSearchRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+  handleDocumentationSearchRoute(ctx: PageContext) {
+    const state: DocumentationViewState = {
       view: GerritView.DOCUMENTATION_SEARCH,
-      filter: data.params['filter'] || null,
-    });
+      filter: ctx.params['filter'] || null,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.documentationViewModel.setState(state);
   }
 
-  handleDocumentationSearchRedirectRoute(data: PageContextWithQueryMap) {
+  handleDocumentationSearchRedirectRoute(ctx: PageContext) {
     this.redirect(
-      '/Documentation/q/filter:' + encodeURIComponent(data.params[0])
+      '/Documentation/q/filter:' + encodeURIComponent(ctx.params[0])
     );
   }
 
-  handleDocumentationRedirectRoute(data: PageContextWithQueryMap) {
-    if (data.params[1]) {
+  handleDocumentationRedirectRoute(ctx: PageContext) {
+    if (ctx.params[1]) {
       windowLocationReload();
     } else {
       // Redirect /Documentation to /Documentation/index.html
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index 4c147bb..854222b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -1,138 +1,45 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {page, PageContext} from '../../../utils/page-wrapper-utils';
 import {
   stubBaseUrl,
   stubRestApi,
   addListenerForTest,
+  waitEventLoop,
 } from '../../../test/test-utils';
-import {
-  GrRouter,
-  PageContextWithQueryMap,
-  PatchRangeParams,
-  _testOnly_RoutePattern,
-} from './gr-router';
+import {GrRouter, routerToken, _testOnly_RoutePattern} from './gr-router';
 import {GerritView} from '../../../services/router/router-model';
 import {
   BasePatchSetNum,
-  BranchName,
-  CommitId,
-  DashboardId,
   GroupId,
   NumericChangeId,
-  ParentPatchSetNum,
-  PatchSetNum,
+  PARENT,
   RepoName,
   RevisionPatchSetNum,
-  TopicName,
   UrlEncodedCommentId,
-  WebLinkInfo,
 } from '../../../types/common';
-import {
-  createGerritInfo,
-  createServerInfo,
-} from '../../../test/test-data-generators';
 import {AppElementParams} from '../../gr-app-types';
+import {assert} from '@open-wc/testing';
+import {AdminChildView} from '../../../models/views/admin';
+import {RepoDetailView} from '../../../models/views/repo';
+import {GroupDetailView} from '../../../models/views/group';
+import {EditViewState} from '../../../models/views/edit';
+import {ChangeViewState} from '../../../models/views/change';
+import {PatchRangeParams} from '../../../utils/url-util';
+import {DependencyRequestEvent} from '../../../models/dependency';
 
 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'}
+    document.dispatchEvent(
+      new DependencyRequestEvent(routerToken, x => (router = x))
     );
-
-    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', () => {
@@ -196,7 +103,6 @@
 
     const requiresAuth: any = {};
     const doesNotRequireAuth: any = {};
-    sinon.stub(GerritNav, 'setup');
     sinon.stub(page, 'start');
     sinon.stub(page, 'base');
     sinon
@@ -294,7 +200,7 @@
 
   test('redirectIfNotLoggedIn while logged in', () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(true));
-    const data = {
+    const ctx = {
       save() {},
       handled: true,
       canonicalPath: '',
@@ -307,7 +213,7 @@
       params: {test: 'test'},
     };
     const redirectStub = sinon.stub(router, 'redirectToLogin');
-    return router.redirectIfNotLoggedIn(data).then(() => {
+    return router.redirectIfNotLoggedIn(ctx).then(() => {
       assert.isFalse(redirectStub.called);
     });
   });
@@ -315,7 +221,7 @@
   test('redirectIfNotLoggedIn while logged out', () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     const redirectStub = sinon.stub(router, 'redirectToLogin');
-    const data = {
+    const ctx = {
       save() {},
       handled: true,
       canonicalPath: '',
@@ -329,7 +235,7 @@
     };
     return new Promise(resolve => {
       router
-        .redirectIfNotLoggedIn(data)
+        .redirectIfNotLoggedIn(ctx)
         .then(() => {
           assert.isTrue(false, 'Should never execute');
         })
@@ -340,345 +246,50 @@
     });
   });
 
-  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,
+          patchNum: 4 as RevisionPatchSetNum,
         };
-        const needsRedirect = router.normalizePatchRangeParams(params);
-        assert.isTrue(needsRedirect);
-        assert.equal(params.basePatchNum, ParentPatchSetNum);
-        assert.equal(params.patchNum, 4 as PatchSetNum);
+        router.normalizePatchRangeParams(params);
+        assert.equal(params.basePatchNum, PARENT);
+        assert.equal(params.patchNum, 4 as RevisionPatchSetNum);
       });
 
       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);
+        router.normalizePatchRangeParams(params);
+        assert.equal(params.basePatchNum, PARENT);
+        assert.equal(params.patchNum, 4 as RevisionPatchSetNum);
       });
     });
   });
 
   suite('route handlers', () => {
     let redirectStub: sinon.SinonStub;
-    let setParamsStub: sinon.SinonStub;
+    let setStateStub: 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`
+    // Simple route handlers are direct mappings from parsed route ctx to a
+    // new set of app.params. This test helper asserts that passing `ctx`
     // into `methodName` results in setting the params specified in `params`.
-    function assertDataToParams(
-      data: PageContextWithQueryMap,
+    function assertctxToParams(
+      ctx: PageContext,
       methodName: string,
-      params: AppElementParams | GenerateUrlParameters
+      params: AppElementParams
     ) {
-      (router as any)[methodName](data);
-      assert.deepEqual(setParamsStub.lastCall.args[0], params);
+      (router as any)[methodName](ctx);
+      assert.deepEqual(setStateStub.lastCall.args[0], params);
     }
 
-    function createPageContext(): PageContextWithQueryMap {
+    function createPageContext(): PageContext {
       return {
-        queryMap: new Map(),
-        save() {},
-        handled: true,
         canonicalPath: '',
         path: '',
         querystring: '',
         pathname: '',
-        state: '',
-        title: '',
         hash: '',
         params: {},
       };
@@ -686,7 +297,7 @@
 
     setup(() => {
       redirectStub = sinon.stub(router, 'redirect');
-      setParamsStub = sinon.stub(router, 'setParams');
+      setStateStub = sinon.stub(router, 'setState');
       handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
     });
 
@@ -710,35 +321,31 @@
     });
 
     test('handleNewAgreementsRoute', () => {
-      const params = createPageContext();
-      router.handleNewAgreementsRoute(params);
-      assert.isTrue(setParamsStub.calledOnce);
-      assert.equal(
-        setParamsStub.lastCall.args[0].view,
-        GerritNav.View.AGREEMENTS
-      );
+      router.handleNewAgreementsRoute();
+      assert.isTrue(setStateStub.calledOnce);
+      assert.equal(setStateStub.lastCall.args[0].view, GerritView.AGREEMENTS);
     });
 
     test('handleSettingsLegacyRoute', () => {
-      const data = {...createPageContext(), params: {0: 'my-token'}};
-      assertDataToParams(data, 'handleSettingsLegacyRoute', {
-        view: GerritNav.View.SETTINGS,
+      const ctx = {...createPageContext(), params: {0: 'my-token'}};
+      assertctxToParams(ctx, 'handleSettingsLegacyRoute', {
+        view: GerritView.SETTINGS,
         emailToken: 'my-token',
       });
     });
 
     test('handleSettingsLegacyRoute with +', () => {
-      const data = {...createPageContext(), params: {0: 'my-token test'}};
-      assertDataToParams(data, 'handleSettingsLegacyRoute', {
-        view: GerritNav.View.SETTINGS,
+      const ctx = {...createPageContext(), params: {0: 'my-token test'}};
+      assertctxToParams(ctx, 'handleSettingsLegacyRoute', {
+        view: GerritView.SETTINGS,
         emailToken: 'my-token+test',
       });
     });
 
     test('handleSettingsRoute', () => {
-      const data = createPageContext();
-      assertDataToParams(data, 'handleSettingsRoute', {
-        view: GerritNav.View.SETTINGS,
+      const ctx = createPageContext();
+      assertctxToParams(ctx, 'handleSettingsRoute', {
+        view: GerritView.SETTINGS,
       });
     });
 
@@ -759,7 +366,6 @@
         onExit = _onExit;
       };
       sinon.stub(page, 'exit').callsFake(onRegisteringExit);
-      sinon.stub(GerritNav, 'setup');
       sinon.stub(page, 'start');
       sinon.stub(page, 'base');
       router.startRouter();
@@ -789,20 +395,20 @@
     });
 
     test('handleQueryRoute', () => {
-      const data: PageContextWithQueryMap = {
+      const ctx: PageContext = {
         ...createPageContext(),
         params: {0: 'project:foo/bar/baz'},
       };
-      assertDataToParams(data, 'handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
+      assertctxToParams(ctx, 'handleQueryRoute', {
+        view: GerritView.SEARCH,
         query: 'project:foo/bar/baz',
         offset: undefined,
       });
 
-      data.params[1] = '123';
-      data.params[2] = '123';
-      assertDataToParams(data, 'handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
+      ctx.params[1] = '123';
+      ctx.params[2] = '123';
+      assertctxToParams(ctx, 'handleQueryRoute', {
+        view: GerritView.SEARCH,
         query: 'project:foo/bar/baz',
         offset: '123',
       });
@@ -816,12 +422,12 @@
     });
 
     test('handleChangeIdQueryRoute', () => {
-      const data = {
+      const ctx = {
         ...createPageContext(),
         params: {0: 'I0123456789abcdef0123456789abcdef01234567'},
       };
-      assertDataToParams(data, 'handleChangeIdQueryRoute', {
-        view: GerritNav.View.SEARCH,
+      assertctxToParams(ctx, 'handleChangeIdQueryRoute', {
+        view: GerritView.SEARCH,
         query: 'I0123456789abcdef0123456789abcdef01234567',
       });
     });
@@ -831,40 +437,40 @@
         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);
+        assert.isTrue(setStateStub.calledOnce);
+        assert.isTrue(setStateStub.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);
+        assert.isTrue(setStateStub.calledOnce);
+        assert.isTrue(setStateStub.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);
+        assert.isTrue(setStateStub.calledOnce);
+        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
       });
     });
 
     suite('handleRootRoute', () => {
       test('closes for closeAfterLogin', () => {
-        const data = {...createPageContext(), querystring: 'closeAfterLogin'};
+        const ctx = {...createPageContext(), querystring: 'closeAfterLogin'};
         const closeStub = sinon.stub(window, 'close');
-        const result = router.handleRootRoute(data);
+        const result = router.handleRootRoute(ctx);
         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);
+        const ctx = {...createPageContext(), canonicalPath: '/', path: '/'};
+        const result = router.handleRootRoute(ctx);
         assert.isOk(result);
         return result!.then(() => {
           assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
@@ -873,8 +479,8 @@
 
       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);
+        const ctx = {...createPageContext(), canonicalPath: '/', path: '/'};
+        const result = router.handleRootRoute(ctx);
         assert.isOk(result);
         return result!.then(() => {
           assert.isTrue(
@@ -885,73 +491,73 @@
 
       suite('GWT hash-path URLs', () => {
         test('redirects hash-path URLs', () => {
-          const data = {
+          const ctx = {
             ...createPageContext(),
             canonicalPath: '/#/foo/bar/baz',
             hash: '/foo/bar/baz',
           };
-          const result = router.handleRootRoute(data);
+          const result = router.handleRootRoute(ctx);
           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 = {
+          const ctx = {
             ...createPageContext(),
             canonicalPath: '/#foo/bar/baz',
             hash: 'foo/bar/baz',
           };
-          const result = router.handleRootRoute(data);
+          const result = router.handleRootRoute(ctx);
           assert.isNotOk(result);
           assert.isTrue(redirectStub.called);
           assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
         });
 
         test('normalizes "/ /" in hash to "/+/"', () => {
-          const data = {
+          const ctx = {
             ...createPageContext(),
             canonicalPath: '/#/foo/bar/+/123/4',
             hash: '/foo/bar/ /123/4',
           };
-          const result = router.handleRootRoute(data);
+          const result = router.handleRootRoute(ctx);
           assert.isNotOk(result);
           assert.isTrue(redirectStub.called);
           assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
         });
 
         test('prepends baseurl to hash-path', () => {
-          const data = {
+          const ctx = {
             ...createPageContext(),
             canonicalPath: '/#/foo/bar',
             hash: '/foo/bar',
           };
           stubBaseUrl('/baz');
-          const result = router.handleRootRoute(data);
+          const result = router.handleRootRoute(ctx);
           assert.isNotOk(result);
           assert.isTrue(redirectStub.called);
           assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
         });
 
         test('normalizes /VE/ settings hash-paths', () => {
-          const data = {
+          const ctx = {
             ...createPageContext(),
             canonicalPath: '/#/VE/foo/bar',
             hash: '/VE/foo/bar',
           };
-          const result = router.handleRootRoute(data);
+          const result = router.handleRootRoute(ctx);
           assert.isNotOk(result);
           assert.isTrue(redirectStub.called);
           assert.isTrue(redirectStub.calledWithExactly('/settings/VE/foo/bar'));
         });
 
         test('does not drop "inner hashes"', () => {
-          const data = {
+          const ctx = {
             ...createPageContext(),
             canonicalPath: '/#/foo/bar#baz',
             hash: '/foo/bar',
           };
-          const result = router.handleRootRoute(data);
+          const result = router.handleRootRoute(ctx);
           assert.isNotOk(result);
           assert.isTrue(redirectStub.called);
           assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
@@ -968,45 +574,45 @@
 
       test('own dashboard but signed out redirects to login', () => {
         stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const data = {
+        const ctx = {
           ...createPageContext(),
           canonicalPath: '/dashboard/',
           params: {0: 'seLF'},
         };
-        return router.handleDashboardRoute(data).then(() => {
+        return router.handleDashboardRoute(ctx).then(() => {
           assert.isTrue(redirectToLoginStub.calledOnce);
           assert.isFalse(redirectStub.called);
-          assert.isFalse(setParamsStub.called);
+          assert.isFalse(setStateStub.called);
         });
       });
 
       test('non-self dashboard but signed out does not redirect', () => {
         stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const data = {
+        const ctx = {
           ...createPageContext(),
           canonicalPath: '/dashboard/',
           params: {0: 'foo'},
         };
-        return router.handleDashboardRoute(data).then(() => {
+        return router.handleDashboardRoute(ctx).then(() => {
           assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(setParamsStub.called);
+          assert.isFalse(setStateStub.called);
           assert.isTrue(redirectStub.calledOnce);
           assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
         });
       });
 
       test('dashboard while signed in sets params', () => {
-        const data = {
+        const ctx = {
           ...createPageContext(),
           canonicalPath: '/dashboard/',
           params: {0: 'foo'},
         };
-        return router.handleDashboardRoute(data).then(() => {
+        return router.handleDashboardRoute(ctx).then(() => {
           assert.isFalse(redirectToLoginStub.called);
           assert.isFalse(redirectStub.called);
-          assert.isTrue(setParamsStub.calledOnce);
-          assert.deepEqual(setParamsStub.lastCall.args[0], {
-            view: GerritNav.View.DASHBOARD,
+          assert.isTrue(setStateStub.calledOnce);
+          assert.deepEqual(setStateStub.lastCall.args[0], {
+            view: GerritView.DASHBOARD,
             user: 'foo',
           });
         });
@@ -1021,95 +627,93 @@
       });
 
       test('no user specified', () => {
-        const data = {
+        const ctx: PageContext = {
           ...createPageContext(),
           canonicalPath: '/dashboard/',
           params: {0: ''},
+          querystring: '',
         };
-        return router.handleCustomDashboardRoute(data, '').then(() => {
-          assert.isFalse(setParamsStub.called);
+        return router.handleCustomDashboardRoute(ctx).then(() => {
+          assert.isFalse(setStateStub.called);
           assert.isTrue(redirectStub.called);
           assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
         });
       });
 
       test('custom dashboard without title', () => {
-        const data = {
+        const ctx: PageContext = {
           ...createPageContext(),
           canonicalPath: '/dashboard/',
           params: {0: ''},
+          querystring: '?a=b&c&d=e',
         };
-        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',
-            });
+        return router.handleCustomDashboardRoute(ctx).then(() => {
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setStateStub.calledOnce);
+          assert.deepEqual(setStateStub.lastCall.args[0], {
+            view: GerritView.DASHBOARD,
+            user: 'self',
+            sections: [
+              {name: 'a', query: 'b'},
+              {name: 'd', query: 'e'},
+            ],
+            title: 'Custom Dashboard',
           });
+        });
       });
 
       test('custom dashboard with title', () => {
-        const data = {
+        const ctx: PageContext = {
           ...createPageContext(),
           canonicalPath: '/dashboard/',
           params: {0: ''},
+          querystring: '?a=b&c&d=&=e&title=t',
         };
-        return router
-          .handleCustomDashboardRoute(data, '?a=b&c&d=&=e&title=t')
-          .then(() => {
-            assert.isFalse(redirectToLoginStub.called);
-            assert.isFalse(redirectStub.called);
-            assert.isTrue(setParamsStub.calledOnce);
-            assert.deepEqual(setParamsStub.lastCall.args[0], {
-              view: GerritNav.View.DASHBOARD,
-              user: 'self',
-              sections: [{name: 'a', query: 'b'}],
-              title: 't',
-            });
+        return router.handleCustomDashboardRoute(ctx).then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setStateStub.calledOnce);
+          assert.deepEqual(setStateStub.lastCall.args[0], {
+            view: GerritView.DASHBOARD,
+            user: 'self',
+            sections: [{name: 'a', query: 'b'}],
+            title: 't',
           });
+        });
       });
 
       test('custom dashboard with foreach', () => {
-        const data = {
+        const ctx: PageContext = {
           ...createPageContext(),
           canonicalPath: '/dashboard/',
           params: {0: ''},
+          querystring: '?a=b&c&d=&=e&foreach=is:open',
         };
-        return router
-          .handleCustomDashboardRoute(data, '?a=b&c&d=&=e&foreach=is:open')
-          .then(() => {
-            assert.isFalse(redirectToLoginStub.called);
-            assert.isFalse(redirectStub.called);
-            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',
-            });
+        return router.handleCustomDashboardRoute(ctx).then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setStateStub.calledOnce);
+          assert.deepEqual(setStateStub.lastCall.args[0], {
+            view: GerritView.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);
+        const ctx = {...createPageContext(), params: {0: '1234'}};
+        router.handleGroupInfoRoute(ctx);
         assert.isTrue(redirectStub.calledOnce);
         assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
       });
 
       test('handleGroupAuditLogRoute', () => {
-        const data = {...createPageContext(), params: {0: '1234'}};
-        assertDataToParams(data, 'handleGroupAuditLogRoute', {
+        const ctx = {...createPageContext(), params: {0: '1234'}};
+        assertctxToParams(ctx, 'handleGroupAuditLogRoute', {
           view: GerritView.GROUP,
           detail: GroupDetailView.LOG,
           groupId: '1234' as GroupId,
@@ -1117,8 +721,8 @@
       });
 
       test('handleGroupMembersRoute', () => {
-        const data = {...createPageContext(), params: {0: '1234'}};
-        assertDataToParams(data, 'handleGroupMembersRoute', {
+        const ctx = {...createPageContext(), params: {0: '1234'}};
+        assertctxToParams(ctx, 'handleGroupMembersRoute', {
           view: GerritView.GROUP,
           detail: GroupDetailView.MEMBERS,
           groupId: '1234' as GroupId,
@@ -1126,28 +730,28 @@
       });
 
       test('handleGroupListOffsetRoute', () => {
-        const data = createPageContext();
-        assertDataToParams(data, 'handleGroupListOffsetRoute', {
+        const ctx = createPageContext();
+        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
           view: GerritView.ADMIN,
-          adminView: 'gr-admin-group-list',
+          adminView: AdminChildView.GROUPS,
           offset: 0,
           filter: null,
           openCreateModal: false,
         });
 
-        data.params[1] = '42';
-        assertDataToParams(data, 'handleGroupListOffsetRoute', {
+        ctx.params[1] = '42';
+        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
           view: GerritView.ADMIN,
-          adminView: 'gr-admin-group-list',
+          adminView: AdminChildView.GROUPS,
           offset: '42',
           filter: null,
           openCreateModal: false,
         });
 
-        data.hash = 'create';
-        assertDataToParams(data, 'handleGroupListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
+        ctx.hash = 'create';
+        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.GROUPS,
           offset: '42',
           filter: null,
           openCreateModal: true,
@@ -1155,30 +759,30 @@
       });
 
       test('handleGroupListFilterOffsetRoute', () => {
-        const data = {
+        const ctx = {
           ...createPageContext(),
           params: {filter: 'foo', offset: '42'},
         };
-        assertDataToParams(data, 'handleGroupListFilterOffsetRoute', {
+        assertctxToParams(ctx, 'handleGroupListFilterOffsetRoute', {
           view: GerritView.ADMIN,
-          adminView: 'gr-admin-group-list',
+          adminView: AdminChildView.GROUPS,
           offset: '42',
           filter: 'foo',
         });
       });
 
       test('handleGroupListFilterRoute', () => {
-        const data = {...createPageContext(), params: {filter: 'foo'}};
-        assertDataToParams(data, 'handleGroupListFilterRoute', {
+        const ctx = {...createPageContext(), params: {filter: 'foo'}};
+        assertctxToParams(ctx, 'handleGroupListFilterRoute', {
           view: GerritView.ADMIN,
-          adminView: 'gr-admin-group-list',
+          adminView: AdminChildView.GROUPS,
           filter: 'foo',
         });
       });
 
       test('handleGroupRoute', () => {
-        const data = {...createPageContext(), params: {0: '4321'}};
-        assertDataToParams(data, 'handleGroupRoute', {
+        const ctx = {...createPageContext(), params: {0: '4321'}};
+        assertctxToParams(ctx, 'handleGroupRoute', {
           view: GerritView.GROUP,
           groupId: '4321' as GroupId,
         });
@@ -1187,22 +791,22 @@
 
     suite('repo routes', () => {
       test('handleProjectsOldRoute', () => {
-        const data = {...createPageContext(), params: {}};
-        router.handleProjectsOldRoute(data);
+        const ctx = {...createPageContext(), params: {}};
+        router.handleProjectsOldRoute(ctx);
         assert.isTrue(redirectStub.calledOnce);
         assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
       });
 
       test('handleProjectsOldRoute test', () => {
-        const data = {...createPageContext(), params: {1: 'test'}};
-        router.handleProjectsOldRoute(data);
+        const ctx = {...createPageContext(), params: {1: 'test'}};
+        router.handleProjectsOldRoute(ctx);
         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);
+        const ctx = {...createPageContext(), params: {1: 'test,branches'}};
+        router.handleProjectsOldRoute(ctx);
         assert.isTrue(redirectStub.calledOnce);
         assert.equal(
           redirectStub.lastCall.args[0],
@@ -1211,8 +815,8 @@
       });
 
       test('handleRepoRoute', () => {
-        const data = {...createPageContext(), path: '/admin/repos/test'};
-        router.handleRepoRoute(data);
+        const ctx = {...createPageContext(), path: '/admin/repos/test'};
+        router.handleRepoRoute(ctx);
         assert.isTrue(redirectStub.calledOnce);
         assert.equal(
           redirectStub.lastCall.args[0],
@@ -1221,50 +825,50 @@
       });
 
       test('handleRepoGeneralRoute', () => {
-        const data = {...createPageContext(), params: {0: '4321'}};
-        assertDataToParams(data, 'handleRepoGeneralRoute', {
+        const ctx = {...createPageContext(), params: {0: '4321'}};
+        assertctxToParams(ctx, 'handleRepoGeneralRoute', {
           view: GerritView.REPO,
-          detail: GerritNav.RepoDetailView.GENERAL,
+          detail: RepoDetailView.GENERAL,
           repo: '4321' as RepoName,
         });
       });
 
       test('handleRepoCommandsRoute', () => {
-        const data = {...createPageContext(), params: {0: '4321'}};
-        assertDataToParams(data, 'handleRepoCommandsRoute', {
+        const ctx = {...createPageContext(), params: {0: '4321'}};
+        assertctxToParams(ctx, 'handleRepoCommandsRoute', {
           view: GerritView.REPO,
-          detail: GerritNav.RepoDetailView.COMMANDS,
+          detail: RepoDetailView.COMMANDS,
           repo: '4321' as RepoName,
         });
       });
 
       test('handleRepoAccessRoute', () => {
-        const data = {...createPageContext(), params: {0: '4321'}};
-        assertDataToParams(data, 'handleRepoAccessRoute', {
+        const ctx = {...createPageContext(), params: {0: '4321'}};
+        assertctxToParams(ctx, 'handleRepoAccessRoute', {
           view: GerritView.REPO,
-          detail: GerritNav.RepoDetailView.ACCESS,
+          detail: RepoDetailView.ACCESS,
           repo: '4321' as RepoName,
         });
       });
 
       suite('branch list routes', () => {
         test('handleBranchListOffsetRoute', () => {
-          const data: PageContextWithQueryMap = {
+          const ctx: PageContext = {
             ...createPageContext(),
             params: {0: '4321'},
           };
-          assertDataToParams(data, 'handleBranchListOffsetRoute', {
+          assertctxToParams(ctx, 'handleBranchListOffsetRoute', {
             view: GerritView.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
+            detail: RepoDetailView.BRANCHES,
             repo: '4321' as RepoName,
             offset: 0,
             filter: null,
           });
 
-          data.params[2] = '42';
-          assertDataToParams(data, 'handleBranchListOffsetRoute', {
+          ctx.params[2] = '42';
+          assertctxToParams(ctx, 'handleBranchListOffsetRoute', {
             view: GerritView.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
+            detail: RepoDetailView.BRANCHES,
             repo: '4321' as RepoName,
             offset: '42',
             filter: null,
@@ -1272,13 +876,13 @@
         });
 
         test('handleBranchListFilterOffsetRoute', () => {
-          const data = {
+          const ctx = {
             ...createPageContext(),
             params: {repo: '4321', filter: 'foo', offset: '42'},
           };
-          assertDataToParams(data, 'handleBranchListFilterOffsetRoute', {
+          assertctxToParams(ctx, 'handleBranchListFilterOffsetRoute', {
             view: GerritView.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
+            detail: RepoDetailView.BRANCHES,
             repo: '4321' as RepoName,
             offset: '42',
             filter: 'foo',
@@ -1286,13 +890,13 @@
         });
 
         test('handleBranchListFilterRoute', () => {
-          const data = {
+          const ctx = {
             ...createPageContext(),
             params: {repo: '4321', filter: 'foo'},
           };
-          assertDataToParams(data, 'handleBranchListFilterRoute', {
+          assertctxToParams(ctx, 'handleBranchListFilterRoute', {
             view: GerritView.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
+            detail: RepoDetailView.BRANCHES,
             repo: '4321' as RepoName,
             filter: 'foo',
           });
@@ -1301,10 +905,10 @@
 
       suite('tag list routes', () => {
         test('handleTagListOffsetRoute', () => {
-          const data = {...createPageContext(), params: {0: '4321'}};
-          assertDataToParams(data, 'handleTagListOffsetRoute', {
+          const ctx = {...createPageContext(), params: {0: '4321'}};
+          assertctxToParams(ctx, 'handleTagListOffsetRoute', {
             view: GerritView.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
+            detail: RepoDetailView.TAGS,
             repo: '4321' as RepoName,
             offset: 0,
             filter: null,
@@ -1312,13 +916,13 @@
         });
 
         test('handleTagListFilterOffsetRoute', () => {
-          const data = {
+          const ctx = {
             ...createPageContext(),
             params: {repo: '4321', filter: 'foo', offset: '42'},
           };
-          assertDataToParams(data, 'handleTagListFilterOffsetRoute', {
+          assertctxToParams(ctx, 'handleTagListFilterOffsetRoute', {
             view: GerritView.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
+            detail: RepoDetailView.TAGS,
             repo: '4321' as RepoName,
             offset: '42',
             filter: 'foo',
@@ -1326,21 +930,21 @@
         });
 
         test('handleTagListFilterRoute', () => {
-          const data: PageContextWithQueryMap = {
+          const ctx: PageContext = {
             ...createPageContext(),
             params: {repo: '4321'},
           };
-          assertDataToParams(data, 'handleTagListFilterRoute', {
+          assertctxToParams(ctx, 'handleTagListFilterRoute', {
             view: GerritView.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
+            detail: RepoDetailView.TAGS,
             repo: '4321' as RepoName,
             filter: null,
           });
 
-          data.params.filter = 'foo';
-          assertDataToParams(data, 'handleTagListFilterRoute', {
+          ctx.params.filter = 'foo';
+          assertctxToParams(ctx, 'handleTagListFilterRoute', {
             view: GerritView.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
+            detail: RepoDetailView.TAGS,
             repo: '4321' as RepoName,
             filter: 'foo',
           });
@@ -1349,28 +953,28 @@
 
       suite('repo list routes', () => {
         test('handleRepoListOffsetRoute', () => {
-          const data = createPageContext();
-          assertDataToParams(data, 'handleRepoListOffsetRoute', {
+          const ctx = createPageContext();
+          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
             view: GerritView.ADMIN,
-            adminView: 'gr-repo-list',
+            adminView: AdminChildView.REPOS,
             offset: 0,
             filter: null,
             openCreateModal: false,
           });
 
-          data.params[1] = '42';
-          assertDataToParams(data, 'handleRepoListOffsetRoute', {
+          ctx.params[1] = '42';
+          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
             view: GerritView.ADMIN,
-            adminView: 'gr-repo-list',
+            adminView: AdminChildView.REPOS,
             offset: '42',
             filter: null,
             openCreateModal: false,
           });
 
-          data.hash = 'create';
-          assertDataToParams(data, 'handleRepoListOffsetRoute', {
+          ctx.hash = 'create';
+          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
             view: GerritView.ADMIN,
-            adminView: 'gr-repo-list',
+            adminView: AdminChildView.REPOS,
             offset: '42',
             filter: null,
             openCreateModal: true,
@@ -1378,30 +982,30 @@
         });
 
         test('handleRepoListFilterOffsetRoute', () => {
-          const data = {
+          const ctx = {
             ...createPageContext(),
             params: {filter: 'foo', offset: '42'},
           };
-          assertDataToParams(data, 'handleRepoListFilterOffsetRoute', {
+          assertctxToParams(ctx, 'handleRepoListFilterOffsetRoute', {
             view: GerritView.ADMIN,
-            adminView: 'gr-repo-list',
+            adminView: AdminChildView.REPOS,
             offset: '42',
             filter: 'foo',
           });
         });
 
         test('handleRepoListFilterRoute', () => {
-          const data = createPageContext();
-          assertDataToParams(data, 'handleRepoListFilterRoute', {
+          const ctx = createPageContext();
+          assertctxToParams(ctx, 'handleRepoListFilterRoute', {
             view: GerritView.ADMIN,
-            adminView: 'gr-repo-list',
+            adminView: AdminChildView.REPOS,
             filter: null,
           });
 
-          data.params.filter = 'foo';
-          assertDataToParams(data, 'handleRepoListFilterRoute', {
+          ctx.params.filter = 'foo';
+          assertctxToParams(ctx, 'handleRepoListFilterRoute', {
             view: GerritView.ADMIN,
-            adminView: 'gr-repo-list',
+            adminView: AdminChildView.REPOS,
             filter: 'foo',
           });
         });
@@ -1410,65 +1014,65 @@
 
     suite('plugin routes', () => {
       test('handlePluginListOffsetRoute', () => {
-        const data = createPageContext();
-        assertDataToParams(data, 'handlePluginListOffsetRoute', {
+        const ctx = createPageContext();
+        assertctxToParams(ctx, 'handlePluginListOffsetRoute', {
           view: GerritView.ADMIN,
-          adminView: 'gr-plugin-list',
+          adminView: AdminChildView.PLUGINS,
           offset: 0,
           filter: null,
         });
 
-        data.params[1] = '42';
-        assertDataToParams(data, 'handlePluginListOffsetRoute', {
+        ctx.params[1] = '42';
+        assertctxToParams(ctx, 'handlePluginListOffsetRoute', {
           view: GerritView.ADMIN,
-          adminView: 'gr-plugin-list',
+          adminView: AdminChildView.PLUGINS,
           offset: '42',
           filter: null,
         });
       });
 
       test('handlePluginListFilterOffsetRoute', () => {
-        const data = {
+        const ctx = {
           ...createPageContext(),
           params: {filter: 'foo', offset: '42'},
         };
-        assertDataToParams(data, 'handlePluginListFilterOffsetRoute', {
+        assertctxToParams(ctx, 'handlePluginListFilterOffsetRoute', {
           view: GerritView.ADMIN,
-          adminView: 'gr-plugin-list',
+          adminView: AdminChildView.PLUGINS,
           offset: '42',
           filter: 'foo',
         });
       });
 
       test('handlePluginListFilterRoute', () => {
-        const data = createPageContext();
-        assertDataToParams(data, 'handlePluginListFilterRoute', {
+        const ctx = createPageContext();
+        assertctxToParams(ctx, 'handlePluginListFilterRoute', {
           view: GerritView.ADMIN,
-          adminView: 'gr-plugin-list',
+          adminView: AdminChildView.PLUGINS,
           filter: null,
         });
 
-        data.params.filter = 'foo';
-        assertDataToParams(data, 'handlePluginListFilterRoute', {
+        ctx.params.filter = 'foo';
+        assertctxToParams(ctx, 'handlePluginListFilterRoute', {
           view: GerritView.ADMIN,
-          adminView: 'gr-plugin-list',
+          adminView: AdminChildView.PLUGINS,
           filter: 'foo',
         });
       });
 
       test('handlePluginListRoute', () => {
-        const data = createPageContext();
-        assertDataToParams(data, 'handlePluginListRoute', {
+        const ctx = createPageContext();
+        assertctxToParams(ctx, 'handlePluginListRoute', {
           view: GerritView.ADMIN,
-          adminView: 'gr-plugin-list',
+          adminView: AdminChildView.PLUGINS,
         });
       });
     });
 
     suite('change/diff routes', () => {
       test('handleChangeNumberLegacyRoute', () => {
-        const data = {...createPageContext(), params: {0: '12345'}};
-        router.handleChangeNumberLegacyRoute(data);
+        const ctx = {...createPageContext(), params: {0: '12345'}};
+        router.handleChangeNumberLegacyRoute(ctx);
         assert.isTrue(redirectStub.calledOnce);
         assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
       });
@@ -1482,7 +1086,7 @@
           params: {0: '1234', 1: 'comment/6789'},
         };
         router.handleChangeLegacyRoute(ctx);
-        await flush();
+        await waitEventLoop();
         assert.isTrue(
           redirectStub.calledWithExactly('/c/project/+/1234' + '/comment/6789')
         );
@@ -1507,12 +1111,7 @@
       });
 
       suite('handleChangeRoute', () => {
-        let normalizeRangeStub: sinon.SinonStub;
-
-        function makeParams(
-          _path: string,
-          _hash: string
-        ): PageContextWithQueryMap {
+        function makeParams(_path: string, _hash: string): PageContext {
           return {
             ...createPageContext(),
             params: {
@@ -1528,26 +1127,12 @@
         }
 
         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', {
+          assertctxToParams(ctx, 'handleChangeRoute', {
             view: GerritView.CHANGE,
             project: 'foo/bar' as RepoName,
             changeNum: 1234 as NumericChangeId,
@@ -1555,18 +1140,19 @@
             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', {
+          const queryMap = new URLSearchParams();
+          queryMap.set('tab', 'checks');
+          queryMap.set('filter', 'fff');
+          queryMap.set('select', 'sss');
+          queryMap.set('attempt', '1');
+          queryMap.set('checksRunsSelected', 'asdf,qwer');
+          queryMap.set('checksResultsFilter', 'asdf.*qwer');
+          ctx.querystring = queryMap.toString();
+          assertctxToParams(ctx, 'handleChangeRoute', {
             view: GerritView.CHANGE,
             project: 'foo/bar' as RepoName,
             changeNum: 1234 as NumericChangeId,
@@ -1574,19 +1160,15 @@
             patchNum: 7 as RevisionPatchSetNum,
             attempt: 1,
             filter: 'fff',
-            select: 'sss',
             tab: 'checks',
+            checksRunsSelected: new Set(['asdf', 'qwer']),
+            checksResultsFilter: 'asdf.*qwer',
           });
         });
       });
 
       suite('handleDiffRoute', () => {
-        let normalizeRangeStub: sinon.SinonStub;
-
-        function makeParams(
-          path: string,
-          hash: string
-        ): PageContextWithQueryMap {
+        function makeParams(path: string, hash: string): PageContext {
           return {
             ...createPageContext(),
             hash,
@@ -1605,26 +1187,12 @@
         }
 
         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', {
+          assertctxToParams(ctx, 'handleDiffRoute', {
             view: GerritView.DIFF,
             project: 'foo/bar' as RepoName,
             changeNum: 1234 as NumericChangeId,
@@ -1635,7 +1203,6 @@
             lineNum: 44,
           });
           assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeStub.called);
         });
 
         test('comment route', () => {
@@ -1646,7 +1213,7 @@
             '264833', // changeNum
             '00049681_f34fd6a9', // commentId
           ]);
-          assertDataToParams(
+          assertctxToParams(
             {params: groups!.slice(1)} as any,
             'handleCommentRoute',
             {
@@ -1667,7 +1234,7 @@
             '264833', // changeNum
             '00049681_f34fd6a9', // commentId
           ]);
-          assertDataToParams(
+          assertctxToParams(
             {params: groups!.slice(1)} as any,
             'handleCommentsRoute',
             {
@@ -1681,10 +1248,6 @@
       });
 
       test('handleDiffEditRoute', () => {
-        const normalizeRangeSpy = sinon.spy(
-          router,
-          'normalizePatchRangeParams'
-        );
         stubRestApi('setInProjectLookup');
         const ctx = {
           ...createPageContext(),
@@ -1696,28 +1259,21 @@
             3: 'foo/bar/baz', // 3 File path
           },
         };
-        const appParams: GenerateUrlEditViewParameters = {
+        const appParams: EditViewState = {
           project: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
-          view: GerritNav.View.EDIT,
+          view: GerritView.EDIT,
           path: 'foo/bar/baz',
-          patchNum: 3 as PatchSetNum,
-          lineNum: '',
+          patchNum: 3 as RevisionPatchSetNum,
+          lineNum: 0,
         };
 
         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);
+        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
       });
 
       test('handleDiffEditRoute with lineNum', () => {
-        const normalizeRangeSpy = sinon.spy(
-          router,
-          'normalizePatchRangeParams'
-        );
         stubRestApi('setInProjectLookup');
         const ctx = {
           ...createPageContext(),
@@ -1729,28 +1285,21 @@
             3: 'foo/bar/baz', // 3 File path
           },
         };
-        const appParams: GenerateUrlEditViewParameters = {
+        const appParams: EditViewState = {
           project: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
-          view: GerritNav.View.EDIT,
+          view: GerritView.EDIT,
           path: 'foo/bar/baz',
-          patchNum: 3 as PatchSetNum,
-          lineNum: '4',
+          patchNum: 3 as RevisionPatchSetNum,
+          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);
+        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
       });
 
       test('handleChangeEditRoute', () => {
-        const normalizeRangeSpy = sinon.spy(
-          router,
-          'normalizePatchRangeParams'
-        );
         stubRestApi('setInProjectLookup');
         const ctx = {
           ...createPageContext(),
@@ -1761,63 +1310,28 @@
             3: '3', // 3 Patch num
           },
         };
-        const appParams: GenerateUrlChangeViewParameters = {
+        const appParams: ChangeViewState = {
           project: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
           view: GerritView.CHANGE,
-          patchNum: 3 as PatchSetNum,
+          patchNum: 3 as RevisionPatchSetNum,
           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);
+        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
       });
     });
 
     test('handlePluginScreen', () => {
       const ctx = {...createPageContext(), params: {0: 'foo', 1: 'bar'}};
-      assertDataToParams(ctx, 'handlePluginScreen', {
-        view: GerritNav.View.PLUGIN_SCREEN,
+      assertctxToParams(ctx, 'handlePluginScreen', {
+        view: GerritView.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 7bbb93b..17edc19 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
@@ -1,29 +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 '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-icon/gr-icon';
 import {ServerInfo} from '../../../types/common';
 import {
   AutocompleteQuery,
   AutocompleteSuggestion,
   GrAutocomplete,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {getDocsBaseUrl} from '../../../utils/url-util';
 import {MergeabilityComputationBehavior} from '../../../constants/constants';
-import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {
@@ -31,11 +19,12 @@
   property,
   state,
   query as queryDec,
-} from 'lit/decorators';
-import {ShortcutController} from '../../lit/shortcut-controller';
-import {query as queryUtil} from '../../../utils/common-util';
+} from 'lit/decorators.js';
+import {Shortcut, ShortcutController} from '../../lit/shortcut-controller';
 import {assertIsDefined} from '../../../utils/common-util';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {configModelToken} from '../../../models/config/config-model';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -164,9 +153,12 @@
   @property({type: Object})
   accountSuggestions: SuggestionProvider = () => Promise.resolve([]);
 
-  @property({type: Object})
+  @state()
   serverConfig?: ServerInfo;
 
+  @state()
+  mergeabilityComputationBehavior?: MergeabilityComputationBehavior;
+
   @property({type: String})
   label = '';
 
@@ -174,22 +166,32 @@
   @state() inputVal = '';
 
   // private but used in test
-  @state() docBaseUrl: string | null = null;
+  @state() docsBaseUrl: 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);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   constructor() {
     super();
     this.query = (input: string) => this.getSearchSuggestions(input);
     this.shortcuts.addAbstract(Shortcut.SEARCH, () => this.handleSearch());
+    subscribe(
+      this,
+      () => this.getConfigModel().mergeabilityComputationBehavior$,
+      mergeabilityComputationBehavior => {
+        this.mergeabilityComputationBehavior = mergeabilityComputationBehavior;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
   }
 
   static override get styles() {
@@ -237,10 +239,7 @@
             target="_blank"
             tabindex="-1"
           >
-            <iron-icon
-              icon="gr-icons:help-outline"
-              title="read documentation"
-            ></iron-icon>
+            <gr-icon icon="help" title="read documentation"></gr-icon>
           </a>
         </gr-autocomplete>
       </form>
@@ -248,47 +247,34 @@
   }
 
   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 searchOperators() {
+    const set = new Set(SEARCH_OPERATORS_WITH_NEGATIONS_SET);
+    if (
+      this.mergeabilityComputationBehavior ===
+        MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX ||
+      this.mergeabilityComputationBehavior ===
+        MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX
+    ) {
+      set.add('is:mergeable');
+      set.add('-is:mergeable');
+    }
+    return set;
+  }
+
   // private but used in test
   computeHelpDocLink() {
     // fallback to gerrit's official doc
     let baseUrl =
-      this.docBaseUrl ||
+      this.docsBaseUrl ||
       'https://gerrit-review.googlesource.com/documentation/';
     if (baseUrl.endsWith('/')) {
       baseUrl = baseUrl.substring(0, baseUrl.length - 1);
@@ -308,18 +294,10 @@
    */
   private preventDefaultAndNavigateToInputVal(e: Event) {
     e.preventDefault();
-    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 (queryUtil(target, '#input')) {
-      queryUtil<HTMLElement>(target, '#input')!.blur();
-    } else {
-      target.blur();
-    }
     if (!this.inputVal) return;
     const trimmedInput = this.inputVal.trim();
     if (trimmedInput) {
-      const predefinedOpOnlyQuery = [...this.searchOperators].some(
+      const predefinedOpOnlyQuery = [...this.searchOperators()].some(
         op => op.endsWith(':') && op === trimmedInput
       );
       if (predefinedOpOnlyQuery) {
@@ -376,7 +354,7 @@
 
       default:
         return Promise.resolve(
-          [...this.searchOperators]
+          [...this.searchOperators()]
             .filter(operator => operator.includes(input))
             .map(operator => {
               return {text: operator};
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 dfdb187..dbb3db9 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
@@ -1,45 +1,107 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-search-bar';
 import {GrSearchBar} from './gr-search-bar';
 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 {
+  mockPromise,
+  pressKey,
+  waitUntil,
+  waitUntilObserved,
+} from '../../../test/test-utils';
 import {
   createChangeConfig,
-  createGerritInfo,
   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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {Key} from '../../../utils/dom-util';
+import {getAppContext} from '../../../services/app-context';
+import {changeModelToken} from '../../../models/change/change-model';
+import {
+  ConfigModel,
+  configModelToken,
+} from '../../../models/config/config-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-search-bar tests', () => {
   let element: GrSearchBar;
+  let configModel: ConfigModel;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    configModel = new ConfigModel(
+      testResolver(changeModelToken),
+      getAppContext().restApiService
+    );
+    const serverConfig = createServerInfo();
+    serverConfig.gerrit.doc_url = 'https://mydocumentationurl.google.com/';
+    configModel.updateServerConfig(serverConfig);
+    await waitUntilObserved(
+      configModel.docsBaseUrl$,
+      docsBaseUrl => docsBaseUrl === 'https://mydocumentationurl.google.com/'
+    );
+
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-search-bar></gr-search-bar>`,
+          configModelToken,
+          configModel
+        )
+      )
+    ).querySelector('gr-search-bar')!;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <form>
+          <gr-autocomplete
+            allow-non-suggested-values=""
+            id="searchInput"
+            multi=""
+            show-search-icon=""
+            tab-complete=""
+          >
+            <a
+              class="help"
+              href="https://mydocumentationurl.google.com/user-search.html"
+              slot="suffix"
+              tabindex="-1"
+              target="_blank"
+            >
+              <gr-icon icon="help" title="read documentation"></gr-icon>
+            </a>
+          </gr-autocomplete>
+        </form>
+      `
+    );
+  });
+
+  test('falls back to gerrit docs url', async () => {
+    const configWithoutDocsUrl = createServerInfo();
+    configWithoutDocsUrl.gerrit.doc_url = undefined;
+
+    configModel.updateServerConfig(configWithoutDocsUrl);
+    await waitUntilObserved(
+      configModel.docsBaseUrl$,
+      docsBaseUrl => docsBaseUrl === 'https://mydocumentationurl.google.com/'
+    );
     await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert<HTMLAnchorElement>(element, 'a')!.href,
+      'https://mydocumentationurl.google.com/user-search.html'
+    );
   });
 
   test('value is propagated to inputVal', async () => {
@@ -65,48 +127,22 @@
     element.value = 'test';
     await element.updateComplete;
     const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
-    MockInteractions.pressAndReleaseKeyOn(
-      queryAndAssert<HTMLInputElement>(searchInput, '#input'),
-      13,
-      null,
-      'enter'
+    pressKey(
+      queryAndAssert<PaperInputElement>(searchInput, '#input'),
+      Key.ENTER
     );
     await promise;
   });
 
-  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(
-      queryAndAssert<PaperInputElement>(
-        queryAndAssert<GrAutocomplete>(element, '#searchInput'),
-        '#input'
-      ),
-      13,
-      null,
-      'enter'
-    );
-    await waitUntil(() => blurSpy.called);
-  });
-
   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(
-      queryAndAssert<HTMLInputElement>(searchInput, '#input'),
-      13,
-      null,
-      'enter'
+    pressKey(
+      queryAndAssert<PaperInputElement>(searchInput, '#input'),
+      Key.ENTER
     );
     assert.isFalse(searchSpy.called);
   });
@@ -117,11 +153,9 @@
     element.value = 'added:';
     await element.updateComplete;
     const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
-    MockInteractions.pressAndReleaseKeyOn(
-      queryAndAssert<HTMLInputElement>(searchInput, '#input'),
-      13,
-      null,
-      'enter'
+    pressKey(
+      queryAndAssert<PaperInputElement>(searchInput, '#input'),
+      Key.ENTER
     );
     assert.isFalse(searchSpy.called);
   });
@@ -132,11 +166,9 @@
     element.value = 'age:1week';
     await element.updateComplete;
     const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
-    MockInteractions.pressAndReleaseKeyOn(
-      queryAndAssert<HTMLInputElement>(searchInput, '#input'),
-      13,
-      null,
-      'enter'
+    pressKey(
+      queryAndAssert<PaperInputElement>(searchInput, '#input'),
+      Key.ENTER
     );
     await waitUntil(() => searchSpy.called);
   });
@@ -147,11 +179,9 @@
     element.value = 'random:1week';
     await element.updateComplete;
     const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
-    MockInteractions.pressAndReleaseKeyOn(
-      queryAndAssert<HTMLInputElement>(searchInput, '#input'),
-      13,
-      null,
-      'enter'
+    pressKey(
+      queryAndAssert<PaperInputElement>(searchInput, '#input'),
+      Key.ENTER
     );
     await waitUntil(() => searchSpy.called);
   });
@@ -162,11 +192,9 @@
     element.value = 'random:';
     await element.updateComplete;
     const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
-    MockInteractions.pressAndReleaseKeyOn(
-      queryAndAssert<HTMLInputElement>(searchInput, '#input'),
-      13,
-      null,
-      'enter'
+    pressKey(
+      queryAndAssert<PaperInputElement>(searchInput, '#input'),
+      Key.ENTER
     );
     await waitUntil(() => searchSpy.called);
   });
@@ -180,22 +208,16 @@
       queryAndAssert<GrAutocomplete>(element, '#searchInput'),
       'selectAll'
     );
-    MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
+    pressKey(document.body, '/');
     assert.isTrue(focusSpy.called);
     assert.isTrue(selectAllSpy.called);
   });
 
   suite('getSearchSuggestions', () => {
     setup(async () => {
-      element = basicFixture.instantiate();
-      element.serverConfig = {
-        ...createServerInfo(),
-        change: {
-          ...createChangeConfig(),
-          mergeability_computation_behavior:
-            'NEVER' as MergeabilityComputationBehavior,
-        },
-      };
+      element = await fixture(html`<gr-search-bar></gr-search-bar>`);
+      element.mergeabilityComputationBehavior =
+        MergeabilityComputationBehavior.NEVER;
       await element.updateComplete;
     });
 
@@ -255,7 +277,7 @@
   ].forEach(mergeability => {
     suite(`mergeability as ${mergeability}`, () => {
       setup(async () => {
-        element = basicFixture.instantiate();
+        element = await fixture(html`<gr-search-bar></gr-search-bar>`);
         element.serverConfig = {
           ...createServerInfo(),
           change: {
@@ -280,28 +302,21 @@
 
   suite('doc url', () => {
     setup(async () => {
-      _testOnly_clearDocsBaseUrlCache();
-      element = basicFixture.instantiate();
-      element.serverConfig = {
-        ...createServerInfo(),
-        gerrit: {
-          ...createGerritInfo(),
-          doc_url: 'https://doc.com/',
-        },
-      };
-      await element.updateComplete;
+      element = await fixture(html`<gr-search-bar></gr-search-bar>`);
     });
 
-    test('compute help doc url with correct path', () => {
-      assert.equal(element.docBaseUrl, 'https://doc.com/');
+    test('compute help doc url with correct path', async () => {
+      element.docsBaseUrl = 'https://doc.com/';
+      await element.updateComplete;
       assert.equal(
         element.computeHelpDocLink(),
         'https://doc.com/user-search.html'
       );
     });
 
-    test('compute help doc url fallback to gerrit url', () => {
-      element.docBaseUrl = null;
+    test('compute help doc url fallback to gerrit url', async () => {
+      element.docsBaseUrl = null;
+      await element.updateComplete;
       assert.equal(
         element.computeHelpDocLink(),
         'https://gerrit-review.googlesource.com/documentation/' +
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 ed6b822..13116fd 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
@@ -1,21 +1,10 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-search-bar/gr-search-bar';
-import {GerritNav} from '../gr-navigation/gr-navigation';
+import {navigationToken} from '../gr-navigation/gr-navigation';
 import {getUserName} from '../../../utils/display-name-util';
 import {AccountInfo, ServerInfo} from '../../../types/common';
 import {
@@ -25,7 +14,11 @@
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getAppContext} from '../../../services/app-context';
 import {LitElement, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {createSearchUrl} from '../../../models/views/search';
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 const SELF_EXPRESSION = 'self';
@@ -45,7 +38,7 @@
   @property({type: String})
   searchQuery = '';
 
-  @property({type: Object})
+  @state()
   serverConfig?: ServerInfo;
 
   @property({type: String})
@@ -53,6 +46,21 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+  }
+
   override render() {
     const accountSuggestions: SuggestionProvider = (predicate, expression) =>
       this.fetchAccounts(predicate, expression);
@@ -68,7 +76,6 @@
         .projectSuggestions=${projectSuggestions}
         .groupSuggestions=${groupSuggestions}
         .accountSuggestions=${accountSuggestions}
-        .serverConfig=${this.serverConfig}
         @handle-search=${(e: CustomEvent<SearchBarHandleSearchDetail>) => {
           this.handleSearch(e);
         }}
@@ -187,9 +194,8 @@
   }
 
   private handleSearch(e: CustomEvent<SearchBarHandleSearchDetail>) {
-    const input = e.detail.inputVal;
-    if (input) {
-      GerritNav.navigateToSearchQuery(input);
-    }
+    const query = e.detail.inputVal;
+    if (!query) return;
+    this.getNavigation().setUrl(createSearchUrl({query}));
   }
 }
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 779844e..a0d49c8 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
@@ -1,34 +1,27 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-smart-search';
 import {GrSmartSearch} from './gr-smart-search';
 import {stubRestApi} from '../../../test/test-utils';
 import {EmailAddress, GroupId, UrlEncodedRepoName} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-smart-search');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-smart-search tests', () => {
   let element: GrSmartSearch;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-smart-search></gr-smart-search>`);
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ' <gr-search-bar id="search"> </gr-search-bar> '
+    );
   });
 
   test('Autocompletes accounts', () => {
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.ts b/polygerrit-ui/app/elements/custom-dark-theme_test.ts
deleted file mode 100644
index 71e0740..0000000
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.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 '../test/common-test-setup-karma';
-import {getComputedStyleValue} from '../utils/dom-util';
-import './gr-app';
-import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
-import {GrApp} from './gr-app';
-
-const basicFixture = fixtureFromElement('gr-app');
-
-suite('gr-app custom dark theme tests', () => {
-  let element: GrApp;
-  setup(async () => {
-    window.localStorage.setItem('dark-theme', 'true');
-
-    element = basicFixture.instantiate();
-    getPluginLoader().loadPlugins([]);
-    await getPluginLoader().awaitPluginsLoaded();
-    await flush();
-  });
-
-  teardown(() => {
-    window.localStorage.removeItem('dark-theme');
-    // The app sends requests to server. This can lead to
-    // unexpected gr-alert elements in document.body
-    document.body.querySelectorAll('gr-alert').forEach(grAlert => {
-      grAlert.remove();
-    });
-  });
-
-  test('should tried to load dark theme', () => {
-    assert.isTrue(!!document.head.querySelector('#dark-theme'));
-  });
-
-  test('applies the right theme', () => {
-    assert.equal(
-      getComputedStyleValue('--header-background-color', element).toLowerCase(),
-      '#3c4043'
-    );
-    assert.equal(
-      getComputedStyleValue('--footer-background-color', element).toLowerCase(),
-      '#3c4043'
-    );
-  });
-});
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.ts b/polygerrit-ui/app/elements/custom-light-theme_test.ts
deleted file mode 100644
index 80a7cab..0000000
--- a/polygerrit-ui/app/elements/custom-light-theme_test.ts
+++ /dev/null
@@ -1,70 +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';
-import {getComputedStyleValue} from '../utils/dom-util';
-import './gr-app';
-import '../styles/themes/app-theme';
-import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
-import {stubRestApi} from '../test/test-utils';
-import {GrApp} from './gr-app';
-import {
-  createAccountDetailWithId,
-  createServerInfo,
-} from '../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-app');
-
-suite('gr-app custom light theme tests', () => {
-  let element: GrApp;
-  setup(async () => {
-    window.localStorage.removeItem('dark-theme');
-    stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
-    stubRestApi('getAccount').returns(
-      Promise.resolve(createAccountDetailWithId())
-    );
-    stubRestApi('getDiffComments').returns(Promise.resolve({}));
-    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-    element = basicFixture.instantiate();
-    getPluginLoader().loadPlugins([]);
-    await getPluginLoader().awaitPluginsLoaded();
-    await flush();
-  });
-  teardown(() => {
-    // The app sends requests to server. This can lead to
-    // unexpected gr-alert elements in document.body
-    document.body.querySelectorAll('gr-alert').forEach(grAlert => {
-      grAlert.remove();
-    });
-  });
-
-  test('should not load dark theme', () => {
-    assert.isFalse(!!document.head.querySelector('#dark-theme'));
-    assert.isTrue(!!document.head.querySelector('#light-theme'));
-  });
-
-  test('applies the right theme', () => {
-    assert.equal(
-      getComputedStyleValue('--header-background-color', element).toLowerCase(),
-      '#f1f3f4'
-    );
-    assert.equal(
-      getComputedStyleValue('--footer-background-color', element).toLowerCase(),
-      'transparent'
-    );
-  });
-});
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 c264978..17d7516 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
@@ -3,33 +3,37 @@
  * 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-icon/gr-icon';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../../embed/diff/gr-diff/gr-diff';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   NumericChangeId,
-  EditPatchSetNum,
-  FixId,
+  EDIT,
   FixSuggestionInfo,
   PatchSetNum,
-  RobotId,
   BasePatchSetNum,
+  FilePathToDiffInfoMap,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {isRobot} from '../../../utils/comment-util';
+import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
 import {OpenFixPreviewEvent} from '../../../types/events';
 import {getAppContext} from '../../../services/app-context';
-import {fireCloseFixPreview, fireEvent} from '../../../utils/event-util';
+import {fireCloseFixPreview} from '../../../utils/event-util';
 import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
 import {css, html, LitElement} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {assert} from '../../../utils/common-util';
+import {resolve} from '../../../models/dependency';
+import {createChangeUrl} from '../../../models/views/change';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 
 interface FilePreview {
   filepath: string;
@@ -41,13 +45,19 @@
   @query('#applyFixOverlay')
   applyFixOverlay?: GrOverlay;
 
+  @query('#applyFixDialog')
+  applyFixDialog?: GrDialog;
+
+  /** The currently observed dialog by `dialogOberserver`. */
+  observedDialog?: GrDialog;
+
+  /** The current observer observing the `observedDialog`. */
+  dialogObserver?: ResizeObserver;
+
   @query('#nextFix')
   nextFix?: GrButton;
 
   @property({type: Object})
-  prefs?: DiffPreferencesInfo;
-
-  @property({type: Object})
   change?: ParsedChangeInfo;
 
   @property({type: Number})
@@ -57,9 +67,6 @@
   patchNum?: PatchSetNum;
 
   @state()
-  robotId?: RobotId;
-
-  @state()
   currentFix?: FixSuggestionInfo;
 
   @state()
@@ -77,27 +84,39 @@
   @state()
   layers: DiffLayer[] = [];
 
+  @state()
+  diffPrefs?: DiffPreferencesInfo;
+
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
-    // TODO Get preferences from model.
-    this.restApiService.getPreferences().then(prefs => {
-      if (!prefs?.disable_token_highlighting) {
-        this.layers = [new TokenHighlightLayer(this)];
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      preferences => {
+        if (!preferences?.disable_token_highlighting) {
+          this.layers = [new TokenHighlightLayer(this)];
+        }
       }
-    });
-    this.addEventListener('diff-context-expanded', () => {
-      if (this.applyFixOverlay) fireEvent(this.applyFixOverlay, 'iron-resize');
-    });
+    );
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      diffPreferences => {
+        if (!diffPreferences) return;
+        this.diffPrefs = diffPreferences;
+      }
+    );
   }
 
   static override styles = [
     sharedStyles,
     css`
-      gr-diff {
-        --content-width: 90vw;
-      }
       .diffContainer {
         padding: var(--spacing-l) 0;
         border-bottom: 1px solid var(--border-color);
@@ -136,11 +155,42 @@
     `;
   }
 
+  override updated() {
+    this.updateDialogObserver();
+  }
+
+  override disconnectedCallback() {
+    this.removeDialogObserver();
+    super.disconnectedCallback();
+  }
+
+  private removeDialogObserver() {
+    this.dialogObserver?.disconnect();
+    this.dialogObserver = undefined;
+    this.observedDialog = undefined;
+  }
+
+  private updateDialogObserver() {
+    if (
+      this.applyFixDialog === this.observedDialog &&
+      this.dialogObserver !== undefined
+    ) {
+      return;
+    }
+
+    this.removeDialogObserver();
+    if (!this.applyFixDialog) return;
+
+    this.observedDialog = this.applyFixDialog;
+    this.dialogObserver = new ResizeObserver(() => {
+      this.applyFixOverlay?.refit();
+    });
+    this.dialogObserver.observe(this.observedDialog);
+  }
+
   private renderHeader() {
     return html`
-      <div slot="header">
-        ${this.robotId ?? ''} - ${this.currentFix?.description ?? ''}
-      </div>
+      <div slot="header">${this.currentFix?.description ?? ''}</div>
     `;
   }
 
@@ -152,7 +202,7 @@
         </div>
         <div class="diffContainer">
           <gr-diff
-            .prefs=${this.overridePartialPrefs()}
+            .prefs=${this.overridePartialDiffPrefs()}
             .path=${item.filepath}
             .diff=${item.preview}
             .layers=${this.layers}
@@ -175,80 +225,80 @@
           @click=${this.onPrevFixClick}
           ?disabled=${id === 0}
         >
-          <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+          <gr-icon icon="chevron_left"></gr-icon>
         </gr-button>
         <gr-button
           id="nextFix"
           @click=${this.onNextFixClick}
           ?disabled=${id === fixCount - 1}
         >
-          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+          <gr-icon icon="chevron_right"></gr-icon>
         </gr-button>
       </div>
     `;
   }
 
   /**
-   * Given robot comment CustomEvent object, fetch diffs associated
-   * with first robot comment suggested fix and open dialog.
-   *
-   * @param e to be passed from gr-comment with robot comment detail.
-   * @return Promise that resolves either when all
-   * preview diffs are fetched or no fix suggestions in custom event detail.
+   * Given event with fixSuggestions, fetch diffs associated with first
+   * suggested fix and open dialog.
    */
   open(e: OpenFixPreviewEvent) {
-    const detail = e.detail;
-    const comment = detail.comment;
-    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) {
-      return Promise.resolve();
-    }
+    this.patchNum = e.detail.patchNum;
+    this.fixSuggestions = e.detail.fixSuggestions;
+    assert(this.fixSuggestions.length > 0, 'no fix in the event');
     this.selectedFixIdx = 0;
     const promises = [];
     promises.push(
       this.showSelectedFixSuggestion(this.fixSuggestions[0]),
       this.applyFixOverlay?.open()
     );
-    return Promise.all(promises).then(() => {
-      if (this.applyFixOverlay) fireEvent(this.applyFixOverlay, 'iron-resize');
-    });
   }
 
-  private showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
+  private async showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
     this.currentFix = fixSuggestion;
-    return this.fetchFixPreview(fixSuggestion.fix_id);
+    await this.fetchFixPreview(fixSuggestion);
   }
 
-  private fetchFixPreview(fixId: FixId) {
+  private async fetchFixPreview(fixSuggestion: FixSuggestionInfo) {
     if (!this.changeNum || !this.patchNum) {
       return Promise.reject(
         new Error('Both patchNum and changeNum must be set')
       );
     }
-    return this.restApiService
-      .getRobotCommentFixPreview(this.changeNum, this.patchNum, fixId)
-      .then(res => {
-        if (res) {
-          this.currentPreviews = Object.keys(res).map(key => {
-            return {filepath: key, preview: res[key]};
-          });
-        }
-      })
-      .catch(err => {
-        this.close(false);
-        throw err;
-      });
+    let res: FilePathToDiffInfoMap | undefined;
+    try {
+      if (fixSuggestion.fix_id === PROVIDED_FIX_ID) {
+        res = await this.restApiService.getFixPreview(
+          this.changeNum,
+          this.patchNum,
+          fixSuggestion.replacements
+        );
+      } else {
+        res = await this.restApiService.getRobotCommentFixPreview(
+          this.changeNum,
+          this.patchNum,
+          fixSuggestion.fix_id
+        );
+      }
+      if (res) {
+        this.currentPreviews = Object.keys(res).map(key => {
+          return {filepath: key, preview: res![key]};
+        });
+      }
+    } catch (e) {
+      this.close(false);
+      throw e;
+    }
+    return res;
   }
 
-  private overridePartialPrefs() {
-    if (!this.prefs) return undefined;
+  private overridePartialDiffPrefs() {
+    if (!this.diffPrefs) return undefined;
     // generate a smaller gr-diff than fullscreen for dialog
-    return {...this.prefs, line_length: 50};
+    return {
+      ...this.diffPrefs,
+      line_length: Math.min(this.diffPrefs.line_length, 100),
+    };
   }
 
   // visible for testing
@@ -289,18 +339,18 @@
 
   private computeTooltip() {
     if (!this.change || !this.patchNum) return '';
-    const currentPatchNum =
+    const latestPatchNum =
       this.change.revisions[this.change.current_revision]._number;
-    return currentPatchNum !== this.patchNum
+    return latestPatchNum !== this.patchNum
       ? 'Fix can only be applied to the latest patchset'
       : '';
   }
 
   private computeDisableApplyFixButton() {
     if (!this.change || !this.patchNum) return true;
-    const currentPatchNum =
+    const latestPatchNum =
       this.change.revisions[this.change.current_revision]._number;
-    return this.patchNum !== currentPatchNum || this.isApplyFixLoading;
+    return this.patchNum !== latestPatchNum || this.isApplyFixLoading;
   }
 
   // visible for testing
@@ -314,16 +364,28 @@
       throw new Error('Not all required properties are set.');
     }
     this.isApplyFixLoading = true;
-    const res = await this.restApiService.applyFixSuggestion(
-      changeNum,
-      patchNum,
-      this.currentFix.fix_id
-    );
+    let res;
+    if (this.fixSuggestions?.[0].fix_id === PROVIDED_FIX_ID) {
+      res = await this.restApiService.applyFixSuggestion(
+        changeNum,
+        patchNum,
+        this.fixSuggestions[0].replacements
+      );
+    } else {
+      res = await this.restApiService.applyRobotFixSuggestion(
+        changeNum,
+        patchNum,
+        this.currentFix.fix_id
+      );
+    }
     if (res && res.ok) {
-      GerritNav.navigateToChange(change, {
-        patchNum: EditPatchSetNum,
-        basePatchNum: patchNum as BasePatchSetNum,
-      });
+      this.getNavigation().setUrl(
+        createChangeUrl({
+          change,
+          patchNum: EDIT,
+          basePatchNum: patchNum as BasePatchSetNum,
+        })
+      );
       this.close(true);
     }
     this.isApplyFixLoading = false;
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 2c0fe0d..4d2d454 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
@@ -3,22 +3,12 @@
  * Copyright 2019 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-apply-fix-dialog';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {GrApplyFixDialog} from './gr-apply-fix-dialog';
-import {
-  BasePatchSetNum,
-  EditPatchSetNum,
-  PatchSetNum,
-  RobotCommentInfo,
-  RobotId,
-  RobotRunId,
-  Timestamp,
-  UrlEncodedCommentId,
-} from '../../../types/common';
-import {Comment} from '../../../utils/comment-util';
+import {PatchSetNum} from '../../../types/common';
 import {
   createFixSuggestionInfo,
   createParsedChange,
@@ -33,30 +23,25 @@
   OpenFixPreviewEventDetail,
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
+import {SinonStub} from 'sinon';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-apply-fix-dialog tests', () => {
   let element: GrApplyFixDialog;
+  let setUrlStub: SinonStub;
 
-  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,
-    robot_run_id: 'run_1' as RobotRunId,
-    properties: {},
-    fix_suggestions: [
+  const TWO_FIXES: OpenFixPreviewEventDetail = {
+    patchNum: 2 as PatchSetNum,
+    fixSuggestions: [
       createFixSuggestionInfo('fix_1'),
       createFixSuggestionInfo('fix_2'),
     ],
   };
 
-  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,
-    robot_run_id: 'run_1' as RobotRunId,
-    properties: {},
-    fix_suggestions: [createFixSuggestionInfo('fix_1')],
+  const ONE_FIX: OpenFixPreviewEventDetail = {
+    patchNum: 2 as PatchSetNum,
+    fixSuggestions: [createFixSuggestionInfo('fix_1')],
   };
 
   function getConfirmButton(): GrButton {
@@ -66,19 +51,17 @@
     );
   }
 
-  async function open(comment: Comment) {
-    await element.open(
+  async function open(detail: OpenFixPreviewEventDetail) {
+    element.open(
       new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-        detail: {
-          patchNum: 2 as PatchSetNum,
-          comment,
-        },
+        detail,
       })
     );
     await element.updateComplete;
   }
 
   setup(async () => {
+    setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     element = await fixture<GrApplyFixDialog>(
       html`<gr-apply-fix-dialog></gr-apply-fix-dialog>`
     );
@@ -90,7 +73,7 @@
     element.changeNum = change._number;
     element.patchNum = change.revisions[change.current_revision]._number;
     element.change = change;
-    element.prefs = {
+    element.diffPrefs = {
       ...createDefaultDiffPrefs(),
       font_size: 12,
       line_length: 100,
@@ -163,10 +146,9 @@
     });
 
     test('dialog opens fetch and sets previews', async () => {
-      await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+      await open(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'), '');
@@ -174,7 +156,7 @@
 
     test('tooltip is hidden if apply fix is loading', async () => {
       element.isApplyFixLoading = true;
-      await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+      await open(TWO_FIXES);
       const button = getConfirmButton();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.equal(button.getAttribute('title'), '');
@@ -186,7 +168,7 @@
         revisions: createRevisions(2),
         current_revision: getCurrentRevision(0),
       };
-      await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+      await open(TWO_FIXES);
       const button = getConfirmButton();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.equal(
@@ -197,12 +179,13 @@
   });
 
   test('renders', async () => {
-    await open(ROBOT_COMMENT_WITH_TWO_FIXES);
-    expect(element).shadowDom.to.equal(
+    await open(TWO_FIXES);
+    assert.shadowDom.equal(
+      element,
       /* 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="header">Fix fix_1</div>
             <div slot="main"></div>
             <div class="fix-picker" slot="footer">
               <span>Suggested fix 1 of 2</span>
@@ -213,7 +196,7 @@
                 role="button"
                 tabindex="-1"
               >
-                <iron-icon icon="gr-icons:chevron-left"> </iron-icon>
+                <gr-icon icon="chevron_left"></gr-icon>
               </gr-button>
               <gr-button
                 aria-disabled="false"
@@ -221,7 +204,7 @@
                 role="button"
                 tabindex="0"
               >
-                <iron-icon icon="gr-icons:chevron-right"> </iron-icon>
+                <gr-icon icon="chevron_right"></gr-icon>
               </gr-button>
             </div>
           </gr-dialog>
@@ -235,10 +218,10 @@
     stubRestApi('getRobotCommentFixPreview').returns(Promise.resolve({}));
     sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
 
-    await open(ROBOT_COMMENT_WITH_ONE_FIX);
+    await open(ONE_FIX);
     await element.updateComplete;
     assert.notOk(element.nextFix);
-    await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+    await open(TWO_FIXES);
     assert.ok(element.nextFix);
     assert.notOk(element.nextFix!.disabled);
   });
@@ -248,7 +231,7 @@
       Promise.reject(new Error('backend error'))
     );
     try {
-      await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+      await open(TWO_FIXES);
     } catch (error) {
       // expected
     }
@@ -256,10 +239,9 @@
   });
 
   test('apply fix button should call apply, navigate to change view and fire close', async () => {
-    const applyFixSuggestionStub = stubRestApi('applyFixSuggestion').returns(
-      Promise.resolve(new Response(null, {status: 200}))
-    );
-    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+    const applyRobotFixSuggestionStub = stubRestApi(
+      'applyRobotFixSuggestion'
+    ).returns(Promise.resolve(new Response(null, {status: 200})));
     element.currentFix = createFixSuggestionInfo('123');
 
     const closeFixPreviewEventSpy = sinon.spy();
@@ -268,18 +250,17 @@
       EventType.CLOSE_FIX_PREVIEW,
       closeFixPreviewEventSpy
     );
+
     await element.handleApplyFix(new CustomEvent('confirm'));
 
     sinon.assert.calledOnceWithExactly(
-      applyFixSuggestionStub,
+      applyRobotFixSuggestionStub,
       element.change!._number,
       2 as PatchSetNum,
       '123'
     );
-    sinon.assert.calledWithExactly(navigateToChangeStub, element.change!, {
-      patchNum: EditPatchSetNum,
-      basePatchNum: element.change!.revisions[2]._number as BasePatchSetNum,
-    });
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/2..edit');
 
     sinon.assert.calledOnceWithExactly(
       closeFixPreviewEventSpy,
@@ -289,35 +270,33 @@
         },
       })
     );
-
     // reset gr-apply-fix-dialog and close
     assert.equal(element.currentFix, undefined);
     assert.equal(element.currentPreviews.length, 0);
   });
 
   test('should not navigate to change view if incorect reponse', async () => {
-    const applyFixSuggestionStub = stubRestApi('applyFixSuggestion').returns(
-      Promise.resolve(new Response(null, {status: 500}))
-    );
-    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+    const applyRobotFixSuggestionStub = stubRestApi(
+      'applyRobotFixSuggestion'
+    ).returns(Promise.resolve(new Response(null, {status: 500})));
     element.currentFix = createFixSuggestionInfo('fix_123');
 
     await element.handleApplyFix(new CustomEvent('confirm'));
+
     sinon.assert.calledWithExactly(
-      applyFixSuggestionStub,
+      applyRobotFixSuggestionStub,
       element.change!._number,
       2 as PatchSetNum,
       'fix_123'
     );
-    assert.isTrue(navigateToChangeStub.notCalled);
-
+    assert.isFalse(setUrlStub.called);
     assert.equal(element.isApplyFixLoading, false);
   });
 
   test('select fix forward and back of multiple suggested fixes', async () => {
     sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
 
-    await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+    await open(TWO_FIXES);
     element.onNextFixClick(new CustomEvent('click'));
     assert.equal(element.currentFix!.fix_id, 'fix_2');
     element.onPrevFixClick(new CustomEvent('click'));
@@ -325,10 +304,9 @@
   });
 
   test('server-error should throw for failed apply call', async () => {
-    stubRestApi('applyFixSuggestion').returns(
+    stubRestApi('applyRobotFixSuggestion').returns(
       Promise.reject(new Error('backend error'))
     );
-    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
     element.currentFix = createFixSuggestionInfo('fix_123');
 
     const closeFixPreviewEventSpy = sinon.spy();
@@ -343,7 +321,7 @@
       expectedError = e;
     });
     assert.isOk(expectedError);
-    assert.isFalse(navigateToChangeStub.called);
+    assert.isFalse(setUrlStub.called);
     sinon.assert.notCalled(closeFixPreviewEventSpy);
   });
 
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 a08bf39..7570ac5 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
@@ -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 {
   PatchRange,
@@ -21,7 +10,7 @@
   UrlEncodedCommentId,
   PathToCommentsInfoMap,
   FileInfo,
-  ParentPatchSetNum,
+  PARENT,
   CommentInfo,
 } from '../../../types/common';
 import {
@@ -45,6 +34,7 @@
   [urlEncodedCommentId: string]: CommentThread;
 };
 
+// TODO: Move file out of elements/ directory
 export class ChangeComments {
   private readonly _comments: PathToCommentsInfoMap;
 
@@ -56,10 +46,6 @@
 
   private readonly _portedDrafts: PathToCommentsInfoMap;
 
-  /**
-   * Construct a change comments object, which can be data-bound to child
-   * elements of that which uses the gr-comment-api.
-   */
   constructor(
     comments?: PathToCommentsInfoMap,
     robotComments?: {[path: string]: RobotCommentInfo[]},
@@ -120,6 +106,8 @@
       for (const [path, comments] of Object.entries(response)) {
         // If don't care about patch range, we know that the path exists.
         if (comments.some(c => !patchRange || isInPatchRange(c, patchRange))) {
+          // TODO: Replace the CommentMap type with just an array or set. We
+          // never set the value to false.
           commentMap[path] = true;
         }
       }
@@ -324,7 +312,7 @@
 
     return createCommentThreads(allComments).filter(thread => {
       // Robot comments and drafts are not ported over. A human reply to
-      // the robot comment will be ported over, thefore it's possible to
+      // the robot comment will be ported over, therefore it's possible to
       // have the root comment of the thread not be ported, hence loop over
       // entire thread
       const portedComment = portedComments.find(portedComment =>
@@ -341,10 +329,7 @@
 
       if (thread.commentSide === CommentSide.PARENT) {
         // TODO(dhruvsri): Add handling for merge parents
-        if (
-          patchRange.basePatchNum !== ParentPatchSetNum ||
-          !!thread.mergeParentNum
-        )
+        if (patchRange.basePatchNum !== PARENT || !!thread.mergeParentNum)
           return false;
       }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_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-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
deleted file mode 100644
index 2738eb8..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ /dev/null
@@ -1,836 +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-comment-api.js';
-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} from '../../../constants/constants.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-comment-api');
-
-suite('gr-comment-api tests', () => {
-  const PARENT = 'PARENT';
-
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('_changeComment methods', () => {
-    setup(() => {
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-    });
-
-    suite('ported comments', () => {
-      let portedComments;
-      let changeComments;
-      const comment1 = {
-        ...createComment(),
-        unresolved: true,
-        id: '1',
-        line: 136,
-        patch_set: 2,
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 1,
-          end_character: 1,
-        },
-      };
-
-      const comment2 = {
-        ...createComment(),
-        patch_set: 2,
-        id: '2',
-        line: 5,
-      };
-
-      const comment3 = {
-        ...createComment(),
-        side: CommentSide.PARENT,
-        line: 10,
-        unresolved: true,
-      };
-
-      const comment4 = {
-        ...comment3,
-        parent: -2,
-      };
-
-      const draft1 = {
-        ...createDraft(),
-        id: 'db977012_e1f13828',
-        line: 4,
-        patch_set: 2,
-      };
-      const draft2 = {
-        ...createDraft(),
-        id: '503008e2_0ab203ee',
-        line: 11,
-        unresolved: true,
-        // slightly larger timestamp so it's sorted higher
-        updated: '2018-02-13 22:49:48.018000001',
-        patch_set: 2,
-      };
-
-      setup(() => {
-        portedComments = {
-          'karma.conf.js': [{
-            ...comment1,
-            patch_set: 4,
-            range: {
-              start_line: 136,
-              start_character: 16,
-              end_line: 136,
-              end_character: 29,
-            },
-          }],
-        };
-
-        changeComments = new ChangeComments(
-            {/* comments */
-              'karma.conf.js': [
-                // resolved comment that will not be ported over
-                comment2,
-                // original comment that will be ported over to patchset 4
-                comment1,
-              ],
-            },
-            {}/* robot comments */,
-            {}/* drafts */,
-            portedComments,
-            {}/* ported drafts */
-        );
-      });
-
-      test('threads containing ported comment are returned', () => {
-        assert.equal(changeComments.getAllThreadsForChange().length,
-            2);
-
-        const portedThreads = changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
-
-        assert.equal(portedThreads.length, 1);
-        // 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,
-          end_line: 136,
-          end_character: 29,
-        });
-
-        // thread ported over if comparing patchset 1 vs patchset 4
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 1}
-        ).length, 1);
-
-        // verify ported thread is not returned if original thread will be
-        // shown
-        // original thread attached to right side
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 2, basePatchNum: 'PARENT'}
-        ).length, 0);
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 2, basePatchNum: 1}
-        ).length, 0);
-
-        // original thread attached to left side
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 3, basePatchNum: 2}
-        ).length, 0);
-      });
-
-      test('threads without any ported comment are filtered out', () => {
-        changeComments = new ChangeComments(
-            {/* comments */
-              // comment that is not ported over
-              'karma.conf.js': [comment2],
-            },
-            {}/* robot comments */,
-            {/* drafts */
-              'karma.conf.js': [draft2],
-            },
-            // comment1 that is ported over but does not have any thread
-            // that has a comment that matches it
-            portedComments,
-            {}/* ported drafts */
-        );
-
-        assert.equal(createCommentThreads(changeComments
-            .getAllCommentsForPath('karma.conf.js')).length, 1);
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'}
-        ).length, 0);
-      });
-
-      test('comments with side=PARENT are ported over', () => {
-        changeComments = new ChangeComments(
-            {/* comments */
-              // comment left on Base
-              'karma.conf.js': [comment3],
-            },
-            {}/* robot comments */,
-            {/* drafts */
-              'karma.conf.js': [draft2],
-            },
-            {/* ported comments */
-              'karma.conf.js': [{
-                ...comment3,
-                line: 31,
-                patch_set: 4,
-              }],
-            },
-            {}/* ported drafts */
-        );
-
-        const portedThreads = changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
-        assert.equal(portedThreads.length, 1);
-        assert.equal(portedThreads[0].line, 31);
-
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
-        ).length, 0);
-
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 2}
-        ).length, 0);
-      });
-
-      test('comments left on merge parent is not ported over', () => {
-        changeComments = new ChangeComments(
-            {/* comments */
-              // comment left on Base
-              'karma.conf.js': [comment4],
-            },
-            {}/* robot comments */,
-            {/* drafts */
-              'karma.conf.js': [draft2],
-            },
-            {/* ported comments */
-              'karma.conf.js': [{
-                ...comment4,
-                line: 31,
-                patch_set: 4,
-              }],
-            },
-            {}/* ported drafts */
-        );
-
-        const portedThreads = changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
-        assert.equal(portedThreads.length, 0);
-
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
-        ).length, 0);
-
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 2}
-        ).length, 0);
-      });
-
-      test('ported comments contribute to comment count', () => {
-        assert.equal(changeComments.computeCommentsString(
-            {basePatchNum: 'PARENT', patchNum: 2}, 'karma.conf.js',
-            {__path: 'karma.conf.js'}), '2 comments (1 unresolved)');
-
-        // comment1 is ported over to patchset 4
-        assert.equal(changeComments.computeCommentsString(
-            {basePatchNum: 'PARENT', patchNum: 4}, 'karma.conf.js',
-            {__path: 'karma.conf.js'}), '1 comment (1 unresolved)');
-      });
-
-      test('drafts are ported over', () => {
-        changeComments = new ChangeComments(
-            {}/* comments */,
-            {}/* robotComments */,
-            {/* drafts */
-              // draft1: resolved draft that will be ported over to ps 4
-              // draft2: unresolved draft that will be ported over to ps 4
-              'karma.conf.js': [draft1, draft2],
-            },
-            {}/* ported comments */,
-            {/* ported drafts */
-              'karma.conf.js': [
-                {
-                  ...draft1,
-                  line: 5,
-                  patch_set: 4,
-                },
-                {
-                  ...draft2,
-                  line: 31,
-                  patch_set: 4,
-                },
-              ],
-            }
-        );
-
-        const portedThreads = changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
-
-        // resolved draft is ported over
-        assert.equal(portedThreads.length, 2);
-        assert.equal(portedThreads[0].line, 5);
-        assert.isTrue(isDraftThread(portedThreads[0]));
-        assert.isFalse(isUnresolved(portedThreads[0]));
-
-        // unresolved draft is ported over
-        assert.equal(portedThreads[1].line, 31);
-        assert.isTrue(isDraftThread(portedThreads[1]));
-        assert.isTrue(isUnresolved(portedThreads[1]));
-
-        assert.equal(createCommentThreads(
-            changeComments.getAllCommentsForPath('karma.conf.js'),
-            {patchNum: 4, basePatchNum: 'PARENT'}).length, 0);
-      });
-    });
-
-    test('_isInBaseOfPatchRange', () => {
-      const comment = {patch_set: 1};
-      const patchRange = {basePatchNum: 1, patchNum: 2};
-      assert.isTrue(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      patchRange.basePatchNum = PARENT;
-      assert.isFalse(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      comment.side = PARENT;
-      assert.isFalse(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      comment.patch_set = 2;
-      assert.isTrue(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      patchRange.basePatchNum = -2;
-      comment.side = PARENT;
-      comment.parent = 1;
-      assert.isFalse(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      comment.parent = 2;
-      assert.isTrue(isInBaseOfPatchRange(comment,
-          patchRange));
-    });
-
-    test('isInRevisionOfPatchRange', () => {
-      const comment = {patch_set: 123};
-      const patchRange = {basePatchNum: 122, patchNum: 124};
-      assert.isFalse(isInRevisionOfPatchRange(
-          comment, patchRange));
-
-      patchRange.patchNum = 123;
-      assert.isTrue(isInRevisionOfPatchRange(
-          comment, patchRange));
-
-      comment.side = PARENT;
-      assert.isFalse(isInRevisionOfPatchRange(
-          comment, patchRange));
-    });
-
-    suite('comment ranges and paths', () => {
-      const commentObjs = {};
-      function makeTime(mins) {
-        return `2013-02-26 15:0${mins}:43.986000000`;
-      }
-
-      setup(() => {
-        commentObjs['01'] = {
-          ...createComment(),
-          id: '01',
-          patch_set: 2,
-          path: 'file/one',
-          side: PARENT,
-          line: 1,
-          updated: makeTime(1),
-          range: {
-            start_line: 1,
-            start_character: 2,
-            end_line: 2,
-            end_character: 2,
-          },
-        };
-
-        commentObjs['02'] = {
-          ...createComment(),
-          id: '02',
-          in_reply_to: '04',
-          patch_set: 2,
-          path: 'file/one',
-          unresolved: true,
-          line: 1,
-          updated: makeTime(3),
-        };
-
-        commentObjs['03'] = {
-          ...createComment(),
-          id: '03',
-          patch_set: 2,
-          path: 'file/one',
-          side: PARENT,
-          line: 2,
-          updated: makeTime(1),
-        };
-
-        commentObjs['04'] = {
-          ...createComment(),
-          id: '04',
-          patch_set: 2,
-          path: 'file/one',
-          line: 1,
-          updated: makeTime(1),
-        };
-
-        commentObjs['05'] = {
-          ...createComment(),
-          id: '05',
-          patch_set: 2,
-          line: 2,
-          updated: makeTime(1),
-        };
-
-        commentObjs['06'] = {
-          ...createComment(),
-          id: '06',
-          patch_set: 3,
-          line: 2,
-          updated: makeTime(1),
-        };
-
-        commentObjs['07'] = {
-          ...createComment(),
-          id: '07',
-          patch_set: 2,
-          side: PARENT,
-          unresolved: false,
-          line: 1,
-          updated: makeTime(1),
-        };
-
-        commentObjs['08'] = {
-          ...createComment(),
-          id: '08',
-          patch_set: 2,
-          side: PARENT,
-          unresolved: true,
-          in_reply_to: '07',
-          line: 1,
-          updated: makeTime(1),
-        };
-
-        commentObjs['09'] = {
-          ...createComment(),
-          id: '09',
-          patch_set: 3,
-          line: 1,
-          updated: makeTime(1),
-        };
-
-        commentObjs['10'] = {
-          ...createComment(),
-          id: '10',
-          patch_set: 5,
-          side: PARENT,
-          line: 1,
-          updated: makeTime(1),
-        };
-
-        commentObjs['11'] = {
-          ...createComment(),
-          id: '11',
-          patch_set: 5,
-          line: 1,
-          updated: makeTime(1),
-        };
-
-        commentObjs['12'] = {
-          ...createDraft(),
-          id: '12',
-          patch_set: 2,
-          side: PARENT,
-          line: 1,
-          updated: makeTime(3),
-          path: 'file/one',
-        };
-
-        commentObjs['13'] = {
-          ...createDraft(),
-          id: '13',
-          in_reply_to: '04',
-          patch_set: 2,
-          line: 1,
-          // 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'] = {
-          ...createDraft(),
-          id: '14',
-          patch_set: 3,
-          line: 1,
-          path: 'file/two',
-          updated: makeTime(3),
-        };
-
-        const drafts = {
-          'file/one': [
-            commentObjs['12'],
-            commentObjs['13'],
-          ],
-          'file/two': [
-            commentObjs['14'],
-          ],
-        };
-        const robotComments = {
-          'file/one': [
-            commentObjs['01'], commentObjs['02'],
-          ],
-        };
-        const comments = {
-          'file/one': [commentObjs['03'], commentObjs['04']],
-          'file/two': [commentObjs['05'], commentObjs['06']],
-          'file/three': [commentObjs['07'], commentObjs['08'],
-            commentObjs['09']],
-          'file/four': [commentObjs['10'], commentObjs['11']],
-        };
-        element._changeComments =
-            new ChangeComments(comments, robotComments, drafts, {}, {});
-      });
-
-      test('getPaths', () => {
-        const patchRange = {basePatchNum: 1, patchNum: 4};
-        let paths = element._changeComments.getPaths(patchRange);
-        assert.equal(Object.keys(paths).length, 0);
-
-        patchRange.basePatchNum = PARENT;
-        patchRange.patchNum = 3;
-        paths = element._changeComments.getPaths(patchRange);
-        assert.notProperty(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-        assert.notProperty(paths, 'file/four');
-
-        patchRange.patchNum = 2;
-        paths = element._changeComments.getPaths(patchRange);
-        assert.property(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-        assert.notProperty(paths, 'file/four');
-
-        paths = element._changeComments.getPaths();
-        assert.property(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-        assert.property(paths, 'file/four');
-      });
-
-      test('getCommentsForPath', () => {
-        const patchRange = {basePatchNum: 1, patchNum: 3};
-        let path = 'file/one';
-        let comments = element._changeComments.getCommentsForPath(path,
-            patchRange);
-        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
-            .length, 0);
-        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
-            patchRange)).length, 0);
-
-        path = 'file/two';
-        comments = element._changeComments.getCommentsForPath(path,
-            patchRange);
-        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
-            .length, 0);
-        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
-            patchRange)).length, 2);
-
-        patchRange.basePatchNum = 2;
-        comments = element._changeComments.getCommentsForPath(path,
-            patchRange);
-        assert.equal(comments.filter(c => isInBaseOfPatchRange(c,
-            patchRange)).length, 1);
-        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
-            patchRange)).length, 2);
-
-        patchRange.basePatchNum = PARENT;
-        path = 'file/three';
-        comments = element._changeComments.getCommentsForPath(path,
-            patchRange);
-        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
-            .length, 0);
-        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
-            patchRange)).length, 1);
-      });
-
-      test('getAllCommentsForPath', () => {
-        let path = 'file/one';
-        let comments = element._changeComments.getAllCommentsForPath(path);
-        assert.equal(comments.length, 4);
-        path = 'file/two';
-        comments = element._changeComments.getAllCommentsForPath(path, 2);
-        assert.equal(comments.length, 1);
-        const aCopyOfComments = element._changeComments
-            .getAllCommentsForPath(path, 2);
-        assert.deepEqual(comments, aCopyOfComments);
-        assert.notEqual(comments[0], aCopyOfComments[0]);
-      });
-
-      test('getAllDraftsForPath', () => {
-        const path = 'file/one';
-        const drafts = element._changeComments.getAllDraftsForPath(path);
-        assert.equal(drafts.length, 2);
-      });
-
-      test('computeUnresolvedNum', () => {
-        assert.equal(element._changeComments
-            .computeUnresolvedNum({
-              patchNum: 2,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeUnresolvedNum({
-              patchNum: 1,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeUnresolvedNum({
-              patchNum: 2,
-              path: 'file/three',
-            }), 1);
-      });
-
-      test('computeUnresolvedNum w/ non-linear thread', () => {
-        const comments = {
-          path: [{
-            id: '9c6ba3c6_28b7d467',
-            patch_set: 1,
-            updated: '2018-02-28 14:41:13.000000000',
-            unresolved: true,
-          }, {
-            id: '3df7b331_0bead405',
-            patch_set: 1,
-            in_reply_to: '1c346623_ab85d14a',
-            updated: '2018-02-28 23:07:55.000000000',
-            unresolved: false,
-          }, {
-            id: '6153dce6_69958d1e',
-            patch_set: 1,
-            in_reply_to: '9c6ba3c6_28b7d467',
-            updated: '2018-02-28 17:11:31.000000000',
-            unresolved: true,
-          }, {
-            id: '1c346623_ab85d14a',
-            patch_set: 1,
-            in_reply_to: '9c6ba3c6_28b7d467',
-            updated: '2018-02-28 23:01:39.000000000',
-            unresolved: false,
-          }],
-        };
-        element._changeComments = new ChangeComments(comments, {}, {}, 1234);
-        assert.equal(
-            element._changeComments.computeUnresolvedNum(1, 'path'), 0);
-      });
-
-      test('computeCommentsString', () => {
-        const changeComments = createChangeComments();
-        const parentTo1 = {
-          basePatchNum: 'PARENT',
-          patchNum: 1,
-        };
-        const parentTo2 = {
-          basePatchNum: 'PARENT',
-          patchNum: 2,
-        };
-        const _1To2 = {
-          basePatchNum: 1,
-          patchNum: 2,
-        };
-
-        assert.equal(
-            changeComments.computeCommentsString(parentTo1, '/COMMIT_MSG',
-                {__path: '/COMMIT_MSG'}), '2 comments (1 unresolved)');
-        assert.equal(
-            changeComments.computeCommentsString(parentTo1, '/COMMIT_MSG',
-                {__path: '/COMMIT_MSG', status: 'U'}, true),
-            '2 comments (1 unresolved)(no changes)');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2, '/COMMIT_MSG',
-                {__path: '/COMMIT_MSG'}), '3 comments (1 unresolved)');
-
-        assert.equal(
-            changeComments.computeCommentsString(parentTo1, 'myfile.txt',
-                {__path: 'myfile.txt'}), '1 comment');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2, 'myfile.txt',
-                {__path: 'myfile.txt'}), '3 comments');
-
-        assert.equal(
-            changeComments.computeCommentsString(parentTo1,
-                'file_added_in_rev2.txt',
-                {__path: 'file_added_in_rev2.txt'}), '');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2,
-                'file_added_in_rev2.txt',
-                {__path: 'file_added_in_rev2.txt'}), '');
-
-        assert.equal(
-            changeComments.computeCommentsString(parentTo2, '/COMMIT_MSG',
-                {__path: '/COMMIT_MSG'}), '1 comment');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2, '/COMMIT_MSG',
-                {__path: '/COMMIT_MSG'}), '3 comments (1 unresolved)');
-
-        assert.equal(
-            changeComments.computeCommentsString(parentTo2, 'myfile.txt',
-                {__path: 'myfile.txt'}), '2 comments');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2, 'myfile.txt',
-                {__path: 'myfile.txt'}), '3 comments');
-
-        assert.equal(
-            changeComments.computeCommentsString(parentTo2,
-                'file_added_in_rev2.txt',
-                {__path: 'file_added_in_rev2.txt'}), '');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2,
-                'file_added_in_rev2.txt',
-                {__path: 'file_added_in_rev2.txt'}), '');
-        assert.equal(
-            changeComments.computeCommentsString(parentTo2, 'unresolved.file',
-                {__path: 'unresolved.file'}), '2 comments (1 unresolved)');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2, 'unresolved.file',
-                {__path: 'unresolved.file'}), '2 comments (1 unresolved)');
-      });
-
-      test('computeCommentThreadCount', () => {
-        assert.equal(element._changeComments
-            .computeCommentThreadCount({
-              patchNum: 2,
-              path: 'file/one',
-            }), 3);
-        assert.equal(element._changeComments
-            .computeCommentThreadCount({
-              patchNum: 1,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeCommentThreadCount({
-              patchNum: 2,
-              path: 'file/three',
-            }), 1);
-      });
-
-      test('computeDraftCount', () => {
-        assert.equal(element._changeComments
-            .computeDraftCount({
-              patchNum: 2,
-              path: 'file/one',
-            }), 2);
-        assert.equal(element._changeComments
-            .computeDraftCount({
-              patchNum: 1,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeDraftCount({
-              patchNum: 2,
-              path: 'file/three',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeDraftCount(), 3);
-      });
-
-      test('getAllPublishedComments', () => {
-        let publishedComments = element._changeComments
-            .getAllPublishedComments();
-        assert.equal(Object.keys(publishedComments).length, 4);
-        assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
-        assert.equal(Object.keys(publishedComments[['file/two']]).length, 2);
-        publishedComments = element._changeComments
-            .getAllPublishedComments(2);
-        assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
-        assert.equal(Object.keys(publishedComments[['file/two']]).length, 1);
-      });
-
-      test('getAllComments', () => {
-        let comments = element._changeComments.getAllComments();
-        assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments[['file/one']]).length, 4);
-        assert.equal(Object.keys(comments[['file/two']]).length, 2);
-        comments = element._changeComments.getAllComments(false, 2);
-        assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments[['file/one']]).length, 4);
-        assert.equal(Object.keys(comments[['file/two']]).length, 1);
-        // Include drafts
-        comments = element._changeComments.getAllComments(true);
-        assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments[['file/one']]).length, 6);
-        assert.equal(Object.keys(comments[['file/two']]).length, 3);
-        comments = element._changeComments.getAllComments(true, 2);
-        assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments[['file/one']]).length, 6);
-        assert.equal(Object.keys(comments[['file/two']]).length, 1);
-      });
-
-      test('computeAllThreads', () => {
-        const expectedThreads = [
-          {
-            ...createCommentThread([{...commentObjs['01'], path: 'file/one'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['03'], path: 'file/one'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['04'], path: 'file/one'},
-              {...commentObjs['02'], path: 'file/one'},
-              {...commentObjs['13'], path: 'file/one'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['05'], path: 'file/two'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['06'], path: 'file/two'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['07'], path: 'file/three'},
-              {...commentObjs['08'], path: 'file/three'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['09'], path: 'file/three'}]
-            ),
-          }, {
-            ...createCommentThread([{...commentObjs['10'], path: 'file/four'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['11'], path: 'file/four'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['12'], path: 'file/one'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['14'], path: 'file/two'}]),
-          },
-        ];
-        const threads = element._changeComments.getAllThreadsForChange();
-        assert.deepEqual(threads, expectedThreads);
-      });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
new file mode 100644
index 0000000..b5ce12883
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
@@ -0,0 +1,1043 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {ChangeComments} from './gr-comment-api';
+import {
+  isInRevisionOfPatchRange,
+  isInBaseOfPatchRange,
+  isDraftThread,
+  isUnresolved,
+  createCommentThreads,
+  DraftInfo,
+  CommentThread,
+} from '../../../utils/comment-util';
+import {
+  createDraft,
+  createComment,
+  createChangeComments,
+  createCommentThread,
+  createFileInfo,
+  createRobotComment,
+} from '../../../test/test-data-generators';
+import {CommentSide, FileInfoStatus} from '../../../constants/constants';
+import {
+  BasePatchSetNum,
+  CommentInfo,
+  PARENT,
+  PatchRange,
+  PatchSetNum,
+  PathToCommentsInfoMap,
+  RevisionPatchSetNum,
+  RobotCommentInfo,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {stubRestApi} from '../../../test/test-utils';
+import {assert} from '@open-wc/testing';
+
+suite('ChangeComments tests', () => {
+  let changeComments: ChangeComments;
+
+  suite('_changeComment methods', () => {
+    setup(() => {
+      stubRestApi('getDiffComments').resolves({});
+      stubRestApi('getDiffRobotComments').resolves({});
+      stubRestApi('getDiffDrafts').resolves({});
+    });
+
+    suite('ported comments', () => {
+      let portedComments: PathToCommentsInfoMap;
+      const comment1: CommentInfo = {
+        ...createComment(),
+        unresolved: true,
+        id: '1' as UrlEncodedCommentId,
+        line: 136,
+        patch_set: 2 as RevisionPatchSetNum,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 1,
+        },
+      };
+
+      const comment2: CommentInfo = {
+        ...createComment(),
+        patch_set: 2 as RevisionPatchSetNum,
+        id: '2' as UrlEncodedCommentId,
+        line: 5,
+      };
+
+      const comment3: CommentInfo = {
+        ...createComment(),
+        side: CommentSide.PARENT,
+        line: 10,
+        unresolved: true,
+      };
+
+      const comment4: CommentInfo = {
+        ...comment3,
+        parent: -2,
+      };
+
+      const draft1: DraftInfo = {
+        ...createDraft(),
+        id: 'db977012_e1f13828' as UrlEncodedCommentId,
+        line: 4,
+        patch_set: 2 as RevisionPatchSetNum,
+      };
+      const draft2: DraftInfo = {
+        ...createDraft(),
+        id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+        line: 11,
+        unresolved: true,
+        // slightly larger timestamp so it's sorted higher
+        updated: '2018-02-13 22:49:48.018000001' as Timestamp,
+        patch_set: 2 as RevisionPatchSetNum,
+      };
+
+      setup(() => {
+        portedComments = {
+          'karma.conf.js': [
+            {
+              ...comment1,
+              patch_set: 4 as RevisionPatchSetNum,
+              range: {
+                start_line: 136,
+                start_character: 16,
+                end_line: 136,
+                end_character: 29,
+              },
+            },
+          ],
+        };
+
+        changeComments = new ChangeComments(
+          {
+            /* comments */
+            'karma.conf.js': [
+              // resolved comment that will not be ported over
+              comment2,
+              // original comment that will be ported over to patchset 4
+              comment1,
+            ],
+          },
+          {} /* robot comments */,
+          {} /* drafts */,
+          portedComments,
+          {} /* ported drafts */
+        );
+      });
+
+      test('threads containing ported comment are returned', () => {
+        assert.equal(changeComments.getAllThreadsForChange().length, 2);
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+          {path: 'karma.conf.js'},
+          {patchNum: 4 as RevisionPatchSetNum, basePatchNum: PARENT}
+        );
+
+        assert.equal(portedThreads.length, 1);
+        // check that the location of the thread matches the ported comment
+        assert.equal(portedThreads[0].patchNum, 4 as RevisionPatchSetNum);
+        assert.deepEqual(portedThreads[0].range, {
+          start_line: 136,
+          start_character: 16,
+          end_line: 136,
+          end_character: 29,
+        });
+
+        // thread ported over if comparing patchset 1 vs patchset 4
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: 1 as BasePatchSetNum,
+            }
+          ).length,
+          1
+        );
+
+        // verify ported thread is not returned if original thread will be
+        // shown
+        // original thread attached to right side
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {patchNum: 2 as RevisionPatchSetNum, basePatchNum: PARENT}
+          ).length,
+          0
+        );
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 2 as RevisionPatchSetNum,
+              basePatchNum: 1 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+
+        // original thread attached to left side
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 3 as RevisionPatchSetNum,
+              basePatchNum: 2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+      });
+
+      test('threads without any ported comment are filtered out', () => {
+        changeComments = new ChangeComments(
+          {
+            /* comments */
+            // comment that is not ported over
+            'karma.conf.js': [comment2],
+          },
+          {} /* robot comments */,
+          {
+            /* drafts */ 'karma.conf.js': [draft2],
+          },
+          // comment1 that is ported over but does not have any thread
+          // that has a comment that matches it
+          portedComments,
+          {} /* ported drafts */
+        );
+
+        assert.equal(
+          createCommentThreads(
+            changeComments.getAllCommentsForPath('karma.conf.js')
+          ).length,
+          1
+        );
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {patchNum: 4 as RevisionPatchSetNum, basePatchNum: PARENT}
+          ).length,
+          0
+        );
+      });
+
+      test('comments with side=PARENT are ported over', () => {
+        changeComments = new ChangeComments(
+          {
+            /* comments */
+            // comment left on Base
+            'karma.conf.js': [comment3],
+          },
+          {} /* robot comments */,
+          {
+            /* drafts */ 'karma.conf.js': [draft2],
+          },
+          {
+            /* ported comments */
+            'karma.conf.js': [
+              {
+                ...comment3,
+                line: 31,
+                patch_set: 4 as RevisionPatchSetNum,
+              },
+            ],
+          },
+          {} /* ported drafts */
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+          {path: 'karma.conf.js'},
+          {patchNum: 4 as RevisionPatchSetNum, basePatchNum: PARENT}
+        );
+        assert.equal(portedThreads.length, 1);
+        assert.equal(portedThreads[0].line, 31);
+
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: -2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: 2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+      });
+
+      test('comments left on merge parent is not ported over', () => {
+        changeComments = new ChangeComments(
+          {
+            /* comments */
+            // comment left on Base
+            'karma.conf.js': [comment4],
+          },
+          {} /* robot comments */,
+          {
+            /* drafts */ 'karma.conf.js': [draft2],
+          },
+          {
+            /* ported comments */
+            'karma.conf.js': [
+              {
+                ...comment4,
+                line: 31,
+                patch_set: 4 as RevisionPatchSetNum,
+              },
+            ],
+          },
+          {} /* ported drafts */
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+          {path: 'karma.conf.js'},
+          {patchNum: 4 as RevisionPatchSetNum, basePatchNum: PARENT}
+        );
+        assert.equal(portedThreads.length, 0);
+
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: -2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: 2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+      });
+
+      test('ported comments contribute to comment count', () => {
+        const fileInfo = createFileInfo();
+        assert.equal(
+          changeComments.computeCommentsString(
+            {basePatchNum: PARENT, patchNum: 2 as RevisionPatchSetNum},
+            'karma.conf.js',
+            fileInfo
+          ),
+          '2 comments (1 unresolved)'
+        );
+
+        // comment1 is ported over to patchset 4
+        assert.equal(
+          changeComments.computeCommentsString(
+            {basePatchNum: PARENT, patchNum: 4 as RevisionPatchSetNum},
+            'karma.conf.js',
+            fileInfo
+          ),
+          '1 comment (1 unresolved)'
+        );
+      });
+
+      test('drafts are ported over', () => {
+        changeComments = new ChangeComments(
+          {} /* comments */,
+          {} /* robotComments */,
+          {
+            /* drafts */
+            // draft1: resolved draft that will be ported over to ps 4
+            // draft2: unresolved draft that will be ported over to ps 4
+            'karma.conf.js': [draft1, draft2],
+          },
+          {} /* ported comments */,
+          {
+            /* ported drafts */
+            'karma.conf.js': [
+              {
+                ...draft1,
+                line: 5,
+                patch_set: 4 as RevisionPatchSetNum,
+              },
+              {
+                ...draft2,
+                line: 31,
+                patch_set: 4 as RevisionPatchSetNum,
+              },
+            ],
+          }
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+          {path: 'karma.conf.js'},
+          {patchNum: 4 as RevisionPatchSetNum, basePatchNum: PARENT}
+        );
+
+        // resolved draft is ported over
+        assert.equal(portedThreads.length, 2);
+        assert.equal(portedThreads[0].line, 5);
+        assert.isTrue(isDraftThread(portedThreads[0]));
+        assert.isFalse(isUnresolved(portedThreads[0]));
+
+        // unresolved draft is ported over
+        assert.equal(portedThreads[1].line, 31);
+        assert.isTrue(isDraftThread(portedThreads[1]));
+        assert.isTrue(isUnresolved(portedThreads[1]));
+
+        assert.equal(
+          createCommentThreads(
+            changeComments.getAllCommentsForPath('karma.conf.js')
+          ).length,
+          0
+        );
+      });
+    });
+
+    test('_isInBaseOfPatchRange', () => {
+      const comment: {
+        patch_set?: PatchSetNum;
+        side?: CommentSide;
+        parent?: number;
+      } = {patch_set: 1 as PatchSetNum};
+      const patchRange = {
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      assert.isTrue(isInBaseOfPatchRange(comment, patchRange));
+
+      patchRange.basePatchNum = PARENT;
+      assert.isFalse(isInBaseOfPatchRange(comment, patchRange));
+
+      comment.side = CommentSide.PARENT;
+      assert.isFalse(isInBaseOfPatchRange(comment, patchRange));
+
+      comment.patch_set = 2 as PatchSetNum;
+      assert.isTrue(isInBaseOfPatchRange(comment, patchRange));
+
+      patchRange.basePatchNum = -2 as BasePatchSetNum;
+      comment.side = CommentSide.PARENT;
+      comment.parent = 1;
+      assert.isFalse(isInBaseOfPatchRange(comment, patchRange));
+
+      comment.parent = 2;
+      assert.isTrue(isInBaseOfPatchRange(comment, patchRange));
+    });
+
+    test('isInRevisionOfPatchRange', () => {
+      const comment: {
+        patch_set?: PatchSetNum;
+        side?: CommentSide;
+      } = {patch_set: 123 as PatchSetNum};
+      const patchRange: PatchRange = {
+        basePatchNum: 122 as BasePatchSetNum,
+        patchNum: 124 as RevisionPatchSetNum,
+      };
+      assert.isFalse(isInRevisionOfPatchRange(comment, patchRange));
+
+      patchRange.patchNum = 123 as RevisionPatchSetNum;
+      assert.isTrue(isInRevisionOfPatchRange(comment, patchRange));
+
+      comment.side = CommentSide.PARENT;
+      assert.isFalse(isInRevisionOfPatchRange(comment, patchRange));
+    });
+
+    suite('comment ranges and paths', () => {
+      const comments = [
+        {
+          ...createRobotComment(),
+          id: '01' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          path: 'file/one',
+          side: CommentSide.PARENT,
+          line: 1,
+          updated: makeTime(1),
+          range: {
+            start_line: 1,
+            start_character: 2,
+            end_line: 2,
+            end_character: 2,
+          },
+        },
+        {
+          ...createRobotComment(),
+          id: '02' as UrlEncodedCommentId,
+          in_reply_to: '04' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          path: 'file/one',
+          unresolved: true,
+          line: 1,
+          updated: makeTime(3),
+        },
+        {
+          ...createComment(),
+          id: '03' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          path: 'file/one',
+          side: CommentSide.PARENT,
+          line: 2,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '04' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          path: 'file/one',
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '05' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          line: 2,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '06' as UrlEncodedCommentId,
+          patch_set: 3 as RevisionPatchSetNum,
+          line: 2,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '07' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          side: CommentSide.PARENT,
+          unresolved: false,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '08' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          side: CommentSide.PARENT,
+          unresolved: true,
+          in_reply_to: '07' as UrlEncodedCommentId,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '09' as UrlEncodedCommentId,
+          patch_set: 3 as RevisionPatchSetNum,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '10' as UrlEncodedCommentId,
+          patch_set: 5 as RevisionPatchSetNum,
+          side: CommentSide.PARENT,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '11' as UrlEncodedCommentId,
+          patch_set: 5 as RevisionPatchSetNum,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createDraft(),
+          id: '12' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          side: CommentSide.PARENT,
+          line: 1,
+          updated: makeTime(3),
+          path: 'file/one',
+        },
+        {
+          ...createDraft(),
+          id: '13' as UrlEncodedCommentId,
+          in_reply_to: '04' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          line: 1,
+          // 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',
+        },
+        {
+          ...createDraft(),
+          id: '14' as UrlEncodedCommentId,
+          patch_set: 3 as RevisionPatchSetNum,
+          line: 1,
+          path: 'file/two',
+          updated: makeTime(3),
+        },
+      ] as const;
+      const drafts: {[path: string]: DraftInfo[]} = {
+        'file/one': [comments[11], comments[12]],
+        'file/two': [comments[13]],
+      };
+      const robotComments: {[path: string]: RobotCommentInfo[]} = {
+        'file/one': [comments[0], comments[1]],
+      };
+      const commentsByFile: PathToCommentsInfoMap = {
+        'file/one': [comments[2], comments[3]],
+        'file/two': [comments[4], comments[5]],
+        'file/three': [comments[6], comments[7], comments[8]],
+        'file/four': [comments[9], comments[10]],
+      };
+
+      function makeTime(mins: number) {
+        return `2013-02-26 15:0${mins}:43.986000000` as Timestamp;
+      }
+
+      setup(() => {
+        changeComments = new ChangeComments(
+          commentsByFile,
+          robotComments,
+          drafts,
+          {} /* portedComments */,
+          {} /* portedDrafts */
+        );
+      });
+
+      test('getPaths', () => {
+        const patchRange: PatchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: 4 as RevisionPatchSetNum,
+        };
+        let paths = changeComments.getPaths(patchRange);
+        assert.equal(Object.keys(paths).length, 0);
+
+        patchRange.basePatchNum = PARENT;
+        patchRange.patchNum = 3 as RevisionPatchSetNum;
+        paths = changeComments.getPaths(patchRange);
+        assert.notProperty(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.notProperty(paths, 'file/four');
+
+        patchRange.patchNum = 2 as RevisionPatchSetNum;
+        paths = changeComments.getPaths(patchRange);
+        assert.property(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.notProperty(paths, 'file/four');
+
+        paths = changeComments.getPaths();
+        assert.property(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.property(paths, 'file/four');
+      });
+
+      test('getCommentsForPath', () => {
+        const patchRange: PatchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: 3 as RevisionPatchSetNum,
+        };
+        let path = 'file/one';
+        let comments = changeComments.getCommentsForPath(path, patchRange);
+        assert.equal(
+          comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
+          0
+        );
+        assert.equal(
+          comments.filter(c => isInRevisionOfPatchRange(c, patchRange)).length,
+          0
+        );
+
+        path = 'file/two';
+        comments = changeComments.getCommentsForPath(path, patchRange);
+        assert.equal(
+          comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
+          0
+        );
+        assert.equal(
+          comments.filter(c => isInRevisionOfPatchRange(c, patchRange)).length,
+          2
+        );
+
+        patchRange.basePatchNum = 2 as BasePatchSetNum;
+        comments = changeComments.getCommentsForPath(path, patchRange);
+        assert.equal(
+          comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
+          1
+        );
+        assert.equal(
+          comments.filter(c => isInRevisionOfPatchRange(c, patchRange)).length,
+          2
+        );
+
+        patchRange.basePatchNum = PARENT;
+        path = 'file/three';
+        comments = changeComments.getCommentsForPath(path, patchRange);
+        assert.equal(
+          comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
+          0
+        );
+        assert.equal(
+          comments.filter(c => isInRevisionOfPatchRange(c, patchRange)).length,
+          1
+        );
+      });
+
+      test('getAllCommentsForPath', () => {
+        let path = 'file/one';
+        let comments = changeComments.getAllCommentsForPath(path);
+        assert.equal(comments.length, 4);
+        path = 'file/two';
+        comments = changeComments.getAllCommentsForPath(path, 2 as PatchSetNum);
+        assert.equal(comments.length, 1);
+        const aCopyOfComments = changeComments.getAllCommentsForPath(
+          path,
+          2 as PatchSetNum
+        );
+        assert.deepEqual(comments, aCopyOfComments);
+        assert.notEqual(comments[0], aCopyOfComments[0]);
+      });
+
+      test('getAllDraftsForPath', () => {
+        const path = 'file/one';
+        const drafts = changeComments.getAllDraftsForPath(path);
+        assert.equal(drafts.length, 2);
+      });
+
+      test('computeUnresolvedNum', () => {
+        assert.equal(
+          changeComments.computeUnresolvedNum({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/one',
+          }),
+          0
+        );
+        assert.equal(
+          changeComments.computeUnresolvedNum({
+            patchNum: 1 as PatchSetNum,
+            path: 'file/one',
+          }),
+          0
+        );
+        assert.equal(
+          changeComments.computeUnresolvedNum({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/three',
+          }),
+          1
+        );
+      });
+
+      test('computeUnresolvedNum w/ non-linear thread', () => {
+        const comments: PathToCommentsInfoMap = {
+          path: [
+            {
+              id: '9c6ba3c6_28b7d467' as UrlEncodedCommentId,
+              patch_set: 1 as RevisionPatchSetNum,
+              updated: '2018-02-28 14:41:13.000000000' as Timestamp,
+              unresolved: true,
+            },
+            {
+              id: '3df7b331_0bead405' as UrlEncodedCommentId,
+              patch_set: 1 as RevisionPatchSetNum,
+              in_reply_to: '1c346623_ab85d14a' as UrlEncodedCommentId,
+              updated: '2018-02-28 23:07:55.000000000' as Timestamp,
+              unresolved: false,
+            },
+            {
+              id: '6153dce6_69958d1e' as UrlEncodedCommentId,
+              patch_set: 1 as RevisionPatchSetNum,
+              in_reply_to: '9c6ba3c6_28b7d467' as UrlEncodedCommentId,
+              updated: '2018-02-28 17:11:31.000000000' as Timestamp,
+              unresolved: true,
+            },
+            {
+              id: '1c346623_ab85d14a' as UrlEncodedCommentId,
+              patch_set: 1 as RevisionPatchSetNum,
+              in_reply_to: '9c6ba3c6_28b7d467' as UrlEncodedCommentId,
+              updated: '2018-02-28 23:01:39.000000000' as Timestamp,
+              unresolved: false,
+            },
+          ],
+        };
+        changeComments = new ChangeComments(comments, {}, {}, {});
+        assert.equal(
+          changeComments.computeUnresolvedNum(
+            {patchNum: 1 as PatchSetNum},
+            true
+          ),
+          0
+        );
+      });
+
+      test('computeCommentsString', () => {
+        const changeComments = createChangeComments();
+        const parentTo1: PatchRange = {
+          basePatchNum: PARENT,
+          patchNum: 1 as RevisionPatchSetNum,
+        };
+        const parentTo2: PatchRange = {
+          basePatchNum: PARENT,
+          patchNum: 2 as RevisionPatchSetNum,
+        };
+        const _1To2: PatchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: 2 as RevisionPatchSetNum,
+        };
+        const fileInfo = createFileInfo();
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo1,
+            '/COMMIT_MSG',
+            fileInfo
+          ),
+          '2 comments (1 unresolved)'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo1,
+            '/COMMIT_MSG',
+            {...fileInfo, status: FileInfoStatus.UNMODIFIED},
+            true
+          ),
+          '2 comments (1 unresolved)(no changes)'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(_1To2, '/COMMIT_MSG', fileInfo),
+          '3 comments (1 unresolved)'
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo1,
+            'myfile.txt',
+            fileInfo
+          ),
+          '1 comment'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(_1To2, 'myfile.txt', fileInfo),
+          '3 comments'
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo1,
+            'file_added_in_rev2.txt',
+            fileInfo
+          ),
+          ''
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            _1To2,
+            'file_added_in_rev2.txt',
+            fileInfo
+          ),
+          ''
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo2,
+            '/COMMIT_MSG',
+            fileInfo
+          ),
+
+          '1 comment'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(_1To2, '/COMMIT_MSG', fileInfo),
+          '3 comments (1 unresolved)'
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo2,
+            'myfile.txt',
+            fileInfo
+          ),
+          '2 comments'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(_1To2, 'myfile.txt', fileInfo),
+          '3 comments'
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo2,
+            'file_added_in_rev2.txt',
+            fileInfo
+          ),
+          ''
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            _1To2,
+            'file_added_in_rev2.txt',
+            fileInfo
+          ),
+          ''
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo2,
+            'unresolved.file',
+            fileInfo
+          ),
+          '2 comments (1 unresolved)'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            _1To2,
+            'unresolved.file',
+            fileInfo
+          ),
+          '2 comments (1 unresolved)'
+        );
+      });
+
+      test('computeCommentThreadCount', () => {
+        assert.equal(
+          changeComments.computeCommentThreadCount({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/one',
+          }),
+          3
+        );
+        assert.equal(
+          changeComments.computeCommentThreadCount({
+            patchNum: 1 as PatchSetNum,
+            path: 'file/one',
+          }),
+          0
+        );
+        assert.equal(
+          changeComments.computeCommentThreadCount({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/three',
+          }),
+          1
+        );
+      });
+
+      test('computeDraftCount', () => {
+        assert.equal(
+          changeComments.computeDraftCount({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/one',
+          }),
+          2
+        );
+        assert.equal(
+          changeComments.computeDraftCount({
+            patchNum: 1 as PatchSetNum,
+            path: 'file/one',
+          }),
+          0
+        );
+        assert.equal(
+          changeComments.computeDraftCount({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/three',
+          }),
+          0
+        );
+        assert.equal(changeComments.computeDraftCount(), 3);
+      });
+
+      test('getAllPublishedComments', () => {
+        let publishedComments = changeComments.getAllPublishedComments();
+        assert.equal(Object.keys(publishedComments).length, 4);
+        assert.equal(Object.keys(publishedComments['file/one']).length, 4);
+        assert.equal(Object.keys(publishedComments['file/two']).length, 2);
+        publishedComments = changeComments.getAllPublishedComments(
+          2 as PatchSetNum
+        );
+        assert.equal(Object.keys(publishedComments['file/one']).length, 4);
+        assert.equal(Object.keys(publishedComments['file/two']).length, 1);
+      });
+
+      test('getAllComments', () => {
+        let comments = changeComments.getAllComments();
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments['file/one']).length, 4);
+        assert.equal(Object.keys(comments['file/two']).length, 2);
+        comments = changeComments.getAllComments(false, 2 as PatchSetNum);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments['file/one']).length, 4);
+        assert.equal(Object.keys(comments['file/two']).length, 1);
+        // Include drafts
+        comments = changeComments.getAllComments(true);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments['file/one']).length, 6);
+        assert.equal(Object.keys(comments['file/two']).length, 3);
+        comments = changeComments.getAllComments(true, 2 as PatchSetNum);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments['file/one']).length, 6);
+        assert.equal(Object.keys(comments['file/two']).length, 1);
+      });
+
+      test('computeAllThreads', () => {
+        const expectedThreads: CommentThread[] = [
+          {
+            ...createCommentThread([{...comments[0], path: 'file/one'}]),
+          },
+          {
+            ...createCommentThread([{...comments[2], path: 'file/one'}]),
+          },
+          {
+            ...createCommentThread([
+              {...comments[3], path: 'file/one'},
+              {...comments[1], path: 'file/one'},
+              {...comments[12], path: 'file/one'},
+            ]),
+          },
+          {
+            ...createCommentThread([{...comments[4], path: 'file/two'}]),
+          },
+          {
+            ...createCommentThread([{...comments[5], path: 'file/two'}]),
+          },
+          {
+            ...createCommentThread([
+              {...comments[6], path: 'file/three'},
+              {...comments[7], path: 'file/three'},
+            ]),
+          },
+          {
+            ...createCommentThread([{...comments[8], path: 'file/three'}]),
+          },
+          {
+            ...createCommentThread([{...comments[9], path: 'file/four'}]),
+          },
+          {
+            ...createCommentThread([{...comments[10], path: 'file/four'}]),
+          },
+          {
+            ...createCommentThread([{...comments[11], path: 'file/one'}]),
+          },
+          {
+            ...createCommentThread([{...comments[13], path: 'file/two'}]),
+          },
+        ];
+        const threads = changeComments.getAllThreadsForChange();
+        assert.deepEqual(threads, expectedThreads);
+      });
+    });
+  });
+});
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 f4e5835..baf89c0 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
@@ -1,29 +1,14 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-comment-thread/gr-comment-thread';
 import '../../checks/gr-diff-check-result';
 import '../../../embed/diff/gr-diff/gr-diff';
-import {htmlTemplate} from './gr-diff-host_html';
-import {
-  GerritNav,
-  GeneratedWebLink,
-} from '../../core/gr-navigation/gr-navigation';
 import {
   anyLineTooLong,
+  getDiffLength,
   getLine,
   getSide,
   SYNTAX_MAX_LINE_LENGTH,
@@ -41,7 +26,6 @@
   isInBaseOfPatchRange,
   isInRevisionOfPatchRange,
 } from '../../../utils/comment-util';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
   CommitRange,
   CoverageRange,
@@ -52,12 +36,13 @@
   Base64ImageFile,
   BlameInfo,
   ChangeInfo,
-  EditPatchSetNum,
+  EDIT,
   NumericChangeId,
-  ParentPatchSetNum,
+  PARENT,
   PatchRange,
   PatchSetNum,
   RepoName,
+  RevisionPatchSetNum,
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {
@@ -70,7 +55,6 @@
   GrDiff,
 } 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 '../../../embed/diff/gr-diff/gr-diff-line';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
@@ -81,16 +65,17 @@
   fireServerError,
   fireEvent,
   waitForEventOnce,
+  fire,
 } from '../../../utils/event-util';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {assertIsDefined} from '../../../utils/common-util';
 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 {Timing, Interaction} from '../../../constants/reporting';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {Subscription} from 'rxjs';
 import {DisplayLine, RenderPreferences} from '../../../api/diff';
-import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {resolve} from '../../../models/dependency';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {checksModelToken, RunResult} from '../../../models/checks/checks-model';
@@ -100,6 +85,16 @@
 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';
+import {html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {
+  debounceP,
+  DelayedPromise,
+  DELAYED_CANCELLATION,
+} from '../../../utils/async-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {GeneratedWebLink} from '../../../utils/weblink-util';
 
 const EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -116,15 +111,28 @@
   return !!(diff.binary && (isA || isB));
 }
 
-interface LineInfo {
+// visible for testing
+export interface LineInfo {
   beforeNumber?: LineNumber;
   afterNumber?: LineNumber;
 }
 
-export interface GrDiffHost {
-  $: {
-    diff: GrDiff;
-  };
+declare global {
+  interface HTMLElementEventMap {
+    /* prettier-ignore */
+    'render': CustomEvent;
+    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
+    'create-comment': CustomEvent<CreateCommentEventDetail>;
+    'is-blame-loaded-changed': ValueChangedEvent<boolean>;
+    'diff-changed': ValueChangedEvent<DiffInfo | undefined>;
+    'edit-weblinks-changed': ValueChangedEvent<GeneratedWebLink[] | undefined>;
+    'files-weblinks-changed': ValueChangedEvent<FilesWebLinks | undefined>;
+    'is-image-diff-changed': ValueChangedEvent<boolean>;
+    // Fired when the user selects a line (See gr-diff).
+    'line-selected': CustomEvent;
+    // Fired if being logged in is required.
+    'show-auth-required': void;
+  }
 }
 
 /**
@@ -135,22 +143,9 @@
  * specific component, while <gr-diff> is a re-usable component.
  */
 @customElement('gr-diff-host')
-export class GrDiffHost extends DIPolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the user selects a line.
-   *
-   * @event line-selected
-   */
-
-  /**
-   * Fired if being logged in is required.
-   *
-   * @event show-auth-required
-   */
+export class GrDiffHost extends LitElement {
+  @query('#diff')
+  diffElement?: GrDiff;
 
   @property({type: Number})
   changeNum?: NumericChangeId;
@@ -179,30 +174,57 @@
   @property({type: Boolean})
   displayLine = false;
 
-  @property({
-    type: Boolean,
-    computed: '_computeIsImageDiff(diff)',
-    notify: true,
-  })
-  isImageDiff?: boolean;
+  @state()
+  private _isImageDiff = false;
+
+  get isImageDiff() {
+    return this._isImageDiff;
+  }
+
+  set isImageDiff(isImageDiff: boolean) {
+    if (this._isImageDiff === isImageDiff) return;
+    this._isImageDiff = isImageDiff;
+    fire(this, 'is-image-diff-changed', {value: isImageDiff});
+  }
 
   @property({type: Object})
   commitRange?: CommitRange;
 
-  @property({type: Object, notify: true})
-  editWeblinks?: GeneratedWebLink[];
+  @state()
+  private _editWeblinks?: GeneratedWebLink[];
 
-  @property({type: Object, notify: true})
-  filesWeblinks: FilesWebLinks | {} = {};
+  get editWeblinks() {
+    return this._editWeblinks;
+  }
 
-  @property({type: Boolean, reflectToAttribute: true})
+  set editWeblinks(editWeblinks: GeneratedWebLink[] | undefined) {
+    if (this._editWeblinks === editWeblinks) return;
+    this._editWeblinks = editWeblinks;
+    fire(this, 'edit-weblinks-changed', {value: editWeblinks});
+  }
+
+  @state()
+  private _filesWeblinks?: FilesWebLinks;
+
+  get filesWeblinks() {
+    return this._filesWeblinks;
+  }
+
+  set filesWeblinks(filesWeblinks: FilesWebLinks | undefined) {
+    if (this._filesWeblinks === filesWeblinks) return;
+    this._filesWeblinks = filesWeblinks;
+    fire(this, 'files-weblinks-changed', {value: filesWeblinks});
+  }
+
+  @property({type: Boolean, reflect: true})
   override hidden = false;
 
   @property({type: Boolean})
   noRenderOnPrefsChange = false;
 
-  @property({type: Object, observer: '_threadsChanged'})
-  threads?: CommentThread[];
+  // Private but used in tests.
+  @state()
+  threads: CommentThread[] = [];
 
   @property({type: Boolean})
   lineWrapping = false;
@@ -216,88 +238,116 @@
   @property({type: Boolean})
   showLoadFailure?: boolean;
 
-  @property({
-    type: Boolean,
-    notify: true,
-    computed: '_computeIsBlameLoaded(_blame)',
-  })
-  isBlameLoaded?: boolean;
+  @state()
+  private loggedIn = false;
 
-  @property({type: Boolean})
-  _loggedIn = false;
+  // Private but used in tests.
+  @state()
+  errorMessage: string | null = null;
 
-  @property({type: String})
-  _errorMessage: string | null = null;
+  @state()
+  private baseImage?: Base64ImageFile;
 
-  @property({type: Object})
-  _baseImage: Base64ImageFile | null = null;
+  @state()
+  private revisionImage?: Base64ImageFile;
 
-  @property({type: Object})
-  _revisionImage: Base64ImageFile | null = null;
+  // Do not use, use diff instead through the getters and setters.
+  // This is not a regular @state because we need to also send the
+  // 'diff-changed' event when it is changed. And if we rely on @state
+  // then the name to look for in willUpdate/update/updated is '_diff'.
+  private _diff?: DiffInfo;
 
-  @property({type: Object, notify: true})
-  diff?: DiffInfo;
+  get diff() {
+    return this._diff;
+  }
 
-  @property({type: Object})
-  changeComments?: ChangeComments;
+  set diff(diff: DiffInfo | undefined) {
+    if (this._diff === diff) return;
+    const oldDiff = this._diff;
+    this._diff = diff;
+    this.isImageDiff = isImageDiff(this._diff);
+    fire(this, 'diff-changed', {value: this._diff});
+    this.requestUpdate('diff', oldDiff);
+  }
 
-  @property({type: Object})
-  _fetchDiffPromise: Promise<DiffInfo> | null = null;
+  @state()
+  private changeComments?: ChangeComments;
 
-  @property({type: Object})
-  _blame: BlameInfo[] | null = null;
+  @state()
+  private fetchDiffPromise: Promise<DiffInfo> | null = null;
 
-  @property({type: Array})
-  _coverageRanges: CoverageRange[] = [];
+  // Do not use, use blame instead through the getters and setters. This is not
+  // a regular @state because we need to also send the
+  // 'is-blame-loading-changed' event when it is changed. And if we rely on
+  // @state then the name to look for in willUpdate/update/updated is '_blame'.
+  private _blame: BlameInfo[] | null = null;
 
-  @property({type: String})
-  _loadedWhitespaceLevel?: IgnoreWhitespaceType;
+  @state()
+  get blame() {
+    return this._blame;
+  }
 
-  @property({type: Number, computed: '_computeParentIndex(patchRange.*)'})
-  _parentIndex: number | null = null;
+  set blame(blame: BlameInfo[] | null) {
+    if (this._blame === blame) return;
+    const oldBlame = this._blame;
+    this._blame = blame;
+    fire(this, 'is-blame-loaded-changed', {value: !!this._blame});
+    this.requestUpdate('blame', oldBlame);
+  }
 
-  @property({
-    type: Boolean,
-    computed: '_isSyntaxHighlightingEnabled(prefs.*, diff)',
-    observer: '_syntaxHighlightingEnabledChanged',
-  })
-  _syntaxHighlightingEnabled?: boolean;
+  @state()
+  private coverageRanges: CoverageRange[] = [];
 
-  @property({type: Array})
-  _layers: DiffLayer[] = [];
+  @state()
+  private loadedWhitespaceLevel?: IgnoreWhitespaceType;
 
-  @property({type: Object})
-  _renderPrefs: RenderPreferences = {
+  @state()
+  private layers: DiffLayer[] = [];
+
+  @state()
+  private renderPrefs: RenderPreferences = {
     num_lines_rendered_at_once: 128,
   };
 
+  // Debounces across multiple reload calls and ensures that waiters can
+  // wait on it whenever a reload is requested.  If more than one reload is
+  // requested within a given time-frame, the first one is canceled but will
+  // still be resolved when the second one is resolved. (and inductively, any
+  // further ones that were requested within a animation-frame).
+  private reloadPromise?: DelayedPromise<void>;
+
   private readonly getBrowserModel = resolve(this, browserModelToken);
 
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
-  private readonly flagService = getAppContext().flagsService;
-
-  private readonly reporting = getAppContext().reportingService;
+  // visible for testing
+  readonly reporting = getAppContext().reportingService;
 
   private readonly flags = getAppContext().flagsService;
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly jsAPI = getAppContext().jsApiService;
+  // visible for testing
+  readonly userModel = getAppContext().userModel;
 
-  private readonly syntaxLayer: GrSyntaxLayerWorker;
+  // visible for testing
+  readonly jsAPI = getAppContext().jsApiService;
+
+  // visible for testing
+  readonly syntaxLayer: GrSyntaxLayerWorker;
 
   private checksSubscription?: Subscription;
 
-  private subscriptions: Subscription[] = [];
+  // for DIFF_AUTOCLOSE logging purposes only
+  readonly uid = performance.now().toString(36) + Math.random().toString(36);
 
   constructor() {
     super();
     this.syntaxLayer = new GrSyntaxLayerWorker();
-    this._renderPrefs = {
-      ...this._renderPrefs,
+    this.renderPrefs = {
+      ...this.renderPrefs,
       use_lit_components: this.flags.isEnabled(
         KnownExperimentId.DIFF_RENDERING_LIT
       ),
@@ -310,51 +360,192 @@
       // change in some way, and that we should update any models we may want
       // to keep in sync.
       'create-comment',
-      e => this._handleCreateThread(e)
+      e => this.handleCreateThread(e)
     );
     this.addEventListener('diff-context-expanded', event =>
-      this._handleDiffContextExpanded(event)
+      this.handleDiffContextExpanded(event)
     );
+    subscribe(
+      this,
+      () => this.getBrowserModel().diffViewMode$,
+      diffView => (this.viewMode = diffView)
+    );
+    subscribe(
+      this,
+      () => this.userModel.loggedIn$,
+      loggedIn => (this.loggedIn = loggedIn)
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().changeComments$,
+      changeComments => {
+        this.changeComments = changeComments;
+      }
+    );
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      diffPreferences => {
+        this.prefs = diffPreferences;
+      }
+    );
+    this.logForDiffAutoClose();
   }
 
-  override ready() {
-    super.ready();
-    if (this._canReload()) {
-      this.reload();
-    }
+  // for DIFF_AUTOCLOSE logging purposes only
+  private logForDiffAutoClose() {
+    this.reporting.reportInteraction(
+      Interaction.DIFF_AUTOCLOSE_DIFF_HOST_CREATED,
+      {uid: this.uid}
+    );
+    setTimeout(() => {
+      if (!this.hasReloadBeenCalledOnce) {
+        this.reporting.reportInteraction(
+          Interaction.DIFF_AUTOCLOSE_DIFF_HOST_NOT_RENDERING,
+          {uid: this.uid}
+        );
+      }
+    }, /* 10 seconds */ 10000);
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.subscriptions.push(
-      this.getBrowserModel().diffViewMode$.subscribe(
-        diffView => (this.viewMode = diffView)
-      )
-    );
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-    });
-    this.subscriptions.push(
-      this.getCommentsModel().changeComments$.subscribe(changeComments => {
-        this.changeComments = changeComments;
-      })
-    );
     this.subscribeToChecks();
   }
 
   override disconnectedCallback() {
+    if (this.reloadPromise) {
+      this.reloadPromise.cancel();
+      this.reloadPromise = undefined;
+    }
     if (this.checksSubscription) {
       this.checksSubscription.unsubscribe();
       this.checksSubscription = undefined;
     }
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
     this.clear();
     super.disconnectedCallback();
   }
 
+  protected override willUpdate(changedProperties: PropertyValues) {
+    // Important to call as this will call render, see LitElement.
+    super.willUpdate(changedProperties);
+    if (changedProperties.has('diff')) {
+      this.isImageDiff = isImageDiff(this.diff);
+    }
+    if (
+      changedProperties.has('changeComments') ||
+      changedProperties.has('patchRange') ||
+      changedProperties.has('file')
+    ) {
+      this.threads = this.computeFileThreads(
+        this.changeComments,
+        this.patchRange,
+        this.file
+      );
+    }
+    if (
+      changedProperties.has('noRenderOnPrefsChange') ||
+      changedProperties.has('prefs') ||
+      changedProperties.has('path') ||
+      changedProperties.has('changeNum')
+    ) {
+      this.syntaxHighlightingChanged(
+        this.noRenderOnPrefsChange,
+        changedProperties.get('prefs'),
+        this.prefs,
+        this.path,
+        this.changeNum
+      );
+    }
+    if (
+      changedProperties.has('prefs') ||
+      changedProperties.has('loadedWhitespaceLevel') ||
+      changedProperties.has('noRenderOnPrefsChange') ||
+      changedProperties.has('path') ||
+      changedProperties.has('changeNum')
+    ) {
+      this.whitespaceChanged(
+        this.prefs?.ignore_whitespace,
+        this.loadedWhitespaceLevel,
+        this.noRenderOnPrefsChange,
+        this.path,
+        this.changeNum
+      );
+    }
+  }
+
+  protected override updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    // This needs to happen in updated() because it has to happen post-render as
+    // this method calls getThreadEls which inspects the DOM. Also <gr-diff>
+    // only starts observing nodes (for thread element changes) after rendering
+    // is done.
+    if (changedProperties.has('threads')) {
+      this.threadsChanged(this.threads);
+    }
+  }
+
+  async waitForReloadToRender(): Promise<void> {
+    await this.updateComplete;
+    if (this.reloadPromise) {
+      try {
+        // If we are reloading, wait for the reload to finish and then ensure
+        // that any changes are captured in another update.
+        await this.reloadPromise;
+      } catch (e: unknown) {
+        // TODO: Consider moving this logic to a helper method.
+        if (e === DELAYED_CANCELLATION) {
+          // Do nothing.
+        } else if (e instanceof Error) {
+          this.reporting.error('GrDiffHost Reload:', e);
+        } else {
+          this.reporting.error(
+            'GrDiffHost Reload:',
+            new Error('reloadPromise error'),
+            e
+          );
+        }
+      }
+      await this.updateComplete;
+    }
+  }
+
+  override render() {
+    const showNewlineWarningLeft =
+      this.hasTrailingNewlines(this.diff, true) === false;
+    const showNewlineWarningRight =
+      this.hasTrailingNewlines(this.diff, false) === false;
+    const useNewImageDiffUi = this.flags.isEnabled(
+      KnownExperimentId.NEW_IMAGE_DIFF_UI
+    );
+
+    return html` <gr-diff
+      id="diff"
+      ?hidden=${this.hidden}
+      .noAutoRender=${this.noAutoRender}
+      .path=${this.path}
+      .prefs=${this.prefs}
+      .displayLine=${this.displayLine}
+      .isImageDiff=${this.isImageDiff}
+      .noRenderOnPrefsChange=${this.noRenderOnPrefsChange}
+      .renderPrefs=${this.renderPrefs}
+      .lineWrapping=${this.lineWrapping}
+      .viewMode=${this.viewMode}
+      .lineOfInterest=${this.lineOfInterest}
+      .loggedIn=${this.loggedIn}
+      .errorMessage=${this.errorMessage}
+      .baseImage=${this.baseImage}
+      .revisionImage=${this.revisionImage}
+      .coverageRanges=${this.coverageRanges}
+      .blame=${this.blame}
+      .layers=${this.layers}
+      .diff=${this.diff}
+      .showNewlineWarningLeft=${showNewlineWarningLeft}
+      .showNewlineWarningRight=${showNewlineWarningRight}
+      .useNewImageDiffUi=${useNewImageDiffUi}
+    ></gr-diff>`;
+  }
+
   async initLayers() {
     const preferencesPromise = this.restApiService.getPreferences();
     await getPluginLoader().awaitPluginsLoaded();
@@ -362,27 +553,64 @@
     const enableTokenHighlight = !prefs?.disable_token_highlighting;
 
     assertIsDefined(this.path, 'path');
-    this._layers = this.getLayers(this.path, enableTokenHighlight);
-    this._coverageRanges = [];
+    this.layers = this.getLayers(this.path, enableTokenHighlight);
+    this.coverageRanges = [];
     // We kick off fetching the data here, but we don't return the promise,
     // so awaiting initLayers() will not wait for coverage data to be
     // completely loaded.
-    this._getCoverageData();
+    this.getCoverageData();
   }
 
   /**
    * @param shouldReportMetric indicate a new Diff Page. This is a
    * signal to report metrics event that started on location change.
    */
-  async reload(shouldReportMetric?: boolean) {
+  reload(shouldReportMetric?: boolean): Promise<void> {
+    this.reloadPromise = debounceP(
+      this.reloadPromise,
+      async () => {
+        try {
+          await this.reloadInternal(shouldReportMetric);
+          return;
+        } catch (e: unknown) {
+          if (e instanceof Error) {
+            this.reporting.error('GrDiffHost Reload:', e);
+          } else {
+            this.reporting.error(
+              'GrDiffHost Reload:',
+              new Error('reloadInternal error'),
+              e
+            );
+          }
+        } finally {
+          this.reloadPromise = undefined;
+        }
+      },
+      0
+    );
+    return this.reloadPromise;
+  }
+
+  // for DIFF_AUTOCLOSE logging purposes only
+  private reloadOngoing = false;
+
+  // for DIFF_AUTOCLOSE logging purposes only
+  private hasReloadBeenCalledOnce = false;
+
+  async reloadInternal(shouldReportMetric?: boolean) {
+    this.hasReloadBeenCalledOnce = true;
     this.reporting.time(Timing.DIFF_TOTAL);
     this.reporting.time(Timing.DIFF_LOAD);
     this.clear();
     assertIsDefined(this.path, 'path');
     assertIsDefined(this.changeNum, 'changeNum');
     this.diff = undefined;
-    this._errorMessage = null;
-    const whitespaceLevel = this._getIgnoreWhitespace();
+    this.errorMessage = null;
+    const whitespaceLevel = this.getIgnoreWhitespace();
+    if (this.reloadOngoing) {
+      this.reporting.reportInteraction(Interaction.DIFF_AUTOCLOSE_DIFF_ONGOING);
+    }
+    this.reloadOngoing = true;
 
     try {
       // We are carefully orchestrating operations that have to wait for another
@@ -391,12 +619,16 @@
       // layers and proceed to rendering. OTOH we want to fetch diffs and diff
       // assets in parallel.
       const layerPromise = this.initLayers();
-      const diff = await this._getDiff();
-      this.subscribeToChecks();
-      this._loadedWhitespaceLevel = whitespaceLevel;
-      this._reportDiff(diff);
+      const diff = await this.getDiff();
+      if (diff === undefined) {
+        this.reporting.reportInteraction(
+          Interaction.DIFF_AUTOCLOSE_DIFF_UNDEFINED
+        );
+      }
+      this.loadedWhitespaceLevel = whitespaceLevel;
+      this.reportDiff(diff);
 
-      await this._loadDiffAssets(diff);
+      await this.loadDiffAssets(diff);
       // Only now we are awaiting layers (and plugin loading), which was kicked
       // off above.
       await layerPromise;
@@ -404,14 +636,16 @@
       // Not waiting for coverage ranges intentionally as
       // plugin loading should not block the content rendering
 
-      this.editWeblinks = this._getEditWeblinks(diff);
-      this.filesWeblinks = this._getFilesWeblinks(diff);
+      this.editWeblinks = this.getEditWeblinks(diff);
+      this.filesWeblinks = this.getFilesWeblinks(diff);
       this.diff = diff;
       this.reporting.timeEnd(Timing.DIFF_LOAD, this.timingDetails());
 
       this.reporting.time(Timing.DIFF_CONTENT);
+      this.syntaxLayer.setEnabled(this.isSyntaxHighlightingEnabled());
       const syntaxLayerPromise = this.syntaxLayer.process(diff);
       await waitForEventOnce(this, 'render');
+      this.subscribeToChecks();
       this.reporting.timeEnd(Timing.DIFF_CONTENT, this.timingDetails());
 
       if (shouldReportMetric) {
@@ -425,14 +659,19 @@
       this.reporting.timeEnd(Timing.DIFF_SYNTAX, this.timingDetails());
     } catch (e: unknown) {
       if (e instanceof Response) {
-        this._handleGetDiffError(e);
+        this.handleGetDiffError(e);
       } else if (e instanceof Error) {
-        this.reporting.error(e);
+        this.reporting.error('GrDiffHost Reload:', e);
       } else {
-        this.reporting.error(new Error('reload error'), undefined, e);
+        this.reporting.error(
+          'GrDiffHost Reload:',
+          new Error('reload error'),
+          e
+        );
       }
     } finally {
       this.reporting.timeEnd(Timing.DIFF_TOTAL, this.timingDetails());
+      this.reloadOngoing = false;
     }
   }
 
@@ -461,7 +700,8 @@
       contentUnchanged,
       contentChanged,
       height:
-        this.$.diff?.shadowRoot?.querySelector('.diffContainer')?.clientHeight,
+        this.diffElement?.shadowRoot?.querySelector('.diffContainer')
+          ?.clientHeight,
     };
   }
 
@@ -478,7 +718,7 @@
 
   clear() {
     if (this.path) this.jsAPI.disposeDiffLayers(this.path);
-    this._layers = [];
+    this.layers = [];
   }
 
   /**
@@ -494,12 +734,9 @@
       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;
+    if (!path || !patchNum || patchNum === EDIT) return;
     this.checksSubscription = this.getChecksModel()
       .allResults$.pipe(
         map(results =>
@@ -517,7 +754,7 @@
   }
 
   /**
-   * Similar to _threadsChanged(), but a bit simpler. We compare the elements
+   * 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.
    */
@@ -525,6 +762,12 @@
     const idToEl = new Map<string, GrDiffCheckResult>();
     const checkEls = this.getCheckEls();
     const dontRemove = new Set<GrDiffCheckResult>();
+    let createdCount = 0;
+    let updatedCount = 0;
+    let removedCount = 0;
+    const checksCount = checks.length;
+    const checkElsCount = checkEls.length;
+    if (checksCount === 0 && checkElsCount === 0) return;
     for (const el of checkEls) {
       const id = el.result?.internalResultId;
       assertIsDefined(id, 'result.internalResultId of gr-diff-check-result');
@@ -536,16 +779,23 @@
       if (existingEl) {
         existingEl.result = check;
         dontRemove.add(existingEl);
+        updatedCount++;
       } else {
         const newEl = this.createCheckEl(check);
         dontRemove.add(newEl);
+        createdCount++;
       }
     }
     // Remove all check els that don't have a matching check anymore.
     for (const el of checkEls) {
       if (dontRemove.has(el)) continue;
       el.remove();
+      removedCount++;
     }
+    this.reporting.reportInteraction(
+      Interaction.COMMENTS_AUTOCLOSE_CHECKS_UPDATED,
+      {createdCount, updatedCount, removedCount, checksCount, checkElsCount}
+    );
   }
 
   /**
@@ -577,11 +827,12 @@
     ) {
       el.setAttribute('range', `${JSON.stringify(pointer.range)}`);
     }
-    this.$.diff.appendChild(el);
+    assertIsDefined(this.diffElement);
+    this.diffElement.appendChild(el);
     return el;
   }
 
-  _getCoverageData() {
+  private getCoverageData() {
     assertIsDefined(this.changeNum, 'changeNum');
     assertIsDefined(this.change, 'change');
     assertIsDefined(this.path, 'path');
@@ -616,8 +867,8 @@
                 return;
               }
 
-              const existingCoverageRanges = this._coverageRanges;
-              this._coverageRanges = coverageRanges;
+              const existingCoverageRanges = this.coverageRanges;
+              this.coverageRanges = coverageRanges;
 
               // Notify with existing coverage ranges in case there is some
               // existing coverage data that needs to be removed
@@ -641,72 +892,58 @@
               });
             })
             .catch(err => {
-              this.reporting.error(err);
+              this.reporting.error('GrDiffHost Coverage', err);
             });
         });
       })
       .catch(err => {
-        this.reporting.error(err);
+        this.reporting.error('GrDiffHost Coverage', err);
       });
   }
 
-  _getEditWeblinks(diff: DiffInfo) {
-    if (!this.projectName || !this.commitRange || !this.path) return undefined;
-    return GerritNav.getEditWebLinks(
-      this.projectName,
-      this.commitRange.baseCommit,
-      this.path,
-      {weblinks: diff?.edit_web_links}
-    );
-  }
-
-  @observe('changeComments', 'patchRange', 'file')
-  computeFileThreads(
+  private computeFileThreads(
     changeComments?: ChangeComments,
     patchRange?: PatchRange,
     file?: PatchSetFile
   ) {
-    if (!changeComments || !patchRange || !file) return;
-    this.threads = changeComments.getThreadsBySideForFile(file, patchRange);
+    if (!changeComments || !patchRange || !file) return this.threads;
+    return changeComments.getThreadsBySideForFile(file, patchRange);
   }
 
-  _getFilesWeblinks(diff: DiffInfo) {
-    if (!this.projectName || !this.commitRange || !this.path) return {};
+  private getEditWeblinks(diff: DiffInfo) {
+    return diff?.edit_web_links ?? [];
+  }
+
+  private getFilesWeblinks(diff: DiffInfo) {
     return {
-      meta_a: GerritNav.getFileWebLinks(
-        this.projectName,
-        this.commitRange.baseCommit,
-        this.path,
-        {weblinks: diff?.meta_a?.web_links}
-      ),
-      meta_b: GerritNav.getFileWebLinks(
-        this.projectName,
-        this.commitRange.commit,
-        this.path,
-        {weblinks: diff?.meta_b?.web_links}
-      ),
+      meta_a: diff?.meta_a?.web_links ?? [],
+      meta_b: diff?.meta_b?.web_links ?? [],
     };
   }
 
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
-    this.$.diff.cancel();
+    this.diffElement?.cancel();
   }
 
   getCursorStops() {
-    return this.$.diff.getCursorStops();
+    assertIsDefined(this.diffElement);
+    return this.diffElement.getCursorStops();
   }
 
   isRangeSelected() {
-    return this.$.diff.isRangeSelected();
+    assertIsDefined(this.diffElement);
+    return this.diffElement.isRangeSelected();
   }
 
   createRangeComment() {
-    this.$.diff.createRangeComment();
+    assertIsDefined(this.diffElement);
+    this.diffElement.createRangeComment();
   }
 
   toggleLeftDiff() {
-    this.$.diff.toggleLeftDiff();
+    assertIsDefined(this.diffElement);
+    this.diffElement.toggleLeftDiff();
   }
 
   /**
@@ -724,43 +961,38 @@
           return Promise.reject(EMPTY_BLAME);
         }
 
-        this._blame = blame;
+        this.blame = blame;
         return blame;
       });
   }
 
   clearBlame() {
-    this._blame = null;
+    this.blame = null;
   }
 
   getThreadEls(): GrCommentThread[] {
-    return Array.from(this.$.diff.querySelectorAll('gr-comment-thread'));
+    assertIsDefined(this.diffElement);
+    return Array.from(this.diffElement.querySelectorAll('gr-comment-thread'));
   }
 
   getCheckEls(): GrDiffCheckResult[] {
-    return Array.from(this.$.diff.querySelectorAll('gr-diff-check-result'));
+    return Array.from(
+      this.diffElement?.querySelectorAll('gr-diff-check-result') ?? []
+    );
   }
 
   addDraftAtLine(el: Element) {
-    this.$.diff.addDraftAtLine(el);
+    assertIsDefined(this.diffElement);
+    this.diffElement.addDraftAtLine(el);
   }
 
   clearDiffContent() {
-    this.$.diff.clearDiffContent();
+    this.diffElement?.clearDiffContent();
   }
 
   toggleAllContext() {
-    this.$.diff.toggleAllContext();
-  }
-
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  _canReload() {
-    return (
-      !!this.changeNum && !!this.patchRange && !!this.path && !this.noAutoRender
-    );
+    assertIsDefined(this.diffElement);
+    this.diffElement.toggleAllContext();
   }
 
   // TODO(milutin): Use rest-api with fetchCacheURL instead of this.
@@ -769,16 +1001,17 @@
       !!this.changeNum &&
       !!this.patchRange &&
       !!this.path &&
-      this._fetchDiffPromise === null
+      this.fetchDiffPromise === null
     ) {
-      this._fetchDiffPromise = this._getDiff();
+      this.fetchDiffPromise = this.getDiff();
     }
   }
 
-  _getDiff(): Promise<DiffInfo> {
-    if (this._fetchDiffPromise !== null) {
-      const fetchDiffPromise = this._fetchDiffPromise;
-      this._fetchDiffPromise = null;
+  // Private but used in tests.
+  getDiff(): Promise<DiffInfo> {
+    if (this.fetchDiffPromise !== null) {
+      const fetchDiffPromise = this.fetchDiffPromise;
+      this.fetchDiffPromise = null;
       return fetchDiffPromise;
     }
     // Wrap the diff request in a new promise so that the error handler
@@ -793,14 +1026,15 @@
           this.patchRange.basePatchNum,
           this.patchRange.patchNum,
           this.path,
-          this._getIgnoreWhitespace(),
+          this.getIgnoreWhitespace(),
           reject
         )
         .then(diff => resolve(diff!)); // reject is called in case of error, so we can't get undefined here
     });
   }
 
-  _handleGetDiffError(response: Response) {
+  // Private but used in tests.
+  handleGetDiffError(response: Response) {
     // Loading the diff may respond with 409 if the file is too large. In this
     // case, use a toast error..
     if (response.status === 409) {
@@ -809,7 +1043,7 @@
     }
 
     if (this.showLoadFailure) {
-      this._errorMessage = [
+      this.errorMessage = [
         'Encountered error when loading the diff:',
         response.status,
         response.statusText,
@@ -822,8 +1056,10 @@
 
   /**
    * Report info about the diff response.
+   *
+   * Private but used in tests.
    */
-  _reportDiff(diff?: DiffInfo) {
+  reportDiff(diff?: DiffInfo) {
     if (!diff || !diff.content) return;
 
     // Count the delta lines stemming from normal deltas, and from
@@ -855,7 +1091,7 @@
     // Report the due_to_rebase percentage in the "diff" category when
     // applicable.
     assertIsDefined(this.patchRange, 'patchRange');
-    if (this.patchRange.basePatchNum === 'PARENT') {
+    if (this.patchRange.basePatchNum === PARENT) {
       this.reporting.reportInteraction(EVENT_AGAINST_PARENT);
     } else if (percentRebaseDelta === 0) {
       this.reporting.reportInteraction(EVENT_ZERO_REBASE);
@@ -866,28 +1102,25 @@
     }
   }
 
-  _loadDiffAssets(diff?: DiffInfo) {
+  private loadDiffAssets(diff?: DiffInfo) {
     if (isImageDiff(diff)) {
       // diff! is justified, because isImageDiff() returns false otherwise
-      return this._getImages(diff!).then(images => {
-        this._baseImage = images.baseImage;
-        this._revisionImage = images.revisionImage;
+      return this.getImages(diff!).then(images => {
+        this.baseImage = images.baseImage ?? undefined;
+        this.revisionImage = images.revisionImage ?? undefined;
       });
     } else {
-      this._baseImage = null;
-      this._revisionImage = null;
+      this.baseImage = undefined;
+      this.revisionImage = undefined;
       return Promise.resolve();
     }
   }
 
-  _computeIsImageDiff(diff?: DiffInfo) {
-    return isImageDiff(diff);
-  }
-
-  _threadsChanged(threads: CommentThread[]) {
+  private threadsChanged(threads: CommentThread[]) {
     const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
     const unsavedThreadEls: GrCommentThread[] = [];
-    for (const threadEl of this.getThreadEls()) {
+    const threadEls = this.getThreadEls();
+    for (const threadEl of threadEls) {
       if (threadEl.rootId) {
         rootIdToThreadEl.set(threadEl.rootId, threadEl);
       } else {
@@ -896,6 +1129,13 @@
       }
     }
     const dontRemove = new Set<GrCommentThread>();
+    let createdCount = 0;
+    let updatedCount = 0;
+    let removedCount = 0;
+    const threadCount = threads.length;
+    const threadElCount = threadEls.length;
+    if (threadCount === 0 && threadElCount === 0) return;
+
     for (const thread of threads) {
       // Let's find an existing DOM element matching the thread. Normally this
       // is as simple as matching the rootIds.
@@ -933,10 +1173,12 @@
       ) {
         existingThreadEl.thread = thread;
         dontRemove.add(existingThreadEl);
+        updatedCount++;
       } else {
-        const threadEl = this._createThreadElement(thread);
-        this._attachThreadElement(threadEl);
+        const threadEl = this.createThreadElement(thread);
+        this.attachThreadElement(threadEl);
         dontRemove.add(threadEl);
+        createdCount++;
       }
     }
     // Remove all threads that are no longer existing.
@@ -946,8 +1188,13 @@
       // 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;
+      removedCount++;
       threadEl.remove();
     }
+    this.reporting.reportInteraction(
+      Interaction.COMMENTS_AUTOCLOSE_THREADS_UPDATED,
+      {createdCount, updatedCount, removedCount, threadCount, threadElCount}
+    );
     const portedThreadsCount = threads.filter(thread => thread.ported).length;
     const portedThreadsWithoutRange = threads.filter(
       thread => thread.ported && thread.rangeInfoLost
@@ -960,11 +1207,7 @@
     }
   }
 
-  _computeIsBlameLoaded(blame: BlameInfo[] | null) {
-    return !!blame;
-  }
-
-  _getImages(diff: DiffInfo) {
+  private getImages(diff: DiffInfo) {
     assertIsDefined(this.changeNum, 'changeNum');
     assertIsDefined(this.patchRange, 'patchRange');
     return this.restApiService.getImagesForDiff(
@@ -974,7 +1217,7 @@
     );
   }
 
-  _handleCreateThread(e: CustomEvent<CreateCommentEventDetail>) {
+  handleCreateThread(e: CustomEvent<CreateCommentEventDetail>) {
     if (!this.patchRange) throw Error('patch range not set');
 
     const {lineNum, side, range} = e.detail;
@@ -1003,23 +1246,24 @@
         : this.path;
     assertIsDefined(path, 'path');
 
+    const parentIndex = this.computeParentIndex();
     const newThread: CommentThread = {
       rootId: undefined,
       comments: [],
-      patchNum,
+      patchNum: patchNum as RevisionPatchSetNum,
       commentSide,
       // TODO: Maybe just compute from patchRange.base on the fly?
-      mergeParentNum: this._parentIndex ?? undefined,
+      mergeParentNum: parentIndex ?? undefined,
       path,
       line: lineNum,
       range,
     };
-    const el = this._createThreadElement(newThread);
-    this._attachThreadElement(el);
+    const el = this.createThreadElement(newThread);
+    this.attachThreadElement(el);
   }
 
   private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
-    if (!this._loggedIn) {
+    if (!this.loggedIn) {
       fireEvent(this, 'show-auth-required');
       return false;
     }
@@ -1028,10 +1272,8 @@
       return false;
     }
 
-    const isEdit = patchNum === EditPatchSetNum;
-    const isEditBase =
-      patchNum === ParentPatchSetNum &&
-      this.patchRange.patchNum === EditPatchSetNum;
+    const isEdit = patchNum === EDIT;
+    const isEditBase = patchNum === PARENT && this.patchRange.patchNum === EDIT;
 
     if (isEdit) {
       fireAlert(this, 'You cannot comment on an edit.');
@@ -1044,8 +1286,9 @@
     return true;
   }
 
-  _attachThreadElement(threadEl: Element) {
-    this.$.diff.appendChild(threadEl);
+  private attachThreadElement(threadEl: Element) {
+    assertIsDefined(this.diffElement);
+    this.diffElement.appendChild(threadEl);
   }
 
   private getDiffSide(thread: CommentThread) {
@@ -1068,7 +1311,7 @@
     return diffSide;
   }
 
-  _createThreadElement(thread: CommentThread) {
+  private createThreadElement(thread: CommentThread) {
     const diffSide = this.getDiffSide(thread);
 
     const threadEl = document.createElement('gr-comment-thread');
@@ -1088,7 +1331,8 @@
     return threadEl;
   }
 
-  _filterThreadElsForLocation(
+  // Private but used in tests.
+  filterThreadElsForLocation(
     threadEls: GrCommentThread[],
     lineInfo: LineInfo,
     side: Side
@@ -1125,75 +1369,71 @@
     );
   }
 
-  _getIgnoreWhitespace(): IgnoreWhitespaceType {
+  private getIgnoreWhitespace(): IgnoreWhitespaceType {
     if (!this.prefs || !this.prefs.ignore_whitespace) {
       return 'IGNORE_NONE';
     }
     return this.prefs.ignore_whitespace;
   }
 
-  @observe(
-    'prefs.ignore_whitespace',
-    '_loadedWhitespaceLevel',
-    'noRenderOnPrefsChange'
-  )
-  _whitespaceChanged(
-    preferredWhitespaceLevel?: IgnoreWhitespaceType,
-    loadedWhitespaceLevel?: IgnoreWhitespaceType,
-    noRenderOnPrefsChange?: boolean
-  ) {
+  private whitespaceChanged(
+    preferredWhitespaceLevel: IgnoreWhitespaceType | undefined,
+    loadedWhitespaceLevel: IgnoreWhitespaceType | undefined,
+    noRenderOnPrefsChange: boolean | undefined,
+    path: string | undefined,
+    changeNum: NumericChangeId | undefined
+  ): void | Promise<void> {
     if (preferredWhitespaceLevel === undefined) return;
     if (loadedWhitespaceLevel === undefined) return;
     if (noRenderOnPrefsChange === undefined) return;
+    if (path === undefined) return;
+    if (changeNum === undefined) return;
 
-    this._fetchDiffPromise = null;
+    this.fetchDiffPromise = null;
     if (
       preferredWhitespaceLevel !== loadedWhitespaceLevel &&
       !noRenderOnPrefsChange
     ) {
-      this.reload();
+      this.reporting.reportInteraction(
+        Interaction.DIFF_AUTOCLOSE_RELOAD_ON_WHITESPACE
+      );
+      return this.reload();
     }
   }
 
-  @observe('noRenderOnPrefsChange', 'prefs.*')
-  _syntaxHighlightingChanged(
-    noRenderOnPrefsChange?: boolean,
-    prefsChangeRecord?: PolymerDeepPropertyChange<
-      DiffPreferencesInfo,
-      DiffPreferencesInfo
-    >
-  ) {
+  private syntaxHighlightingChanged(
+    noRenderOnPrefsChange: boolean | undefined,
+    oldPrefs: DiffPreferencesInfo | undefined,
+    prefs: DiffPreferencesInfo | undefined,
+    path: string | undefined,
+    changeNum: NumericChangeId | undefined
+  ): void | Promise<void> {
     if (noRenderOnPrefsChange === undefined) return;
-    if (prefsChangeRecord === undefined) return;
-    if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') return;
+    if (prefs === undefined) return;
+    if (path === undefined) return;
+    if (changeNum === undefined) return;
+    if (oldPrefs?.syntax_highlighting === prefs.syntax_highlighting) return;
 
-    if (!noRenderOnPrefsChange) this.reload();
+    if (!noRenderOnPrefsChange) {
+      this.reporting.reportInteraction(
+        Interaction.DIFF_AUTOCLOSE_RELOAD_ON_SYNTAX
+      );
+      return this.reload();
+    }
   }
 
-  _computeParentIndex(
-    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
-  ) {
-    if (!patchRangeRecord.base) return null;
-    return isMergeParent(patchRangeRecord.base.basePatchNum)
-      ? getParentIndex(patchRangeRecord.base.basePatchNum)
+  private computeParentIndex() {
+    if (!this.patchRange) return null;
+    return isMergeParent(this.patchRange.basePatchNum)
+      ? getParentIndex(this.patchRange.basePatchNum)
       : null;
   }
 
-  _syntaxHighlightingEnabledChanged(_syntaxHighlightingEnabled: boolean) {
-    this.syntaxLayer.setEnabled(_syntaxHighlightingEnabled);
-  }
-
-  _isSyntaxHighlightingEnabled(
-    preferenceChangeRecord?: PolymerDeepPropertyChange<
-      DiffPreferencesInfo,
-      DiffPreferencesInfo
-    >,
-    diff?: DiffInfo
-  ) {
-    if (!preferenceChangeRecord?.base?.syntax_highlighting || !diff) {
+  private isSyntaxHighlightingEnabled() {
+    if (!this.prefs?.syntax_highlighting || !this.diff) {
       return false;
     }
-    if (anyLineTooLong(diff)) {
+    if (anyLineTooLong(this.diff)) {
       fireAlert(
         this,
         `Files with line longer than ${SYNTAX_MAX_LINE_LENGTH} characters` +
@@ -1201,7 +1441,8 @@
       );
       return false;
     }
-    if (this.$.diff.getDiffLength(diff) > CODE_MAX_LINES) {
+    assertIsDefined(this.diffElement);
+    if (getDiffLength(this.diff) > CODE_MAX_LINES) {
       fireAlert(
         this,
         `Files with more than ${CODE_MAX_LINES} lines` +
@@ -1212,7 +1453,9 @@
     return true;
   }
 
-  _handleDiffContextExpanded(e: CustomEvent<DiffContextExpandedEventDetail>) {
+  private handleDiffContextExpanded(
+    e: CustomEvent<DiffContextExpandedEventDetail>
+  ) {
     this.reporting.reportInteraction('diff-context-expanded', {
       numLines: e.detail.numLines,
     });
@@ -1225,8 +1468,10 @@
    * false if testing the revision.
    * @return returns the chunk object or null if there was
    * no chunk for that side.
+   *
+   * Private but used in tests.
    */
-  _lastChunkForSide(diff: DiffInfo | undefined, leftSide: boolean) {
+  lastChunkForSide(diff: DiffInfo | undefined, leftSide: boolean) {
     if (!diff?.content.length) {
       return null;
     }
@@ -1265,9 +1510,11 @@
    * @return Return true if the side has a trailing newline.
    * Return false if it doesn't. Return null if not applicable (for
    * example, if the diff has no content on the specified side).
+   *
+   * Private but used in tests.
    */
-  _hasTrailingNewlines(diff: DiffInfo | undefined, leftSide: boolean) {
-    const chunk = this._lastChunkForSide(diff, leftSide);
+  hasTrailingNewlines(diff: DiffInfo | undefined, leftSide: boolean) {
+    const chunk = this.lastChunkForSide(diff, leftSide);
     if (!chunk) return null;
     let lines;
     if (chunk.ab) {
@@ -1278,18 +1525,6 @@
     if (!lines) return null;
     return lines[lines.length - 1] === '';
   }
-
-  _showNewlineWarningLeft(diff?: DiffInfo) {
-    return this._hasTrailingNewlines(diff, true) === false;
-  }
-
-  _showNewlineWarningRight(diff?: DiffInfo) {
-    return this._hasTrailingNewlines(diff, false) === false;
-  }
-
-  _useNewImageDiffUi() {
-    return this.flags.isEnabled(KnownExperimentId.NEW_IMAGE_DIFF_UI);
-  }
 }
 
 declare global {
@@ -1297,15 +1532,3 @@
     'gr-diff-host': GrDiffHost;
   }
 }
-
-// TODO(TS): Be more specific than CustomEvent, which has detail:any.
-declare global {
-  interface HTMLElementEventMap {
-    /* prettier-ignore */
-    'render': CustomEvent;
-    'normalize-range': CustomEvent;
-    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
-    'create-comment': 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
deleted file mode 100644
index 93b6157..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
+++ /dev/null
@@ -1,46 +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-diff
-    id="diff"
-    no-auto-render="[[noAutoRender]]"
-    path="[[path]]"
-    prefs="[[prefs]]"
-    display-line="[[displayLine]]"
-    is-image-diff="[[isImageDiff]]"
-    hidden$="[[hidden]]"
-    no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
-    render-prefs="[[_renderPrefs]]"
-    line-wrapping="[[lineWrapping]]"
-    view-mode="[[viewMode]]"
-    line-of-interest="[[lineOfInterest]]"
-    logged-in="[[_loggedIn]]"
-    error-message="[[_errorMessage]]"
-    base-image="[[_baseImage]]"
-    revision-image="[[_revisionImage]]"
-    coverage-ranges="[[_coverageRanges]]"
-    blame="[[_blame]]"
-    layers="[[_layers]]"
-    diff="[[diff]]"
-    show-newline-warning-left="[[_showNewlineWarningLeft(diff)]]"
-    show-newline-warning-right="[[_showNewlineWarningRight(diff)]]"
-    use-new-image-diff-ui="[[_useNewImageDiffUi()]]"
-  >
-  </gr-diff>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
deleted file mode 100644
index 6f8b1a4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ /dev/null
@@ -1,1633 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-diff-host.js';
-
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {createDefaultDiffPrefs, Side} from '../../../constants/constants.js';
-import {createChange, createComment, createCommentThread} from '../../../test/test-data-generators.js';
-import {addListenerForTest, mockPromise, stubRestApi} from '../../../test/test-utils.js';
-import {EditPatchSetNum, ParentPatchSetNum} from '../../../types/common.js';
-import {CoverageType} from '../../../types/types.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {GrDiffBuilderImage} from '../../../embed/diff/gr-diff-builder/gr-diff-builder-image.js';
-
-const basicFixture = fixtureFromElement('gr-diff-host');
-
-suite('gr-diff-host tests', () => {
-  let element;
-
-  let loggedIn;
-
-  setup(async () => {
-    loggedIn = false;
-    stubRestApi('getLoggedIn').callsFake(() => Promise.resolve(loggedIn));
-    element = basicFixture.instantiate();
-    element.changeNum = 123;
-    element.path = 'some/path';
-    sinon.stub(element.reporting, 'time');
-    sinon.stub(element.reporting, 'timeEnd');
-    await flush();
-  });
-
-  suite('plugin layers', () => {
-    const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
-    setup(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element.jsAPI, 'getDiffLayers').returns(pluginLayers);
-      element.changeNum = 123;
-      element.path = 'some/path';
-    });
-    test('plugin layers requested', async () => {
-      element.patchRange = {};
-      element.change = createChange();
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      await element.reload();
-      assert(element.jsAPI.getDiffLayers.called);
-    });
-  });
-
-  suite('render reporting', () => {
-    test('ends total and syntax timer after syntax layer', async () => {
-      sinon.stub(element.reporting, 'diffViewContentDisplayed');
-      let notifySyntaxProcessed;
-      sinon.stub(element.syntaxLayer, 'process').returns(
-          new Promise(resolve => {
-            notifySyntaxProcessed = resolve;
-          })
-      );
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      element.patchRange = {};
-      element.change = createChange();
-      element.prefs = createDefaultDiffPrefs();
-      element.reload(true);
-      // Multiple cascading microtasks are scheduled.
-      await flush();
-      notifySyntaxProcessed();
-      // Multiple cascading microtasks are scheduled.
-      await flush();
-      const calls = element.reporting.timeEnd.getCalls();
-      assert.equal(calls.length, 4);
-      assert.equal(calls[0].args[0], 'Diff Load Render');
-      assert.equal(calls[1].args[0], 'Diff Content Render');
-      assert.equal(calls[2].args[0], 'Diff Syntax Render');
-      assert.equal(calls[3].args[0], 'Diff Total Render');
-      assert.isTrue(element.reporting.diffViewContentDisplayed.called);
-    });
-
-    test('completes reload promise after syntax layer processing', async () => {
-      let notifySyntaxProcessed;
-      sinon.stub(element.syntaxLayer, 'process').returns(new Promise(
-          resolve => {
-            notifySyntaxProcessed = resolve;
-          }));
-      stubRestApi('getDiff').returns(
-          Promise.resolve({content: []}));
-      element.patchRange = {};
-      element.change = createChange();
-      let reloadComplete = false;
-      element.prefs = createDefaultDiffPrefs();
-      element.reload().then(() => {
-        reloadComplete = true;
-      });
-      // Multiple cascading microtasks are scheduled.
-      await flush();
-      assert.isFalse(reloadComplete);
-      notifySyntaxProcessed();
-      // Assert after the notification task is processed.
-      await flush();
-      assert.isTrue(reloadComplete);
-    });
-  });
-
-  test('reload() cancels before network resolves', () => {
-    const cancelStub = sinon.stub(element.$.diff, 'cancel');
-
-    // Stub the network calls into requests that never resolve.
-    sinon.stub(element, '_getDiff').callsFake(() => new Promise(() => {}));
-    element.patchRange = {};
-    element.change = createChange();
-
-    // Needs to be set to something first for it to cancel.
-    element.diff = {
-      content: [{
-        a: ['foo'],
-      }],
-    };
-
-    element.reload();
-    assert.isTrue(cancelStub.called);
-  });
-
-  test('reload() loads files weblinks', async () => {
-    element.change = createChange();
-    const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
-        .returns({name: 'stubb', url: '#s'});
-    stubRestApi('getDiff').returns(Promise.resolve({
-      content: [],
-    }));
-    element.projectName = 'test-project';
-    element.path = 'test-path';
-    element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
-    element.patchRange = {};
-
-    await element.reload();
-
-    assert.equal(weblinksStub.callCount, 3);
-    assert.deepEqual(weblinksStub.firstCall.args[0], {
-      commit: 'test-base',
-      file: 'test-path',
-      options: {
-        weblinks: undefined,
-      },
-      repo: 'test-project',
-      type: GerritNav.WeblinkType.EDIT});
-    assert.deepEqual(element.editWeblinks, [{
-      name: 'stubb', url: '#s',
-    }]);
-    assert.deepEqual(weblinksStub.secondCall.args[0], {
-      commit: 'test-base',
-      file: 'test-path',
-      options: {
-        weblinks: undefined,
-      },
-      repo: 'test-project',
-      type: GerritNav.WeblinkType.FILE});
-    assert.deepEqual(weblinksStub.thirdCall.args[0], {
-      commit: 'test-commit',
-      file: 'test-path',
-      options: {
-        weblinks: undefined,
-      },
-      repo: 'test-project',
-      type: GerritNav.WeblinkType.FILE});
-    assert.deepEqual(element.filesWeblinks, {
-      meta_a: [{name: 'stubb', url: '#s'}],
-      meta_b: [{name: 'stubb', url: '#s'}],
-    });
-  });
-
-  test('prefetch getDiff', async () => {
-    const diffRestApiStub = stubRestApi('getDiff')
-        .returns(Promise.resolve({content: []}));
-    element.changeNum = 123;
-    element.patchRange = {basePatchNum: 1, patchNum: 2};
-    element.path = 'file.txt';
-    element.prefetchDiff();
-    await element._getDiff();
-    assert.isTrue(diffRestApiStub.calledOnce);
-  });
-
-  test('_getDiff handles null diff responses', async () => {
-    stubRestApi('getDiff').returns(Promise.resolve(null));
-    element.changeNum = 123;
-    element.patchRange = {basePatchNum: 1, patchNum: 2};
-    element.path = 'file.txt';
-    await element._getDiff();
-  });
-
-  test('reload resolves on error', () => {
-    const onErrStub = sinon.stub(element, '_handleGetDiffError');
-    const error = new Response(null, {ok: false, status: 500});
-    stubRestApi('getDiff').callsFake(
-        (changeNum, basePatchNum, patchNum, path, whitespace, onErr) => {
-          onErr(error);
-        });
-    element.patchRange = {};
-    return element.reload().then(() => {
-      assert.isTrue(onErrStub.calledOnce);
-    });
-  });
-
-  suite('_handleGetDiffError', () => {
-    let serverErrorStub;
-    let pageErrorStub;
-
-    setup(() => {
-      serverErrorStub = sinon.stub();
-      addListenerForTest(document, 'server-error', serverErrorStub);
-      pageErrorStub = sinon.stub();
-      addListenerForTest(document, 'page-error', pageErrorStub);
-    });
-
-    test('page error on HTTP-409', () => {
-      element._handleGetDiffError({status: 409});
-      assert.isTrue(serverErrorStub.calledOnce);
-      assert.isFalse(pageErrorStub.called);
-      assert.isNotOk(element._errorMessage);
-    });
-
-    test('server error on non-HTTP-409', () => {
-      element._handleGetDiffError({
-        status: 500,
-        text: () => Promise.resolve(''),
-      });
-      assert.isFalse(serverErrorStub.called);
-      assert.isTrue(pageErrorStub.calledOnce);
-      assert.isNotOk(element._errorMessage);
-    });
-
-    test('error message if showLoadFailure', () => {
-      element.showLoadFailure = true;
-      element._handleGetDiffError({status: 500, statusText: 'Failure!'});
-      assert.isFalse(serverErrorStub.called);
-      assert.isFalse(pageErrorStub.called);
-      assert.equal(element._errorMessage,
-          'Encountered error when loading the diff: 500 Failure!');
-    });
-  });
-
-  suite('image diffs', () => {
-    let mockFile1;
-    let mockFile2;
-    setup(() => {
-      mockFile1 = {
-        body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-        'wsAAAAAAAAAAAAAAAAA/w==',
-        type: 'image/bmp',
-      };
-      mockFile2 = {
-        body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-        'wsAAAAAAAAAAAAA/////w==',
-        type: 'image/bmp',
-      };
-
-      element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-      element.change = createChange();
-      element.comments = {
-        left: [],
-        right: [],
-        meta: {patchRange: element.patchRange},
-      };
-    });
-
-    test('renders image diffs with same file name', async () => {
-      const mockDiff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        intraline_status: 'OK',
-        change_type: 'MODIFIED',
-        diff_header: [
-          'diff --git a/carrot.jpg b/carrot.jpg',
-          'index 2adc47d..f9c2f2c 100644',
-          '--- a/carrot.jpg',
-          '+++ b/carrot.jpg',
-          'Binary files differ',
-        ],
-        content: [{skip: 66}],
-        binary: true,
-      };
-      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
-      stubRestApi('getImagesForDiff').returns(Promise.resolve({
-        baseImage: {
-          ...mockFile1,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot.jpg',
-        },
-        revisionImage: {
-          ...mockFile2,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot.jpg',
-        },
-      }));
-
-      const promise = mockPromise();
-      const rendered = () => {
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        // Left image rendered with the parent commit's version of the file.
-        const leftImage =
-            element.$.diff.$.diffTable.querySelector('td.left img');
-        const leftLabel =
-            element.$.diff.$.diffTable.querySelector('td.left label');
-        const leftLabelContent = leftLabel.querySelector('.label');
-        const leftLabelName = leftLabel.querySelector('.name');
-
-        const rightImage =
-            element.$.diff.$.diffTable.querySelector('td.right img');
-        const rightLabel = element.$.diff.$.diffTable.querySelector(
-            'td.right label');
-        const rightLabelContent = rightLabel.querySelector('.label');
-        const rightLabelName = rightLabel.querySelector('.name');
-
-        assert.isNotOk(rightLabelName);
-        assert.isNotOk(leftLabelName);
-
-        let leftLoaded = false;
-        let rightLoaded = false;
-
-        leftImage.addEventListener('load', () => {
-          assert.isOk(leftImage);
-          assert.equal(leftImage.getAttribute('src'),
-              'data:image/bmp;base64,' + mockFile1.body);
-          assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-          leftLoaded = true;
-          if (rightLoaded) {
-            element.removeEventListener('render', rendered);
-            promise.resolve();
-          }
-        });
-
-        rightImage.addEventListener('load', () => {
-          assert.isOk(rightImage);
-          assert.equal(rightImage.getAttribute('src'),
-              'data:image/bmp;base64,' + mockFile2.body);
-          assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-          rightLoaded = true;
-          if (leftLoaded) {
-            element.removeEventListener('render', rendered);
-            promise.resolve();
-          }
-        });
-      };
-
-      element.addEventListener('render', rendered);
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-
-    test('renders image diffs with a different file name', async () => {
-      const mockDiff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-        meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        intraline_status: 'OK',
-        change_type: 'MODIFIED',
-        diff_header: [
-          'diff --git a/carrot.jpg b/carrot2.jpg',
-          'index 2adc47d..f9c2f2c 100644',
-          '--- a/carrot.jpg',
-          '+++ b/carrot2.jpg',
-          'Binary files differ',
-        ],
-        content: [{skip: 66}],
-        binary: true,
-      };
-      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
-      stubRestApi('getImagesForDiff').returns(Promise.resolve({
-        baseImage: {
-          ...mockFile1,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot.jpg',
-        },
-        revisionImage: {
-          ...mockFile2,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot2.jpg',
-        },
-      }));
-
-      const promise = mockPromise();
-      const rendered = () => {
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        // Left image rendered with the parent commit's version of the file.
-        const leftImage =
-            element.$.diff.$.diffTable.querySelector('td.left img');
-        const leftLabel =
-            element.$.diff.$.diffTable.querySelector('td.left label');
-        const leftLabelContent = leftLabel.querySelector('.label');
-        const leftLabelName = leftLabel.querySelector('.name');
-
-        const rightImage =
-            element.$.diff.$.diffTable.querySelector('td.right img');
-        const rightLabel = element.$.diff.$.diffTable.querySelector(
-            'td.right label');
-        const rightLabelContent = rightLabel.querySelector('.label');
-        const rightLabelName = rightLabel.querySelector('.name');
-
-        assert.isOk(rightLabelName);
-        assert.isOk(leftLabelName);
-        assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-        assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-        let leftLoaded = false;
-        let rightLoaded = false;
-
-        leftImage.addEventListener('load', () => {
-          assert.isOk(leftImage);
-          assert.equal(leftImage.getAttribute('src'),
-              'data:image/bmp;base64,' + mockFile1.body);
-          assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-          leftLoaded = true;
-          if (rightLoaded) {
-            element.removeEventListener('render', rendered);
-            promise.resolve();
-          }
-        });
-
-        rightImage.addEventListener('load', () => {
-          assert.isOk(rightImage);
-          assert.equal(rightImage.getAttribute('src'),
-              'data:image/bmp;base64,' + mockFile2.body);
-          assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-          rightLoaded = true;
-          if (leftLoaded) {
-            element.removeEventListener('render', rendered);
-            promise.resolve();
-          }
-        });
-      };
-
-      element.addEventListener('render', rendered);
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-
-    test('renders added image', async () => {
-      const mockDiff = {
-        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        intraline_status: 'OK',
-        change_type: 'ADDED',
-        diff_header: [
-          'diff --git a/carrot.jpg b/carrot.jpg',
-          'index 0000000..f9c2f2c 100644',
-          '--- /dev/null',
-          '+++ b/carrot.jpg',
-          'Binary files differ',
-        ],
-        content: [{skip: 66}],
-        binary: true,
-      };
-      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
-      stubRestApi('getImagesForDiff').returns(Promise.resolve({
-        baseImage: null,
-        revisionImage: {
-          ...mockFile2,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot2.jpg',
-        },
-      }));
-
-      const promise = mockPromise();
-      element.addEventListener('render', () => {
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        const leftImage =
-            element.$.diff.$.diffTable.querySelector('td.left img');
-        const rightImage =
-            element.$.diff.$.diffTable.querySelector('td.right img');
-
-        assert.isNotOk(leftImage);
-        assert.isOk(rightImage);
-        promise.resolve();
-      });
-
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-
-    test('renders removed image', async () => {
-      const mockDiff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        intraline_status: 'OK',
-        change_type: 'DELETED',
-        diff_header: [
-          'diff --git a/carrot.jpg b/carrot.jpg',
-          'index f9c2f2c..0000000 100644',
-          '--- a/carrot.jpg',
-          '+++ /dev/null',
-          'Binary files differ',
-        ],
-        content: [{skip: 66}],
-        binary: true,
-      };
-      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
-      stubRestApi('getImagesForDiff').returns(Promise.resolve({
-        baseImage: {
-          ...mockFile1,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot.jpg',
-        },
-        revisionImage: null,
-      }));
-
-      const promise = mockPromise();
-      element.addEventListener('render', () => {
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        const leftImage =
-            element.$.diff.$.diffTable.querySelector('td.left img');
-        const rightImage =
-            element.$.diff.$.diffTable.querySelector('td.right img');
-
-        assert.isOk(leftImage);
-        assert.isNotOk(rightImage);
-        promise.resolve();
-      });
-
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-
-    test('does not render disallowed image type', async () => {
-      const mockDiff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-          lines: 560},
-        intraline_status: 'OK',
-        change_type: 'DELETED',
-        diff_header: [
-          'diff --git a/carrot.jpg b/carrot.jpg',
-          'index f9c2f2c..0000000 100644',
-          '--- a/carrot.jpg',
-          '+++ /dev/null',
-          'Binary files differ',
-        ],
-        content: [{skip: 66}],
-        binary: true,
-      };
-      mockFile1.type = 'image/jpeg-evil';
-
-      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
-      stubRestApi('getImagesForDiff').returns(Promise.resolve({
-        baseImage: {
-          ...mockFile1,
-          _expectedType: 'image/jpeg',
-          _name: 'carrot.jpg',
-        },
-        revisionImage: null,
-      }));
-
-      const promise = mockPromise();
-      element.addEventListener('render', () => {
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-        const leftImage =
-            element.$.diff.$.diffTable.querySelector('td.left img');
-        assert.isNotOk(leftImage);
-        promise.resolve();
-      });
-
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-  });
-
-  test('cannot create comments when not logged in', () => {
-    element.patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 2,
-    };
-    const showAuthRequireSpy = sinon.spy();
-    element.addEventListener('show-auth-required', showAuthRequireSpy);
-
-    element.dispatchEvent(new CustomEvent('create-comment', {
-      detail: {
-        lineNum: 3,
-        side: Side.LEFT,
-        path: '/p',
-      },
-    }));
-
-    const threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
-
-    assert.equal(threads.length, 0);
-
-    assert.isTrue(showAuthRequireSpy.called);
-  });
-
-  test('delegates cancel()', () => {
-    const stub = sinon.stub(element.$.diff, 'cancel');
-    element.patchRange = {};
-    element.cancel();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates getCursorStops()', () => {
-    const returnValue = [document.createElement('b')];
-    const stub = sinon.stub(element.$.diff, 'getCursorStops')
-        .returns(returnValue);
-    assert.equal(element.getCursorStops(), returnValue);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates isRangeSelected()', () => {
-    const returnValue = true;
-    const stub = sinon.stub(element.$.diff, 'isRangeSelected')
-        .returns(returnValue);
-    assert.equal(element.isRangeSelected(), returnValue);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates toggleLeftDiff()', () => {
-    const stub = sinon.stub(element.$.diff, 'toggleLeftDiff');
-    element.toggleLeftDiff();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  suite('blame', () => {
-    setup(async () => {
-      element = basicFixture.instantiate();
-      element.changeNum = 123;
-      element.path = 'some/path';
-      await flush();
-    });
-
-    test('clearBlame', () => {
-      element._blame = [];
-      const setBlameSpy = sinon.spy(element.$.diff.$.diffBuilder, 'setBlame');
-      element.clearBlame();
-      assert.isNull(element._blame);
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
-      assert.equal(element.isBlameLoaded, false);
-    });
-
-    test('loadBlame', () => {
-      const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-      const showAlertStub = sinon.stub();
-      element.addEventListener('show-alert', showAlertStub);
-      const getBlameStub = stubRestApi('getBlame')
-          .returns(Promise.resolve(mockBlame));
-      element.changeNum = 42;
-      element.patchRange = {patchNum: 5, basePatchNum: 4};
-      element.path = 'foo/bar.baz';
-      return element.loadBlame().then(() => {
-        assert.isTrue(getBlameStub.calledWithExactly(
-            42, 5, 'foo/bar.baz', true));
-        assert.isFalse(showAlertStub.called);
-        assert.equal(element._blame, mockBlame);
-        assert.equal(element.isBlameLoaded, true);
-      });
-    });
-
-    test('loadBlame empty', () => {
-      const mockBlame = [];
-      const showAlertStub = sinon.stub();
-      element.addEventListener('show-alert', showAlertStub);
-      stubRestApi('getBlame')
-          .returns(Promise.resolve(mockBlame));
-      element.changeNum = 42;
-      element.patchRange = {patchNum: 5, basePatchNum: 4};
-      element.path = 'foo/bar.baz';
-      return element.loadBlame()
-          .then(() => {
-            assert.isTrue(false, 'Promise should not resolve');
-          })
-          .catch(() => {
-            assert.isTrue(showAlertStub.calledOnce);
-            assert.isNull(element._blame);
-            assert.equal(element.isBlameLoaded, false);
-          });
-    });
-  });
-
-  test('getThreadEls() returns .comment-threads', () => {
-    const threadEl = document.createElement('gr-comment-thread');
-    threadEl.className = 'comment-thread';
-    element.$.diff.appendChild(threadEl);
-    assert.deepEqual(element.getThreadEls(), [threadEl]);
-  });
-
-  test('delegates addDraftAtLine(el)', () => {
-    const param0 = document.createElement('b');
-    const stub = sinon.stub(element.$.diff, 'addDraftAtLine');
-    element.addDraftAtLine(param0);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 1);
-    assert.equal(stub.lastCall.args[0], param0);
-  });
-
-  test('delegates clearDiffContent()', () => {
-    const stub = sinon.stub(element.$.diff, 'clearDiffContent');
-    element.clearDiffContent();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates toggleAllContext()', () => {
-    const stub = sinon.stub(element.$.diff, 'toggleAllContext');
-    element.toggleAllContext();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('passes in noAutoRender', () => {
-    const value = true;
-    element.noAutoRender = value;
-    assert.equal(element.$.diff.noAutoRender, value);
-  });
-
-  test('passes in path', () => {
-    const value = 'some/file/path';
-    element.path = value;
-    assert.equal(element.$.diff.path, value);
-  });
-
-  test('passes in prefs', () => {
-    const value = {};
-    element.prefs = value;
-    assert.equal(element.$.diff.prefs, value);
-  });
-
-  test('passes in displayLine', () => {
-    const value = true;
-    element.displayLine = value;
-    assert.equal(element.$.diff.displayLine, value);
-  });
-
-  test('passes in hidden', () => {
-    const value = true;
-    element.hidden = value;
-    assert.equal(element.$.diff.hidden, value);
-    assert.isNotNull(element.getAttribute('hidden'));
-  });
-
-  test('passes in noRenderOnPrefsChange', () => {
-    const value = true;
-    element.noRenderOnPrefsChange = value;
-    assert.equal(element.$.diff.noRenderOnPrefsChange, value);
-  });
-
-  test('passes in lineWrapping', () => {
-    const value = true;
-    element.lineWrapping = value;
-    assert.equal(element.$.diff.lineWrapping, value);
-  });
-
-  test('passes in viewMode', () => {
-    const value = 'SIDE_BY_SIDE';
-    element.viewMode = value;
-    assert.equal(element.$.diff.viewMode, value);
-  });
-
-  test('passes in lineOfInterest', () => {
-    const value = {lineNum: 123, side: Side.LEFT};
-    element.lineOfInterest = value;
-    assert.equal(element.$.diff.lineOfInterest, value);
-  });
-
-  suite('_reportDiff', () => {
-    let reportStub;
-
-    setup(async () => {
-      element = basicFixture.instantiate();
-      element.changeNum = 123;
-      element.path = 'file.txt';
-      element.patchRange = {basePatchNum: 1};
-      reportStub = sinon.stub(element.reporting, 'reportInteraction');
-      await flush();
-    });
-
-    test('null and content-less', () => {
-      element._reportDiff(null);
-      assert.isFalse(reportStub.called);
-
-      element._reportDiff({});
-      assert.isFalse(reportStub.called);
-    });
-
-    test('diff w/ no delta', () => {
-      const diff = {
-        content: [
-          {ab: ['foo', 'bar']},
-          {ab: ['baz', 'foo']},
-        ],
-      };
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-      assert.isUndefined(reportStub.lastCall.args[1]);
-    });
-
-    test('diff w/ no rebase delta', () => {
-      const diff = {
-        content: [
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo']},
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo'], b: ['bar', 'baz']},
-          {ab: ['foo', 'bar']},
-          {b: ['baz', 'foo']},
-          {ab: ['foo', 'bar']},
-        ],
-      };
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-      assert.isUndefined(reportStub.lastCall.args[1]);
-    });
-
-    test('diff w/ some rebase delta', () => {
-      const diff = {
-        content: [
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo'], due_to_rebase: true},
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo'], b: ['bar', 'baz']},
-          {ab: ['foo', 'bar']},
-          {b: ['baz', 'foo'], due_to_rebase: true},
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo']},
-        ],
-      };
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.isTrue(reportStub.calledWith(
-          'rebase-percent-nonzero',
-          {percentRebaseDelta: 50}
-      ));
-    });
-
-    test('diff w/ all rebase delta', () => {
-      const diff = {content: [{
-        a: ['foo', 'bar'],
-        b: ['baz', 'foo'],
-        due_to_rebase: true,
-      }]};
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.isTrue(reportStub.calledWith(
-          'rebase-percent-nonzero',
-          {percentRebaseDelta: 100}
-      ));
-    });
-
-    test('diff against parent event', () => {
-      element.patchRange.basePatchNum = 'PARENT';
-      const diff = {content: [{
-        a: ['foo', 'bar'],
-        b: ['baz', 'foo'],
-      }]};
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
-      assert.isUndefined(reportStub.lastCall.args[1]);
-    });
-  });
-
-  suite('createCheckEl method', () => {
-    test('start_line:12', () => {
-      const result = {
-        codePointers: [{range: {start_line: 12}}],
-      };
-      const el = element.createCheckEl(result);
-      assert.equal(el.getAttribute('slot'), 'right-12');
-      assert.equal(el.getAttribute('diff-side'), 'right');
-      assert.equal(el.getAttribute('line-num'), '12');
-      assert.equal(el.getAttribute('range'), null);
-      assert.equal(el.result, result);
-    });
-
-    test('start_line:13 end_line:14 without char positions', () => {
-      const result = {
-        codePointers: [{range: {start_line: 13, end_line: 14}}],
-      };
-      const el = element.createCheckEl(result);
-      assert.equal(el.getAttribute('slot'), 'right-14');
-      assert.equal(el.getAttribute('diff-side'), 'right');
-      assert.equal(el.getAttribute('line-num'), '14');
-      assert.equal(el.getAttribute('range'), null);
-      assert.equal(el.result, result);
-    });
-
-    test('start_line:13 end_line:14 with char positions', () => {
-      const result = {
-        codePointers: [
-          {
-            range: {
-              start_line: 13,
-              end_line: 14,
-              start_character: 5,
-              end_character: 7,
-            },
-          },
-        ],
-      };
-      const el = element.createCheckEl(result);
-      assert.equal(el.getAttribute('slot'), 'right-14');
-      assert.equal(el.getAttribute('diff-side'), 'right');
-      assert.equal(el.getAttribute('line-num'), '14');
-      assert.equal(el.getAttribute('range'),
-          '{"start_line":13,' +
-          '"end_line":14,' +
-          '"start_character":5,' +
-          '"end_character":7}');
-      assert.equal(el.result, result);
-    });
-
-    test('empty range', () => {
-      const result = {
-        codePointers: [{range: {}}],
-      };
-      const el = element.createCheckEl(result);
-      assert.equal(el.getAttribute('slot'), 'right-FILE');
-      assert.equal(el.getAttribute('diff-side'), 'right');
-      assert.equal(el.getAttribute('line-num'), 'FILE');
-      assert.equal(el.getAttribute('range'), null);
-      assert.equal(el.result, result);
-    });
-  });
-
-  suite('create-comment', () => {
-    setup(async () => {
-      loggedIn = true;
-      element.connectedCallback();
-      await flush();
-    });
-
-    test('creates comments if they do not exist yet', () => {
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          lineNum: 3,
-          side: Side.LEFT,
-          path: '/p',
-        },
-      }));
-
-      let threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      assert.equal(threads[0].thread.commentSide, 'PARENT');
-      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-      assert.equal(threads[0].thread.range, undefined);
-      assert.equal(threads[0].thread.patchNum, 2);
-
-      // Try to fetch a thread with a different range.
-      const range = {
-        start_line: 1,
-        start_character: 1,
-        end_line: 1,
-        end_character: 3,
-      };
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          lineNum: 1,
-          side: Side.LEFT,
-          path: '/p',
-          range,
-        },
-      }));
-
-      threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 2);
-      assert.equal(threads[0].thread.commentSide, 'PARENT');
-      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-      assert.equal(threads[1].thread.range, range);
-      assert.equal(threads[1].thread.patchNum, 3);
-    });
-
-    test('should not be on parent if on the right', () => {
-      element.patchRange = {
-        basePatchNum: 2,
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.RIGHT,
-        },
-      }));
-
-      const threadEl = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread')[0];
-
-      assert.equal(threadEl.thread.commentSide, 'REVISION');
-      assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
-    });
-
-    test('should be on parent if right and base is PARENT', () => {
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-        },
-      }));
-
-      const threadEl = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread')[0];
-
-      assert.equal(threadEl.thread.commentSide, 'PARENT');
-      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
-    });
-
-    test('should be on parent if right and base negative', () => {
-      element.patchRange = {
-        basePatchNum: -2, // merge parents have negative numbers
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-        },
-      }));
-
-      const threadEl = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread')[0];
-
-      assert.equal(threadEl.thread.commentSide, 'PARENT');
-      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
-    });
-
-    test('should not be on parent otherwise', () => {
-      element.patchRange = {
-        basePatchNum: 2, // merge parents have negative numbers
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-        },
-      }));
-
-      const threadEl = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread')[0];
-
-      assert.equal(threadEl.thread.commentSide, 'REVISION');
-      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
-    });
-
-    test('thread should use old file path if first created ' +
-    'on patch set (left) before renaming', async () => {
-      element.patchRange = {
-        basePatchNum: 2,
-        patchNum: 3,
-      };
-      element.file = {basePath: 'file_renamed.txt', path: element.path};
-      await flush();
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-          path: '/p',
-        },
-      }));
-
-      const threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-      assert.equal(threads[0].thread.path, element.file.basePath);
-    });
-
-    test('thread should use new file path if first created ' +
-    'on patch set (right) after renaming', async () => {
-      element.patchRange = {
-        basePatchNum: 2,
-        patchNum: 3,
-      };
-      element.file = {basePath: 'file_renamed.txt', path: element.path};
-      await flush();
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.RIGHT,
-          path: '/p',
-        },
-      }));
-
-      const threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
-      assert.equal(threads[0].thread.path, element.file.path);
-    });
-
-    test('multiple threads created on the same range', async () => {
-      element.patchRange = {
-        basePatchNum: 2,
-        patchNum: 3,
-      };
-      element.file = {basePath: 'file_renamed.txt', path: element.path};
-      await flush();
-
-      const comment = {
-        ...createComment(),
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 2,
-        },
-        patch_set: 3,
-      };
-      const thread = createCommentThread([comment]);
-      element.threads = [thread];
-
-      let threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      element.threads= [...element.threads, thread];
-
-      threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-      // Threads have same rootId so element is reused
-      assert.equal(threads.length, 1);
-
-      const newThread = {...thread};
-      newThread.rootId = 'differentRootId';
-      element.threads= [...element.threads, newThread];
-      threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-      // New thread has a different rootId
-      assert.equal(threads.length, 2);
-    });
-
-    test('unsaved thread changes to draft', async () => {
-      element.patchRange = {
-        basePatchNum: 2,
-        patchNum: 3,
-      };
-      element.file = {basePath: 'file_renamed.txt', path: element.path};
-      element.threads = [];
-      await flush();
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.RIGHT,
-          path: element.path,
-          lineNum: 13,
-        },
-      }));
-      await flush();
-      assert.equal(element.getThreadEls().length, 1);
-      const threadEl = element.getThreadEls()[0];
-      assert.equal(threadEl.thread.line, 13);
-      assert.isDefined(threadEl.unsavedComment);
-      assert.equal(threadEl.thread.comments.length, 0);
-
-      const draftThread = createCommentThread([{
-        path: element.path,
-        patch_set: 3,
-        line: 13,
-        __draft: true,
-      }]);
-      element.threads = [draftThread];
-      await flush();
-
-      // We expect that no additional thread element was created.
-      assert.equal(element.getThreadEls().length, 1);
-      // In fact the thread element must still be the same.
-      assert.equal(element.getThreadEls()[0], threadEl);
-      // But it must have been updated from unsaved to draft:
-      assert.isUndefined(threadEl.unsavedComment);
-      assert.equal(threadEl.thread.comments.length, 1);
-    });
-
-    test('thread should use new file path if first created ' +
-    'on patch set (left) but is base', async () => {
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 3,
-      };
-      element.file = {basePath: 'file_renamed.txt', path: element.path};
-      await flush();
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-          path: '/p',
-        },
-      }));
-
-      const threads =
-          dom(element.$.diff).queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-      assert.equal(threads[0].thread.path, element.file.path);
-    });
-
-    test('cannot create thread on an edit', () => {
-      const alertSpy = sinon.spy();
-      element.addEventListener('show-alert', alertSpy);
-
-      const diffSide = Side.LEFT;
-      element.patchRange = {
-        basePatchNum: EditPatchSetNum,
-        patchNum: 3,
-      };
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: diffSide,
-          path: '/p',
-        },
-      }));
-
-      const threads =
-          dom(element.$.diff).queryDistributedElements('gr-comment-thread');
-      assert.equal(threads.length, 0);
-      assert.isTrue(alertSpy.called);
-    });
-
-    test('cannot create thread on an edit base', () => {
-      const alertSpy = sinon.spy();
-      element.addEventListener('show-alert', alertSpy);
-
-      const diffSide = Side.LEFT;
-      element.patchRange = {
-        basePatchNum: ParentPatchSetNum,
-        patchNum: EditPatchSetNum,
-      };
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: diffSide,
-          path: '/p',
-        },
-      }));
-
-      const threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-      assert.equal(threads.length, 0);
-      assert.isTrue(alertSpy.called);
-    });
-  });
-
-  test('_filterThreadElsForLocation with no threads', () => {
-    const line = {beforeNumber: 3, afterNumber: 5};
-
-    const threads = [];
-    assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
-    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
-        Side.LEFT), []);
-    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
-        Side.RIGHT), []);
-  });
-
-  test('_filterThreadElsForLocation for line comments', () => {
-    const line = {beforeNumber: 3, afterNumber: 5};
-
-    const l3 = document.createElement('div');
-    l3.setAttribute('line-num', 3);
-    l3.setAttribute('diff-side', Side.LEFT);
-
-    const l5 = document.createElement('div');
-    l5.setAttribute('line-num', 5);
-    l5.setAttribute('diff-side', Side.LEFT);
-
-    const r3 = document.createElement('div');
-    r3.setAttribute('line-num', 3);
-    r3.setAttribute('diff-side', Side.RIGHT);
-
-    const r5 = document.createElement('div');
-    r5.setAttribute('line-num', 5);
-    r5.setAttribute('diff-side', Side.RIGHT);
-
-    const threadEls = [l3, l5, r3, r5];
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.LEFT), [l3]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.RIGHT), [r5]);
-  });
-
-  test('_filterThreadElsForLocation for file comments', () => {
-    const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
-
-    const l = document.createElement('div');
-    l.setAttribute('diff-side', Side.LEFT);
-    l.setAttribute('line-num', 'FILE');
-
-    const r = document.createElement('div');
-    r.setAttribute('diff-side', Side.RIGHT);
-    r.setAttribute('line-num', 'FILE');
-
-    const threadEls = [l, r];
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.LEFT), [l]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.RIGHT), [r]);
-  });
-
-  suite('syntax layer with syntax_highlighting on', () => {
-    setup(() => {
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      element.patchRange = {};
-      element.prefs = prefs;
-      element.changeNum = 123;
-      element.change = createChange();
-      element.path = 'some/path';
-    });
-
-    test('gr-diff-host provides syntax highlighting layer', async () => {
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      await element.reload();
-      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
-    });
-
-    test('rendering normal-sized diff does not disable syntax', () => {
-      element.diff = {
-        content: [{
-          a: ['foo'],
-        }],
-      };
-      assert.isTrue(element.syntaxLayer.enabled);
-    });
-
-    test('rendering large diff disables syntax', () => {
-      // Before it renders, set the first diff line to 500 '*' characters.
-      element.diff = {
-        content: [{
-          a: [new Array(501).join('*')],
-        }],
-      };
-      assert.isFalse(element.syntaxLayer.enabled);
-    });
-
-    test('starts syntax layer processing on render event', async () => {
-      sinon.stub(element.syntaxLayer, 'process')
-          .returns(Promise.resolve());
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      await element.reload();
-      element.dispatchEvent(
-          new CustomEvent('render', {bubbles: true, composed: true}));
-      assert.isTrue(element.syntaxLayer.process.called);
-    });
-  });
-
-  suite('syntax layer with syntax_highlighting off', () => {
-    setup(() => {
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-      };
-      element.diff = {
-        content: [{
-          a: ['foo'],
-        }],
-      };
-      element.patchRange = {};
-      element.change = createChange();
-      element.prefs = prefs;
-    });
-
-    test('gr-diff-host provides syntax highlighting layer', async () => {
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      await element.reload();
-      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
-    });
-
-    test('syntax layer should be disabled', () => {
-      assert.isFalse(element.syntaxLayer.enabled);
-    });
-
-    test('still disabled for large diff', () => {
-      // Before it renders, set the first diff line to 500 '*' characters.
-      element.diff = {
-        content: [{
-          a: [new Array(501).join('*')],
-        }],
-      };
-      assert.isFalse(element.syntaxLayer.enabled);
-    });
-  });
-
-  suite('coverage layer', () => {
-    let notifyStub;
-    let coverageProviderStub;
-    const exampleRanges = [
-      {
-        type: CoverageType.COVERED,
-        side: Side.RIGHT,
-        code_range: {
-          start_line: 1,
-          end_line: 2,
-        },
-      },
-      {
-        type: CoverageType.NOT_COVERED,
-        side: Side.RIGHT,
-        code_range: {
-          start_line: 3,
-          end_line: 4,
-        },
-      },
-    ];
-
-    setup(async () => {
-      notifyStub = sinon.stub();
-      coverageProviderStub = sinon.stub().returns(
-          Promise.resolve(exampleRanges));
-
-      element = basicFixture.instantiate();
-      sinon.stub(element.jsAPI, 'getCoverageAnnotationApis').returns(
-          Promise.resolve([{
-            notify: notifyStub,
-            getCoverageProvider() {
-              return coverageProviderStub;
-            },
-          }]));
-      element.changeNum = 123;
-      element.change = createChange();
-      element.path = 'some/path';
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-      };
-      element.diff = {
-        content: [{
-          a: ['foo'],
-        }],
-      };
-      element.patchRange = {};
-      element.prefs = prefs;
-      stubRestApi('getDiff').returns(Promise.resolve(element.diff));
-      await flush();
-    });
-
-    test('getCoverageAnnotationApis should be called', async () => {
-      await element.reload();
-      assert.isTrue(element.jsAPI.getCoverageAnnotationApis.calledOnce);
-    });
-
-    test('coverageRangeChanged should be called', async () => {
-      await element.reload();
-      assert.equal(notifyStub.callCount, 2);
-      assert.isTrue(notifyStub.calledWithExactly(
-          'some/path', 1, 2, Side.RIGHT));
-      assert.isTrue(notifyStub.calledWithExactly(
-          'some/path', 3, 4, Side.RIGHT));
-    });
-
-    test('provider is called with appropriate params', async () => {
-      element.patchRange.basePatchNum = 1;
-      element.patchRange.patchNum = 3;
-
-      await element.reload();
-      assert.isTrue(coverageProviderStub.calledWithExactly(
-          123, 'some/path', 1, 3, element.change));
-    });
-
-    test('provider is called with appropriate params - special patchset values',
-        async () => {
-          element.patchRange.basePatchNum = 'PARENT';
-          element.patchRange.patchNum = 'invalid';
-
-          await element.reload();
-          assert.isTrue(coverageProviderStub.calledWithExactly(
-              123, 'some/path', undefined, undefined, element.change));
-        });
-  });
-
-  suite('trailing newlines', () => {
-    setup(() => {
-    });
-
-    suite('_lastChunkForSide', () => {
-      test('deltas', () => {
-        const diff = {content: [
-          {a: ['foo', 'bar'], b: ['baz']},
-          {ab: ['foo', 'bar', 'baz']},
-          {b: ['foo']},
-        ]};
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
-
-        diff.content.push({a: ['foo'], b: ['bar']});
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
-      });
-
-      test('addition with a undefined', () => {
-        const diff = {content: [
-          {b: ['foo', 'bar', 'baz']},
-        ]};
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
-        assert.isNull(element._lastChunkForSide(diff, true));
-      });
-
-      test('addition with a empty', () => {
-        const diff = {content: [
-          {a: [], b: ['foo', 'bar', 'baz']},
-        ]};
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
-        assert.isNull(element._lastChunkForSide(diff, true));
-      });
-
-      test('deletion with b undefined', () => {
-        const diff = {content: [
-          {a: ['foo', 'bar', 'baz']},
-        ]};
-        assert.isNull(element._lastChunkForSide(diff, false));
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
-      });
-
-      test('deletion with b empty', () => {
-        const diff = {content: [
-          {a: ['foo', 'bar', 'baz'], b: []},
-        ]};
-        assert.isNull(element._lastChunkForSide(diff, false));
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
-      });
-
-      test('empty', () => {
-        const diff = {content: []};
-        assert.isNull(element._lastChunkForSide(diff, false));
-        assert.isNull(element._lastChunkForSide(diff, true));
-      });
-    });
-
-    suite('_hasTrailingNewlines', () => {
-      test('shared no trailing', () => {
-        const diff = undefined;
-        sinon.stub(element, '_lastChunkForSide')
-            .returns({ab: ['foo', 'bar']});
-        assert.isFalse(element._hasTrailingNewlines(diff, false));
-        assert.isFalse(element._hasTrailingNewlines(diff, true));
-      });
-
-      test('delta trailing in right', () => {
-        const diff = undefined;
-        sinon.stub(element, '_lastChunkForSide')
-            .returns({a: ['foo', 'bar'], b: ['baz', '']});
-        assert.isTrue(element._hasTrailingNewlines(diff, false));
-        assert.isFalse(element._hasTrailingNewlines(diff, true));
-      });
-
-      test('addition', () => {
-        const diff = undefined;
-        sinon.stub(element, '_lastChunkForSide').callsFake((diff, leftSide) => {
-          if (leftSide) { return null; }
-          return {b: ['foo', '']};
-        });
-        assert.isTrue(element._hasTrailingNewlines(diff, false));
-        assert.isNull(element._hasTrailingNewlines(diff, true));
-      });
-
-      test('deletion', () => {
-        const diff = undefined;
-        sinon.stub(element, '_lastChunkForSide').callsFake((diff, leftSide) => {
-          if (!leftSide) { return null; }
-          return {a: ['foo']};
-        });
-        assert.isNull(element._hasTrailingNewlines(diff, false));
-        assert.isFalse(element._hasTrailingNewlines(diff, true));
-      });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
new file mode 100644
index 0000000..598819b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -0,0 +1,1802 @@
+/**
+ * @license
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-host';
+import {
+  CommentSide,
+  createDefaultDiffPrefs,
+  Side,
+} from '../../../constants/constants';
+import {
+  createAccountDetailWithId,
+  createBlame,
+  createChange,
+  createComment,
+  createCommentThread,
+  createDiff,
+  createPatchRange,
+  createRunResult,
+} from '../../../test/test-data-generators';
+import {
+  addListenerForTest,
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  BasePatchSetNum,
+  BlameInfo,
+  CommentRange,
+  EDIT,
+  ImageInfo,
+  NumericChangeId,
+  PARENT,
+  PatchSetNum,
+  RevisionPatchSetNum,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {CoverageType} from '../../../types/types';
+import {GrDiffBuilderImage} from '../../../embed/diff/gr-diff-builder/gr-diff-builder-image';
+import {GrDiffHost, LineInfo} from './gr-diff-host';
+import {DiffInfo, DiffViewMode, IgnoreWhitespaceType} from '../../../api/diff';
+import {ErrorCallback} from '../../../api/rest';
+import {SinonStub} from 'sinon';
+import {RunResult} from '../../../models/checks/checks-model';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {assertIsDefined} from '../../../utils/common-util';
+import {GrAnnotationActionsInterface} from '../../shared/gr-js-api-interface/gr-annotation-actions-js-api';
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
+
+suite('gr-diff-host tests', () => {
+  let element: GrDiffHost;
+  let account = createAccountDetailWithId(1);
+  let getDiffRestApiStub: SinonStub;
+
+  setup(async () => {
+    stubRestApi('getAccount').callsFake(() => Promise.resolve(account));
+    element = await fixture(html`<gr-diff-host></gr-diff-host>`);
+    element.changeNum = 123 as NumericChangeId;
+    element.path = 'some/path';
+    element.change = createChange();
+    element.patchRange = createPatchRange();
+    getDiffRestApiStub = stubRestApi('getDiff');
+    // Fall back in case a test forgets to set one up
+    getDiffRestApiStub.returns(Promise.resolve(createDiff()));
+    await element.updateComplete;
+  });
+
+  suite('plugin layers', () => {
+    let getDiffLayersStub: sinon.SinonStub;
+    const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
+    setup(async () => {
+      element = await fixture(html`<gr-diff-host></gr-diff-host>`);
+      getDiffLayersStub = sinon
+        .stub(element.jsAPI, 'getDiffLayers')
+        .returns(pluginLayers);
+      element.changeNum = 123 as NumericChangeId;
+      element.change = createChange();
+      element.patchRange = createPatchRange();
+      element.path = 'some/path';
+      await element.updateComplete;
+    });
+
+    test('plugin layers requested', async () => {
+      getDiffRestApiStub.returns(Promise.resolve(createDiff()));
+      await element.reload();
+      assert(getDiffLayersStub.called);
+    });
+  });
+
+  suite('render reporting', () => {
+    test('ends total and syntax timer after syntax layer', async () => {
+      const displayedStub = stubReporting('diffViewContentDisplayed');
+
+      element.patchRange = createPatchRange();
+      element.change = createChange();
+      element.prefs = createDefaultDiffPrefs();
+      await element.updateComplete;
+      // Force a reload because it's not possible to wait on the reload called
+      // from update().
+      await element.reload();
+      const timeEndStub = sinon.stub(element.reporting, 'timeEnd');
+      let notifySyntaxProcessed: () => void = () => {};
+      sinon.stub(element.syntaxLayer, 'process').returns(
+        new Promise(resolve => {
+          notifySyntaxProcessed = resolve;
+        })
+      );
+      const promise = element.reload(true);
+      // Multiple cascading microtasks are scheduled.
+      notifySyntaxProcessed();
+      await element.updateComplete;
+      await promise;
+      const calls = timeEndStub.getCalls();
+      assert.equal(calls.length, 4);
+      assert.equal(calls[0].args[0], 'Diff Load Render');
+      assert.equal(calls[1].args[0], 'Diff Content Render');
+      assert.equal(calls[2].args[0], 'Diff Syntax Render');
+      assert.equal(calls[3].args[0], 'Diff Total Render');
+      assert.isTrue(displayedStub.called);
+    });
+
+    test('completes reload promise after syntax layer processing', async () => {
+      let notifySyntaxProcessed: () => void = () => {};
+      sinon.stub(element.syntaxLayer, 'process').returns(
+        new Promise(resolve => {
+          notifySyntaxProcessed = resolve;
+        })
+      );
+      getDiffRestApiStub.returns(Promise.resolve(createDiff()));
+      element.patchRange = createPatchRange();
+      element.change = createChange();
+      let reloadComplete = false;
+      element.prefs = createDefaultDiffPrefs();
+      const promise = mockPromise();
+      element.reload().then(() => {
+        reloadComplete = true;
+        promise.resolve();
+      });
+      // Multiple cascading microtasks are scheduled.
+      assert.isFalse(reloadComplete);
+      notifySyntaxProcessed();
+      await promise;
+      assert.isTrue(reloadComplete);
+    });
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-diff
+          id="diff"
+          style="--line-limit-marker:-1px; --content-width:100ch; --diff-max-width:none; --font-size:12px;"
+        >
+        </gr-diff>
+      `
+    );
+  });
+
+  test('reload() cancels before network resolves', async () => {
+    assertIsDefined(element.diffElement);
+    const cancelStub = sinon.stub(element.diffElement, 'cancel');
+
+    // Stub the network calls into requests that never resolve.
+    sinon.stub(element, 'getDiff').callsFake(() => new Promise(() => {}));
+    element.patchRange = createPatchRange();
+    element.change = createChange();
+    element.prefs = undefined;
+
+    // Needs to be set to something first for it to cancel.
+    element.diff = createDiff();
+    await element.updateComplete;
+
+    element.reload();
+    assert.isTrue(cancelStub.called);
+  });
+
+  test('prefetch getDiff', async () => {
+    getDiffRestApiStub.returns(Promise.resolve(createDiff()));
+    element.changeNum = 123 as NumericChangeId;
+    element.patchRange = createPatchRange();
+    element.path = 'file.txt';
+    element.prefetchDiff();
+    await element.getDiff();
+    assert.isTrue(getDiffRestApiStub.calledOnce);
+  });
+
+  test('getDiff handles undefined diff responses', async () => {
+    getDiffRestApiStub.returns(Promise.resolve(undefined));
+    element.changeNum = 123 as NumericChangeId;
+    element.patchRange = createPatchRange();
+    element.path = 'file.txt';
+    await element.getDiff();
+  });
+
+  test('reload resolves on error', () => {
+    const onErrStub = sinon.stub(element, 'handleGetDiffError');
+    const error = new Response(null, {status: 500});
+    getDiffRestApiStub.callsFake(
+      (
+        _1: NumericChangeId,
+        _2: PatchSetNum,
+        _3: PatchSetNum,
+        _4: string,
+        _5?: IgnoreWhitespaceType,
+        onErr?: ErrorCallback
+      ) => {
+        if (onErr) onErr(error);
+        return Promise.resolve(undefined);
+      }
+    );
+    element.patchRange = createPatchRange();
+    return element.reload().then(() => {
+      assert.isTrue(onErrStub.calledOnce);
+    });
+  });
+
+  suite('handleGetDiffError', () => {
+    let serverErrorStub: sinon.SinonStub;
+    let pageErrorStub: sinon.SinonStub;
+
+    setup(() => {
+      serverErrorStub = sinon.stub();
+      addListenerForTest(document, 'server-error', serverErrorStub);
+      pageErrorStub = sinon.stub();
+      addListenerForTest(document, 'page-error', pageErrorStub);
+    });
+
+    test('page error on HTTP-409', () => {
+      element.handleGetDiffError({status: 409} as Response);
+      assert.isTrue(serverErrorStub.calledOnce);
+      assert.isFalse(pageErrorStub.called);
+      assert.isNotOk(element.errorMessage);
+    });
+
+    test('server error on non-HTTP-409', () => {
+      element.handleGetDiffError({
+        status: 500,
+        text: () => Promise.resolve(''),
+      } as Response);
+      assert.isFalse(serverErrorStub.called);
+      assert.isTrue(pageErrorStub.calledOnce);
+      assert.isNotOk(element.errorMessage);
+    });
+
+    test('error message if showLoadFailure', () => {
+      element.showLoadFailure = true;
+      element.handleGetDiffError({
+        status: 500,
+        statusText: 'Failure!',
+      } as Response);
+      assert.isFalse(serverErrorStub.called);
+      assert.isFalse(pageErrorStub.called);
+      assert.equal(
+        element.errorMessage,
+        'Encountered error when loading the diff: 500 Failure!'
+      );
+    });
+  });
+
+  suite('image diffs', () => {
+    let mockFile1: ImageInfo;
+    let mockFile2: ImageInfo;
+    setup(() => {
+      mockFile1 = {
+        body:
+          'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAAAAAA/w==',
+        type: 'image/bmp',
+        _expectedType: 'image/bmp',
+        _name: 'carrot.bmp',
+      };
+      mockFile2 = {
+        body:
+          'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAA/////w==',
+        type: 'image/bmp',
+        _expectedType: 'image/bmp',
+        _name: 'potato.bmp',
+      };
+
+      element.patchRange = createPatchRange();
+      element.change = createChange();
+    });
+
+    test('renders image diffs with same file name', async () => {
+      const mockDiff: DiffInfo = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index 2adc47d..f9c2f2c 100644',
+          '--- a/carrot.jpg',
+          '+++ b/carrot.jpg',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      getDiffRestApiStub.returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: {
+            ...mockFile1,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot.jpg',
+          },
+          revisionImage: {
+            ...mockFile2,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot.jpg',
+          },
+        })
+      );
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await element.waitForReloadToRender();
+
+      // Recognizes that it should be an image diff.
+      assert.isTrue(element.isImageDiff);
+      assertIsDefined(element.diffElement);
+      assert.instanceOf(
+        element.diffElement.diffBuilder.builder,
+        GrDiffBuilderImage
+      );
+
+      // Left image rendered with the parent commit's version of the file.
+      assertIsDefined(element.diffElement);
+      assertIsDefined(element.diffElement.diffTable);
+      const diffTable = element.diffElement.diffTable;
+      const leftImage = queryAndAssert(diffTable, 'td.left img');
+      const leftLabel = queryAndAssert(diffTable, 'td.left label');
+      const leftLabelContent = leftLabel.querySelector('.label');
+      const leftLabelName = leftLabel.querySelector('.name');
+
+      const rightImage = queryAndAssert(diffTable, 'td.right img');
+      const rightLabel = queryAndAssert(diffTable, 'td.right label');
+      const rightLabelContent = rightLabel.querySelector('.label');
+      const rightLabelName = rightLabel.querySelector('.name');
+
+      assert.isOk(leftImage);
+      assert.equal(
+        leftImage.getAttribute('src'),
+        'data:image/bmp;base64,' + mockFile1.body
+      );
+      assert.isTrue(leftLabelContent?.textContent?.includes('image/bmp'));
+      assert.isNotOk(leftLabelName);
+
+      assert.isOk(rightImage);
+      assert.equal(
+        rightImage.getAttribute('src'),
+        'data:image/bmp;base64,' + mockFile2.body
+      );
+      assert.isTrue(rightLabelContent?.textContent?.includes('image/bmp'));
+      assert.isNotOk(rightLabelName);
+    });
+
+    test('renders image diffs with a different file name', async () => {
+      const mockDiff: DiffInfo = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg', lines: 560},
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot2.jpg',
+          'index 2adc47d..f9c2f2c 100644',
+          '--- a/carrot.jpg',
+          '+++ b/carrot2.jpg',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      getDiffRestApiStub.returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: {
+            ...mockFile1,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot.jpg',
+          },
+          revisionImage: {
+            ...mockFile2,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot2.jpg',
+          },
+        })
+      );
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await element.waitForReloadToRender();
+
+      // Recognizes that it should be an image diff.
+      assert.isTrue(element.isImageDiff);
+      assertIsDefined(element.diffElement);
+      assert.instanceOf(
+        element.diffElement.diffBuilder.builder,
+        GrDiffBuilderImage
+      );
+
+      // Left image rendered with the parent commit's version of the file.
+      assertIsDefined(element.diffElement.diffTable);
+      const diffTable = element.diffElement.diffTable;
+      const leftImage = queryAndAssert(diffTable, 'td.left img');
+      const leftLabel = queryAndAssert(diffTable, 'td.left label');
+      const leftLabelContent = leftLabel.querySelector('.label');
+      const leftLabelName = leftLabel.querySelector('.name');
+
+      const rightImage = queryAndAssert(diffTable, 'td.right img');
+      const rightLabel = queryAndAssert(diffTable, 'td.right label');
+      const rightLabelContent = rightLabel.querySelector('.label');
+      const rightLabelName = rightLabel.querySelector('.name');
+
+      assert.isOk(rightLabelName);
+      assert.isOk(leftLabelName);
+      assert.equal(leftLabelName?.textContent, mockDiff.meta_a?.name);
+      assert.equal(rightLabelName?.textContent, mockDiff.meta_b?.name);
+
+      assert.isOk(leftImage);
+      assert.equal(
+        leftImage.getAttribute('src'),
+        'data:image/bmp;base64,' + mockFile1.body
+      );
+      assert.isTrue(leftLabelContent?.textContent?.includes('image/bmp'));
+
+      assert.isOk(rightImage);
+      assert.equal(
+        rightImage.getAttribute('src'),
+        'data:image/bmp;base64,' + mockFile2.body
+      );
+      assert.isTrue(rightLabelContent?.textContent?.includes('image/bmp'));
+    });
+
+    test('renders added image', async () => {
+      const mockDiff: DiffInfo = {
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+        intraline_status: 'OK',
+        change_type: 'ADDED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index 0000000..f9c2f2c 100644',
+          '--- /dev/null',
+          '+++ b/carrot.jpg',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      getDiffRestApiStub.returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: null,
+          revisionImage: {
+            ...mockFile2,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot2.jpg',
+          },
+        })
+      );
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await element.waitForReloadToRender().then(() => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assertIsDefined(element.diffElement);
+        assert.instanceOf(
+          element.diffElement.diffBuilder.builder,
+          GrDiffBuilderImage
+        );
+        assertIsDefined(element.diffElement.diffTable);
+        const diffTable = element.diffElement.diffTable;
+
+        const leftImage = query(diffTable, 'td.left img');
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+
+        assert.isNotOk(leftImage);
+        assert.isOk(rightImage);
+      });
+    });
+
+    test('renders removed image', async () => {
+      const mockDiff: DiffInfo = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+        intraline_status: 'OK',
+        change_type: 'DELETED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index f9c2f2c..0000000 100644',
+          '--- a/carrot.jpg',
+          '+++ /dev/null',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      getDiffRestApiStub.returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: {
+            ...mockFile1,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot.jpg',
+          },
+          revisionImage: null,
+        })
+      );
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await element.waitForReloadToRender().then(() => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assertIsDefined(element.diffElement);
+        assert.instanceOf(
+          element.diffElement.diffBuilder.builder,
+          GrDiffBuilderImage
+        );
+
+        assertIsDefined(element.diffElement.diffTable);
+        const diffTable = element.diffElement.diffTable;
+
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const rightImage = query(diffTable, 'td.right img');
+
+        assert.isOk(leftImage);
+        assert.isNotOk(rightImage);
+      });
+    });
+
+    test('does not render disallowed image type', async () => {
+      const mockDiff: DiffInfo = {
+        meta_a: {
+          name: 'carrot.jpg',
+          content_type: 'image/jpeg-evil',
+          lines: 560,
+        },
+        intraline_status: 'OK',
+        change_type: 'DELETED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index f9c2f2c..0000000 100644',
+          '--- a/carrot.jpg',
+          '+++ /dev/null',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      mockFile1.type = 'image/jpeg-evil';
+
+      getDiffRestApiStub.returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: {
+            ...mockFile1,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot.jpg',
+          },
+          revisionImage: null,
+        })
+      );
+
+      element.prefs = createDefaultDiffPrefs();
+      element.updateComplete.then(() => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assertIsDefined(element.diffElement);
+        assert.instanceOf(
+          element.diffElement.diffBuilder.builder,
+          GrDiffBuilderImage
+        );
+        assertIsDefined(element.diffElement.diffTable);
+        const diffTable = element.diffElement.diffTable;
+
+        const leftImage = query(diffTable, 'td.left img');
+        assert.isNotOk(leftImage);
+      });
+    });
+  });
+
+  test('cannot create comments when not logged in', () => {
+    element.userModel.setAccount(undefined);
+    element.patchRange = createPatchRange();
+    const showAuthRequireSpy = sinon.spy();
+    element.addEventListener('show-auth-required', showAuthRequireSpy);
+
+    element.dispatchEvent(
+      new CustomEvent('create-comment', {
+        detail: {
+          lineNum: 3,
+          side: Side.LEFT,
+          path: '/p',
+        },
+      })
+    );
+
+    assertIsDefined(element.diffElement);
+    const threads = queryAll(element.diffElement, 'gr-comment-thread');
+    assert.equal(threads.length, 0);
+    assert.isTrue(showAuthRequireSpy.called);
+  });
+
+  test('delegates cancel()', () => {
+    assertIsDefined(element.diffElement);
+    const stub = sinon.stub(element.diffElement, 'cancel');
+    element.patchRange = createPatchRange();
+    element.cancel();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates getCursorStops()', () => {
+    const returnValue = [document.createElement('b')];
+    assertIsDefined(element.diffElement);
+    const stub = sinon
+      .stub(element.diffElement, 'getCursorStops')
+      .returns(returnValue);
+    assert.equal(element.getCursorStops(), returnValue);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates isRangeSelected()', () => {
+    const returnValue = true;
+    assertIsDefined(element.diffElement);
+    const stub = sinon
+      .stub(element.diffElement, 'isRangeSelected')
+      .returns(returnValue);
+    assert.equal(element.isRangeSelected(), returnValue);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates toggleLeftDiff()', () => {
+    assertIsDefined(element.diffElement);
+    const stub = sinon.stub(element.diffElement, 'toggleLeftDiff');
+    element.toggleLeftDiff();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  suite('blame', () => {
+    setup(async () => {
+      element = await fixture(html`<gr-diff-host></gr-diff-host>`);
+      element.changeNum = 123 as NumericChangeId;
+      element.path = 'some/path';
+      await element.updateComplete;
+    });
+
+    test('clearBlame', async () => {
+      element.blame = [];
+      await element.updateComplete;
+      assertIsDefined(element.diffElement);
+      const setBlameSpy = sinon.spy(
+        element.diffElement.diffBuilder,
+        'setBlame'
+      );
+      const isBlameLoadedStub = sinon.stub();
+      element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
+      element.clearBlame();
+      await element.updateComplete;
+      assert.isNull(element.blame);
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isTrue(isBlameLoadedStub.calledOnce);
+      assert.isFalse(isBlameLoadedStub.args[0][0].detail.value);
+    });
+
+    test('loadBlame', async () => {
+      const mockBlame: BlameInfo[] = [createBlame()];
+      const showAlertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, showAlertStub);
+      const getBlameStub = stubRestApi('getBlame').returns(
+        Promise.resolve(mockBlame)
+      );
+      const changeNum = 42 as NumericChangeId;
+      element.changeNum = changeNum;
+      element.patchRange = createPatchRange();
+      element.path = 'foo/bar.baz';
+      await element.updateComplete;
+      const isBlameLoadedStub = sinon.stub();
+      element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
+
+      return element.loadBlame().then(() => {
+        assert.isTrue(
+          getBlameStub.calledWithExactly(
+            changeNum,
+            1 as RevisionPatchSetNum,
+            'foo/bar.baz',
+            true
+          )
+        );
+        assert.isFalse(showAlertStub.called);
+        assert.equal(element.blame, mockBlame);
+        assert.isTrue(isBlameLoadedStub.calledOnce);
+        assert.isTrue(isBlameLoadedStub.args[0][0].detail.value);
+      });
+    });
+
+    test('loadBlame empty', async () => {
+      const mockBlame: BlameInfo[] = [];
+      const showAlertStub = sinon.stub();
+      const isBlameLoadedStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, showAlertStub);
+      element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
+      stubRestApi('getBlame').returns(Promise.resolve(mockBlame));
+      const changeNum = 42 as NumericChangeId;
+      element.changeNum = changeNum;
+      element.patchRange = createPatchRange();
+      element.path = 'foo/bar.baz';
+      await element.updateComplete;
+      return element
+        .loadBlame()
+        .then(() => {
+          assert.isTrue(false, 'Promise should not resolve');
+        })
+        .catch(() => {
+          assert.isTrue(showAlertStub.calledOnce);
+          assert.isNull(element.blame);
+          // We don't expect a call because
+          assert.isTrue(isBlameLoadedStub.notCalled);
+        });
+    });
+  });
+
+  test('getThreadEls() returns .comment-threads', () => {
+    const threadEl = document.createElement('gr-comment-thread');
+    threadEl.className = 'comment-thread';
+    assertIsDefined(element.diffElement);
+    element.diffElement.appendChild(threadEl);
+    assert.deepEqual(element.getThreadEls(), [threadEl]);
+  });
+
+  test('delegates addDraftAtLine(el)', () => {
+    const param0 = document.createElement('b');
+    assertIsDefined(element.diffElement);
+    const stub = sinon.stub(element.diffElement, 'addDraftAtLine');
+    element.addDraftAtLine(param0);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 1);
+    assert.equal(stub.lastCall.args[0], param0);
+  });
+
+  test('delegates clearDiffContent()', () => {
+    assertIsDefined(element.diffElement);
+    const stub = sinon.stub(element.diffElement, 'clearDiffContent');
+    element.clearDiffContent();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates toggleAllContext()', () => {
+    assertIsDefined(element.diffElement);
+    const stub = sinon.stub(element.diffElement, 'toggleAllContext');
+    element.toggleAllContext();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('passes in noAutoRender', async () => {
+    const value = true;
+    element.noAutoRender = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.noAutoRender, value);
+  });
+
+  test('passes in path', async () => {
+    const value = 'some/file/path';
+    element.path = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.path, value);
+  });
+
+  test('passes in prefs', async () => {
+    const value = createDefaultDiffPrefs();
+    element.prefs = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.prefs, value);
+  });
+
+  test('passes in displayLine', async () => {
+    const value = true;
+    element.displayLine = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.displayLine, value);
+  });
+
+  test('passes in hidden', async () => {
+    const value = true;
+    element.hidden = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.hidden, value);
+    assert.isNotNull(element.getAttribute('hidden'));
+  });
+
+  test('passes in noRenderOnPrefsChange', async () => {
+    const value = true;
+    element.noRenderOnPrefsChange = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.noRenderOnPrefsChange, value);
+  });
+
+  test('passes in lineWrapping', async () => {
+    const value = true;
+    element.lineWrapping = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.lineWrapping, value);
+  });
+
+  test('passes in viewMode', async () => {
+    const value = DiffViewMode.SIDE_BY_SIDE;
+    element.viewMode = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.viewMode, value);
+  });
+
+  test('passes in lineOfInterest', async () => {
+    const value = {lineNum: 123, side: Side.LEFT};
+    element.lineOfInterest = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.lineOfInterest, value);
+  });
+
+  suite('reportDiff', () => {
+    let reportStub: SinonStub;
+
+    setup(async () => {
+      element = await fixture(html`<gr-diff-host></gr-diff-host>`);
+      element.changeNum = 123 as NumericChangeId;
+      element.path = 'file.txt';
+      element.patchRange = createPatchRange(1, 2);
+      reportStub = sinon.stub(element.reporting, 'reportInteraction');
+      await element.updateComplete;
+      reportStub.reset();
+    });
+
+    test('undefined', () => {
+      element.reportDiff(undefined);
+      assert.isFalse(reportStub.called);
+    });
+
+    test('diff w/ no delta', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [{ab: ['foo', 'bar']}, {ab: ['baz', 'foo']}],
+      };
+      element.reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+
+    test('diff w/ no rebase delta', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo']},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], b: ['bar', 'baz']},
+          {ab: ['foo', 'bar']},
+          {b: ['baz', 'foo']},
+          {ab: ['foo', 'bar']},
+        ],
+      };
+      element.reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+
+    test('diff w/ some rebase delta', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], due_to_rebase: true},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], b: ['bar', 'baz']},
+          {ab: ['foo', 'bar']},
+          {b: ['baz', 'foo'], due_to_rebase: true},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo']},
+        ],
+      };
+      element.reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.isTrue(
+        reportStub.calledWith('rebase-percent-nonzero', {
+          percentRebaseDelta: 50,
+        })
+      );
+    });
+
+    test('diff w/ all rebase delta', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {
+            a: ['foo', 'bar'],
+            b: ['baz', 'foo'],
+            due_to_rebase: true,
+          },
+        ],
+      };
+      element.reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.isTrue(
+        reportStub.calledWith('rebase-percent-nonzero', {
+          percentRebaseDelta: 100,
+        })
+      );
+    });
+
+    test('diff against parent event', () => {
+      element.patchRange = createPatchRange();
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {
+            a: ['foo', 'bar'],
+            b: ['baz', 'foo'],
+          },
+        ],
+      };
+      element.reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+  });
+
+  suite('createCheckEl method', () => {
+    test('start_line:12', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        codePointers: [{path: 'a', range: {start_line: 12} as CommentRange}],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-12');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), '12');
+      assert.equal(el.getAttribute('range'), null);
+      assert.equal(el.result, result);
+    });
+
+    test('start_line:13 end_line:14 without char positions', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        codePointers: [
+          {path: 'a', range: {start_line: 13, end_line: 14} as CommentRange},
+        ],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-14');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), '14');
+      assert.equal(el.getAttribute('range'), null);
+      assert.equal(el.result, result);
+    });
+
+    test('start_line:13 end_line:14 with char positions', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        codePointers: [
+          {
+            path: 'a',
+            range: {
+              start_line: 13,
+              end_line: 14,
+              start_character: 5,
+              end_character: 7,
+            },
+          },
+        ],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-14');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), '14');
+      assert.equal(
+        el.getAttribute('range'),
+        '{"start_line":13,' +
+          '"end_line":14,' +
+          '"start_character":5,' +
+          '"end_character":7}'
+      );
+      assert.equal(el.result, result);
+    });
+
+    test('empty range', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        codePointers: [{path: 'a', range: {} as CommentRange}],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-FILE');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), 'FILE');
+      assert.equal(el.getAttribute('range'), null);
+      assert.equal(el.result, result);
+    });
+  });
+
+  suite('create-comment', () => {
+    setup(async () => {
+      account = createAccountDetailWithId(1);
+      element.disconnectedCallback();
+      element.connectedCallback();
+      await element.updateComplete;
+    });
+
+    test('creates comments if they do not exist yet', async () => {
+      element.patchRange = createPatchRange();
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            lineNum: 3,
+            side: Side.LEFT,
+            path: '/p',
+          },
+        })
+      );
+      assertIsDefined(element.diffElement);
+      let threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].thread?.commentSide, CommentSide.PARENT);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[0].thread?.range, undefined);
+      assert.equal(threads[0].thread?.patchNum, 1 as RevisionPatchSetNum);
+
+      // Try to fetch a thread with a different range.
+      const range = {
+        start_line: 1,
+        start_character: 1,
+        end_line: 1,
+        end_character: 3,
+      };
+      element.patchRange = createPatchRange();
+
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            lineNum: 1,
+            side: Side.LEFT,
+            path: '/p',
+            range,
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+
+      assert.equal(threads.length, 2);
+      assert.equal(threads[0].thread?.commentSide, CommentSide.PARENT);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[1].thread?.range, range);
+      assert.equal(threads[1].thread?.patchNum, 1 as RevisionPatchSetNum);
+    });
+
+    test('should not be on parent if on the right', async () => {
+      element.patchRange = createPatchRange(2, 3);
+      // Need to recompute threads.
+      await element.updateComplete;
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.RIGHT,
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      const threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      assert.equal(threads.length, 1);
+      const threadEl = threads[0];
+
+      assert.equal(threadEl.thread?.commentSide, CommentSide.REVISION);
+      assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
+    });
+
+    test('should be on parent if right and base is PARENT', () => {
+      element.patchRange = createPatchRange();
+
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.LEFT,
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      const threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      const threadEl = threads[0];
+
+      assert.equal(threadEl.thread?.commentSide, CommentSide.PARENT);
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+    });
+
+    test('should be on parent if right and base negative', () => {
+      element.patchRange = createPatchRange(-2, 3);
+
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.LEFT,
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      const threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      const threadEl = threads[0];
+
+      assert.equal(threadEl.thread?.commentSide, CommentSide.PARENT);
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+    });
+
+    test('should not be on parent otherwise', () => {
+      element.patchRange = createPatchRange(2, 3);
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.LEFT,
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      const threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      const threadEl = threads[0];
+
+      assert.equal(threadEl.thread?.commentSide, CommentSide.REVISION);
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+    });
+
+    test(
+      'thread should use old file path if first created ' +
+        'on patch set (left) before renaming',
+      async () => {
+        element.patchRange = createPatchRange(2, 3);
+        element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+        await element.updateComplete;
+
+        element.dispatchEvent(
+          new CustomEvent('create-comment', {
+            detail: {
+              side: Side.LEFT,
+              path: '/p',
+            },
+          })
+        );
+
+        assertIsDefined(element.diffElement);
+        const threads =
+          element.diffElement.querySelectorAll<GrCommentThread>(
+            'gr-comment-thread'
+          );
+        assert.equal(threads.length, 1);
+        assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+        assert.equal(threads[0].thread?.path, element.file.basePath);
+      }
+    );
+
+    test(
+      'thread should use new file path if first created ' +
+        'on patch set (right) after renaming',
+      async () => {
+        element.patchRange = createPatchRange(2, 3);
+        element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+        await element.updateComplete;
+
+        element.dispatchEvent(
+          new CustomEvent('create-comment', {
+            detail: {
+              side: Side.RIGHT,
+              path: '/p',
+            },
+          })
+        );
+
+        assertIsDefined(element.diffElement);
+        const threads =
+          element.diffElement.querySelectorAll<GrCommentThread>(
+            'gr-comment-thread'
+          );
+
+        assert.equal(threads.length, 1);
+        assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
+        assert.equal(threads[0].thread?.path, element.file.path);
+      }
+    );
+
+    test('multiple threads created on the same range', async () => {
+      element.patchRange = createPatchRange(2, 3);
+      element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+      await element.updateComplete;
+
+      const comment = {
+        ...createComment(),
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 2,
+        },
+        patch_set: 3 as RevisionPatchSetNum,
+      };
+      const thread = createCommentThread([comment]);
+      element.threads = [thread];
+      await element.updateComplete;
+
+      assertIsDefined(element.diffElement);
+      let threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+
+      assert.equal(threads.length, 1);
+      element.threads = [...element.threads, thread];
+      await element.updateComplete;
+
+      assertIsDefined(element.diffElement);
+      threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      // Threads have same rootId so element is reused
+      assert.equal(threads.length, 1);
+
+      const newThread = {...thread};
+      newThread.rootId = 'differentRootId' as UrlEncodedCommentId;
+      element.threads = [...element.threads, newThread];
+      await element.updateComplete;
+      threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      // New thread has a different rootId
+      assert.equal(threads.length, 2);
+    });
+
+    test('unsaved thread changes to draft', async () => {
+      element.patchRange = createPatchRange(2, 3);
+      element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+      element.threads = [];
+      await element.updateComplete;
+
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.RIGHT,
+            path: element.path,
+            lineNum: 13,
+          },
+        })
+      );
+      await element.updateComplete;
+      assert.equal(element.getThreadEls().length, 1);
+      const threadEl = element.getThreadEls()[0];
+      assert.equal(threadEl.thread?.line, 13);
+      assert.isDefined(threadEl.unsavedComment);
+      assert.equal(threadEl.thread?.comments.length, 0);
+
+      const draftThread = createCommentThread([
+        {
+          path: element.path,
+          patch_set: 3 as RevisionPatchSetNum,
+          line: 13,
+          __draft: true,
+        },
+      ]);
+      element.threads = [draftThread];
+      await element.updateComplete;
+
+      // We expect that no additional thread element was created.
+      assert.equal(element.getThreadEls().length, 1);
+      // In fact the thread element must still be the same.
+      assert.equal(element.getThreadEls()[0], threadEl);
+      // But it must have been updated from unsaved to draft:
+      assert.isUndefined(threadEl.unsavedComment);
+      assert.equal(threadEl.thread?.comments.length, 1);
+    });
+
+    test(
+      'thread should use new file path if first created ' +
+        'on patch set (left) but is base',
+      async () => {
+        element.patchRange = createPatchRange();
+        element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+        await element.updateComplete;
+
+        element.dispatchEvent(
+          new CustomEvent('create-comment', {
+            detail: {
+              side: Side.LEFT,
+              path: '/p',
+            },
+          })
+        );
+
+        assertIsDefined(element.diffElement);
+        const threads =
+          element.diffElement.querySelectorAll<GrCommentThread>(
+            'gr-comment-thread'
+          );
+
+        assert.equal(threads.length, 1);
+        assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+        assert.equal(threads[0].thread?.path, element.file.path);
+      }
+    );
+
+    test('cannot create thread on an edit', () => {
+      const alertSpy = sinon.spy();
+      element.addEventListener(EventType.SHOW_ALERT, alertSpy);
+
+      const diffSide = Side.RIGHT;
+      element.patchRange = {
+        basePatchNum: 3 as BasePatchSetNum,
+        patchNum: EDIT,
+      };
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: diffSide,
+            path: '/p',
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      const threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+
+      assert.equal(threads.length, 0);
+      assert.isTrue(alertSpy.called);
+    });
+
+    test('cannot create thread on an edit base', () => {
+      const alertSpy = sinon.spy();
+      element.addEventListener(EventType.SHOW_ALERT, alertSpy);
+
+      const diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: EDIT,
+      };
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: diffSide,
+            path: '/p',
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      const threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      assert.equal(threads.length, 0);
+      assert.isTrue(alertSpy.called);
+    });
+  });
+
+  test('filterThreadElsForLocation with no threads', () => {
+    const line = {beforeNumber: 3, afterNumber: 5};
+    const threads: GrCommentThread[] = [];
+    assert.deepEqual(
+      element.filterThreadElsForLocation(threads, line, Side.LEFT),
+      []
+    );
+    assert.deepEqual(
+      element.filterThreadElsForLocation(threads, line, Side.RIGHT),
+      []
+    );
+  });
+
+  test('filterThreadElsForLocation for line comments', () => {
+    const line = {beforeNumber: 3, afterNumber: 5};
+
+    const l3 = document.createElement('gr-comment-thread');
+    l3.setAttribute('line-num', '3');
+    l3.setAttribute('diff-side', Side.LEFT);
+
+    const l5 = document.createElement('gr-comment-thread');
+    l5.setAttribute('line-num', '5');
+    l5.setAttribute('diff-side', Side.LEFT);
+
+    const r3 = document.createElement('gr-comment-thread');
+    r3.setAttribute('line-num', '3');
+    r3.setAttribute('diff-side', Side.RIGHT);
+
+    const r5 = document.createElement('gr-comment-thread');
+    r5.setAttribute('line-num', '5');
+    r5.setAttribute('diff-side', Side.RIGHT);
+
+    const threadEls: GrCommentThread[] = [l3, l5, r3, r5];
+    assert.deepEqual(
+      element.filterThreadElsForLocation(threadEls, line, Side.LEFT),
+      [l3]
+    );
+    assert.deepEqual(
+      element.filterThreadElsForLocation(threadEls, line, Side.RIGHT),
+      [r5]
+    );
+  });
+
+  test('filterThreadElsForLocation for file comments', () => {
+    const line: LineInfo = {beforeNumber: 'FILE', afterNumber: 'FILE'};
+
+    const l = document.createElement('gr-comment-thread');
+    l.setAttribute('diff-side', Side.LEFT);
+    l.setAttribute('line-num', 'FILE');
+
+    const r = document.createElement('gr-comment-thread');
+    r.setAttribute('diff-side', Side.RIGHT);
+    r.setAttribute('line-num', 'FILE');
+
+    const threadEls: GrCommentThread[] = [l, r];
+    assert.deepEqual(
+      element.filterThreadElsForLocation(threadEls, line, Side.LEFT),
+      [l]
+    );
+    assert.deepEqual(
+      element.filterThreadElsForLocation(threadEls, line, Side.RIGHT),
+      [r]
+    );
+  });
+
+  suite('syntax layer with syntax_highlighting on', async () => {
+    setup(async () => {
+      const prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      element.patchRange = createPatchRange();
+      element.prefs = prefs;
+      element.changeNum = 123 as NumericChangeId;
+      element.change = createChange();
+      element.path = 'some/path';
+    });
+
+    test('gr-diff-host provides syntax highlighting layer', async () => {
+      getDiffRestApiStub.returns(Promise.resolve(createDiff()));
+      await element.updateComplete;
+      assertIsDefined(element.diffElement);
+      assertIsDefined(element.diffElement.layers);
+      assert.equal(element.diffElement.layers[1], element.syntaxLayer);
+    });
+
+    test('rendering normal-sized diff does not disable syntax', async () => {
+      element.diff = createDiff();
+      getDiffRestApiStub.returns(Promise.resolve(element.diff));
+      await element.updateComplete;
+      assert.isTrue(element.syntaxLayer.enabled);
+    });
+
+    test('rendering large diff disables syntax', async () => {
+      // Before it renders, set the first diff line to 500 '*' characters.
+      getDiffRestApiStub.returns(
+        Promise.resolve({
+          ...createDiff(),
+          content: [
+            {
+              a: [new Array(501).join('*')],
+            },
+          ],
+        })
+      );
+      element.reload();
+      await element.waitForReloadToRender();
+      assert.isFalse(element.syntaxLayer.enabled);
+    });
+
+    test('starts syntax layer processing on render event', async () => {
+      const stub = sinon
+        .stub(element.syntaxLayer, 'process')
+        .returns(Promise.resolve());
+      getDiffRestApiStub.returns(Promise.resolve(createDiff()));
+      await element.reload();
+      element.dispatchEvent(
+        new CustomEvent('render', {bubbles: true, composed: true})
+      );
+      assert.isTrue(stub.called);
+    });
+  });
+
+  suite('syntax layer with syntax_highlighting off', () => {
+    setup(async () => {
+      const prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: false,
+      };
+      element.patchRange = createPatchRange();
+      element.change = createChange();
+      element.prefs = prefs;
+    });
+
+    test('gr-diff-host provides syntax highlighting layer', async () => {
+      await element.waitForReloadToRender();
+      assertIsDefined(element.diffElement);
+      assertIsDefined(element.diffElement.layers);
+      assert.equal(element.diffElement.layers[1], element.syntaxLayer);
+    });
+
+    test('syntax layer should be disabled', async () => {
+      await element.waitForReloadToRender();
+      assert.isFalse(element.syntaxLayer.enabled);
+    });
+
+    test('still disabled for large diff', async () => {
+      getDiffRestApiStub.callsFake(() =>
+        Promise.resolve({
+          ...createDiff(),
+          content: [
+            {
+              a: [new Array(501).join('*')],
+            },
+          ],
+        })
+      );
+      await element.waitForReloadToRender();
+      assert.isFalse(element.syntaxLayer.enabled);
+    });
+  });
+
+  suite('coverage layer', () => {
+    let notifyStub: SinonStub;
+    let coverageProviderStub: SinonStub;
+    let getCoverageAnnotationApisStub: SinonStub;
+    const exampleRanges = [
+      {
+        type: CoverageType.COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 1,
+          end_line: 2,
+        },
+      },
+      {
+        type: CoverageType.NOT_COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 3,
+          end_line: 4,
+        },
+      },
+    ];
+
+    setup(async () => {
+      notifyStub = sinon.stub();
+      coverageProviderStub = sinon
+        .stub()
+        .returns(Promise.resolve(exampleRanges));
+      element = await fixture(html`<gr-diff-host></gr-diff-host>`);
+      element.changeNum = 123 as NumericChangeId;
+      element.change = createChange();
+      element.path = 'some/path';
+      const prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+      };
+      element.patchRange = createPatchRange();
+      element.prefs = prefs;
+      await element.updateComplete;
+
+      getDiffRestApiStub.returns(
+        Promise.resolve({
+          ...createDiff(),
+          content: [{a: ['foo']}],
+        })
+      );
+      getCoverageAnnotationApisStub = sinon
+        .stub(element.jsAPI, 'getCoverageAnnotationApis')
+        .returns(
+          Promise.resolve([
+            {
+              notify: notifyStub,
+              getCoverageProvider() {
+                return coverageProviderStub;
+              },
+            } as unknown as GrAnnotationActionsInterface,
+          ])
+        );
+      await element.reload();
+    });
+
+    test('getCoverageAnnotationApis should be called', async () => {
+      await element.waitForReloadToRender();
+      assert.isTrue(getCoverageAnnotationApisStub.calledOnce);
+    });
+
+    test('coverageRangeChanged should be called', async () => {
+      await element.waitForReloadToRender();
+      assert.equal(notifyStub.callCount, 2);
+      assert.isTrue(
+        notifyStub.calledWithExactly('some/path', 1, 2, Side.RIGHT)
+      );
+      assert.isTrue(
+        notifyStub.calledWithExactly('some/path', 3, 4, Side.RIGHT)
+      );
+    });
+
+    test('provider is called with appropriate params', async () => {
+      element.patchRange = createPatchRange(1, 3);
+      await element.updateComplete;
+      await element.reload();
+      await element.waitForReloadToRender();
+      assert.isTrue(
+        coverageProviderStub.calledWithExactly(
+          123,
+          'some/path',
+          1,
+          3,
+          element.change
+        )
+      );
+    });
+
+    test('provider is called with appropriate params - special patchset values', async () => {
+      element.patchRange = createPatchRange();
+      await element.waitForReloadToRender();
+      assert.isTrue(
+        coverageProviderStub.calledWithExactly(
+          123,
+          'some/path',
+          undefined,
+          1,
+          element.change
+        )
+      );
+    });
+  });
+
+  suite('trailing newlines', () => {
+    setup(() => {});
+
+    suite('lastChunkForSide', () => {
+      test('deltas', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [
+            {a: ['foo', 'bar'], b: ['baz']},
+            {ab: ['foo', 'bar', 'baz']},
+            {b: ['foo']},
+          ],
+        };
+        assert.equal(element.lastChunkForSide(diff, false), diff.content[2]);
+        assert.equal(element.lastChunkForSide(diff, true), diff.content[1]);
+
+        diff.content.push({a: ['foo'], b: ['bar']});
+        assert.equal(element.lastChunkForSide(diff, false), diff.content[3]);
+        assert.equal(element.lastChunkForSide(diff, true), diff.content[3]);
+      });
+
+      test('addition with a undefined', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [{b: ['foo', 'bar', 'baz']}],
+        };
+        assert.equal(element.lastChunkForSide(diff, false), diff.content[0]);
+        assert.isNull(element.lastChunkForSide(diff, true));
+      });
+
+      test('addition with a empty', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [{a: [], b: ['foo', 'bar', 'baz']}],
+        };
+        assert.equal(element.lastChunkForSide(diff, false), diff.content[0]);
+        assert.isNull(element.lastChunkForSide(diff, true));
+      });
+
+      test('deletion with b undefined', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [{a: ['foo', 'bar', 'baz']}],
+        };
+        assert.isNull(element.lastChunkForSide(diff, false));
+        assert.equal(element.lastChunkForSide(diff, true), diff.content[0]);
+      });
+
+      test('deletion with b empty', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [{a: ['foo', 'bar', 'baz'], b: []}],
+        };
+        assert.isNull(element.lastChunkForSide(diff, false));
+        assert.equal(element.lastChunkForSide(diff, true), diff.content[0]);
+      });
+
+      test('empty', () => {
+        const diff: DiffInfo = {...createDiff(), content: []};
+        assert.isNull(element.lastChunkForSide(diff, false));
+        assert.isNull(element.lastChunkForSide(diff, true));
+      });
+    });
+
+    suite('hasTrailingNewlines', () => {
+      test('shared no trailing', () => {
+        const diff = undefined;
+        sinon.stub(element, 'lastChunkForSide').returns({ab: ['foo', 'bar']});
+        assert.isFalse(element.hasTrailingNewlines(diff, false));
+        assert.isFalse(element.hasTrailingNewlines(diff, true));
+      });
+
+      test('delta trailing in right', () => {
+        const diff = undefined;
+        sinon
+          .stub(element, 'lastChunkForSide')
+          .returns({a: ['foo', 'bar'], b: ['baz', '']});
+        assert.isTrue(element.hasTrailingNewlines(diff, false));
+        assert.isFalse(element.hasTrailingNewlines(diff, true));
+      });
+
+      test('addition', () => {
+        const diff: DiffInfo | undefined = undefined;
+        sinon
+          .stub(element, 'lastChunkForSide')
+          .callsFake((_: DiffInfo | undefined, leftSide: boolean) => {
+            if (leftSide) {
+              return null;
+            }
+            return {b: ['foo', '']};
+          });
+        assert.isTrue(element.hasTrailingNewlines(diff, false));
+        assert.isNull(element.hasTrailingNewlines(diff, true));
+      });
+
+      test('deletion', () => {
+        const diff: DiffInfo | undefined = undefined;
+        sinon
+          .stub(element, 'lastChunkForSide')
+          .callsFake((_: DiffInfo | undefined, leftSide: boolean) => {
+            if (!leftSide) {
+              return null;
+            }
+            return {a: ['foo']};
+          });
+        assert.isNull(element.hasTrailingNewlines(diff, false));
+        assert.isFalse(element.hasTrailingNewlines(diff, true));
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
index 9fc4dd0..17004d9 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
@@ -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 '../../shared/gr-button/gr-button';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-overlay/gr-overlay';
@@ -24,7 +12,7 @@
 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 {customElement, query, state} from 'lit/decorators.js';
 import {ValueChangedEvent} from '../../../types/events';
 
 @customElement('gr-diff-preferences-dialog')
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 9f63123..1b484b0 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-diff-preferences-dialog';
 import {GrDiffPreferencesDialog} from './gr-diff-preferences-dialog';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
@@ -23,7 +11,7 @@
 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';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-diff-preferences-dialog', () => {
   let element: GrDiffPreferencesDialog;
@@ -44,6 +32,50 @@
     `);
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-overlay
+          aria-hidden="true"
+          id="diffPrefsOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <div aria-labelledby="diffPreferencesTitle" role="dialog">
+            <h3 class="diffHeader heading-3" id="diffPreferencesTitle">
+              Diff Preferences
+            </h3>
+            <gr-diff-preferences id="diffPreferences"> </gr-diff-preferences>
+            <div class="diffActions">
+              <gr-button
+                aria-disabled="false"
+                id="cancelButton"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Cancel
+              </gr-button>
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                id="saveButton"
+                link=""
+                primary=""
+                role="button"
+                tabindex="-1"
+              >
+                Save
+              </gr-button>
+            </div>
+          </div>
+        </gr-overlay>
+      `
+    );
+  });
+
   test('changes applies only on save', async () => {
     element.open();
     await element.updateComplete;
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 1c64a7e..6ad8e2f 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
@@ -1,18 +1,7 @@
 /**
  * @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 '@polymer/iron-dropdown/iron-dropdown';
 import '@polymer/iron-input/iron-input';
@@ -21,10 +10,9 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-select/gr-select';
 import '../../shared/revision-info/revision-info';
-import '../gr-comment-api/gr-comment-api';
 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';
@@ -33,23 +21,14 @@
 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 {htmlTemplate} from './gr-diff-view_html';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-  ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {
-  GeneratedWebLink,
-  GerritNav,
-} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
   PatchSet,
+  isMergeParent,
+  getParentIndex,
 } from '../../../utils/patch-set-util';
 import {
   addUnmodifiedFiles,
@@ -59,7 +38,6 @@
   specialFilePathCompare,
 } from '../../../utils/path-list-util';
 import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
-import {customElement, observe, property} from '@polymer/decorators';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
 import {
   DropdownItem,
@@ -67,18 +45,17 @@
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 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,
-  EditPatchSetNum,
+  EDIT,
   FileInfo,
   NumericChangeId,
-  ParentPatchSetNum,
+  PARENT,
   PatchRange,
   PatchSetNum,
+  PatchSetNumber,
   PreferencesInfo,
   RepoName,
   RevisionInfo,
@@ -87,14 +64,12 @@
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 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 '../../../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';
@@ -104,34 +79,49 @@
   getPatchRangeForCommentUrl,
   isInBaseOfPatchRange,
 } from '../../../utils/comment-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 {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 {Key, toggleClass} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
 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 {combineLatest} from 'rxjs';
+import {
+  Shortcut,
+  ShortcutSection,
+  shortcutsServiceToken,
+} 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 {resolve} from '../../../models/dependency';
 import {BehaviorSubject} from 'rxjs';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {subscribe} from '../../lit/subscription-controller';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {configModelToken} from '../../../models/config/config-model';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+import {
+  createDiffUrl,
+  diffViewModelToken,
+  DiffViewState,
+} from '../../../models/views/diff';
+import {createChangeUrl} from '../../../models/views/change';
+import {createEditUrl} from '../../../models/views/edit';
+import {GeneratedWebLink} from '../../../utils/weblink-util';
 
 const LOADING_BLAME = 'Loading blame...';
 const LOADED_BLAME = 'Blame loaded';
@@ -139,7 +129,8 @@
 // Time in which pressing n key again after the toast navigates to next file
 const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
 
-interface Files {
+// visible for testing
+export interface Files {
   sortedFileList: string[];
   changeFilesByPath: {[path: string]: FileInfo};
 }
@@ -148,29 +139,8 @@
   previous: string | null;
   next: string | null;
 }
-
-export interface GrDiffView {
-  $: {
-    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(DIPolymerElement);
-
 @customElement('gr-diff-view')
-export class GrDiffView extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDiffView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
@@ -182,181 +152,138 @@
    *
    * @event show-alert
    */
+  @query('#diffHost')
+  diffHost?: GrDiffHost;
 
-  @property({type: Object, observer: '_paramsChanged'})
-  params?: AppElementParams;
+  @query('#reviewed')
+  reviewed?: HTMLInputElement;
 
-  @property({type: Object})
-  changeViewState: Partial<ChangeViewState> = {};
+  @query('#downloadOverlay')
+  downloadOverlay?: GrOverlay;
 
-  @property({type: Object})
-  _patchRange?: PatchRange;
+  @query('#downloadDialog')
+  downloadDialog?: GrDownloadDialog;
 
-  @property({type: Object})
-  _commitRange?: CommitRange;
+  @query('#dropdown')
+  dropdown?: GrDropdownList;
 
-  @property({type: Object})
-  _change?: ParsedChangeInfo;
+  @query('#applyFixDialog')
+  applyFixDialog?: GrApplyFixDialog;
 
-  @property({type: Object})
-  _changeComments?: ChangeComments;
+  @query('#diffPreferencesDialog')
+  diffPreferencesDialog?: GrOverlay;
 
-  @property({type: String})
-  _changeNum?: NumericChangeId;
+  private _viewState: DiffViewState | undefined;
 
-  @property({type: Object})
-  _diff?: DiffInfo;
+  @state()
+  get viewState(): DiffViewState | undefined {
+    return this._viewState;
+  }
 
-  @property({
-    type: Array,
-    computed: '_formatFilesForDropdown(_files, _patchRange, _changeComments)',
-  })
-  _formattedFiles?: DropdownItem[];
+  set viewState(viewState: DiffViewState | undefined) {
+    if (this._viewState === viewState) return;
+    const oldViewState = this._viewState;
+    this._viewState = viewState;
+    this.viewStateChanged();
+    this.requestUpdate('viewState', oldViewState);
+  }
 
-  @property({type: Array, computed: '_getSortedFileList(_files)'})
-  _fileList?: string[];
+  // Private but used in tests.
+  @state()
+  patchRange?: PatchRange;
 
-  @property({type: Object})
-  _files: Files = {sortedFileList: [], changeFilesByPath: {}};
+  // Private but used in tests.
+  @state()
+  commitRange?: CommitRange;
 
-  @property({type: Object, computed: '_getCurrentFile(_files, _path)'})
-  _file?: FileInfo;
+  // Private but used in tests.
+  @state()
+  change?: ParsedChangeInfo;
 
-  @property({type: String, observer: '_pathChanged'})
+  // Private but used in tests.
+  @state()
+  changeComments?: ChangeComments;
+
+  // Private but used in tests.
+  @state()
+  changeNum?: NumericChangeId;
+
+  // Private but used in tests.
+  @state()
+  diff?: DiffInfo;
+
+  // TODO: Move to using files-model.
+  // Private but used in tests.
+  @state()
+  files: Files = {sortedFileList: [], changeFilesByPath: {}};
+
+  // Private but used in tests
+  // Use path getter/setter.
   _path?: string;
 
-  @property({type: Number, computed: '_computeFileNum(_path, _formattedFiles)'})
-  _fileNum?: number;
-
-  @property({type: Boolean})
-  _loggedIn = false;
-
-  @property({type: Boolean})
-  _loading = true;
-
-  @property({type: Object})
-  _prefs?: DiffPreferencesInfo;
-
-  @property({type: Object})
-  _projectConfig?: ConfigInfo;
-
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
-
-  @property({type: Object})
-  _userPrefs?: PreferencesInfo;
-
-  @property({type: Boolean})
-  _isImageDiff?: boolean;
-
-  @property({type: Object})
-  _editWeblinks?: GeneratedWebLink[];
-
-  @property({type: Object})
-  _filesWeblinks?: FilesWebLinks;
-
-  @property({type: Object})
-  _commentMap?: CommentMap;
-
-  @property({
-    type: Object,
-    computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
-  })
-  _commentSkips?: CommentSkips;
-
-  @property({type: Boolean, computed: '_computeEditMode(_patchRange.*)'})
-  _editMode?: boolean;
-
-  @property({type: Boolean})
-  _isBlameLoaded?: boolean;
-
-  @property({type: Boolean})
-  _isBlameLoading = false;
-
-  @property({
-    type: Array,
-    computed: '_computeAllPatchSets(_change, _change.revisions.*)',
-  })
-  _allPatchSets?: PatchSet[] = [];
-
-  @property({type: Object, computed: '_getRevisionInfo(_change)'})
-  _revisionInfo?: RevisionInfoObj;
-
-  @property({type: Number})
-  _focusLineNum?: number;
-
-  /** 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.NEXT_LINE, _ => this._handleNextLine()),
-      listen(Shortcut.PREV_LINE, _ => this._handlePrevLine()),
-      listen(Shortcut.VISIBLE_LINE, _ => this.cursor?.moveToVisibleArea()),
-      listen(Shortcut.NEXT_FILE_WITH_COMMENTS, _ =>
-        this._moveToNextFileWithComment()
-      ),
-      listen(Shortcut.PREV_FILE_WITH_COMMENTS, _ =>
-        this._moveToPreviousFileWithComment()
-      ),
-      listen(Shortcut.NEW_COMMENT, _ => this._handleNewComment()),
-      listen(Shortcut.SAVE_COMMENT, _ => {}),
-      listen(Shortcut.NEXT_FILE, _ => this._handleNextFile()),
-      listen(Shortcut.PREV_FILE, _ => this._handlePrevFile()),
-      listen(Shortcut.NEXT_CHUNK, _ => this._handleNextChunk()),
-      listen(Shortcut.PREV_CHUNK, _ => this._handlePrevChunk()),
-      listen(Shortcut.NEXT_COMMENT_THREAD, _ =>
-        this._handleNextCommentThread()
-      ),
-      listen(Shortcut.PREV_COMMENT_THREAD, _ =>
-        this._handlePrevCommentThread()
-      ),
-      listen(Shortcut.OPEN_REPLY_DIALOG, _ => this._handleOpenReplyDialog()),
-      listen(Shortcut.TOGGLE_LEFT_PANE, _ => this._handleToggleLeftPane()),
-      listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ =>
-        this._handleOpenDownloadDialog()
-      ),
-      listen(Shortcut.UP_TO_CHANGE, _ => this._handleUpToChange()),
-      listen(Shortcut.OPEN_DIFF_PREFS, _ => this._handleCommaKey()),
-      listen(Shortcut.TOGGLE_DIFF_MODE, _ => this._handleToggleDiffMode()),
-      listen(Shortcut.TOGGLE_FILE_REVIEWED, e => {
-        if (this._throttledToggleFileReviewed) {
-          this._throttledToggleFileReviewed(e);
-        }
-      }),
-      listen(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, _ =>
-        this._handleToggleAllDiffContext()
-      ),
-      listen(Shortcut.NEXT_UNREVIEWED_FILE, _ =>
-        this._handleNextUnreviewedFile()
-      ),
-      listen(Shortcut.TOGGLE_BLAME, _ => this._handleToggleBlame()),
-      listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
-        this._handleToggleHideAllCommentThreads()
-      ),
-      listen(Shortcut.OPEN_FILE_LIST, _ => this._handleOpenFileList()),
-      listen(Shortcut.DIFF_AGAINST_BASE, _ => this._handleDiffAgainstBase()),
-      listen(Shortcut.DIFF_AGAINST_LATEST, _ =>
-        this._handleDiffAgainstLatest()
-      ),
-      listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ =>
-        this._handleDiffBaseAgainstLeft()
-      ),
-      listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ =>
-        this._handleDiffRightAgainstLatest()
-      ),
-      listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ =>
-        this._handleDiffBaseAgainstLatest()
-      ),
-      listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}), // docOnly
-      listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}), // docOnly
-    ];
+  get path() {
+    return this._path;
   }
 
+  set path(path: string | undefined) {
+    if (this._path === path) return;
+    const oldPath = this._path;
+    this._path = path;
+    this.pathChanged();
+    this.requestUpdate('path', oldPath);
+  }
+
+  // Private but used in tests.
+  @state()
+  loggedIn = false;
+
+  // Private but used in tests.
+  @state()
+  loading = true;
+
+  @property({type: Object})
+  prefs?: DiffPreferencesInfo;
+
+  @state()
+  private serverConfig?: ServerInfo;
+
+  // Private but used in tests.
+  @state()
+  userPrefs?: PreferencesInfo;
+
+  @state()
+  private isImageDiff?: boolean;
+
+  @state()
+  private editWeblinks?: GeneratedWebLink[];
+
+  @state()
+  private filesWeblinks?: FilesWebLinks;
+
+  // Private but used in tests.
+  @state()
+  commentMap?: CommentMap;
+
+  @state()
+  private commentSkips?: CommentSkips;
+
+  // Private but used in tests.
+  @state()
+  isBlameLoaded?: boolean;
+
+  @state()
+  private isBlameLoading = false;
+
+  @state()
+  private allPatchSets?: PatchSet[] = [];
+
+  // Private but used in tests.
+  @state()
+  focusLineNum?: number;
+
+  // visible for testing
+  reviewedFiles = new Set<string>();
+
   private readonly reporting = getAppContext().reportingService;
 
   private readonly restApiService = getAppContext().restApiService;
@@ -376,92 +303,188 @@
   // Private but used in tests.
   readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private readonly shortcuts = getAppContext().shortcutsService;
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
-  _throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
+  private readonly getConfigModel = resolve(this, configModelToken);
 
-  _onRenderHandler?: EventListener;
+  private readonly getViewModel = resolve(this, diffViewModelToken);
 
-  private cursor?: GrDiffCursor;
+  private throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
 
-  private subscriptions: Subscription[] = [];
+  @state()
+  cursor?: GrDiffCursor;
 
   private connected$ = new BehaviorSubject(false);
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.connected$.next(true);
-    this._throttledToggleFileReviewed = throttleWrap(_ =>
-      this._handleToggleFileReviewed()
+  private readonly shortcutsController = new ShortcutController(this);
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  constructor() {
+    super();
+    this.setupKeyboardShortcuts();
+    this.setupSubscriptions();
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      x => (this.viewState = x)
     );
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
+  }
+
+  private setupKeyboardShortcuts() {
+    const listen = (shortcut: Shortcut, fn: (e: KeyboardEvent) => void) => {
+      this.shortcutsController.addAbstract(shortcut, fn);
+    };
+    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.NEXT_FILE_WITH_COMMENTS, _ =>
+      this.moveToNextFileWithComment()
+    );
+    listen(Shortcut.PREV_FILE_WITH_COMMENTS, _ =>
+      this.moveToPreviousFileWithComment()
+    );
+    listen(Shortcut.NEW_COMMENT, _ => this.handleNewComment());
+    listen(Shortcut.SAVE_COMMENT, _ => {});
+    listen(Shortcut.NEXT_FILE, _ => this.handleNextFile());
+    listen(Shortcut.PREV_FILE, _ => this.handlePrevFile());
+    listen(Shortcut.NEXT_CHUNK, _ => this.handleNextChunk());
+    listen(Shortcut.PREV_CHUNK, _ => this.handlePrevChunk());
+    listen(Shortcut.NEXT_COMMENT_THREAD, _ => this.handleNextCommentThread());
+    listen(Shortcut.PREV_COMMENT_THREAD, _ => this.handlePrevCommentThread());
+    listen(Shortcut.OPEN_REPLY_DIALOG, _ => this.handleOpenReplyDialog());
+    listen(Shortcut.TOGGLE_LEFT_PANE, _ => this.handleToggleLeftPane());
+    listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ => this.handleOpenDownloadDialog());
+    listen(Shortcut.UP_TO_CHANGE, _ => this.handleUpToChange());
+    listen(Shortcut.OPEN_DIFF_PREFS, _ => this.handleCommaKey());
+    listen(Shortcut.TOGGLE_DIFF_MODE, _ => this.handleToggleDiffMode());
+    listen(Shortcut.TOGGLE_FILE_REVIEWED, e => {
+      if (this.throttledToggleFileReviewed) {
+        this.throttledToggleFileReviewed(e);
+      }
     });
-    this.restApiService.getConfig().then(config => {
-      this._serverConfig = config;
+    listen(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, _ =>
+      this.handleToggleAllDiffContext()
+    );
+    listen(Shortcut.NEXT_UNREVIEWED_FILE, _ => this.handleNextUnreviewedFile());
+    listen(Shortcut.TOGGLE_BLAME, _ => this.toggleBlame());
+    listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
+      this.handleToggleHideAllCommentThreads()
+    );
+    listen(Shortcut.OPEN_FILE_LIST, _ => this.handleOpenFileList());
+    listen(Shortcut.DIFF_AGAINST_BASE, _ => this.handleDiffAgainstBase());
+    listen(Shortcut.DIFF_AGAINST_LATEST, _ => this.handleDiffAgainstLatest());
+    listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ =>
+      this.handleDiffBaseAgainstLeft()
+    );
+    listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ =>
+      this.handleDiffRightAgainstLatest()
+    );
+    listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ =>
+      this.handleDiffBaseAgainstLatest()
+    );
+    listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}); // docOnly
+    listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}); // docOnly
+    this.shortcutsController.addGlobal({key: Key.ESC}, _ => {
+      assertIsDefined(this.diffHost, 'diffHost');
+      this.diffHost.displayLine = false;
     });
+  }
 
-    this.subscriptions.push(
-      this.getCommentsModel().changeComments$.subscribe(changeComments => {
-        this._changeComments = changeComments;
-      })
+  private setupSubscriptions() {
+    subscribe(
+      this,
+      () => this.userModel.loggedIn$,
+      loggedIn => {
+        this.loggedIn = loggedIn;
+      }
     );
-
-    this.subscriptions.push(
-      this.userModel.preferences$.subscribe(preferences => {
-        this._userPrefs = preferences;
-      })
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
     );
-    this.subscriptions.push(
-      this.userModel.diffPreferences$.subscribe(diffPreferences => {
-        this._prefs = diffPreferences;
-      })
+    subscribe(
+      this,
+      () => this.getCommentsModel().changeComments$,
+      changeComments => {
+        this.changeComments = changeComments;
+      }
     );
-    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;
-      })
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      preferences => {
+        this.userPrefs = preferences;
+      }
     );
-
-    this.subscriptions.push(
-      this.getChangeModel().reviewedFiles$.subscribe(reviewedFiles => {
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      diffPreferences => {
+        this.prefs = diffPreferences;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      change => {
+        // The diff view is tied to a specific change number, so don't update
+        // change to undefined.
+        if (change) this.change = change;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().reviewedFiles$,
+      reviewedFiles => {
         this.reviewedFiles = new Set(reviewedFiles) ?? new Set();
-      })
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().diffPath$,
+      path => (this.path = path)
     );
 
-    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);
-      })
+    subscribe(
+      this,
+      () =>
+        combineLatest([
+          this.getChangeModel().diffPath$,
+          this.getChangeModel().reviewedFiles$,
+        ]),
+      ([path, files]) => {
+        this.updateComplete.then(() => {
+          assertIsDefined(this.reviewed, 'reviewed');
+          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(
+    subscribe(
+      this,
+      () =>
+        this.getChangeModel().diffPath$.pipe(
           filter(diffPath => !!diffPath),
           switchMap(() =>
-            combineLatest(
-              this.getChangeModel().currentPatchNum$,
+            combineLatest([
+              this.getChangeModel().patchNum$,
               this.routerModel.routerView$,
               this.userModel.diffPreferences$,
-              this.getChangeModel().reviewedFiles$
-            ).pipe(
+              this.getChangeModel().reviewedFiles$,
+            ]).pipe(
               filter(
-                ([currentPatchNum, routerView, diffPrefs, reviewedFiles]) =>
-                  !!currentPatchNum &&
+                ([patchNum, routerView, diffPrefs, reviewedFiles]) =>
+                  !!patchNum &&
                   routerView === GerritView.DIFF &&
                   !!diffPrefs &&
                   !!reviewedFiles
@@ -469,254 +492,734 @@
               take(1)
             )
           )
-        )
-        .subscribe(([currentPatchNum, _routerView, diffPrefs]) => {
-          this.setReviewedStatus(currentPatchNum!, diffPrefs);
-        })
+        ),
+      ([patchNum, _routerView, diffPrefs]) => {
+        this.setReviewedStatus(patchNum!, diffPrefs);
+      }
     );
-    this.subscriptions.push(
-      this.getChangeModel().diffPath$.subscribe(path => (this._path = path))
+    subscribe(
+      this,
+      () => this.getChangeModel().diffPath$,
+      path => (this.path = path)
     );
-    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
+  }
+
+  static override get styles() {
+    return [
+      a11yStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          background-color: var(--view-background-color);
+        }
+        .hidden {
+          display: none;
+        }
+        gr-patch-range-select {
+          display: block;
+        }
+        gr-diff {
+          border: none;
+        }
+        .stickyHeader {
+          background-color: var(--view-background-color);
+          position: sticky;
+          top: 0;
+          /* TODO(dhruvsri): This is required only because of 'position:relative' in
+            <gr-diff-highlight> (which could maybe be removed??). */
+          z-index: 1;
+          box-shadow: var(--elevation-level-1);
+          /* This is just for giving the box-shadow some space. */
+          margin-bottom: 2px;
+        }
+        header,
+        .subHeader {
+          align-items: center;
+          display: flex;
+          justify-content: space-between;
+        }
+        header {
+          padding: var(--spacing-s) var(--spacing-xl);
+          border-bottom: 1px solid var(--border-color);
+        }
+        .changeNumberColon {
+          color: transparent;
+        }
+        .headerSubject {
+          margin-right: var(--spacing-m);
+          font-weight: var(--font-weight-bold);
+        }
+        .patchRangeLeft {
+          align-items: center;
+          display: flex;
+        }
+        .navLink:not([href]) {
+          color: var(--deemphasized-text-color);
+        }
+        .navLinks {
+          align-items: center;
+          display: flex;
+          white-space: nowrap;
+        }
+        .navLink {
+          padding: 0 var(--spacing-xs);
+        }
+        .reviewed {
+          display: inline-block;
+          margin: 0 var(--spacing-xs);
+          vertical-align: top;
+          position: relative;
+          top: 8px;
+        }
+        .jumpToFileContainer {
+          display: inline-block;
+          word-break: break-all;
+        }
+        .mobile {
+          display: none;
+        }
+        gr-button {
+          padding: var(--spacing-s) 0;
+          text-decoration: none;
+        }
+        .loading {
+          color: var(--deemphasized-text-color);
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h1);
+          font-weight: var(--font-weight-h1);
+          line-height: var(--line-height-h1);
+          height: 100%;
+          padding: var(--spacing-l);
+          text-align: center;
+        }
+        .subHeader {
+          background-color: var(--background-color-secondary);
+          flex-wrap: wrap;
+          padding: 0 var(--spacing-l);
+        }
+        .prefsButton {
+          text-align: right;
+        }
+        .editMode .hideOnEdit {
+          display: none;
+        }
+        .blameLoader,
+        .fileNum {
+          display: none;
+        }
+        .blameLoader.show,
+        .fileNum.show,
+        .download,
+        .preferences,
+        .rightControls {
+          align-items: center;
+          display: flex;
+        }
+        .diffModeSelector,
+        .editButton {
+          align-items: center;
+          display: flex;
+        }
+        .diffModeSelector span,
+        .editButton span {
+          margin-right: var(--spacing-xs);
+        }
+        .diffModeSelector.hide,
+        .separator.hide {
+          display: none;
+        }
+        .editButtona a {
+          text-decoration: none;
+        }
+        @media screen and (max-width: 50em) {
+          header {
+            padding: var(--spacing-s) var(--spacing-l);
+          }
+          .dash {
+            display: none;
+          }
+          .desktop {
+            display: none;
+          }
+          .fileNav {
+            align-items: flex-start;
+            display: flex;
+            margin: 0 var(--spacing-xs);
+          }
+          .fullFileName {
+            display: block;
+            font-style: italic;
+            min-width: 50%;
+            padding: 0 var(--spacing-xxs);
+            text-align: center;
+            width: 100%;
+            word-wrap: break-word;
+          }
+          .reviewed {
+            vertical-align: -1px;
+          }
+          .mobileNavLink {
+            color: var(--primary-text-color);
+            font-family: var(--header-font-family);
+            font-size: var(--font-size-h2);
+            font-weight: var(--font-weight-h2);
+            line-height: var(--line-height-h2);
+            text-decoration: none;
+          }
+          .mobileNavLink:not([href]) {
+            color: var(--deemphasized-text-color);
+          }
+          .jumpToFileContainer {
+            display: block;
+            width: 100%;
+            word-break: break-all;
+          }
+          /* prettier formatter removes semi-colons after css mixins. */
+          /* prettier-ignore */
+          gr-dropdown-list {
+            width: 100%;
+            --gr-select-style-width: 100%;
+            --gr-select-style-display: block;
+            --native-select-style-width: 100%;
+          }
+        }
+        :host(.hideComments) {
+          --gr-comment-thread-display: none;
+        }
+      `,
+    ];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.connected$.next(true);
+    this.throttledToggleFileReviewed = throttleWrap(_ =>
+      this.handleToggleFileReviewed()
+    );
+    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.$.diffHost.addEventListener('render', this._onRenderHandler);
-    this.cleanups.push(
-      addGlobalShortcut(
-        {key: Key.ESC},
-        _ => (this.$.diffHost.displayLine = false)
-      )
-    );
   }
 
   override disconnectedCallback() {
     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();
   }
 
+  protected override willUpdate(changedProperties: PropertyValues) {
+    super.willUpdate(changedProperties);
+    if (changedProperties.has('change')) {
+      this.allPatchSets = computeAllPatchSets(this.change);
+    }
+    if (
+      changedProperties.has('commentMap') ||
+      changedProperties.has('files') ||
+      changedProperties.has('path')
+    ) {
+      this.commentSkips = this.computeCommentSkips(
+        this.commentMap,
+        this.files?.sortedFileList,
+        this.path
+      );
+    }
+
+    if (
+      changedProperties.has('changeNum') ||
+      changedProperties.has('changeComments') ||
+      changedProperties.has('patchRange')
+    ) {
+      this.fetchFiles();
+    }
+  }
+
+  private reInitCursor() {
+    assertIsDefined(this.diffHost, 'diffHost');
+    this.cursor?.replaceDiffs([this.diffHost]);
+    this.cursor?.reInitCursor();
+  }
+
+  protected override updated(changedProperties: PropertyValues): void {
+    super.updated(changedProperties);
+    if (
+      changedProperties.has('changeComments') ||
+      changedProperties.has('path') ||
+      changedProperties.has('patchRange') ||
+      changedProperties.has('files')
+    ) {
+      if (this.changeComments && this.path && this.patchRange) {
+        assertIsDefined(this.diffHost, 'diffHost');
+        const file = this.files?.changeFilesByPath
+          ? this.files.changeFilesByPath[this.path]
+          : undefined;
+        this.diffHost.updateComplete.then(() => {
+          assertIsDefined(this.path);
+          assertIsDefined(this.patchRange);
+          assertIsDefined(this.diffHost);
+          assertIsDefined(this.changeComments);
+          this.diffHost.threads = this.changeComments.getThreadsBySideForFile(
+            {path: this.path, basePath: file?.old_path},
+            this.patchRange
+          );
+        });
+      }
+    }
+  }
+
+  override render() {
+    const file = this.getFileRange();
+    return html`
+      ${this.renderStickyHeader()}
+      <div class="loading" ?hidden=${!this.loading}>Loading...</div>
+      <h2 class="assistive-tech-only">Diff view</h2>
+      <gr-diff-host
+        id="diffHost"
+        ?hidden=${this.loading}
+        .changeNum=${this.changeNum}
+        .change=${this.change}
+        .commitRange=${this.commitRange}
+        .patchRange=${this.patchRange}
+        .file=${file}
+        .path=${this.path}
+        .projectName=${this.change?.project}
+        @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
+        @comment-anchor-tap=${this.onLineSelected}
+        @line-selected=${this.onLineSelected}
+        @diff-changed=${this.onDiffChanged}
+        @edit-weblinks-changed=${this.onEditWeblinksChanged}
+        @files-weblinks-changed=${this.onFilesWeblinksChanged}
+        @is-image-diff-changed=${this.onIsImageDiffChanged}
+        @render=${this.reInitCursor}
+      >
+      </gr-diff-host>
+      ${this.renderDialogs()}
+    `;
+  }
+
+  private renderStickyHeader() {
+    return html` <div
+      class="stickyHeader ${this.computeEditMode() ? 'editMode' : ''}"
+    >
+      <h1 class="assistive-tech-only">
+        Diff of ${this.path ? computeTruncatedPath(this.path) : ''}
+      </h1>
+      <header>${this.renderHeader()}</header>
+      <div class="subHeader">
+        ${this.renderPatchRangeLeft()} ${this.renderRightControls()}
+      </div>
+      <div class="fileNav mobile">
+        <a class="mobileNavLink" href=${ifDefined(this.computeNavLinkURL(-1))}
+          >&lt;</a
+        >
+        <div class="fullFileName mobile">${computeDisplayPath(this.path)}</div>
+        <a class="mobileNavLink" href=${ifDefined(this.computeNavLinkURL(1))}
+          >&gt;</a
+        >
+      </div>
+    </div>`;
+  }
+
+  private renderHeader() {
+    const formattedFiles = this.formatFilesForDropdown();
+    const fileNum = this.computeFileNum(formattedFiles);
+    const fileNumClass = this.computeFileNumClass(fileNum, formattedFiles);
+    return html` <div>
+        <a href=${this.getChangePath()}>${this.changeNum}</a
+        ><span class="changeNumberColon">:</span>
+        <span class="headerSubject">${this.change?.subject}</span>
+        <input
+          id="reviewed"
+          class="reviewed hideOnEdit"
+          type="checkbox"
+          ?hidden=${!this.loggedIn}
+          title="Toggle reviewed status of file"
+          aria-label="file reviewed"
+          @change=${this.handleReviewedChange}
+        />
+        <div class="jumpToFileContainer">
+          <gr-dropdown-list
+            id="dropdown"
+            .value=${this.path}
+            .items=${formattedFiles}
+            show-copy-for-trigger-text
+            @value-change=${this.handleFileChange}
+          ></gr-dropdown-list>
+        </div>
+      </div>
+      <div class="navLinks desktop">
+        <span class="fileNum ${ifDefined(fileNumClass)}">
+          File ${fileNum} of ${formattedFiles.length}
+          <span class="separator"></span>
+        </span>
+        <a
+          class="navLink"
+          title=${this.createTitle(
+            Shortcut.PREV_FILE,
+            ShortcutSection.NAVIGATION
+          )}
+          href=${ifDefined(this.computeNavLinkURL(-1))}
+          >Prev</a
+        >
+        <span class="separator"></span>
+        <a
+          class="navLink"
+          title=${this.createTitle(
+            Shortcut.UP_TO_CHANGE,
+            ShortcutSection.NAVIGATION
+          )}
+          href=${this.getChangePath()}
+          >Up</a
+        >
+        <span class="separator"></span>
+        <a
+          class="navLink"
+          title=${this.createTitle(
+            Shortcut.NEXT_FILE,
+            ShortcutSection.NAVIGATION
+          )}
+          href=${ifDefined(this.computeNavLinkURL(1))}
+          >Next</a
+        >
+      </div>`;
+  }
+
+  private renderPatchRangeLeft() {
+    const revisionInfo = this.change
+      ? new RevisionInfoObj(this.change)
+      : undefined;
+    return html` <div class="patchRangeLeft">
+      <gr-patch-range-select
+        id="rangeSelect"
+        .changeNum=${this.changeNum}
+        .patchNum=${this.patchRange?.patchNum}
+        .basePatchNum=${this.patchRange?.basePatchNum}
+        .filesWeblinks=${this.filesWeblinks}
+        .availablePatches=${this.allPatchSets}
+        .revisions=${this.change?.revisions}
+        .revisionInfo=${revisionInfo}
+        @patch-range-change=${this.handlePatchChange}
+      >
+      </gr-patch-range-select>
+      <span class="download desktop">
+        <span class="separator"></span>
+        <gr-dropdown
+          link=""
+          down-arrow=""
+          .items=${this.computeDownloadDropdownLinks()}
+          horizontal-align="left"
+        >
+          <span class="downloadTitle"> Download </span>
+        </gr-dropdown>
+      </span>
+    </div>`;
+  }
+
+  private renderRightControls() {
+    const blameLoaderClass =
+      !isMagicPath(this.path) && !this.isImageDiff ? 'show' : '';
+    const blameToggleLabel =
+      this.isBlameLoaded && !this.isBlameLoading ? 'Hide blame' : 'Show blame';
+    const diffModeSelectorClass = !this.diff || this.diff.binary ? 'hide' : '';
+    return html` <div class="rightControls">
+      <span class="blameLoader ${blameLoaderClass}">
+        <gr-button
+          link=""
+          id="toggleBlame"
+          title=${this.createTitle(
+            Shortcut.TOGGLE_BLAME,
+            ShortcutSection.DIFFS
+          )}
+          ?disabled=${this.isBlameLoading}
+          @click=${this.toggleBlame}
+          >${blameToggleLabel}</gr-button
+        >
+      </span>
+      ${when(
+        this.computeCanEdit(),
+        () => html`
+          <span class="separator"></span>
+          <span class="editButton">
+            <gr-button
+              link=""
+              title="Edit current file"
+              @click=${this.goToEditFile}
+              >edit</gr-button
+            >
+          </span>
+        `
+      )}
+      ${when(
+        this.computeShowEditLinks(),
+        () => html`
+          <span class="separator"></span>
+          ${this.editWeblinks!.map(
+            weblink => html`
+              <a target="_blank" href=${ifDefined(weblink.url)}
+                >${weblink.name}</a
+              >
+            `
+          )}
+        `
+      )}
+      <span class="separator"></span>
+      <div class="diffModeSelector ${diffModeSelectorClass}">
+        <span>Diff view:</span>
+        <gr-diff-mode-selector
+          id="modeSelect"
+          .saveOnChange=${this.loggedIn}
+          show-tooltip-below
+        ></gr-diff-mode-selector>
+      </div>
+      ${when(
+        this.loggedIn && this.prefs,
+        () => html`
+          <span id="diffPrefsContainer">
+            <span class="preferences desktop">
+              <gr-tooltip-content
+                has-tooltip=""
+                position-below=""
+                title="Diff preferences"
+              >
+                <gr-button
+                  link=""
+                  class="prefsButton"
+                  @click=${(e: Event) => this.handlePrefsTap(e)}
+                  ><gr-icon icon="settings" filled></gr-icon
+                ></gr-button>
+              </gr-tooltip-content>
+            </span>
+          </span>
+        `
+      )}
+      <gr-endpoint-decorator name="annotation-toggler">
+        <span hidden="" id="annotation-span">
+          <label for="annotation-checkbox" id="annotation-label"></label>
+          <iron-input>
+            <input
+              is="iron-input"
+              type="checkbox"
+              id="annotation-checkbox"
+              disabled=""
+            />
+          </iron-input>
+        </span>
+      </gr-endpoint-decorator>
+    </div>`;
+  }
+
+  private renderDialogs() {
+    return html` <gr-apply-fix-dialog
+        id="applyFixDialog"
+        .change=${this.change}
+        .changeNum=${this.changeNum}
+      >
+      </gr-apply-fix-dialog>
+      <gr-diff-preferences-dialog
+        id="diffPreferencesDialog"
+        @reload-diff-preference=${this.handleReloadingDiffPreference}
+      >
+      </gr-diff-preferences-dialog>
+      <gr-overlay id="downloadOverlay">
+        <gr-download-dialog
+          id="downloadDialog"
+          .change=${this.change}
+          .patchNum=${this.patchRange?.patchNum}
+          .config=${this.serverConfig?.download}
+          @close=${this.handleDownloadDialogClose}
+        ></gr-download-dialog>
+      </gr-overlay>`;
+  }
+
   /**
    * Set initial review status of the file.
    * automatically mark the file as reviewed if manual review is not set.
    */
-
-  async setReviewedStatus(
-    currentPatchNum: PatchSetNum,
+  setReviewedStatus(
+    patchNum: RevisionPatchSetNum,
     diffPrefs: DiffPreferencesInfo
   ) {
-    const loggedIn = await this._getLoggedIn();
-    if (!loggedIn) return;
+    if (!this.loggedIn) return;
     if (!diffPrefs.manual_review) {
-      this._setReviewed(true, currentPatchNum as RevisionPatchSetNum);
+      this.setReviewed(true, patchNum);
     }
   }
 
-  @observe('_changeComments', '_path', '_patchRange')
-  computeThreads(
-    changeComments?: ChangeComments,
-    path?: string,
-    patchRange?: PatchRange
-  ) {
-    if (
-      changeComments === undefined ||
-      path === undefined ||
-      patchRange === undefined
-    ) {
-      return;
-    }
-    // TODO(dhruvsri): check if basePath should be set here
-    this.$.diffHost.threads = changeComments.getThreadsBySideForFile(
-      {path},
-      patchRange
-    );
-  }
-
-  _getLoggedIn(): Promise<boolean> {
-    return this.restApiService.getLoggedIn();
-  }
-
-  @observe('_change.project')
-  _getProjectConfig(project?: RepoName) {
-    if (!project) return;
-    return this.restApiService.getProjectConfig(project).then(config => {
-      this._projectConfig = config;
-    });
-  }
-
-  _getSortedFileList(files?: Files) {
-    if (!files) return [];
-    return files.sortedFileList;
-  }
-
-  _getCurrentFile(files?: Files, path?: string) {
-    if (!files || !path) return;
-    const fileInfo = files.changeFilesByPath[path];
-    const fileRange: FileRange = {path};
+  private getFileRange() {
+    if (!this.files || !this.path) return;
+    const fileInfo = this.files.changeFilesByPath[this.path];
+    const fileRange: FileRange = {path: this.path};
     if (fileInfo && fileInfo.old_path) {
       fileRange.basePath = fileInfo.old_path;
     }
     return fileRange;
   }
 
-  @observe('_changeNum', '_patchRange.*', '_changeComments')
-  _getFiles(
-    changeNum: NumericChangeId,
-    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>,
-    changeComments: ChangeComments
-  ) {
-    // Polymer 2: check for undefined
-    if (
-      [changeNum, patchRangeRecord, patchRangeRecord.base, changeComments].some(
-        arg => arg === undefined
-      )
-    ) {
+  // Private but used in tests.
+  fetchFiles() {
+    if (!this.changeNum || !this.patchRange || !this.changeComments) {
       return Promise.resolve();
     }
 
-    if (!patchRangeRecord.base.patchNum) {
+    if (!this.patchRange.patchNum) {
       return Promise.resolve();
     }
 
-    const patchRange = patchRangeRecord.base;
     return this.restApiService
-      .getChangeFiles(changeNum, patchRange)
+      .getChangeFiles(this.changeNum, this.patchRange)
       .then(changeFiles => {
         if (!changeFiles) return;
-        const commentedPaths = changeComments.getPaths(patchRange);
+        const commentedPaths = this.changeComments!.getPaths(this.patchRange);
         const files = {...changeFiles};
         addUnmodifiedFiles(files, commentedPaths);
-        this._files = {
+        this.files = {
           sortedFileList: Object.keys(files).sort(specialFilePathCompare),
           changeFilesByPath: files,
         };
       });
   }
 
-  _getPreferences() {
-    return this.restApiService.getPreferences();
+  private handleReviewedChange(e: Event) {
+    const input = e.target as HTMLInputElement;
+    this.setReviewed(input.checked ?? false);
   }
 
-  _handleReviewedChange(e: Event) {
-    this._setReviewed(
-      ((dom(e) as EventApi).rootTarget as HTMLInputElement).checked
-    );
-  }
-
-  _setReviewed(
+  // Private but used in tests.
+  setReviewed(
     reviewed: boolean,
-    patchNum: RevisionPatchSetNum | undefined = this._patchRange?.patchNum
+    patchNum: RevisionPatchSetNum | undefined = this.patchRange?.patchNum
   ) {
-    if (this._editMode) return;
-    if (!patchNum || !this._path || !this._changeNum) return;
-    const path = this._path;
+    if (this.computeEditMode()) return;
+    if (!patchNum || !this.path || !this.changeNum) return;
     // if file is already reviewed then do not make a saveReview request
-    if (this.reviewedFiles.has(path) && reviewed) return;
+    if (this.reviewedFiles.has(this.path) && reviewed) return;
     this.getChangeModel().setReviewedFilesStatus(
-      this._changeNum,
+      this.changeNum,
       patchNum,
-      path,
+      this.path,
       reviewed
     );
   }
 
-  _handleToggleFileReviewed() {
-    this._setReviewed(!this.$.reviewed.checked);
+  // Private but used in tests.
+  handleToggleFileReviewed() {
+    assertIsDefined(this.reviewed);
+    this.setReviewed(!this.reviewed.checked);
   }
 
-  _handlePrevLine() {
-    this.$.diffHost.displayLine = true;
+  private handlePrevLine() {
+    assertIsDefined(this.diffHost, 'diffHost');
+    this.diffHost.displayLine = true;
     this.cursor?.moveUp();
   }
 
-  _onOpenFixPreview(e: OpenFixPreviewEvent) {
-    this.$.applyFixDialog.open(e);
+  private onOpenFixPreview(e: OpenFixPreviewEvent) {
+    assertIsDefined(this.applyFixDialog, 'applyFixDialog');
+    this.applyFixDialog.open(e);
   }
 
-  _handleNextLine() {
-    this.$.diffHost.displayLine = true;
+  private onIsBlameLoadedChanged(e: ValueChangedEvent<boolean>) {
+    this.isBlameLoaded = e.detail.value;
+  }
+
+  private onDiffChanged(e: ValueChangedEvent<DiffInfo>) {
+    this.diff = e.detail.value;
+  }
+
+  private onEditWeblinksChanged(
+    e: ValueChangedEvent<GeneratedWebLink[] | undefined>
+  ) {
+    this.editWeblinks = e.detail.value;
+  }
+
+  private onFilesWeblinksChanged(
+    e: ValueChangedEvent<FilesWebLinks | undefined>
+  ) {
+    this.filesWeblinks = e.detail.value;
+  }
+
+  private onIsImageDiffChanged(e: ValueChangedEvent<boolean>) {
+    this.isImageDiff = e.detail.value;
+  }
+
+  private handleNextLine() {
+    assertIsDefined(this.diffHost, 'diffHost');
+    this.diffHost.displayLine = true;
     this.cursor?.moveDown();
   }
 
-  _moveToPreviousFileWithComment() {
-    if (!this._commentSkips) return;
-    if (!this._change) return;
-    if (!this._patchRange?.patchNum) return;
+  // Private but used in tests.
+  moveToPreviousFileWithComment() {
+    if (!this.commentSkips) return;
+    if (!this.change) return;
+    if (!this.patchRange?.patchNum) return;
 
     // If there is no previous diff with comments, then return to the change
     // view.
-    if (!this._commentSkips.previous) {
-      this._navToChangeView();
+    if (!this.commentSkips.previous) {
+      this.navToChangeView();
       return;
     }
 
-    GerritNav.navigateToDiff(
-      this._change,
-      this._commentSkips.previous,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        path: this.commentSkips.previous,
+        patchNum: this.patchRange.patchNum,
+        basePatchNum: this.patchRange.basePatchNum,
+      })
     );
   }
 
-  _moveToNextFileWithComment() {
-    if (!this._commentSkips) return;
-    if (!this._change) return;
-    if (!this._patchRange?.patchNum) return;
+  // Private but used in tests.
+  moveToNextFileWithComment() {
+    if (!this.commentSkips) return;
+    if (!this.change) return;
+    if (!this.patchRange?.patchNum) return;
 
     // If there is no next diff with comments, then return to the change view.
-    if (!this._commentSkips.next) {
-      this._navToChangeView();
+    if (!this.commentSkips.next) {
+      this.navToChangeView();
       return;
     }
 
-    GerritNav.navigateToDiff(
-      this._change,
-      this._commentSkips.next,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        path: this.commentSkips.next,
+        patchNum: this.patchRange.patchNum,
+        basePatchNum: this.patchRange.basePatchNum,
+      })
     );
   }
 
-  _handleNewComment() {
+  private handleNewComment() {
     this.classList.remove('hideComments');
     this.cursor?.createCommentInPlace();
   }
 
-  _handlePrevFile() {
-    if (!this._path) return;
-    if (!this._fileList) return;
-    this._navToFile(this._path, this._fileList, -1);
+  private handlePrevFile() {
+    if (!this.path) return;
+    if (!this.files?.sortedFileList) return;
+    this.navToFile(this.files.sortedFileList, -1);
   }
 
-  _handleNextFile() {
-    if (!this._path) return;
-    if (!this._fileList) return;
-    this._navToFile(this._path, this._fileList, 1);
+  private handleNextFile() {
+    if (!this.path) return;
+    if (!this.files?.sortedFileList) return;
+    this.navToFile(this.files.sortedFileList, 1);
   }
 
-  _handleNextChunk() {
+  private handleNextChunk() {
     const result = this.cursor?.moveToNextChunk();
     if (result === CursorMoveResult.CLIPPED && this.cursor?.isAtEnd()) {
       this.showToastAndNavigateFile('next', 'n');
     }
   }
 
-  _handleNextCommentThread() {
+  private handleNextCommentThread() {
     const result = this.cursor?.moveToNextCommentThread();
     if (result === CursorMoveResult.CLIPPED) {
-      this._navigateToNextFileWithCommentThread();
+      this.navigateToNextFileWithCommentThread();
     }
   }
 
@@ -746,74 +1249,72 @@
   }
 
   private navigateToUnreviewedFile(direction: string) {
-    if (!this._path) return;
-    if (!this._fileList) return;
+    if (!this.path) return;
+    if (!this.files?.sortedFileList) 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)
+    const unreviewedFiles = this.files.sortedFileList.filter(
+      file => file === this.path || !this.reviewedFiles.has(file)
     );
 
-    this._navToFile(this._path, unreviewedFiles, direction === 'next' ? 1 : -1);
+    this.navToFile(unreviewedFiles, direction === 'next' ? 1 : -1);
   }
 
-  _handlePrevChunk() {
+  private handlePrevChunk() {
     this.cursor?.moveToPreviousChunk();
     if (this.cursor?.isAtStart()) {
       this.showToastAndNavigateFile('previous', 'p');
     }
   }
 
-  _handlePrevCommentThread() {
+  private handlePrevCommentThread() {
     this.cursor?.moveToPreviousCommentThread();
   }
 
-  // Similar to gr-change-view._handleOpenReplyDialog
-  _handleOpenReplyDialog() {
-    this._getLoggedIn().then(isLoggedIn => {
-      if (!isLoggedIn) {
-        fireEvent(this, 'show-auth-required');
-        return;
-      }
+  // Similar to gr-change-view.handleOpenReplyDialog
+  private handleOpenReplyDialog() {
+    if (!this.loggedIn) {
+      fireEvent(this, 'show-auth-required');
+      return;
+    }
+    this.navToChangeView(true);
+  }
 
-      this.set('changeViewState.showReplyDialog', true);
-      fire(this, 'view-state-change-view-changed', {
-        value: this.changeViewState as ChangeViewState,
-      });
-      this._navToChangeView();
+  private handleToggleLeftPane() {
+    assertIsDefined(this.diffHost, 'diffHost');
+    this.diffHost.toggleLeftDiff();
+  }
+
+  private handleOpenDownloadDialog() {
+    assertIsDefined(this.downloadOverlay, 'downloadOverlay');
+    this.downloadOverlay.open().then(() => {
+      assertIsDefined(this.downloadOverlay, 'downloadOverlay');
+      assertIsDefined(this.downloadDialog, 'downloadOverlay');
+      this.downloadOverlay.setFocusStops(this.downloadDialog.getFocusStops());
+      this.downloadDialog.focus();
     });
   }
 
-  _handleToggleLeftPane() {
-    this.$.diffHost.toggleLeftDiff();
+  private handleDownloadDialogClose() {
+    assertIsDefined(this.downloadOverlay, 'downloadOverlay');
+    this.downloadOverlay.close();
   }
 
-  _handleOpenDownloadDialog() {
-    this.$.downloadOverlay.open().then(() => {
-      this.$.downloadOverlay.setFocusStops(
-        this.$.downloadDialog.getFocusStops()
-      );
-      this.$.downloadDialog.focus();
-    });
+  private handleUpToChange() {
+    this.navToChangeView();
   }
 
-  _handleDownloadDialogClose() {
-    this.$.downloadOverlay.close();
+  private handleCommaKey() {
+    if (!this.loggedIn) return;
+    assertIsDefined(this.diffPreferencesDialog, 'diffPreferencesDialog');
+    this.diffPreferencesDialog.open();
   }
 
-  _handleUpToChange() {
-    this._navToChangeView();
-  }
-
-  _handleCommaKey() {
-    if (!this._loggedIn) return;
-    this.$.diffPreferencesDialog.open();
-  }
-
-  _handleToggleDiffMode() {
-    if (!this._userPrefs) return;
-    if (this._userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
+  // Private but used in tests.
+  handleToggleDiffMode() {
+    if (!this.userPrefs) return;
+    if (this.userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
       this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
       this.userModel.updatePreferences({
@@ -822,33 +1323,35 @@
     }
   }
 
-  _navToChangeView() {
-    if (!this._changeNum || !this._patchRange?.patchNum) {
+  // Private but used in tests.
+  navToChangeView(openReplyDialog = false) {
+    if (!this.changeNum || !this.patchRange?.patchNum) {
       return;
     }
-    this._navigateToChange(
-      this._change,
-      this._patchRange,
-      this._change && this._change.revisions
+    this.navigateToChange(
+      this.change,
+      this.patchRange,
+      this.change && this.change.revisions,
+      openReplyDialog
     );
   }
 
-  _navToFile(
-    path: string,
+  // Private but used in tests.
+  navToFile(
     fileList: string[],
     direction: -1 | 1,
     navigateToFirstComment?: boolean
   ) {
-    const newPath = this._getNavLinkPath(path, fileList, direction);
+    const newPath = this.getNavLinkPath(fileList, direction);
     if (!newPath) return;
-    if (!this._change) return;
-    if (!this._patchRange) return;
+    if (!this.change) return;
+    if (!this.patchRange) return;
 
     if (newPath.up) {
-      this._navigateToChange(
-        this._change,
-        this._patchRange,
-        this._change && this._change.revisions
+      this.navigateToChange(
+        this.change,
+        this.patchRange,
+        this.change && this.change.revisions
       );
       return;
     }
@@ -856,67 +1359,58 @@
     if (!newPath.path) return;
     let lineNum;
     if (navigateToFirstComment)
-      lineNum = this._changeComments?.getCommentsForPath(
+      lineNum = this.changeComments?.getCommentsForPath(
         newPath.path,
-        this._patchRange
+        this.patchRange
       )?.[0].line;
-    GerritNav.navigateToDiff(
-      this._change,
-      newPath.path,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum,
-      lineNum
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        path: newPath.path,
+        patchNum: this.patchRange.patchNum,
+        basePatchNum: this.patchRange.basePatchNum,
+        lineNum,
+      })
     );
   }
 
   /**
-   * @param path The path of the current file being shown.
-   * @param fileList The list of files in this change and
-   * patch range.
    * @param direction Either 1 (next file) or -1 (prev file).
    * @return The next URL when proceeding in the specified
    * direction.
    */
-  _computeNavLinkURL(
-    change?: ChangeInfo,
-    path?: string,
-    fileList?: string[],
-    direction?: -1 | 1
-  ) {
-    if (!change) return null;
-    if (!path) return null;
-    if (!fileList) return null;
-    if (!direction) return null;
+  private computeNavLinkURL(direction?: -1 | 1) {
+    if (!this.change) return;
+    if (!this.path) return;
+    if (!this.files?.sortedFileList) return;
+    if (!direction) return;
 
-    const newPath = this._getNavLinkPath(path, fileList, direction);
+    const newPath = this.getNavLinkPath(this.files.sortedFileList, direction);
     if (!newPath) {
-      return null;
+      return;
     }
 
     if (newPath.up) {
-      return this._getChangePath(
-        this._change,
-        this._patchRange,
-        this._change && this._change.revisions
-      );
+      return this.getChangePath();
     }
-    return this._getDiffUrl(this._change, this._patchRange, newPath.path);
+    return this.getDiffUrl(this.change, this.patchRange, newPath.path);
   }
 
-  _goToEditFile() {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  private goToEditFile() {
+    if (!this.change) return;
+    if (!this.path) return;
+    if (!this.patchRange) return;
 
     // TODO(taoalpha): add a shortcut for editing
     const cursorAddress = this.cursor?.getAddress();
-    const editUrl = GerritNav.getEditUrlForDiff(
-      this._change,
-      this._path,
-      this._patchRange.patchNum,
-      cursorAddress?.number
-    );
-    GerritNav.navigateToRelativeUrl(editUrl);
+    const editUrl = createEditUrl({
+      changeNum: this.change._number,
+      project: this.change.project,
+      path: this.path,
+      patchNum: this.patchRange.patchNum,
+      lineNum: cursorAddress?.number,
+    });
+    this.getNavigation().setUrl(editUrl);
   }
 
   /**
@@ -934,12 +1428,12 @@
    * patch range.
    * @param direction Either 1 (next file) or -1 (prev file).
    */
-  _getNavLinkPath(path: string, fileList: string[], direction: -1 | 1) {
-    if (!path || !fileList || fileList.length === 0) {
+  private getNavLinkPath(fileList: string[], direction: -1 | 1) {
+    if (!this.path || !fileList || fileList.length === 0) {
       return null;
     }
 
-    let idx = fileList.indexOf(path);
+    let idx = fileList.indexOf(this.path);
     if (idx === -1) {
       const file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
       return {path: file};
@@ -955,138 +1449,140 @@
     return {path: fileList[idx]};
   }
 
-  _initLineOfInterestAndCursor(leftSide: boolean) {
-    this.$.diffHost.lineOfInterest = this._getLineOfInterest(leftSide);
-    this._initCursor(leftSide);
+  // Private but used in tests.
+  initLineOfInterestAndCursor(leftSide: boolean) {
+    assertIsDefined(this.diffHost, 'diffHost');
+    this.diffHost.lineOfInterest = this.getLineOfInterest(leftSide);
+    this.initCursor(leftSide);
   }
 
-  _displayDiffBaseAgainstLeftToast() {
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  displayDiffBaseAgainstLeftToast() {
+    if (!this.patchRange) return;
     fireAlert(
       this,
-      `Patchset ${this._patchRange.basePatchNum} vs ` +
-        `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
-        `Base vs ${this._patchRange.basePatchNum}`
+      `Patchset ${this.patchRange.basePatchNum} vs ` +
+        `${this.patchRange.patchNum} selected. Press v + \u2190 to view ` +
+        `Base vs ${this.patchRange.basePatchNum}`
     );
   }
 
-  _displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
-    if (!this._patchRange) return;
+  private displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
+    if (!this.patchRange) return;
     const leftPatchset =
-      this._patchRange.basePatchNum === ParentPatchSetNum
+      this.patchRange.basePatchNum === PARENT
         ? 'Base'
-        : `Patchset ${this._patchRange.basePatchNum}`;
+        : `Patchset ${this.patchRange.basePatchNum}`;
     fireAlert(
       this,
       `${leftPatchset} vs
-            ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
+            ${this.patchRange.patchNum} selected\n. Press v + \u2191 to view
             ${leftPatchset} vs Patchset ${latestPatchNum}`
     );
   }
 
-  _displayToasts() {
-    if (!this._patchRange) return;
-    if (this._patchRange.basePatchNum !== ParentPatchSetNum) {
-      this._displayDiffBaseAgainstLeftToast();
+  private displayToasts() {
+    if (!this.patchRange) return;
+    if (this.patchRange.basePatchNum !== PARENT) {
+      this.displayDiffBaseAgainstLeftToast();
       return;
     }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (this._patchRange.patchNum !== latestPatchNum) {
-      this._displayDiffAgainstLatestToast(latestPatchNum);
+    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
+    if (this.patchRange.patchNum !== latestPatchNum) {
+      this.displayDiffAgainstLatestToast(latestPatchNum);
       return;
     }
   }
 
-  _initCommitRange() {
+  private initCommitRange() {
     let commit: CommitId | undefined;
     let baseCommit: CommitId | undefined;
-    if (!this._change) return;
-    if (!this._patchRange || !this._patchRange.patchNum) return;
-    const revisions = this._change.revisions ?? {};
+    if (!this.change) return;
+    if (!this.patchRange || !this.patchRange.patchNum) return;
+    const revisions = this.change.revisions ?? {};
     for (const [commitSha, revision] of Object.entries(revisions)) {
       const patchNum = revision._number;
-      if (patchNum === this._patchRange.patchNum) {
+      if (patchNum === this.patchRange.patchNum) {
         commit = commitSha as CommitId;
         const commitObj = revision.commit;
         const parents = commitObj?.parents || [];
-        if (
-          this._patchRange.basePatchNum === ParentPatchSetNum &&
-          parents.length
-        ) {
+        if (this.patchRange.basePatchNum === PARENT && parents.length) {
           baseCommit = parents[parents.length - 1].commit;
         }
-      } else if (patchNum === this._patchRange.basePatchNum) {
+      } else if (patchNum === this.patchRange.basePatchNum) {
         baseCommit = commitSha as CommitId;
       }
     }
-    this._commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
+    this.commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
   }
 
-  _updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
-    if (!this._change) return;
-    if (!this._patchRange) return;
-    if (!this._changeNum) return;
-    if (!this._path) return;
-    const url = GerritNav.getUrlForDiffById(
-      this._changeNum,
-      this._change.project,
-      this._path,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum,
+  private updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
+    if (!this.change) return;
+    if (!this.patchRange) return;
+    if (!this.changeNum) return;
+    if (!this.path) return;
+    const url = createDiffUrl({
+      changeNum: this.changeNum,
+      project: this.change.project,
+      path: this.path,
+      patchNum: this.patchRange.patchNum,
+      basePatchNum: this.patchRange.basePatchNum,
       lineNum,
-      leftSide
-    );
+      leftSide,
+    });
     history.replaceState(null, '', url);
   }
 
-  _initPatchRange() {
+  // Private but used in tests.
+  initPatchRange() {
     let leftSide = false;
-    if (!this._change) return;
-    if (this.params?.view !== GerritView.DIFF) return;
-    if (this.params?.commentId) {
-      const comment = this._changeComments?.findCommentById(
-        this.params.commentId
+    if (!this.change) return;
+    if (this.viewState?.view !== GerritView.DIFF) return;
+    if (this.viewState?.commentId) {
+      const comment = this.changeComments?.findCommentById(
+        this.viewState.commentId
       );
       if (!comment) {
         fireAlert(this, 'comment not found');
-        GerritNav.navigateToChange(this._change);
+        this.getNavigation().setUrl(createChangeUrl({change: this.change}));
         return;
       }
       this.getChangeModel().updatePath(comment.path);
 
-      const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-      if (!latestPatchNum) throw new Error('Missing _allPatchSets');
-      this._patchRange = getPatchRangeForCommentUrl(comment, latestPatchNum);
-      leftSide = isInBaseOfPatchRange(comment, this._patchRange);
+      const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
+      if (!latestPatchNum) throw new Error('Missing allPatchSets');
+      this.patchRange = getPatchRangeForCommentUrl(comment, latestPatchNum);
+      leftSide = isInBaseOfPatchRange(comment, this.patchRange);
 
-      this._focusLineNum = comment.line;
+      this.focusLineNum = comment.line;
     } else {
-      if (this.params.path) {
-        this.getChangeModel().updatePath(this.params.path);
+      if (this.viewState.path) {
+        this.getChangeModel().updatePath(this.viewState.path);
       }
-      if (this.params.patchNum) {
-        this._patchRange = {
-          patchNum: this.params.patchNum,
-          basePatchNum: this.params.basePatchNum || ParentPatchSetNum,
+      if (this.viewState.patchNum) {
+        this.patchRange = {
+          patchNum: this.viewState.patchNum,
+          basePatchNum: this.viewState.basePatchNum || PARENT,
         };
       }
-      if (this.params.lineNum) {
-        this._focusLineNum = this.params.lineNum;
-        leftSide = !!this.params.leftSide;
+      if (this.viewState.lineNum) {
+        this.focusLineNum = this.viewState.lineNum;
+        leftSide = !!this.viewState.leftSide;
       }
     }
-    assertIsDefined(this._patchRange, '_patchRange');
-    this._initLineOfInterestAndCursor(leftSide);
+    assertIsDefined(this.patchRange, 'patchRange');
+    this.initLineOfInterestAndCursor(leftSide);
 
-    if (this.params?.commentId) {
+    if (this.viewState?.commentId) {
       // url is of type /comment/{commentId} which isn't meaningful
-      this._updateUrlToDiffUrl(this._focusLineNum, leftSide);
+      this.updateUrlToDiffUrl(this.focusLineNum, leftSide);
     }
 
-    this._commentMap = this._getPaths(this._patchRange);
+    this.commentMap = this.getPaths();
   }
 
-  _isFileUnchanged(diff?: DiffInfo) {
+  // Private but used in tests.
+  isFileUnchanged(diff?: DiffInfo) {
     if (!diff || !diff.content) return false;
     return !diff.content.some(
       content =>
@@ -1094,11 +1590,11 @@
     );
   }
 
-  private isSameDiffLoaded(value: AppElementDiffViewParam) {
+  private isSameDiffLoaded(value: DiffViewState) {
     return (
-      this._patchRange?.basePatchNum === value.basePatchNum &&
-      this._patchRange?.patchNum === value.patchNum &&
-      this._path === value.path
+      this.patchRange?.basePatchNum === value.basePatchNum &&
+      this.patchRange?.patchNum === value.patchNum &&
+      this.path === value.path
     );
   }
 
@@ -1116,10 +1612,10 @@
     );
   }
 
-  _paramsChanged(value: AppElementParams) {
-    if (value.view !== GerritView.DIFF) {
-      return;
-    }
+  // Private but used in tests.
+  viewStateChanged() {
+    if (this.viewState === undefined) return;
+    const viewState = this.viewState;
 
     // 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
@@ -1130,74 +1626,84 @@
 
     // 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) {
+    const changeChanged = this.changeNum !== viewState.changeNum;
+    if (this.changeNum !== undefined && changeChanged) {
       fireEvent(this, EventType.RECREATE_DIFF_VIEW);
       return;
-    } else if (this._changeNum !== undefined && this.isSameDiffLoaded(value)) {
+    } else if (
+      this.changeNum !== undefined &&
+      this.isSameDiffLoaded(viewState)
+    ) {
       // 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();
+      this.reInitCursor();
+      this.diffHost?.initLayers();
       return;
     }
 
-    this._files = {sortedFileList: [], changeFilesByPath: {}};
+    this.files = {sortedFileList: [], changeFilesByPath: {}};
     if (this.isConnected) {
       this.getChangeModel().updatePath(undefined);
     }
-    this._patchRange = undefined;
-    this._commitRange = undefined;
-    this._focusLineNum = undefined;
+    this.patchRange = undefined;
+    this.commitRange = undefined;
+    this.focusLineNum = undefined;
 
-    if (value.changeNum && value.project) {
-      this.restApiService.setInProjectLookup(value.changeNum, value.project);
+    if (viewState.changeNum && viewState.project) {
+      this.restApiService.setInProjectLookup(
+        viewState.changeNum,
+        viewState.project
+      );
     }
 
-    this._changeNum = value.changeNum;
+    this.changeNum = viewState.changeNum;
     this.classList.remove('hideComments');
 
     // When navigating away from the page, there is a possibility that the
     // patch number is no longer a part of the URL (say when navigating to
     // the top-level change info view) and therefore undefined in `params`.
     // If route is of type /comment/<commentId>/ then no patchNum is present
-    if (!value.patchNum && !value.commentLink) {
+    if (!viewState.patchNum && !viewState.commentLink) {
       this.reporting.error(
-        new Error(`Invalid diff view URL, no patchNum found: ${value}`)
+        'GrDiffView',
+        new Error(`Invalid diff view URL, no patchNum found: ${this.viewState}`)
       );
       return;
     }
 
     const promises: Promise<unknown>[] = [];
-    if (!this._change) {
+    if (!this.change) {
       promises.push(this.untilModelLoaded());
     }
     promises.push(this.waitUntilCommentsLoaded());
 
-    this.$.diffHost.cancel();
-    this.$.diffHost.clearDiffContent();
-    this._loading = true;
+    if (this.diffHost) {
+      this.diffHost.cancel();
+      this.diffHost.clearDiffContent();
+    }
+    this.loading = true;
     return Promise.all(promises)
       .then(() => {
-        this._loading = false;
-        this._initPatchRange();
-        this._initCommitRange();
-        return this.$.diffHost.reload(true);
+        this.loading = false;
+        this.initPatchRange();
+        this.initCommitRange();
+        return this.updateComplete.then(() => this.diffHost!.reload(true));
       })
       .then(() => {
         this.reporting.diffViewDisplayed();
       })
       .then(() => {
-        const fileUnchanged = this._isFileUnchanged(this._diff);
-        if (fileUnchanged && value.commentLink) {
-          assertIsDefined(this._change, '_change');
-          assertIsDefined(this._path, '_path');
-          assertIsDefined(this._patchRange, '_patchRange');
+        const fileUnchanged = this.isFileUnchanged(this.diff);
+        if (fileUnchanged && viewState.commentLink) {
+          assertIsDefined(this.change, 'change');
+          assertIsDefined(this.path, 'path');
+          assertIsDefined(this.patchRange, 'patchRange');
 
-          if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+          if (this.patchRange.basePatchNum === PARENT) {
             // file is unchanged between Base vs X
             // hence should not show diff between Base vs Base
             return;
@@ -1206,25 +1712,27 @@
           fireAlert(
             this,
             `File is unchanged between Patchset
-                  ${this._patchRange.basePatchNum} and
-                  ${this._patchRange.patchNum}. Showing diff of Base vs
-                  ${this._patchRange.basePatchNum}`
+                  ${this.patchRange.basePatchNum} and
+                  ${this.patchRange.patchNum}. Showing diff of Base vs
+                  ${this.patchRange.basePatchNum}`
           );
-          GerritNav.navigateToDiff(
-            this._change,
-            this._path,
-            this._patchRange.basePatchNum,
-            ParentPatchSetNum,
-            this._focusLineNum
+          this.getNavigation().setUrl(
+            createDiffUrl({
+              change: this.change,
+              path: this.path,
+              patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
+              basePatchNum: PARENT,
+              lineNum: this.focusLineNum,
+            })
           );
           return;
         }
-        if (value.commentLink) {
-          this._displayToasts();
+        if (viewState.commentLink) {
+          this.displayToasts();
         }
         // If the blame was loaded for a previous file and user navigates to
         // another file, then we load the blame for this file too
-        if (this._isBlameLoaded) this._loadBlame();
+        if (this.isBlameLoaded) this.loadBlame();
       });
   }
 
@@ -1235,9 +1743,10 @@
 
   /**
    * If the params specify a diff address then configure the diff cursor.
+   * Private but used in tests.
    */
-  _initCursor(leftSide: boolean) {
-    if (this._focusLineNum === undefined) {
+  initCursor(leftSide: boolean) {
+    if (this.focusLineNum === undefined) {
       return;
     }
     if (!this.cursor) return;
@@ -1246,47 +1755,42 @@
     } else {
       this.cursor.side = Side.RIGHT;
     }
-    this.cursor.initialLineNumber = this._focusLineNum;
+    this.cursor.initialLineNumber = this.focusLineNum;
   }
 
-  _getLineOfInterest(leftSide: boolean): DisplayLine | undefined {
+  // Private but used in tests.
+  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) {
+    if (!this.focusLineNum) {
       return undefined;
     }
 
     return {
-      lineNum: this._focusLineNum,
+      lineNum: this.focusLineNum,
       side: leftSide ? Side.LEFT : Side.RIGHT,
     };
   }
 
-  _pathChanged(path: string) {
-    if (path) {
-      fireTitleChange(this, computeTruncatedPath(path));
+  private pathChanged() {
+    if (this.path) {
+      fireTitleChange(this, computeTruncatedPath(this.path));
     }
-
-    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(
+  private getDiffUrl(
     change?: ChangeInfo | ParsedChangeInfo,
     patchRange?: PatchRange,
     path?: string
   ) {
     if (!change || !patchRange || !path) return '';
-    return GerritNav.getUrlForDiff(
-      change,
+    return createDiffUrl({
+      changeNum: change._number,
+      project: change.project,
       path,
-      patchRange.patchNum,
-      patchRange.basePatchNum
-    );
+      patchNum: patchRange.patchNum,
+      basePatchNum: patchRange.basePatchNum,
+    });
   }
 
   /**
@@ -1294,7 +1798,7 @@
    * patch) then the patch range need not appear in the URL. Return a patch
    * range object with undefined values when a range is not needed.
    */
-  _getChangeUrlRange(
+  private getChangeUrlRange(
     patchRange?: PatchRange,
     revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo}
   ) {
@@ -1308,7 +1812,7 @@
     }
     if (!patchRange) return {patchNum, basePatchNum};
     if (
-      patchRange.basePatchNum !== ParentPatchSetNum ||
+      patchRange.basePatchNum !== PARENT ||
       patchRange.patchNum !== latestPatchNum
     ) {
       patchNum = patchRange.patchNum;
@@ -1317,162 +1821,154 @@
     return {patchNum, basePatchNum};
   }
 
-  _getChangePath(
-    change?: ChangeInfo | ParsedChangeInfo,
-    patchRange?: PatchRange,
-    revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo}
-  ) {
-    if (!change) return '';
-    if (!patchRange) return '';
+  private getChangePath() {
+    if (!this.change) return '';
+    if (!this.patchRange) return '';
 
-    const range = this._getChangeUrlRange(patchRange, revisions);
-    return GerritNav.getUrlForChange(change, {
+    const range = this.getChangeUrlRange(
+      this.patchRange,
+      this.change.revisions
+    );
+    return createChangeUrl({
+      change: this.change,
       patchNum: range.patchNum,
       basePatchNum: range.basePatchNum,
     });
   }
 
-  _navigateToChange(
+  // Private but used in tests.
+  navigateToChange(
     change?: ChangeInfo | ParsedChangeInfo,
     patchRange?: PatchRange,
-    revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo}
+    revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo},
+    openReplyDialog?: boolean
   ) {
     if (!change) return;
-    const range = this._getChangeUrlRange(patchRange, revisions);
-    GerritNav.navigateToChange(change, {
-      patchNum: range.patchNum,
-      basePatchNum: range.basePatchNum,
-    });
+    const range = this.getChangeUrlRange(patchRange, revisions);
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change,
+        patchNum: range.patchNum,
+        basePatchNum: range.basePatchNum,
+        openReplyDialog: !!openReplyDialog,
+      })
+    );
   }
 
-  _computeChangePath(
-    change?: ChangeInfo,
-    patchRangeRecord?: PolymerDeepPropertyChange<PatchRange, PatchRange>,
-    revisions?: {[revisionId: string]: RevisionInfo}
-  ) {
-    if (!patchRangeRecord) return '';
-    return this._getChangePath(change, patchRangeRecord.base, revisions);
-  }
-
-  _formatFilesForDropdown(
-    files?: Files,
-    patchRange?: PatchRange,
-    changeComments?: ChangeComments
-  ): DropdownItem[] {
-    if (!files) return [];
-    if (!patchRange) return [];
-    if (!changeComments) return [];
+  // Private but used in tests
+  formatFilesForDropdown(): DropdownItem[] {
+    if (!this.files) return [];
+    if (!this.patchRange) return [];
+    if (!this.changeComments) return [];
 
     const dropdownContent: DropdownItem[] = [];
-    for (const path of files.sortedFileList) {
+    for (const path of this.files.sortedFileList) {
       dropdownContent.push({
         text: computeDisplayPath(path),
         mobileText: computeTruncatedPath(path),
         value: path,
-        bottomText: changeComments.computeCommentsString(
-          patchRange,
+        bottomText: this.changeComments.computeCommentsString(
+          this.patchRange,
           path,
-          files.changeFilesByPath[path],
+          this.files.changeFilesByPath[path],
           /* includeUnmodified= */ true
         ),
-        file: {...files.changeFilesByPath[path], __path: path},
+        file: {...this.files.changeFilesByPath[path], __path: path},
       });
     }
     return dropdownContent;
   }
 
-  _computePrefsButtonHidden(prefs?: DiffPreferencesInfo, loggedIn?: boolean) {
-    return !loggedIn || !prefs;
-  }
-
-  _handleFileChange(e: CustomEvent) {
-    if (!this._change) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handleFileChange(e: CustomEvent) {
+    if (!this.change) return;
+    if (!this.patchRange) return;
 
     // This is when it gets set initially.
     const path = e.detail.value;
-    if (path === this._path) {
+    if (path === this.path) {
       return;
     }
 
-    GerritNav.navigateToDiff(
-      this._change,
-      path,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        path,
+        patchNum: this.patchRange.patchNum,
+        basePatchNum: this.patchRange.basePatchNum,
+      })
     );
   }
 
-  _handlePatchChange(e: CustomEvent) {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handlePatchChange(e: CustomEvent) {
+    if (!this.change) return;
+    if (!this.path) return;
+    if (!this.patchRange) return;
 
     const {basePatchNum, patchNum} = e.detail;
     if (
-      basePatchNum === this._patchRange.basePatchNum &&
-      patchNum === this._patchRange.patchNum
+      basePatchNum === this.patchRange.basePatchNum &&
+      patchNum === this.patchRange.patchNum
     ) {
       return;
     }
-    GerritNav.navigateToDiff(this._change, this._path, patchNum, basePatchNum);
-  }
-
-  _handlePrefsTap(e: Event) {
-    e.preventDefault();
-    this.$.diffPreferencesDialog.open();
-  }
-
-  _computeModeSelectHideClass(diff?: DiffInfo) {
-    return !diff || diff.binary ? 'hide' : '';
-  }
-
-  _onLineSelected(
-    _: Event,
-    detail: {side: Side | CommentSide; number: number}
-  ) {
-    // for on-comment-anchor-tap side can be PARENT/REVISIONS
-    // for on-line-selected side can be left/right
-    this._updateUrlToDiffUrl(
-      detail.number,
-      detail.side === Side.LEFT || detail.side === CommentSide.PARENT
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        path: this.path,
+        patchNum,
+        basePatchNum,
+      })
     );
   }
 
-  _computeDownloadDropdownLinks(
-    project?: RepoName,
-    changeNum?: NumericChangeId,
-    patchRange?: PatchRange,
-    path?: string,
-    diff?: DiffInfo
-  ) {
-    if (!project) return [];
-    if (!changeNum) return [];
-    if (!patchRange || !patchRange.patchNum) return [];
-    if (!path) return [];
+  // Private but used in tests.
+  handlePrefsTap(e: Event) {
+    e.preventDefault();
+    assertIsDefined(this.diffPreferencesDialog, 'diffPreferencesDialog');
+    this.diffPreferencesDialog.open();
+  }
+
+  // Private but used in tests.
+  onLineSelected(e: CustomEvent) {
+    // for on-comment-anchor-tap side can be PARENT/REVISIONS
+    // for on-line-selected side can be left/right
+    this.updateUrlToDiffUrl(
+      e.detail.number,
+      e.detail.side === Side.LEFT || e.detail.side === CommentSide.PARENT
+    );
+  }
+
+  // Private but used in tests.
+  computeDownloadDropdownLinks() {
+    if (!this.change?.project) return [];
+    if (!this.changeNum) return [];
+    if (!this.patchRange?.patchNum) return [];
+    if (!this.path) return [];
 
     const links = [
       {
-        url: this._computeDownloadPatchLink(
-          project,
-          changeNum,
-          patchRange,
-          path
+        url: this.computeDownloadPatchLink(
+          this.change.project,
+          this.changeNum,
+          this.patchRange,
+          this.path
         ),
         name: 'Patch',
       },
     ];
 
-    if (diff && diff.meta_a) {
-      let leftPath = path;
-      if (diff.change_type === 'RENAMED') {
-        leftPath = diff.meta_a.name;
+    if (this.diff && this.diff.meta_a) {
+      let leftPath = this.path;
+      if (this.diff.change_type === 'RENAMED') {
+        leftPath = this.diff.meta_a.name;
       }
       links.push({
-        url: this._computeDownloadFileLink(
-          project,
-          changeNum,
-          patchRange,
+        url: this.computeDownloadFileLink(
+          this.change.project,
+          this.changeNum,
+          this.patchRange,
           leftPath,
           true
         ),
@@ -1480,13 +1976,13 @@
       });
     }
 
-    if (diff && diff.meta_b) {
+    if (this.diff && this.diff.meta_b) {
       links.push({
-        url: this._computeDownloadFileLink(
-          project,
-          changeNum,
-          patchRange,
-          path,
+        url: this.computeDownloadFileLink(
+          this.change.project,
+          this.changeNum,
+          this.patchRange,
+          this.path,
           false
         ),
         name: 'Right Content',
@@ -1496,7 +1992,8 @@
     return links;
   }
 
-  _computeDownloadFileLink(
+  // Private but used in tests.
+  computeDownloadFileLink(
     project: RepoName,
     changeNum: NumericChangeId,
     patchRange: PatchRange,
@@ -1504,25 +2001,27 @@
     isBase?: boolean
   ) {
     let patchNum = patchRange.patchNum;
+    let parent: number | undefined = undefined;
 
-    const comparedAgainstParent = patchRange.basePatchNum === 'PARENT';
-
-    if (isBase && !comparedAgainstParent) {
-      patchNum = patchRange.basePatchNum as RevisionPatchSetNum;
+    if (isBase) {
+      if (isMergeParent(patchRange.basePatchNum)) {
+        parent = getParentIndex(patchRange.basePatchNum);
+      } else if (patchRange.basePatchNum === PARENT) {
+        parent = 1;
+      } else {
+        patchNum = patchRange.basePatchNum as PatchSetNumber;
+      }
     }
-
     let url =
       changeBaseURL(project, changeNum, patchNum) +
       `/files/${encodeURIComponent(path)}/download`;
-
-    if (isBase && comparedAgainstParent) {
-      url += '?parent=1';
-    }
+    if (parent) url += `?parent=${parent}`;
 
     return url;
   }
 
-  _computeDownloadPatchLink(
+  // Private but used in tests.
+  computeDownloadPatchLink(
     project: RepoName,
     changeNum: NumericChangeId,
     patchRange: PatchRange,
@@ -1533,45 +2032,18 @@
     return url;
   }
 
-  @observe(
-    '_changeComments',
-    '_files.changeFilesByPath',
-    '_path',
-    '_patchRange',
-    '_projectConfig'
-  )
-  _recomputeComments(
-    changeComments?: ChangeComments,
-    files?: {[path: string]: FileInfo},
-    path?: string,
-    patchRange?: PatchRange,
-    projectConfig?: ConfigInfo
-  ) {
-    if (!files) return;
-    if (!path) return;
-    if (!patchRange) return;
-    if (!projectConfig) return;
-    if (!changeComments) return;
-
-    const file = files[path];
-    if (file && file.old_path) {
-      this.$.diffHost.threads = changeComments.getThreadsBySideForFile(
-        {path, basePath: file.old_path},
-        patchRange
-      );
-    }
+  // Private but used in tests.
+  getPaths(): CommentMap {
+    if (!this.changeComments) return {};
+    return this.changeComments.getPaths(this.patchRange);
   }
 
-  _getPaths(patchRange: PatchRange) {
-    if (!this._changeComments) return {};
-    return this._changeComments.getPaths(patchRange);
-  }
-
-  _computeCommentSkips(
+  // Private but used in tests.
+  computeCommentSkips(
     commentMap?: CommentMap,
     fileList?: string[],
     path?: string
-  ) {
+  ): CommentSkips | undefined {
     if (!commentMap) return undefined;
     if (!fileList) return undefined;
     if (!path) return undefined;
@@ -1601,32 +2073,24 @@
     return skips;
   }
 
-  _computeContainerClass(editMode: boolean) {
-    return editMode ? 'editMode' : '';
+  // Private but used in tests.
+  computeEditMode() {
+    return this.patchRange?.patchNum === EDIT;
   }
 
-  _computeEditMode(
-    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
-  ) {
-    const patchRange = patchRangeRecord.base || {};
-    return patchRange.patchNum === EditPatchSetNum;
-  }
-
-  _computeBlameToggleLabel(loaded?: boolean, loading?: boolean) {
-    return loaded && !loading ? 'Hide blame' : 'Show blame';
-  }
-
-  _loadBlame() {
-    this._isBlameLoading = true;
+  // Private but used in tests.
+  loadBlame() {
+    this.isBlameLoading = true;
     fireAlert(this, LOADING_BLAME);
-    this.$.diffHost
+    assertIsDefined(this.diffHost, 'diffHost');
+    this.diffHost
       .loadBlame()
       .then(() => {
-        this._isBlameLoading = false;
+        this.isBlameLoading = false;
         fireAlert(this, LOADED_BLAME);
       })
       .catch(() => {
-        this._isBlameLoading = false;
+        this.isBlameLoading = false;
       });
   }
 
@@ -1634,210 +2098,196 @@
    * Load and display blame information if it has not already been loaded.
    * Otherwise hide it.
    */
-  _toggleBlame() {
-    if (this._isBlameLoaded) {
-      this.$.diffHost.clearBlame();
+  private toggleBlame() {
+    assertIsDefined(this.diffHost, 'diffHost');
+    if (this.isBlameLoaded) {
+      this.diffHost.clearBlame();
       return;
     }
-    this._loadBlame();
+    this.loadBlame();
   }
 
-  _handleToggleBlame() {
-    this._toggleBlame();
-  }
-
-  _handleToggleHideAllCommentThreads() {
+  private handleToggleHideAllCommentThreads() {
     toggleClass(this, 'hideComments');
   }
 
-  _handleOpenFileList() {
-    this.$.dropdown.open();
+  private handleOpenFileList() {
+    assertIsDefined(this.dropdown, 'dropdown');
+    this.dropdown.open();
   }
 
-  _handleDiffAgainstBase() {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handleDiffAgainstBase() {
+    if (!this.change) return;
+    if (!this.path) return;
+    if (!this.patchRange) return;
 
-    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+    if (this.patchRange.basePatchNum === PARENT) {
       fireAlert(this, 'Base is already selected.');
       return;
     }
-    GerritNav.navigateToDiff(
-      this._change,
-      this._path,
-      this._patchRange.patchNum
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        path: this.path,
+        patchNum: this.patchRange.patchNum,
+      })
     );
   }
 
-  _handleDiffBaseAgainstLeft() {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handleDiffBaseAgainstLeft() {
+    if (!this.change) return;
+    if (!this.path) return;
+    if (!this.patchRange) return;
 
-    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+    if (this.patchRange.basePatchNum === PARENT) {
       fireAlert(this, 'Left is already base.');
       return;
     }
-    GerritNav.navigateToDiff(
-      this._change,
-      this._path,
-      this._patchRange.basePatchNum,
-      'PARENT' as BasePatchSetNum,
-      this.params?.view === GerritView.DIFF && this.params?.commentLink
-        ? this._focusLineNum
-        : undefined
+    const lineNum =
+      this.viewState?.view === GerritView.DIFF && this.viewState?.commentLink
+        ? this.focusLineNum
+        : undefined;
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        path: this.path,
+        patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
+        basePatchNum: PARENT,
+        lineNum,
+      })
     );
   }
 
-  _handleDiffAgainstLatest() {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handleDiffAgainstLatest() {
+    if (!this.change) return;
+    if (!this.path) return;
+    if (!this.patchRange) return;
 
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (this._patchRange.patchNum === latestPatchNum) {
+    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
+    if (this.patchRange.patchNum === latestPatchNum) {
       fireAlert(this, 'Latest is already selected.');
       return;
     }
 
-    GerritNav.navigateToDiff(
-      this._change,
-      this._path,
-      latestPatchNum,
-      this._patchRange.basePatchNum
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        path: this.path,
+        patchNum: latestPatchNum,
+        basePatchNum: this.patchRange.basePatchNum,
+      })
     );
   }
 
-  _handleDiffRightAgainstLatest() {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handleDiffRightAgainstLatest() {
+    if (!this.change) return;
+    if (!this.path) return;
+    if (!this.patchRange) return;
 
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (this._patchRange.patchNum === latestPatchNum) {
+    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
+    if (this.patchRange.patchNum === latestPatchNum) {
       fireAlert(this, 'Right is already latest.');
       return;
     }
-    GerritNav.navigateToDiff(
-      this._change,
-      this._path,
-      latestPatchNum,
-      this._patchRange.patchNum as BasePatchSetNum
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        path: this.path,
+        patchNum: latestPatchNum,
+        basePatchNum: this.patchRange.patchNum as BasePatchSetNum,
+      })
     );
   }
 
-  _handleDiffBaseAgainstLatest() {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handleDiffBaseAgainstLatest() {
+    if (!this.change) return;
+    if (!this.path) return;
+    if (!this.patchRange) return;
 
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
     if (
-      this._patchRange.patchNum === latestPatchNum &&
-      this._patchRange.basePatchNum === ParentPatchSetNum
+      this.patchRange.patchNum === latestPatchNum &&
+      this.patchRange.basePatchNum === PARENT
     ) {
       fireAlert(this, 'Already diffing base against latest.');
       return;
     }
-    GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        path: this.path,
+        patchNum: latestPatchNum,
+      })
+    );
   }
 
-  _computeBlameLoaderClass(isImageDiff?: boolean, path?: string) {
-    return !isMagicPath(path) && !isImageDiff ? 'show' : '';
+  // Private but used in tests.
+  computeFileNum(files: DropdownItem[]) {
+    if (!this.path || !files) return undefined;
+
+    return files.findIndex(({value}) => value === this.path) + 1;
   }
 
-  _getRevisionInfo(change: ChangeInfo) {
-    return new RevisionInfoObj(change);
-  }
-
-  _computeFileNum(file?: string, files?: DropdownItem[]) {
-    if (!file || !files) return undefined;
-
-    return files.findIndex(({value}) => value === file) + 1;
-  }
-
-  _computeFileNumClass(fileNum?: number, files?: DropdownItem[]) {
+  // Private but used in tests.
+  computeFileNumClass(fileNum?: number, files?: DropdownItem[]) {
     if (files && fileNum && fileNum > 0) {
       return 'show';
     }
     return '';
   }
 
-  _handleToggleAllDiffContext() {
-    this.$.diffHost.toggleAllContext();
+  private handleToggleAllDiffContext() {
+    assertIsDefined(this.diffHost, 'diffHost');
+    this.diffHost.toggleAllContext();
   }
 
-  _handleNextUnreviewedFile() {
-    this._setReviewed(true);
+  private handleNextUnreviewedFile() {
+    this.setReviewed(true);
     this.navigateToUnreviewedFile('next');
   }
 
-  _navigateToNextFileWithCommentThread() {
-    if (!this._path) return;
-    if (!this._fileList) return;
-    if (!this._patchRange) return;
-    if (!this._change) return;
+  private navigateToNextFileWithCommentThread() {
+    if (!this.path) return;
+    if (!this.files?.sortedFileList) return;
+    if (!this.patchRange) return;
+    if (!this.change) return;
     const hasComment = (path: string) =>
-      this._changeComments?.getCommentsForPath(path, this._patchRange!)
-        ?.length ?? 0 > 0;
-    const filesWithComments = this._fileList.filter(
-      file => file === this._path || hasComment(file)
+      this.changeComments?.getCommentsForPath(path, this.patchRange!)?.length ??
+      0 > 0;
+    const filesWithComments = this.files.sortedFileList.filter(
+      file => file === this.path || hasComment(file)
     );
-    this._navToFile(this._path, filesWithComments, 1, true);
+    this.navToFile(filesWithComments, 1, true);
   }
 
-  _handleReloadingDiffPreference() {
+  private handleReloadingDiffPreference() {
     this.userModel.getDiffPreferences();
   }
 
-  _computeCanEdit(
-    loggedIn?: boolean,
-    editWeblinks?: GeneratedWebLink[],
-    changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
-  ) {
-    if (!changeChangeRecord?.base) return false;
+  private computeCanEdit() {
     return (
-      loggedIn &&
-      changeIsOpen(changeChangeRecord.base) &&
-      (!editWeblinks || editWeblinks.length === 0)
+      !!this.change &&
+      !!this.loggedIn &&
+      changeIsOpen(this.change) &&
+      !this.computeShowEditLinks()
     );
   }
 
-  _computeShowEditLinks(editWeblinks?: GeneratedWebLink[]) {
-    return !!editWeblinks && editWeblinks.length > 0;
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeAllPatchSets(change: ChangeInfo) {
-    return computeAllPatchSets(change);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeDisplayPath(path: string) {
-    return computeDisplayPath(path);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeTruncatedPath(path?: string) {
-    return path ? computeTruncatedPath(path) : '';
+  private computeShowEditLinks() {
+    return !!this.editWeblinks && this.editWeblinks.length > 0;
   }
 
   createTitle(shortcutName: Shortcut, section: ShortcutSection) {
-    return this.shortcuts.createTitle(shortcutName, section);
+    return this.getShortcutsService().createTitle(shortcutName, section);
   }
 }
 
 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
deleted file mode 100644
index 677bca3..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ /dev/null
@@ -1,436 +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;
-      background-color: var(--view-background-color);
-    }
-    .hidden {
-      display: none;
-    }
-    gr-patch-range-select {
-      display: block;
-    }
-    gr-diff {
-      border: none;
-    }
-    .stickyHeader {
-      background-color: var(--view-background-color);
-      position: sticky;
-      top: 0;
-      /* TODO(dhruvsri): This is required only because of 'position:relative' in
-         <gr-diff-highlight> (which could maybe be removed??). */
-      z-index: 1;
-      box-shadow: var(--elevation-level-1);
-      /* This is just for giving the box-shadow some space. */
-      margin-bottom: 2px;
-    }
-    header,
-    .subHeader {
-      align-items: center;
-      display: flex;
-      justify-content: space-between;
-    }
-    header {
-      padding: var(--spacing-s) var(--spacing-xl);
-      border-bottom: 1px solid var(--border-color);
-    }
-    .changeNumberColon {
-      color: transparent;
-    }
-    .headerSubject {
-      margin-right: var(--spacing-m);
-      font-weight: var(--font-weight-bold);
-    }
-    .patchRangeLeft {
-      align-items: center;
-      display: flex;
-    }
-    .navLink:not([href]) {
-      color: var(--deemphasized-text-color);
-    }
-    .navLinks {
-      align-items: center;
-      display: flex;
-      white-space: nowrap;
-    }
-    .navLink {
-      padding: 0 var(--spacing-xs);
-    }
-    .reviewed {
-      display: inline-block;
-      margin: 0 var(--spacing-xs);
-      vertical-align: top;
-      position: relative;
-      top: 8px;
-    }
-    .jumpToFileContainer {
-      display: inline-block;
-      word-break: break-all;
-    }
-    .mobile {
-      display: none;
-    }
-    gr-button {
-      padding: var(--spacing-s) 0;
-      text-decoration: none;
-    }
-    .loading {
-      color: var(--deemphasized-text-color);
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h1);
-      font-weight: var(--font-weight-h1);
-      line-height: var(--line-height-h1);
-      height: 100%;
-      padding: var(--spacing-l);
-      text-align: center;
-    }
-    .subHeader {
-      background-color: var(--background-color-secondary);
-      flex-wrap: wrap;
-      padding: 0 var(--spacing-l);
-    }
-    .prefsButton {
-      text-align: right;
-    }
-    .editMode .hideOnEdit {
-      display: none;
-    }
-    .blameLoader,
-    .fileNum {
-      display: none;
-    }
-    .blameLoader.show,
-    .fileNum.show,
-    .download,
-    .preferences,
-    .rightControls {
-      align-items: center;
-      display: flex;
-    }
-    .diffModeSelector,
-    .editButton {
-      align-items: center;
-      display: flex;
-    }
-    .diffModeSelector span,
-    .editButton span {
-      margin-right: var(--spacing-xs);
-    }
-    .diffModeSelector.hide,
-    .separator.hide {
-      display: none;
-    }
-    .editButtona a {
-      text-decoration: none;
-    }
-    @media screen and (max-width: 50em) {
-      header {
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      .dash {
-        display: none;
-      }
-      .desktop {
-        display: none;
-      }
-      .fileNav {
-        align-items: flex-start;
-        display: flex;
-        margin: 0 var(--spacing-xs);
-      }
-      .fullFileName {
-        display: block;
-        font-style: italic;
-        min-width: 50%;
-        padding: 0 var(--spacing-xxs);
-        text-align: center;
-        width: 100%;
-        word-wrap: break-word;
-      }
-      .reviewed {
-        vertical-align: -1px;
-      }
-      .mobileNavLink {
-        color: var(--primary-text-color);
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h2);
-        font-weight: var(--font-weight-h2);
-        line-height: var(--line-height-h2);
-        text-decoration: none;
-      }
-      .mobileNavLink:not([href]) {
-        color: var(--deemphasized-text-color);
-      }
-      .jumpToFileContainer {
-        display: block;
-        width: 100%;
-        word-break: break-all;
-      }
-      gr-dropdown-list {
-        width: 100%;
-        --gr-select-style: {
-          display: block;
-          width: 100%;
-        }
-        --native-select-style: {
-          width: 100%;
-        }
-      }
-    }
-    :host(.hideComments) {
-      --gr-comment-thread-display: none;
-    }
-  </style>
-  <div class$="stickyHeader [[_computeContainerClass(_editMode)]]">
-    <h1 class="assistive-tech-only">
-      Diff of [[_computeTruncatedPath(_path)]]
-    </h1>
-    <header>
-      <div>
-        <a
-          href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]"
-          >[[_changeNum]]</a
-        ><!--
-       --><span class="changeNumberColon">:</span>
-        <span class="headerSubject">[[_change.subject]]</span>
-        <input
-          id="reviewed"
-          class="reviewed hideOnEdit"
-          type="checkbox"
-          on-change="_handleReviewedChange"
-          hidden$="[[!_loggedIn]]"
-          hidden=""
-          title="Toggle reviewed status of file"
-          aria-label="file reviewed"
-        /><!--
-       -->
-        <div class="jumpToFileContainer">
-          <gr-dropdown-list
-            id="dropdown"
-            value="[[_path]]"
-            on-value-change="_handleFileChange"
-            items="[[_formattedFiles]]"
-            initial-count="75"
-            show-copy-for-trigger-text
-          >
-          </gr-dropdown-list>
-        </div>
-      </div>
-      <div class="navLinks desktop">
-        <span
-          class$="fileNum [[_computeFileNumClass(_fileNum, _formattedFiles)]]"
-        >
-          File [[_fileNum]] of [[_formattedFiles.length]]
-          <span class="separator"></span>
-        </span>
-        <a
-          class="navLink"
-          title="[[createTitle(Shortcut.PREV_FILE,
-                    ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeNavLinkURL(_change, _path, _fileList, -1)]]"
-        >
-          Prev</a
-        >
-        <span class="separator"></span>
-        <a
-          class="navLink"
-          title="[[createTitle(Shortcut.UP_TO_CHANGE,
-                ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]"
-        >
-          Up</a
-        >
-        <span class="separator"></span>
-        <a
-          class="navLink"
-          title="[[createTitle(Shortcut.NEXT_FILE,
-                ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeNavLinkURL(_change, _path, _fileList, 1)]]"
-        >
-          Next</a
-        >
-      </div>
-    </header>
-    <div class="subHeader">
-      <div class="patchRangeLeft">
-        <gr-patch-range-select
-          id="rangeSelect"
-          change-num="[[_changeNum]]"
-          patch-num="[[_patchRange.patchNum]]"
-          base-patch-num="[[_patchRange.basePatchNum]]"
-          files-weblinks="[[_filesWeblinks]]"
-          available-patches="[[_allPatchSets]]"
-          revisions="[[_change.revisions]]"
-          revision-info="[[_revisionInfo]]"
-          on-patch-range-change="_handlePatchChange"
-        >
-        </gr-patch-range-select>
-        <span class="download desktop">
-          <span class="separator"></span>
-          <gr-dropdown
-            link=""
-            down-arrow=""
-            items="[[_computeDownloadDropdownLinks(_change.project, _changeNum, _patchRange, _path, _diff)]]"
-            horizontal-align="left"
-          >
-            <span class="downloadTitle"> Download </span>
-          </gr-dropdown>
-        </span>
-      </div>
-      <div class="rightControls">
-        <span
-          class$="blameLoader [[_computeBlameLoaderClass(_isImageDiff, _path)]]"
-        >
-          <gr-button
-            link=""
-            id="toggleBlame"
-            title="[[createTitle(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS)]]"
-            disabled="[[_isBlameLoading]]"
-            on-click="_toggleBlame"
-            >[[_computeBlameToggleLabel(_isBlameLoaded,
-            _isBlameLoading)]]</gr-button
-          >
-        </span>
-        <template
-          is="dom-if"
-          if="[[_computeCanEdit(_loggedIn, _editWeblinks, _change.*)]]"
-        >
-          <span class="separator"></span>
-          <span class="editButton">
-            <gr-button
-              link=""
-              title="Edit current file"
-              on-click="_goToEditFile"
-              >edit</gr-button
-            >
-          </span>
-        </template>
-        <template is="dom-if" if="[[_computeShowEditLinks(_editWeblinks)]]">
-          <span class="separator"></span>
-          <template is="dom-repeat" items="[[_editWeblinks]]" as="weblink">
-            <a target="_blank" href$="[[weblink.url]]">[[weblink.name]]</a>
-          </template>
-        </template>
-        <span class="separator"></span>
-        <div class$="diffModeSelector [[_computeModeSelectHideClass(_diff)]]">
-          <span>Diff view:</span>
-          <gr-diff-mode-selector
-            id="modeSelect"
-            save-on-change="[[_loggedIn]]"
-            show-tooltip-below=""
-          ></gr-diff-mode-selector>
-        </div>
-        <span
-          id="diffPrefsContainer"
-          hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]"
-          hidden=""
-        >
-          <span class="preferences desktop">
-            <gr-tooltip-content
-              has-tooltip=""
-              position-below=""
-              title="Diff preferences"
-            >
-              <gr-button link="" class="prefsButton" on-click="_handlePrefsTap"
-                ><iron-icon icon="gr-icons:settings"></iron-icon
-              ></gr-button>
-            </gr-tooltip-content>
-          </span>
-        </span>
-        <gr-endpoint-decorator name="annotation-toggler">
-          <span hidden="" id="annotation-span">
-            <label for="annotation-checkbox" id="annotation-label"></label>
-            <iron-input type="checkbox" disabled="">
-              <input
-                is="iron-input"
-                type="checkbox"
-                id="annotation-checkbox"
-                disabled=""
-              />
-            </iron-input>
-          </span>
-        </gr-endpoint-decorator>
-      </div>
-    </div>
-    <div class="fileNav mobile">
-      <a
-        class="mobileNavLink"
-        href$="[[_computeNavLinkURL(_change, _path, _fileList, -1)]]"
-      >
-        &lt;</a
-      >
-      <div class="fullFileName mobile">[[_computeDisplayPath(_path)]]</div>
-      <a
-        class="mobileNavLink"
-        href$="[[_computeNavLinkURL(_change, _path, _fileList, 1)]]"
-      >
-        &gt;</a
-      >
-    </div>
-  </div>
-  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-  <h2 class="assistive-tech-only">Diff view</h2>
-  <gr-diff-host
-    id="diffHost"
-    hidden=""
-    hidden$="[[_loading]]"
-    is-image-diff="{{_isImageDiff}}"
-    edit-weblinks="{{_editWeblinks}}"
-    files-weblinks="{{_filesWeblinks}}"
-    diff="{{_diff}}"
-    change-num="[[_changeNum]]"
-    change="[[_change]]"
-    commit-range="[[_commitRange]]"
-    patch-range="[[_patchRange]]"
-    file="[[_file]]"
-    path="[[_path]]"
-    prefs="[[_prefs]]"
-    project-name="[[_change.project]]"
-    is-blame-loaded="{{_isBlameLoaded}}"
-    on-comment-anchor-tap="_onLineSelected"
-    on-line-selected="_onLineSelected"
-  >
-  </gr-diff-host>
-  <gr-apply-fix-dialog
-    id="applyFixDialog"
-    prefs="[[_prefs]]"
-    change="[[_change]]"
-    change-num="[[_changeNum]]"
-  >
-  </gr-apply-fix-dialog>
-  <gr-diff-preferences-dialog
-    id="diffPreferencesDialog"
-    on-reload-diff-preference="_handleReloadingDiffPreference"
-  >
-  </gr-diff-preferences-dialog>
-  <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
deleted file mode 100644
index 331e527..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ /dev/null
@@ -1,2116 +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-diff-view.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.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 {Side} from '../../../api/diff.js';
-import {assertIsDefined} from '../../../utils/common-util.js';
-
-const basicFixture = fixtureFromElement('gr-diff-view');
-
-suite('gr-diff-view tests', () => {
-  suite('basic tests', () => {
-    let element;
-    let clock;
-    let diffCommentsStub;
-
-    const PARENT = 'PARENT';
-
-    function getFilesFromFileList(fileList) {
-      const changeFilesByPath = fileList.reduce((files, path) => {
-        files[path] = {};
-        return files;
-      }, {});
-      return {
-        sortedFileList: fileList,
-        changeFilesByPath,
-      };
-    }
-
-    setup(async () => {
-      stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
-      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-      stubRestApi('getChangeFiles').returns(Promise.resolve({}));
-      stubRestApi('saveFileReviewed').returns(Promise.resolve());
-      diffCommentsStub = stubRestApi('getDiffComments');
-      diffCommentsStub.returns(Promise.resolve({}));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      stubRestApi('getPortedComments').returns(Promise.resolve({}));
-
-      element = basicFixture.instantiate();
-      element._changeNum = '42';
-      element._path = 'some/path.txt';
-      element._change = {};
-      element._diff = {content: []};
-      element._patchRange = {
-        patchNum: 77,
-        basePatchNum: 'PARENT',
-      };
-      element._changeComments = new ChangeComments({'/COMMIT_MSG': [
-        {
-          ...createComment(),
-          id: 'c1',
-          line: 10,
-          patch_set: 2,
-          path: '/COMMIT_MSG',
-        }, {
-          ...createComment(),
-          id: 'c3',
-          line: 10,
-          patch_set: 'PARENT',
-          path: '/COMMIT_MSG',
-        },
-      ]});
-      await flush();
-
-      element.getCommentsModel().setState({
-        comments: {},
-        robotComments: {},
-        drafts: {},
-        portedComments: {},
-        portedDrafts: {},
-        discardedDrafts: [],
-      });
-    });
-
-    teardown(() => {
-      clock && clock.restore();
-      sinon.restore();
-    });
-
-    test('params change triggers diffViewDisplayed()', () => {
-      sinon.stub(element.reporting, 'diffViewDisplayed');
-      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-      sinon.stub(element, '_initPatchRange');
-      sinon.stub(element, '_getFiles');
-      sinon.spy(element, '_paramsChanged');
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: 2,
-        basePatchNum: 1,
-        path: '/COMMIT_MSG',
-      };
-      element._path = '/COMMIT_MSG';
-      element._patchRange = {};
-      return element._paramsChanged.returnValues[0].then(() => {
-        assert.isTrue(element.reporting.diffViewDisplayed.calledOnce);
-      });
-    });
-
-    suite('comment route', () => {
-      let initLineOfInterestAndCursorStub; let getUrlStub; let replaceStateStub;
-      setup(() => {
-        initLineOfInterestAndCursorStub =
-        sinon.stub(element, '_initLineOfInterestAndCursor');
-        getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
-        replaceStateStub = sinon.stub(history, 'replaceState');
-        sinon.stub(element, '_getFiles');
-        sinon.stub(element.reporting, 'diffViewDisplayed');
-        sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-        sinon.spy(element, '_paramsChanged');
-        element.getChangeModel().setState({
-          change: {
-            ...createChange(),
-            revisions: createRevisions(11),
-          }});
-      });
-
-      test('comment url resolves to comment.patch_set vs latest', () => {
-        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',
-          commentLink: true,
-          commentId: 'c1',
-          path: 'abcd',
-        };
-        element._change = {
-          ...createChange(),
-          revisions: createRevisions(11),
-        };
-        return element._paramsChanged.returnValues[0].then(() => {
-          assert.isTrue(initLineOfInterestAndCursorStub.
-              calledWithExactly(true));
-          assert.equal(element._focusLineNum, 10);
-          assert.equal(element._patchRange.patchNum, 11);
-          assert.equal(element._patchRange.basePatchNum, 2);
-          assert.isTrue(replaceStateStub.called);
-          assert.isTrue(getUrlStub.calledWithExactly('42', 'test-project',
-              '/COMMIT_MSG', 11, 2, 10, true));
-        });
-      });
-    });
-
-    test('params change causes blame to load if it was set to true', () => {
-      // Blame loads for subsequent files if it was loaded for one file
-      element._isBlameLoaded = true;
-      sinon.stub(element.reporting, 'diffViewDisplayed');
-      sinon.stub(element, '_loadBlame');
-      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-      sinon.spy(element, '_paramsChanged');
-      sinon.stub(element, '_initPatchRange');
-      sinon.stub(element, '_getFiles');
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: 2,
-        basePatchNum: 1,
-        path: '/COMMIT_MSG',
-      };
-      element._path = '/COMMIT_MSG';
-      element._patchRange = {};
-      return element._paramsChanged.returnValues[0].then(() => {
-        assert.isTrue(element._isBlameLoaded);
-        assert.isTrue(element._loadBlame.calledOnce);
-      });
-    });
-
-    test('unchanged diff X vs latest from comment links navigates to base vs X'
-        , () => {
-          const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-          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');
-          element.getChangeModel().setState({
-            change: {
-              ...createChange(),
-              revisions: createRevisions(11),
-            }});
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '42',
-            path: '/COMMIT_MSG',
-            commentLink: true,
-            commentId: 'c1',
-          };
-          element._change = {
-            ...createChange(),
-            revisions: createRevisions(11),
-          };
-          return element._paramsChanged.returnValues[0].then(() => {
-            assert.isTrue(diffNavStub.lastCall.calledWithExactly(
-                element._change, '/COMMIT_MSG', 2, 'PARENT', 10));
-          });
-        });
-
-    test('unchanged diff Base vs latest from comment does not navigate'
-        , () => {
-          const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-          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');
-          element.getChangeModel().setState({
-            change: {
-              ...createChange(),
-              revisions: createRevisions(11),
-            }});
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '42',
-            path: '/COMMIT_MSG',
-            commentLink: true,
-            commentId: 'c3',
-          };
-          element._change = {
-            ...createChange(),
-            revisions: createRevisions(11),
-          };
-          return element._paramsChanged.returnValues[0].then(() => {
-            assert.isFalse(diffNavStub.called);
-          });
-        });
-
-    test('_isFileUnchanged', () => {
-      let diff = {
-        content: [
-          {a: 'abcd', ab: 'ef'},
-          {b: 'ancd', a: 'xx'},
-        ],
-      };
-      assert.equal(element._isFileUnchanged(diff), false);
-      diff = {
-        content: [
-          {ab: 'abcd'},
-          {ab: 'ancd'},
-        ],
-      };
-      assert.equal(element._isFileUnchanged(diff), true);
-      diff = {
-        content: [
-          {a: 'abcd', ab: 'ef', common: true},
-          {b: 'ancd', ab: 'xx'},
-        ],
-      };
-      assert.equal(element._isFileUnchanged(diff), false);
-      diff = {
-        content: [
-          {a: 'abcd', ab: 'ef', common: true},
-          {b: 'ancd', ab: 'xx', common: true},
-        ],
-      };
-      assert.equal(element._isFileUnchanged(diff), true);
-    });
-
-    test('diff toast to go to latest is shown and not base', async () => {
-      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;
-      element.getChangeModel().setState({
-        change: {
-          ...createChange(),
-          revisions: createRevisions(11),
-        }});
-      element._patchRange = {
-        patchNum: 2,
-        basePatchNum: 1,
-      };
-      sinon.stub(element, '_isFileUnchanged').returns(false);
-      const toastStub =
-          sinon.stub(element, '_displayDiffBaseAgainstLeftToast');
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        project: 'p',
-        commentId: 'c1',
-        commentLink: true,
-      };
-      await element._paramsChanged.returnValues[0];
-      assert.isTrue(toastStub.called);
-    });
-
-    test('toggle left diff with a hotkey', () => {
-      const toggleLeftDiffStub = sinon.stub(
-          element.$.diffHost, 'toggleLeftDiff');
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'A');
-      assert.isTrue(toggleLeftDiffStub.calledOnce);
-    });
-
-    test('keyboard shortcuts', () => {
-      clock = sinon.useFakeTimers();
-      element._changeNum = '42';
-      element.getBrowserModel().setScreenWidth(0);
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 10,
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 10, commit: {parents: []}},
-        },
-      };
-      element._files = getFilesFromFileList(
-          ['chell.go', 'glados.txt', 'wheatley.md']);
-      element._path = 'glados.txt';
-      element.changeViewState.selectedFileIndex = 1;
-      element._loggedIn = true;
-
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWith(element._change),
-          'Should navigate to /c/42/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'wheatley.md',
-          10, PARENT), 'Should navigate to /c/42/10/wheatley.md');
-      element._path = 'wheatley.md';
-      assert.equal(element.changeViewState.selectedFileIndex, 2);
-      assert.isTrue(element._loading);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'glados.txt',
-          10, PARENT), 'Should navigate to /c/42/10/glados.txt');
-      element._path = 'glados.txt';
-      assert.equal(element.changeViewState.selectedFileIndex, 1);
-      assert.isTrue(element._loading);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', 10,
-          PARENT), 'Should navigate to /c/42/10/chell.go');
-      element._path = 'chell.go';
-      assert.equal(element.changeViewState.selectedFileIndex, 0);
-      assert.isTrue(element._loading);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(changeNavStub.lastCall.calledWith(element._change),
-          'Should navigate to /c/42/');
-      assert.equal(element.changeViewState.selectedFileIndex, 0);
-      assert.isTrue(element._loading);
-
-      const showPrefsStub =
-          sinon.stub(element.$.diffPreferencesDialog, 'open').callsFake(
-              () => Promise.resolve());
-
-      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);
-
-      scrollStub = sinon.stub(element.cursor, 'moveToPreviousChunk');
-      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-      assert(scrollStub.calledOnce);
-
-      scrollStub = sinon.stub(element.cursor, 'moveToNextCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
-      assert(scrollStub.calledOnce);
-
-      scrollStub = sinon.stub(element.cursor,
-          'moveToPreviousCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'P');
-      assert(scrollStub.calledOnce);
-
-      const computeContainerClassStub = sinon.stub(element.$.diffHost.$.diff,
-          '_computeContainerClass');
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert(computeContainerClassStub.lastCall.calledWithExactly(
-          false, DiffViewMode.SIDE_BY_SIDE, true));
-
-      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'Escape');
-      assert(computeContainerClassStub.lastCall.calledWithExactly(
-          false, DiffViewMode.SIDE_BY_SIDE, false));
-
-      // Note that stubbing _setReviewed means that the value of the
-      // `element.$.reviewed` checkbox is not flipped.
-      sinon.stub(element, '_setReviewed');
-      sinon.spy(element, '_handleToggleFileReviewed');
-      element.$.reviewed.checked = false;
-      assert.isFalse(element._handleToggleFileReviewed.called);
-      assert.isFalse(element._setReviewed.called);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-      assert.isTrue(element._handleToggleFileReviewed.calledOnce);
-      assert.isTrue(element._setReviewed.calledOnce);
-      assert.equal(element._setReviewed.lastCall.args[0], true);
-
-      // Handler is throttled, so another key press within 500 ms is ignored.
-      clock.tick(100);
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-      assert.isTrue(element._handleToggleFileReviewed.calledOnce);
-      assert.isTrue(element._setReviewed.calledOnce);
-
-      clock.tick(1000);
-      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 = {
-        'wheatley.md': [{
-          ...createComment(),
-          patch_set: 10,
-          line: 21,
-        }],
-      };
-      element._changeComments = new ChangeComments(comment);
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 10,
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 10, commit: {parents: []}},
-        },
-      };
-      element._files = getFilesFromFileList(
-          ['chell.go', 'glados.txt', 'wheatley.md']);
-      element._path = 'glados.txt';
-      element.changeViewState.selectedFileIndex = 1;
-      element._loggedIn = true;
-
-      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
-      flush();
-      assert.isTrue(diffNavStub.calledWithExactly(
-          element._change, 'wheatley.md', 10, PARENT, 21));
-
-      element._path = 'wheatley.md'; // navigated to next file
-
-      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
-      flush();
-
-      assert.isTrue(diffChangeStub.called);
-    });
-
-    test('shift+x shortcut toggles all diff context', () => {
-      const toggleStub = sinon.stub(element.$.diffHost, 'toggleAllContext');
-      MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'X');
-      flush();
-      assert.isTrue(toggleStub.called);
-    });
-
-    test('diff against base', () => {
-      element._patchRange = {
-        basePatchNum: 5,
-        patchNum: 10,
-      };
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      element._handleDiffAgainstBase(new CustomEvent(''));
-      const args = diffNavStub.getCall(0).args;
-      assert.equal(args[2], 10);
-      assert.isNotOk(args[3]);
-    });
-
-    test('diff against latest', () => {
-      element._change = {
-        ...createChange(),
-        revisions: createRevisions(12),
-      };
-      element._patchRange = {
-        basePatchNum: 5,
-        patchNum: 10,
-      };
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      element._handleDiffAgainstLatest(new CustomEvent(''));
-      const args = diffNavStub.getCall(0).args;
-      assert.equal(args[2], 12);
-      assert.equal(args[3], 5);
-    });
-
-    test('_handleDiffBaseAgainstLeft', () => {
-      element._change = {
-        ...createChange(),
-        revisions: createRevisions(10),
-      };
-      element._patchRange = {
-        patchNum: 3,
-        basePatchNum: 1,
-      };
-      element.params = {};
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      element._handleDiffBaseAgainstLeft(new CustomEvent(''));
-      assert(diffNavStub.called);
-      const args = diffNavStub.getCall(0).args;
-      assert.equal(args[2], 1);
-      assert.equal(args[3], 'PARENT');
-      assert.isNotOk(args[4]);
-    });
-
-    test('_handleDiffBaseAgainstLeft when initially navigating to a comment',
-        () => {
-          element._change = {
-            ...createChange(),
-            revisions: createRevisions(10),
-          };
-          element._patchRange = {
-            patchNum: 3,
-            basePatchNum: 1,
-          };
-          sinon.stub(element, '_paramsChanged');
-          element.params = {commentLink: true, view: GerritView.DIFF};
-          element._focusLineNum = 10;
-          const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-          element._handleDiffBaseAgainstLeft(new CustomEvent(''));
-          assert(diffNavStub.called);
-          const args = diffNavStub.getCall(0).args;
-          assert.equal(args[2], 1);
-          assert.equal(args[3], 'PARENT');
-          assert.equal(args[4], 10);
-        });
-
-    test('_handleDiffRightAgainstLatest', () => {
-      element._change = {
-        ...createChange(),
-        revisions: createRevisions(10),
-      };
-      element._patchRange = {
-        basePatchNum: 1,
-        patchNum: 3,
-      };
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      element._handleDiffRightAgainstLatest(new CustomEvent(''));
-      assert(diffNavStub.called);
-      const args = diffNavStub.getCall(0).args;
-      assert.equal(args[2], 10);
-      assert.equal(args[3], 3);
-    });
-
-    test('_handleDiffBaseAgainstLatest', () => {
-      element._change = {
-        ...createChange(),
-        revisions: createRevisions(10),
-      };
-      element._patchRange = {
-        basePatchNum: 1,
-        patchNum: 3,
-      };
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      element._handleDiffBaseAgainstLatest(new CustomEvent(''));
-      assert(diffNavStub.called);
-      const args = diffNavStub.getCall(0).args;
-      assert.equal(args[2], 10);
-      assert.isNotOk(args[3]);
-    });
-
-    test('A fires an error event when not logged in', async () => {
-      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
-      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
-      const loggedInErrorSpy = sinon.spy();
-      element.addEventListener('show-auth-required', loggedInErrorSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      await flush();
-      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
-        'should only work when the user is logged in.');
-      assert.isNull(window.sessionStorage.getItem(
-          'changeView.showReplyDialog'));
-      assert.isTrue(loggedInErrorSpy.called);
-    });
-
-    test('A navigates to change with logged in', async () => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 5,
-        patchNum: 10,
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 10, commit: {parents: []}},
-          b: {_number: 5, commit: {parents: []}},
-        },
-      };
-      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
-      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-      const loggedInErrorSpy = sinon.spy();
-      element.addEventListener('show-auth-required', loggedInErrorSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      await flush();
-      assert.isTrue(element.changeViewState.showReplyDialog);
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, {
-        patchNum: 10, basePatchNum: 5}), 'Should navigate to /c/42/5..10');
-      assert.isFalse(loggedInErrorSpy.called);
-    });
-
-    test('A navigates to change with old patch number with logged in',
-        async () => {
-          element._changeNum = '42';
-          element._patchRange = {
-            basePatchNum: PARENT,
-            patchNum: 1,
-          };
-          element._change = {
-            _number: 42,
-            revisions: {
-              a: {_number: 1, commit: {parents: []}},
-              b: {_number: 2, commit: {parents: []}},
-            },
-          };
-          const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
-          sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-          const loggedInErrorSpy = sinon.spy();
-          element.addEventListener('show-auth-required', loggedInErrorSpy);
-          MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-          await flush();
-          assert.isTrue(element.changeViewState.showReplyDialog);
-          assert(changeNavStub.lastCall.calledWithExactly(element._change, {
-            patchNum: 1, basePatchNum: PARENT}), 'Should navigate to /c/42/1');
-          assert.isFalse(loggedInErrorSpy.called);
-        });
-
-    test('keyboard shortcuts with patch range', () => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 5,
-        patchNum: 10,
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 10, commit: {parents: []}},
-          b: {_number: 5, commit: {parents: []}},
-        },
-      };
-      element._files = getFilesFromFileList(
-          ['chell.go', 'glados.txt', 'wheatley.md']);
-      element._path = 'glados.txt';
-
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      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);
-      assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'wheatley.md', 10, 5, undefined),
-      'Should navigate to /c/42/5..10/wheatley.md');
-      element._path = 'wheatley.md';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert.isTrue(element._loading);
-      assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'glados.txt', 10, 5, undefined),
-      'Should navigate to /c/42/5..10/glados.txt');
-      element._path = 'glados.txt';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert.isTrue(element._loading);
-      assert(diffNavStub.lastCall.calledWithExactly(
-          element._change,
-          'chell.go',
-          10,
-          5,
-          undefined),
-      'Should navigate to /c/42/5..10/chell.go');
-      element._path = 'chell.go';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert.isTrue(element._loading);
-      assert(changeNavStub.lastCall.calledWithExactly(element._change,
-          {patchNum: 10, basePatchNum: 5}),
-      'Should navigate to /c/42/5..10');
-
-      const downloadOverlayStub = sinon.stub(element.$.downloadOverlay, 'open')
-          .returns(Promise.resolve());
-      MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
-      assert.isTrue(downloadOverlayStub.called);
-    });
-
-    test('keyboard shortcuts with old patch number', () => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1,
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 1, commit: {parents: []}},
-          b: {_number: 2, commit: {parents: []}},
-        },
-      };
-      element._files = getFilesFromFileList(
-          ['chell.go', 'glados.txt', 'wheatley.md']);
-      element._path = 'glados.txt';
-
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      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,
-          'wheatley.md', 1, PARENT, undefined),
-      'Should navigate to /c/42/1/wheatley.md');
-      element._path = 'wheatley.md';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'glados.txt', 1, PARENT, undefined),
-      'Should navigate to /c/42/1/glados.txt');
-      element._path = 'glados.txt';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWithExactly(
-          element._change,
-          'chell.go',
-          1,
-          PARENT,
-          undefined), 'Should navigate to /c/42/1/chell.go');
-      element._path = 'chell.go';
-
-      changeNavStub.reset();
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change,
-          {patchNum: 1, basePatchNum: PARENT}), 'Should navigate to /c/42/1');
-      assert.isTrue(changeNavStub.calledOnce);
-    });
-
-    test('edit should redirect to edit page', async () => {
-      element._loggedIn = true;
-      element._path = 't.txt';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1,
-      };
-      element._change = {
-        _number: 42,
-        project: 'gerrit',
-        status: ChangeStatus.NEW,
-        revisions: {
-          a: {_number: 1, commit: {parents: []}},
-          b: {_number: 2, commit: {parents: []}},
-        },
-      };
-      const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
-      await flush();
-      const editBtn = element.shadowRoot
-          .querySelector('.editButton gr-button');
-      assert.isTrue(!!editBtn);
-      MockInteractions.tap(editBtn);
-      assert.isTrue(redirectStub.called);
-      assert.isTrue(redirectStub.lastCall.calledWithExactly(
-          GerritNav.getEditUrlForDiff(
-              element._change,
-              element._path,
-              element._patchRange.patchNum
-          )));
-    });
-
-    test('edit should redirect to edit page with line number', async () => {
-      const lineNumber = 42;
-      element._loggedIn = true;
-      element._path = 't.txt';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1,
-      };
-      element._change = {
-        _number: 42,
-        project: 'gerrit',
-        status: ChangeStatus.NEW,
-        revisions: {
-          a: {_number: 1, commit: {parents: []}},
-          b: {_number: 2, commit: {parents: []}},
-        },
-      };
-      assertIsDefined(element.cursor);
-      sinon.stub(element.cursor, 'getAddress')
-          .returns({number: lineNumber, isLeftSide: false});
-      const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
-      await flush();
-      const editBtn = element.shadowRoot
-          .querySelector('.editButton gr-button');
-      assert.isTrue(!!editBtn);
-      MockInteractions.tap(editBtn);
-      assert.isTrue(redirectStub.called);
-      assert.isTrue(redirectStub.lastCall.calledWithExactly(
-          GerritNav.getEditUrlForDiff(
-              element._change,
-              element._path,
-              element._patchRange.patchNum,
-              lineNumber
-          )));
-    });
-
-    function isEditVisibile({loggedIn, changeStatus}) {
-      return new Promise(resolve => {
-        element._loggedIn = loggedIn;
-        element._path = 't.txt';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: 1,
-        };
-        element._change = {
-          _number: 42,
-          status: changeStatus,
-          revisions: {
-            a: {_number: 1, commit: {parents: []}},
-            b: {_number: 2, commit: {parents: []}},
-          },
-        };
-        flush(() => {
-          const editBtn = element.shadowRoot
-              .querySelector('.editButton gr-button');
-          resolve(!!editBtn);
-        });
-      });
-    }
-
-    test('edit visible only when logged and status NEW', async () => {
-      for (const changeStatus of Object.keys(ChangeStatus)) {
-        assert.isFalse(await isEditVisibile({loggedIn: false, changeStatus}),
-            `loggedIn: false, changeStatus: ${changeStatus}`);
-
-        if (changeStatus !== ChangeStatus.NEW) {
-          assert.isFalse(await isEditVisibile({loggedIn: true, changeStatus}),
-              `loggedIn: true, changeStatus: ${changeStatus}`);
-        } else {
-          assert.isTrue(await isEditVisibile({loggedIn: true, changeStatus}),
-              `loggedIn: true, changeStatus: ${changeStatus}`);
-        }
-      }
-    });
-
-    test('edit visible when logged and status NEW', async () => {
-      assert.isTrue(await isEditVisibile(
-          {loggedIn: true, changeStatus: ChangeStatus.NEW}));
-    });
-
-    test('edit hidden when logged and status ABANDONED', async () => {
-      assert.isFalse(await isEditVisibile(
-          {loggedIn: true, changeStatus: ChangeStatus.ABANDONED}));
-    });
-
-    test('edit hidden when logged and status MERGED', async () => {
-      assert.isFalse(await isEditVisibile(
-          {loggedIn: true, changeStatus: ChangeStatus.MERGED}));
-    });
-
-    suite('diff prefs hidden', () => {
-      test('when no prefs or logged out', () => {
-        element._prefs = undefined;
-        element.disableDiffPrefs = false;
-        element._loggedIn = false;
-        flush();
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-        element._loggedIn = true;
-        flush();
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-        element._loggedIn = false;
-        element._prefs = {font_size: '12'};
-        flush();
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-        element._loggedIn = true;
-        element._prefs = {font_size: '12'};
-        flush();
-        assert.isFalse(element.$.diffPrefsContainer.hidden);
-      });
-    });
-
-    test('prefsButton opens gr-diff-preferences', () => {
-      const handlePrefsTapSpy = sinon.spy(element, '_handlePrefsTap');
-      const overlayOpenStub = sinon.stub(element.$.diffPreferencesDialog,
-          'open');
-      const prefsButton =
-          element.root.querySelector('.prefsButton');
-
-      MockInteractions.tap(prefsButton);
-
-      assert.isTrue(handlePrefsTapSpy.called);
-      assert.isTrue(overlayOpenStub.called);
-    });
-
-    suite('url params', () => {
-      setup(() => {
-        sinon.stub(element, '_getFiles');
-        sinon.stub(
-            GerritNav,
-            'getUrlForDiff')
-            .callsFake((c, p, pn, bpn) => `${c._number}-${p}-${pn}-${bpn}`);
-        sinon.stub(
-            GerritNav
-            , 'getUrlForChange')
-            .callsFake((c, ops) =>
-              `${c._number}-${ops.patchNum}-${ops.basePatchNum}`);
-      });
-
-      test('_formattedFiles', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: 10,
-        };
-        element._change = {_number: 42};
-        element._files = getFilesFromFileList(
-            ['chell.go', 'glados.txt', 'wheatley.md',
-              '/COMMIT_MSG', '/MERGE_LIST']);
-        element._path = 'glados.txt';
-        const expectedFormattedFiles = [
-          {
-            text: 'chell.go',
-            mobileText: 'chell.go',
-            value: 'chell.go',
-            bottomText: '',
-            file: {
-              __path: 'chell.go',
-            },
-          }, {
-            text: 'glados.txt',
-            mobileText: 'glados.txt',
-            value: 'glados.txt',
-            bottomText: '',
-            file: {
-              __path: 'glados.txt',
-            },
-          }, {
-            text: 'wheatley.md',
-            mobileText: 'wheatley.md',
-            value: 'wheatley.md',
-            bottomText: '',
-            file: {
-              __path: 'wheatley.md',
-            },
-          },
-          {
-            text: 'Commit message',
-            mobileText: 'Commit message',
-            value: '/COMMIT_MSG',
-            bottomText: '',
-            file: {
-              __path: '/COMMIT_MSG',
-            },
-          },
-          {
-            text: 'Merge list',
-            mobileText: 'Merge list',
-            value: '/MERGE_LIST',
-            bottomText: '',
-            file: {
-              __path: '/MERGE_LIST',
-            },
-          },
-        ];
-
-        assert.deepEqual(element._formattedFiles, expectedFormattedFiles);
-        assert.equal(element._formattedFiles[1].value, element._path);
-      });
-
-      test('prev/up/next links', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: 10,
-        };
-        element._change = {
-          _number: 42,
-          revisions: {
-            a: {_number: 10, commit: {parents: []}},
-          },
-        };
-        element._files = getFilesFromFileList(
-            ['chell.go', 'glados.txt', 'wheatley.md']);
-        element._path = 'glados.txt';
-        flush();
-        const linkEls = element.root.querySelectorAll('.navLink');
-        assert.equal(linkEls.length, 3);
-        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-PARENT');
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[2].getAttribute('href'),
-            '42-wheatley.md-10-PARENT');
-        element._path = 'wheatley.md';
-        flush();
-        assert.equal(linkEls[0].getAttribute('href'),
-            '42-glados.txt-10-PARENT');
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[2].getAttribute('href'), '42-undefined-undefined');
-        element._path = 'chell.go';
-        flush();
-        assert.equal(linkEls[0].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[2].getAttribute('href'),
-            '42-glados.txt-10-PARENT');
-        element._path = 'not_a_real_file';
-        flush();
-        assert.equal(linkEls[0].getAttribute('href'),
-            '42-wheatley.md-10-PARENT');
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[2].getAttribute('href'), '42-chell.go-10-PARENT');
-      });
-
-      test('prev/up/next links with patch range', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: 5,
-          patchNum: 10,
-        };
-        element._change = {
-          _number: 42,
-          revisions: {
-            a: {_number: 5, commit: {parents: []}},
-            b: {_number: 10, commit: {parents: []}},
-          },
-        };
-        element._files = getFilesFromFileList(
-            ['chell.go', 'glados.txt', 'wheatley.md']);
-        element._path = 'glados.txt';
-        flush();
-        const linkEls = element.root.querySelectorAll('.navLink');
-        assert.equal(linkEls.length, 3);
-        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-5');
-        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-        assert.equal(linkEls[2].getAttribute('href'), '42-wheatley.md-10-5');
-        element._path = 'wheatley.md';
-        flush();
-        assert.equal(linkEls[0].getAttribute('href'), '42-glados.txt-10-5');
-        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-        assert.equal(linkEls[2].getAttribute('href'), '42-10-5');
-        element._path = 'chell.go';
-        flush();
-        assert.equal(linkEls[0].getAttribute('href'),
-            '42-10-5');
-        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-        assert.equal(linkEls[2].getAttribute('href'), '42-glados.txt-10-5');
-      });
-    });
-
-    test('_handlePatchChange calls navigateToDiff correctly', () => {
-      const navigateStub = sinon.stub(GerritNav, 'navigateToDiff');
-      element._change = {_number: 321, project: 'foo/bar'};
-      element._path = 'path/to/file.txt';
-
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 3,
-      };
-
-      const detail = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-
-      element.$.rangeSelect.dispatchEvent(
-          new CustomEvent('patch-range-change', {detail, bubbles: false}));
-
-      assert(navigateStub.lastCall.calledWithExactly(element._change,
-          element._path, 1, 'PARENT'));
-    });
-
-    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');
-      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,
-      };
-
-      await waitUntil(() => setReviewedStatusStub.called);
-
-      assert.isFalse(setReviewedFileStatusStub.called);
-
-      // if prefs are updated then the reviewed status should not be set again
-      element.userModel.setDiffPreferences(createDefaultDiffPrefs());
-
-      await flush();
-      assert.isFalse(setReviewedFileStatusStub.called);
-    });
-
-    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.userModel.setDiffPreferences(createDefaultDiffPrefs());
-
-      element.routerModel.setState({
-        changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
-      );
-
-      element._patchRange = {
-        patchNum: 2,
-        basePatchNum: 1,
-      };
-
-      await waitUntil(() => saveReviewedStub.called);
-
-      element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', true);
-      await flush();
-
-      const reviewedStatusCheckBox = element.root.querySelector(
-          'input[type="checkbox"]');
-
-      assert.isTrue(reviewedStatusCheckBox.checked);
-      assert.deepEqual(saveReviewedStub.lastCall.args,
-          ['42', 2, '/COMMIT_MSG', 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);
-      await flush();
-
-      // saveReviewedState observer observes params, but should not fire when
-      // view !== GerritNav.View.DIFF.
-      assert.equal(saveReviewedStub.callCount, callCount);
-    });
-
-    test('file review status with edit loaded', () => {
-      const saveReviewedStub =
-          sinon.stub(element.getChangeModel(), 'setReviewedFilesStatus');
-
-      element._patchRange = {patchNum: EditPatchSetNum};
-      flush();
-
-      assert.isTrue(element._editMode);
-      element._setReviewed();
-      assert.isFalse(saveReviewedStub.called);
-    });
-
-    test('hash is determined from params', async () => {
-      sinon.stub(element.$.diffHost, 'reload');
-      sinon.stub(element, '_initLineOfInterestAndCursor');
-
-      element._loggedIn = true;
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: 2,
-        basePatchNum: 1,
-        path: '/COMMIT_MSG',
-        hash: 10,
-      };
-
-      await flush();
-      assert.isTrue(element._initLineOfInterestAndCursor.calledOnce);
-    });
-
-    test('diff mode selector correctly toggles the diff', () => {
-      const select = element.$.modeSelect;
-      const diffDisplay = element.$.diffHost;
-      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._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.
-      element._handleToggleDiffMode();
-      assert.isTrue(userStub.calledWithExactly({
-        diff_view: DiffViewMode.UNIFIED}));
-    });
-
-    test('diff mode selector should be hidden for binary', async () => {
-      element._diff = {binary: true, content: []};
-
-      await flush();
-      const diffModeSelector = element.shadowRoot
-          .querySelector('.diffModeSelector');
-      assert.isTrue(diffModeSelector.classList.contains('hide'));
-    });
-
-    suite('_commitRange', () => {
-      const change = {
-        _number: 42,
-        revisions: {
-          'commit-sha-1': {
-            _number: 1,
-            commit: {
-              parents: [{commit: 'sha-1-parent'}],
-            },
-          },
-          'commit-sha-2': {_number: 2, commit: {parents: []}},
-          'commit-sha-3': {_number: 3, commit: {parents: []}},
-          'commit-sha-4': {_number: 4, commit: {parents: []}},
-          'commit-sha-5': {
-            _number: 5,
-            commit: {
-              parents: [{commit: 'sha-5-parent'}],
-            },
-          },
-        },
-      };
-      setup(() => {
-        sinon.stub(element.$.diffHost, 'reload');
-        sinon.stub(element, '_initCursor');
-        element._change = change;
-      });
-
-      test('uses the patchNum and basePatchNum ', async () => {
-        element.params = {
-          view: GerritNav.View.DIFF,
-          changeNum: '42',
-          patchNum: 4,
-          basePatchNum: 2,
-          path: '/COMMIT_MSG',
-        };
-        element._change = change;
-        await flush();
-        assert.deepEqual(element._commitRange, {
-          baseCommit: 'commit-sha-2',
-          commit: 'commit-sha-4',
-        });
-      });
-
-      test('uses the parent when there is no base patch num ', async () => {
-        element.params = {
-          view: GerritNav.View.DIFF,
-          changeNum: '42',
-          patchNum: 5,
-          path: '/COMMIT_MSG',
-        };
-        element._change = change;
-        await flush();
-        assert.deepEqual(element._commitRange, {
-          commit: 'commit-sha-5',
-          baseCommit: 'sha-5-parent',
-        });
-      });
-    });
-
-    test('_initCursor', () => {
-      assertIsDefined(element.cursor);
-      assert.isNotOk(element.cursor.initialLineNumber);
-
-      // Does nothing when params specify no cursor address:
-      element._initCursor(false);
-      assert.isNotOk(element.cursor.initialLineNumber);
-
-      // Does nothing when params specify side but no number:
-      element._initCursor(true);
-      assert.isNotOk(element.cursor.initialLineNumber);
-
-      // Revision hash: specifies lineNum but not side.
-
-      element._focusLineNum = 234;
-      element._initCursor(false);
-      assert.equal(element.cursor.initialLineNumber, 234);
-      assert.equal(element.cursor.side, 'right');
-
-      // Base hash: specifies lineNum and side.
-      element._focusLineNum = 345;
-      element._initCursor(true);
-      assert.equal(element.cursor.initialLineNumber, 345);
-      assert.equal(element.cursor.side, 'left');
-
-      // Specifies right side:
-      element._focusLineNum = 123;
-      element._initCursor(false);
-      assert.equal(element.cursor.initialLineNumber, 123);
-      assert.equal(element.cursor.side, 'right');
-    });
-
-    test('_getLineOfInterest', () => {
-      assert.isUndefined(element._getLineOfInterest(false));
-
-      element._focusLineNum = 12;
-      let result = element._getLineOfInterest(false);
-      assert.equal(result.lineNum, 12);
-      assert.equal(result.side, Side.RIGHT);
-
-      result = element._getLineOfInterest(true);
-      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});
-
-      element._changeNum = 321;
-      element._change = {_number: 321, project: 'foo/bar'};
-      element._patchRange = {
-        basePatchNum: 3,
-        patchNum: 5,
-      };
-      const e = {};
-      const detail = {number: 123, side: 'right'};
-
-      element._onLineSelected(e, detail);
-
-      assert.isTrue(replaceStateStub.called);
-      assert.isTrue(getUrlStub.called);
-      assert.isFalse(getUrlStub.lastCall.args[6]);
-    });
-
-    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});
-
-      element._changeNum = 321;
-      element._change = {_number: 321, project: 'foo/bar'};
-      element._patchRange = {
-        basePatchNum: 3,
-        patchNum: 5,
-      };
-      const e = {};
-      const detail = {number: 123, side: 'left'};
-
-      element._onLineSelected(e, detail);
-
-      assert.isTrue(replaceStateStub.called);
-      assert.isTrue(getUrlStub.called);
-      assert.isTrue(getUrlStub.lastCall.args[6]);
-    });
-
-    test('_handleToggleDiffMode', () => {
-      const userStub = stubUsers('updatePreferences');
-      const e = new CustomEvent('keydown', {
-        detail: {keyboardEvent: new KeyboardEvent('keydown'), key: 'x'},
-      });
-      element._userPrefs = {diff_view: DiffViewMode.SIDE_BY_SIDE};
-
-      element._handleToggleDiffMode(e);
-      assert.deepEqual(userStub.lastCall.args[0], {
-        diff_view: DiffViewMode.UNIFIED});
-
-      element._userPrefs = {diff_view: DiffViewMode.UNIFIED};
-
-      element._handleToggleDiffMode(e);
-      assert.deepEqual(userStub.lastCall.args[0], {
-        diff_view: DiffViewMode.SIDE_BY_SIDE});
-    });
-
-    suite('_initPatchRange', () => {
-      setup(async () => {
-        stubRestApi('getDiff').returns(Promise.resolve({}));
-        element.params = {
-          view: GerritView.DIFF,
-          changeNum: '42',
-          patchNum: 3,
-          path: 'abcd',
-        };
-        await flush();
-      });
-      test('empty', () => {
-        sinon.stub(element, '_getPaths').returns(new Map());
-        element._initPatchRange();
-        assert.equal(Object.keys(element._commentMap).length, 0);
-      });
-
-      test('has paths', () => {
-        sinon.stub(element, '_getFiles');
-        sinon.stub(element, '_getPaths').returns({
-          'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
-          'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
-        });
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: 3,
-          patchNum: 5,
-        };
-        element._initPatchRange();
-        assert.deepEqual(Object.keys(element._commentMap),
-            ['path/to/file/one.cpp', 'path-to/file/two.py']);
-      });
-    });
-
-    suite('_computeCommentSkips', () => {
-      test('empty file list', () => {
-        const commentMap = {
-          'path/one.jpg': true,
-          'path/three.wav': true,
-        };
-        const path = 'path/two.m4v';
-        const fileList = [];
-        const result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.isNull(result.previous);
-        assert.isNull(result.next);
-      });
-
-      test('finds skips', () => {
-        const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
-        let path = fileList[1];
-        const commentMap = {};
-        commentMap[fileList[0]] = true;
-        commentMap[fileList[1]] = false;
-        commentMap[fileList[2]] = true;
-
-        let result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.equal(result.previous, fileList[0]);
-        assert.equal(result.next, fileList[2]);
-
-        commentMap[fileList[1]] = true;
-
-        result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.equal(result.previous, fileList[0]);
-        assert.equal(result.next, fileList[2]);
-
-        path = fileList[0];
-
-        result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.isNull(result.previous);
-        assert.equal(result.next, fileList[1]);
-
-        path = fileList[2];
-
-        result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.equal(result.previous, fileList[1]);
-        assert.isNull(result.next);
-      });
-
-      suite('skip next/previous', () => {
-        let navToChangeStub;
-        let navToDiffStub;
-
-        setup(() => {
-          navToChangeStub = sinon.stub(element, '_navToChangeView');
-          navToDiffStub = sinon.stub(GerritNav, 'navigateToDiff');
-          element._files = getFilesFromFileList([
-            'path/one.jpg', 'path/two.m4v', 'path/three.wav',
-          ]);
-          element._patchRange = {patchNum: 2, basePatchNum: 1};
-        });
-
-        suite('_moveToPreviousFileWithComment', () => {
-          test('no skips', () => {
-            element._moveToPreviousFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('no previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = false;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = true;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToPreviousFileWithComment();
-            assert.isTrue(navToChangeStub.calledOnce);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('w/ previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = true;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = true;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToPreviousFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isTrue(navToDiffStub.calledOnce);
-          });
-        });
-
-        suite('_moveToNextFileWithComment', () => {
-          test('no skips', () => {
-            element._moveToNextFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('no previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = true;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = false;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToNextFileWithComment();
-            assert.isTrue(navToChangeStub.calledOnce);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('w/ previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = true;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = true;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToNextFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isTrue(navToDiffStub.calledOnce);
-          });
-        });
-      });
-    });
-
-    test('_computeEditMode', () => {
-      const callCompute = range => element._computeEditMode({base: range});
-      assert.isFalse(callCompute({}));
-      assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}));
-      assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}));
-      assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}));
-    });
-
-    test('_computeFileNum', () => {
-      assert.equal(element._computeFileNum('/foo',
-          [{value: '/foo'}, {value: '/bar'}]), 1);
-      assert.equal(element._computeFileNum('/bar',
-          [{value: '/foo'}, {value: '/bar'}]), 2);
-    });
-
-    test('_computeFileNumClass', () => {
-      assert.equal(element._computeFileNumClass(0, []), '');
-      assert.equal(element._computeFileNumClass(1,
-          [{value: '/foo'}, {value: '/bar'}]), 'show');
-    });
-
-    test('f open file dropdown', () => {
-      assert.isFalse(element.$.dropdown.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 70, null, 'f');
-      flush();
-      assert.isTrue(element.$.dropdown.$.dropdown.opened);
-    });
-
-    suite('blame', () => {
-      test('toggle blame with button', () => {
-        const toggleBlame = sinon.stub(
-            element.$.diffHost, 'loadBlame')
-            .callsFake(() => Promise.resolve());
-        MockInteractions.tap(element.$.toggleBlame);
-        assert.isTrue(toggleBlame.calledOnce);
-      });
-      test('toggle blame with shortcut', () => {
-        const toggleBlame = sinon.stub(
-            element.$.diffHost, 'loadBlame').callsFake(() => Promise.resolve());
-        MockInteractions.pressAndReleaseKeyOn(element, 66, null, 'b');
-        assert.isTrue(toggleBlame.calledOnce);
-      });
-    });
-
-    suite('editMode behavior', () => {
-      setup(() => {
-        element._loggedIn = true;
-      });
-
-      const isVisible = el => {
-        assert.ok(el);
-        return getComputedStyle(el).getPropertyValue('display') !== 'none';
-      };
-
-      test('reviewed checkbox', () => {
-        sinon.stub(element, '_handlePatchChange');
-        element._patchRange = {patchNum: 1};
-        // Reviewed checkbox should be shown.
-        assert.isTrue(isVisible(element.$.reviewed));
-        element.set('_patchRange.patchNum', EditPatchSetNum);
-        flush();
-
-        assert.isFalse(isVisible(element.$.reviewed));
-      });
-    });
-
-    suite('switching files', () => {
-      let dispatchEventStub;
-      let navToFileStub;
-      let moveToPreviousChunkStub;
-      let moveToNextChunkStub;
-      let isAtStartStub;
-      let isAtEndStub;
-      let nowStub;
-
-      setup(() => {
-        dispatchEventStub = sinon.stub(
-            element, 'dispatchEvent').callThrough();
-        navToFileStub = sinon.stub(element, '_navToFile');
-        assertIsDefined(element.cursor);
-        moveToPreviousChunkStub =
-            sinon.stub(element.cursor, 'moveToPreviousChunk');
-        moveToNextChunkStub =
-            sinon.stub(element.cursor, 'moveToNextChunk');
-        isAtStartStub = sinon.stub(element.cursor, 'isAtStart');
-        isAtEndStub = sinon.stub(element.cursor, 'isAtEnd');
-        nowStub = sinon.stub(Date, 'now');
-      });
-
-      test('shows toast when at the end of file', () => {
-        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtEndStub.returns(true);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-
-        assert.isTrue(moveToNextChunkStub.called);
-        assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
-        assert.isFalse(navToFileStub.called);
-      });
-
-      test('navigates to next file when n is tapped again', () => {
-        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtEndStub.returns(true);
-
-        element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-        element.reviewedFiles = new Set(['file2']);
-        element._path = 'file1';
-
-        nowStub.returns(5);
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        nowStub.returns(10);
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-
-        assert.isTrue(navToFileStub.called);
-        assert.deepEqual(navToFileStub.lastCall.args, [
-          'file1',
-          ['file1', 'file3'],
-          1,
-        ]);
-      });
-
-      test('does not navigate if n is tapped twice too slow', () => {
-        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtEndStub.returns(true);
-
-        nowStub.returns(5);
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        nowStub.returns(6000);
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-
-        assert.isFalse(navToFileStub.called);
-      });
-
-      test('shows toast when at the start of file', () => {
-        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtStartStub.returns(true);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-
-        assert.isTrue(moveToPreviousChunkStub.called);
-        assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
-        assert.isFalse(navToFileStub.called);
-      });
-
-      test('navigates to prev file when p is tapped again', () => {
-        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtStartStub.returns(true);
-
-        element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-        element.reviewedFiles = new Set(['file2']);
-        element._path = 'file3';
-
-        nowStub.returns(5);
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-        nowStub.returns(10);
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-
-        assert.isTrue(navToFileStub.called);
-        assert.deepEqual(navToFileStub.lastCall.args, [
-          'file3',
-          ['file1', 'file3'],
-          -1,
-        ]);
-      });
-
-      test('does not navigate if p is tapped twice too slow', () => {
-        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtStartStub.returns(true);
-
-        nowStub.returns(5);
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-        nowStub.returns(6000);
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-
-        assert.isFalse(navToFileStub.called);
-      });
-
-      test('does not navigate when tapping n then p', () => {
-        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtEndStub.returns(true);
-
-        nowStub.returns(5);
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-
-        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtStartStub.returns(true);
-
-        nowStub.returns(10);
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-
-        assert.isFalse(navToFileStub.called);
-      });
-    });
-
-    test('shift+m navigates to next unreviewed file', () => {
-      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      element.reviewedFiles = new Set(['file1', 'file2']);
-      element._path = 'file1';
-      const reviewedStub = sinon.stub(element, '_setReviewed');
-      const navStub = sinon.stub(element, '_navToFile');
-      MockInteractions.pressAndReleaseKeyOn(element, 77, null, 'M');
-      flush();
-
-      assert.isTrue(reviewedStub.lastCall.args[0]);
-      assert.deepEqual(navStub.lastCall.args, [
-        'file1',
-        ['file1', 'file3'],
-        1,
-      ]);
-    });
-
-    test('File change should trigger navigateToDiff once', async () => {
-      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      sinon.stub(element, '_initLineOfInterestAndCursor');
-      sinon.stub(GerritNav, 'navigateToDiff');
-
-      // Load file1
-      element.params = {
-        view: GerritNav.View.DIFF,
-        patchNum: 1,
-        changeNum: 101,
-        project: 'test-project',
-        path: 'file1',
-      };
-      element._patchRange = {
-        patchNum: 1,
-        basePatchNum: 'PARENT',
-      };
-      element._change = {
-        ...createChange(),
-        revisions: createRevisions(1),
-      };
-      await flush();
-      assert.isTrue(GerritNav.navigateToDiff.notCalled);
-
-      // Switch to file2
-      element._handleFileChange({detail: {value: 'file2'}});
-      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
-
-      // This is to mock the param change triggered by above navigate
-      element.params = {
-        view: GerritNav.View.DIFF,
-        patchNum: 1,
-        changeNum: 101,
-        project: 'test-project',
-        path: 'file2',
-      };
-      element._patchRange = {
-        patchNum: 1,
-        basePatchNum: 'PARENT',
-      };
-
-      // No extra call
-      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
-    });
-
-    test('_computeDownloadDropdownLinks', () => {
-      const downloadLinks = [
-        {
-          url: '/changes/test~12/revisions/1/patch?zip&path=index.php',
-          name: 'Patch',
-        },
-        {
-          url: '/changes/test~12/revisions/1' +
-              '/files/index.php/download?parent=1',
-          name: 'Left Content',
-        },
-        {
-          url: '/changes/test~12/revisions/1' +
-              '/files/index.php/download',
-          name: 'Right Content',
-        },
-      ];
-
-      const side = {
-        meta_a: true,
-        meta_b: true,
-      };
-
-      const base = {
-        patchNum: 1,
-        basePatchNum: 'PARENT',
-      };
-
-      assert.deepEqual(
-          element._computeDownloadDropdownLinks(
-              'test', 12, base, 'index.php', side),
-          downloadLinks);
-    });
-
-    test('_computeDownloadDropdownLinks diff returns renamed', () => {
-      const downloadLinks = [
-        {
-          url: '/changes/test~12/revisions/3/patch?zip&path=index.php',
-          name: 'Patch',
-        },
-        {
-          url: '/changes/test~12/revisions/2' +
-              '/files/index2.php/download',
-          name: 'Left Content',
-        },
-        {
-          url: '/changes/test~12/revisions/3' +
-              '/files/index.php/download',
-          name: 'Right Content',
-        },
-      ];
-
-      const side = {
-        change_type: 'RENAMED',
-        meta_a: {
-          name: 'index2.php',
-        },
-        meta_b: true,
-      };
-
-      const base = {
-        patchNum: 3,
-        basePatchNum: 2,
-      };
-
-      assert.deepEqual(
-          element._computeDownloadDropdownLinks(
-              'test', 12, base, 'index.php', side),
-          downloadLinks);
-    });
-
-    test('_computeDownloadFileLink', () => {
-      const base = {
-        patchNum: 1,
-        basePatchNum: 'PARENT',
-      };
-
-      assert.equal(
-          element._computeDownloadFileLink(
-              'test', 12, base, 'index.php', true),
-          '/changes/test~12/revisions/1/files/index.php/download?parent=1');
-
-      assert.equal(
-          element._computeDownloadFileLink(
-              'test', 12, base, 'index.php', false),
-          '/changes/test~12/revisions/1/files/index.php/download');
-    });
-
-    test('_computeDownloadPatchLink', () => {
-      assert.equal(
-          element._computeDownloadPatchLink(
-              'test', 12, {patchNum: 1}, 'index.php'),
-          '/changes/test~12/revisions/1/patch?zip&path=index.php');
-    });
-  });
-
-  suite('unmodified files with comments', () => {
-    let element;
-    setup(() => {
-      const changedFiles = {
-        'file1.txt': {},
-        'a/b/test.c': {},
-      };
-      stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
-
-      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-      stubRestApi('getChangeFiles').returns(Promise.resolve(changedFiles));
-      stubRestApi('saveFileReviewed').returns(Promise.resolve());
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      stubRestApi('getReviewedFiles').returns(
-          Promise.resolve([]));
-      element = basicFixture.instantiate();
-      element._changeNum = '42';
-    });
-
-    test('_getFiles add files with comments without changes', () => {
-      const patchChangeRecord = {
-        base: {
-          basePatchNum: 5,
-          patchNum: 10,
-        },
-      };
-      const changeComments = {
-        getPaths: sinon.stub().returns({
-          'file2.txt': {},
-          'file1.txt': {},
-        }),
-      };
-      return element._getFiles(23, patchChangeRecord, changeComments)
-          .then(() => {
-            assert.deepEqual(element._files, {
-              sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
-              changeFilesByPath: {
-                'file1.txt': {},
-                'file2.txt': {status: 'U'},
-                'a/b/test.c': {},
-              },
-            });
-          });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
new file mode 100644
index 0000000..b6e26ab
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -0,0 +1,2570 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-view';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+  ChangeStatus,
+  DiffViewMode,
+  createDefaultDiffPrefs,
+  createDefaultPreferences,
+} from '../../../constants/constants';
+import {
+  isVisible,
+  pressKey,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+  stubUsers,
+  waitEventLoop,
+  waitUntil,
+} from '../../../test/test-utils';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api';
+import {GerritView} from '../../../services/router/router-model';
+import {
+  createRevisions,
+  createComment as createCommentGeneric,
+  TEST_NUMERIC_CHANGE_ID,
+  createDiff,
+  createPatchRange,
+  createServerInfo,
+  createConfig,
+  createParsedChange,
+  createRevision,
+  createCommit,
+  createFileInfo,
+} from '../../../test/test-data-generators';
+import {
+  BasePatchSetNum,
+  CommentInfo,
+  CommitId,
+  EDIT,
+  FileInfo,
+  NumericChangeId,
+  PARENT,
+  PatchRange,
+  PatchSetNum,
+  PatchSetNumber,
+  PathToCommentsInfoMap,
+  RepoName,
+  RevisionPatchSetNum,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {CursorMoveResult} from '../../../api/core';
+import {DiffInfo, Side} from '../../../api/diff';
+import {Files, GrDiffView} from './gr-diff-view';
+import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {SinonFakeTimers, SinonStub, SinonSpy} from 'sinon';
+import {LoadingStatus} from '../../../models/change/change-model';
+import {CommentMap} from '../../../utils/comment-util';
+import {ParsedChangeInfo} from '../../../types/types';
+import {assertIsDefined} from '../../../utils/common-util';
+import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
+import {Key} from '../../../utils/dom-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+
+function createComment(
+  id: string,
+  line: number,
+  ps: number | PatchSetNum,
+  path: string
+): CommentInfo {
+  return {
+    ...createCommentGeneric(),
+    id: id as UrlEncodedCommentId,
+    line,
+    patch_set: ps as RevisionPatchSetNum,
+    path,
+  };
+}
+
+suite('gr-diff-view tests', () => {
+  suite('basic tests', () => {
+    let element: GrDiffView;
+    let clock: SinonFakeTimers;
+    let diffCommentsStub;
+    let getDiffRestApiStub: SinonStub;
+    let setUrlStub: SinonStub;
+
+    function getFilesFromFileList(fileList: string[]): Files {
+      const changeFilesByPath = fileList.reduce((files, path) => {
+        files[path] = createFileInfo();
+        return files;
+      }, {} as {[path: string]: FileInfo});
+      return {
+        sortedFileList: fileList,
+        changeFilesByPath,
+      };
+    }
+
+    setup(async () => {
+      setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+      stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+      stubRestApi('getChangeFiles').returns(
+        Promise.resolve({
+          'chell.go': createFileInfo(),
+          'glados.txt': createFileInfo(),
+          'wheatley.md': createFileInfo(),
+        })
+      );
+      stubRestApi('saveFileReviewed').returns(Promise.resolve(new Response()));
+      diffCommentsStub = stubRestApi('getDiffComments');
+      diffCommentsStub.returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubRestApi('getPortedComments').returns(Promise.resolve({}));
+
+      element = await fixture(html`<gr-diff-view></gr-diff-view>`);
+      element.changeNum = 42 as NumericChangeId;
+      element.path = 'some/path.txt';
+      element.change = createParsedChange();
+      element.diff = {...createDiff(), content: []};
+      getDiffRestApiStub = stubRestApi('getDiff');
+      // Delayed in case a test updates element.diff.
+      getDiffRestApiStub.callsFake(() => Promise.resolve(element.diff));
+      element.patchRange = createPatchRange();
+      element.changeComments = new ChangeComments({
+        '/COMMIT_MSG': [
+          createComment('c1', 10, 2, '/COMMIT_MSG'),
+          createComment('c3', 10, PARENT, '/COMMIT_MSG'),
+        ],
+      });
+      await element.updateComplete;
+
+      element.getCommentsModel().setState({
+        comments: {},
+        robotComments: {},
+        drafts: {},
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+    });
+
+    teardown(() => {
+      clock && clock.restore();
+      sinon.restore();
+    });
+
+    test('viewState change triggers diffViewDisplayed()', () => {
+      const diffViewDisplayedStub = stubReporting('diffViewDisplayed');
+      assertIsDefined(element.diffHost);
+      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
+      sinon.stub(element, 'initPatchRange');
+      sinon.stub(element, 'fetchFiles');
+      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
+      element.viewState = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        patchNum: 2 as RevisionPatchSetNum,
+        basePatchNum: 1 as BasePatchSetNum,
+        path: '/COMMIT_MSG',
+      };
+      element.path = '/COMMIT_MSG';
+      element.patchRange = createPatchRange();
+      return viewStateChangedSpy.returnValues[0]?.then(() => {
+        assert.isTrue(diffViewDisplayedStub.calledOnce);
+      });
+    });
+
+    suite('comment route', () => {
+      let initLineOfInterestAndCursorStub: SinonStub;
+      let replaceStateStub: SinonStub;
+      let viewStateChangedSpy: SinonSpy;
+      setup(() => {
+        initLineOfInterestAndCursorStub = sinon.stub(
+          element,
+          'initLineOfInterestAndCursor'
+        );
+        replaceStateStub = sinon.stub(history, 'replaceState');
+        sinon.stub(element, 'fetchFiles');
+        stubReporting('diffViewDisplayed');
+        assertIsDefined(element.diffHost);
+        sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
+        viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
+        element.getChangeModel().setState({
+          change: {
+            ...createParsedChange(),
+            revisions: createRevisions(11),
+          },
+          loadingStatus: LoadingStatus.LOADED,
+        });
+      });
+
+      test('comment url resolves to comment.patch_set vs latest', () => {
+        element.getCommentsModel().setState({
+          comments: {
+            '/COMMIT_MSG': [
+              createComment('c1', 10, 2, '/COMMIT_MSG'),
+              createComment('c3', 10, PARENT, '/COMMIT_MSG'),
+            ],
+          },
+          robotComments: {},
+          drafts: {},
+          portedComments: {},
+          portedDrafts: {},
+          discardedDrafts: [],
+        });
+        element.viewState = {
+          view: GerritView.DIFF,
+          changeNum: 42 as NumericChangeId,
+          commentLink: true,
+          commentId: 'c1' as UrlEncodedCommentId,
+          path: 'abcd',
+          patchNum: 1 as RevisionPatchSetNum,
+        };
+        element.change = {
+          ...createParsedChange(),
+          revisions: createRevisions(11),
+        };
+        return viewStateChangedSpy.returnValues[0].then(() => {
+          assert.isTrue(
+            initLineOfInterestAndCursorStub.calledWithExactly(true)
+          );
+          assert.equal(element.focusLineNum, 10);
+          assert.equal(element.patchRange?.patchNum, 11 as RevisionPatchSetNum);
+          assert.equal(element.patchRange?.basePatchNum, 2 as BasePatchSetNum);
+          assert.isTrue(replaceStateStub.called);
+        });
+      });
+    });
+
+    test('viewState change causes blame to load if it was set to true', () => {
+      // Blame loads for subsequent files if it was loaded for one file
+      element.isBlameLoaded = true;
+      stubReporting('diffViewDisplayed');
+      const loadBlameStub = sinon.stub(element, 'loadBlame');
+      assertIsDefined(element.diffHost);
+      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
+      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
+      sinon.stub(element, 'initPatchRange');
+      sinon.stub(element, 'fetchFiles');
+      element.viewState = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        patchNum: 2 as RevisionPatchSetNum,
+        basePatchNum: 1 as BasePatchSetNum,
+        path: '/COMMIT_MSG',
+      };
+      element.path = '/COMMIT_MSG';
+      element.patchRange = createPatchRange();
+      return viewStateChangedSpy.returnValues[0]!.then(() => {
+        assert.isTrue(element.isBlameLoaded);
+        assert.isTrue(loadBlameStub.calledOnce);
+      });
+    });
+
+    test('unchanged diff X vs latest from comment links navigates to base vs X', async () => {
+      element.getCommentsModel().setState({
+        comments: {
+          '/COMMIT_MSG': [
+            createComment('c1', 10, 2, '/COMMIT_MSG'),
+            createComment('c3', 10, PARENT, '/COMMIT_MSG'),
+          ],
+        },
+        robotComments: {},
+        drafts: {},
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+      stubReporting('diffViewDisplayed');
+      sinon.stub(element, 'loadBlame');
+      assertIsDefined(element.diffHost);
+      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
+      sinon.stub(element, 'isFileUnchanged').returns(true);
+      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
+      element.getChangeModel().setState({
+        change: {
+          ...createParsedChange(),
+          revisions: createRevisions(11),
+        },
+        loadingStatus: LoadingStatus.LOADED,
+      });
+      element.viewState = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        path: '/COMMIT_MSG',
+        commentLink: true,
+        commentId: 'c1' as UrlEncodedCommentId,
+      };
+      element.change = {
+        ...createParsedChange(),
+        revisions: createRevisions(11),
+      };
+      await viewStateChangedSpy.returnValues[0];
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/2//COMMIT_MSG#10'
+      );
+    });
+
+    test('unchanged diff Base vs latest from comment does not navigate', async () => {
+      element.getCommentsModel().setState({
+        comments: {
+          '/COMMIT_MSG': [
+            createComment('c1', 10, 2, '/COMMIT_MSG'),
+            createComment('c3', 10, PARENT, '/COMMIT_MSG'),
+          ],
+        },
+        robotComments: {},
+        drafts: {},
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+      stubReporting('diffViewDisplayed');
+      sinon.stub(element, 'loadBlame');
+      assertIsDefined(element.diffHost);
+      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
+      sinon.stub(element, 'isFileUnchanged').returns(true);
+      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
+      element.getChangeModel().setState({
+        change: {
+          ...createParsedChange(),
+          revisions: createRevisions(11),
+        },
+        loadingStatus: LoadingStatus.LOADED,
+      });
+      element.viewState = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        path: '/COMMIT_MSG',
+        commentLink: true,
+        commentId: 'c3' as UrlEncodedCommentId,
+      };
+      element.change = {
+        ...createParsedChange(),
+        revisions: createRevisions(11),
+      };
+      await viewStateChangedSpy.returnValues[0];
+      assert.isFalse(setUrlStub.calledOnce);
+    });
+
+    test('isFileUnchanged', () => {
+      let diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {a: ['abcd'], ab: ['ef']},
+          {b: ['ancd'], a: ['xx']},
+        ],
+      };
+      assert.equal(element.isFileUnchanged(diff), false);
+      diff = {
+        ...createDiff(),
+        content: [{ab: ['abcd']}, {ab: ['ancd']}],
+      };
+      assert.equal(element.isFileUnchanged(diff), true);
+      diff = {
+        ...createDiff(),
+        content: [
+          {a: ['abcd'], ab: ['ef'], common: true},
+          {b: ['ancd'], ab: ['xx']},
+        ],
+      };
+      assert.equal(element.isFileUnchanged(diff), false);
+      diff = {
+        ...createDiff(),
+        content: [
+          {a: ['abcd'], ab: ['ef'], common: true},
+          {b: ['ancd'], ab: ['xx'], common: true},
+        ],
+      };
+      assert.equal(element.isFileUnchanged(diff), true);
+    });
+
+    test('diff toast to go to latest is shown and not base', async () => {
+      element.getCommentsModel().setState({
+        comments: {
+          '/COMMIT_MSG': [
+            createComment('c1', 10, 2, '/COMMIT_MSG'),
+            createComment('c3', 10, PARENT, '/COMMIT_MSG'),
+          ],
+        },
+        robotComments: {},
+        drafts: {},
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+
+      stubReporting('diffViewDisplayed');
+      sinon.stub(element, 'loadBlame');
+      assertIsDefined(element.diffHost);
+      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
+      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
+      element.change = undefined;
+      element.getChangeModel().setState({
+        change: {
+          ...createParsedChange(),
+          revisions: createRevisions(11),
+        },
+        loadingStatus: LoadingStatus.LOADED,
+      });
+      element.patchRange = {
+        patchNum: 2 as RevisionPatchSetNum,
+        basePatchNum: 1 as BasePatchSetNum,
+      };
+      sinon.stub(element, 'isFileUnchanged').returns(false);
+      const toastStub = sinon.stub(element, 'displayDiffBaseAgainstLeftToast');
+      element.viewState = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        project: 'p' as RepoName,
+        commentId: 'c1' as UrlEncodedCommentId,
+        commentLink: true,
+      };
+      await viewStateChangedSpy.returnValues[0];
+      assert.isTrue(toastStub.called);
+    });
+
+    test('toggle left diff with a hotkey', () => {
+      assertIsDefined(element.diffHost);
+      const toggleLeftDiffStub = sinon.stub(element.diffHost, 'toggleLeftDiff');
+      pressKey(element, 'A');
+      assert.isTrue(toggleLeftDiffStub.calledOnce);
+    });
+
+    test('renders', async () => {
+      clock = sinon.useFakeTimers();
+      element.changeNum = 42 as NumericChangeId;
+      element.getBrowserModel().setScreenWidth(0);
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 10 as RevisionPatchSetNum,
+      };
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(10),
+        },
+      };
+      element.files = getFilesFromFileList([
+        'chell.go',
+        'glados.txt',
+        'wheatley.md',
+      ]);
+      element.path = 'glados.txt';
+      element.loggedIn = true;
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="stickyHeader">
+            <h1 class="assistive-tech-only">Diff of glados.txt</h1>
+            <header>
+              <div>
+                <a href="/c/test-project/+/42"> 42 </a>
+                <span class="changeNumberColon"> : </span>
+                <span class="headerSubject"> Test subject </span>
+                <input
+                  aria-label="file reviewed"
+                  class="hideOnEdit reviewed"
+                  id="reviewed"
+                  title="Toggle reviewed status of file"
+                  type="checkbox"
+                />
+                <div class="jumpToFileContainer">
+                  <gr-dropdown-list id="dropdown" show-copy-for-trigger-text="">
+                  </gr-dropdown-list>
+                </div>
+              </div>
+              <div class="desktop navLinks">
+                <span class="fileNum show">
+                  File 2 of 3
+                  <span class="separator"> </span>
+                </span>
+                <a
+                  class="navLink"
+                  href="/c/test-project/+/42/10/chell.go"
+                  title="Go to previous file (shortcut: [)"
+                >
+                  Prev
+                </a>
+                <span class="separator"> </span>
+                <a
+                  class="navLink"
+                  href="/c/test-project/+/42"
+                  title="Up to change (shortcut: u)"
+                >
+                  Up
+                </a>
+                <span class="separator"> </span>
+                <a
+                  class="navLink"
+                  href="/c/test-project/+/42/10/wheatley.md"
+                  title="Go to next file (shortcut: ])"
+                >
+                  Next
+                </a>
+              </div>
+            </header>
+            <div class="subHeader">
+              <div class="patchRangeLeft">
+                <gr-patch-range-select id="rangeSelect">
+                </gr-patch-range-select>
+                <span class="desktop download">
+                  <span class="separator"> </span>
+                  <gr-dropdown down-arrow="" horizontal-align="left" link="">
+                    <span class="downloadTitle"> Download </span>
+                  </gr-dropdown>
+                </span>
+              </div>
+              <div class="rightControls">
+                <span class="blameLoader show">
+                  <gr-button
+                    aria-disabled="false"
+                    id="toggleBlame"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                    title="Toggle blame (shortcut: b)"
+                  >
+                    Show blame
+                  </gr-button>
+                </span>
+                <span class="separator"> </span>
+                <span class="editButton">
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                    title="Edit current file"
+                  >
+                    edit
+                  </gr-button>
+                </span>
+                <span class="separator"> </span>
+                <div class="diffModeSelector">
+                  <span> Diff view: </span>
+                  <gr-diff-mode-selector id="modeSelect" show-tooltip-below="">
+                  </gr-diff-mode-selector>
+                </div>
+                <span id="diffPrefsContainer">
+                  <span class="desktop preferences">
+                    <gr-tooltip-content
+                      has-tooltip=""
+                      position-below=""
+                      title="Diff preferences"
+                    >
+                      <gr-button
+                        aria-disabled="false"
+                        class="prefsButton"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        <gr-icon icon="settings" filled></gr-icon>
+                      </gr-button>
+                    </gr-tooltip-content>
+                  </span>
+                </span>
+                <gr-endpoint-decorator name="annotation-toggler">
+                  <span hidden="" id="annotation-span">
+                    <label for="annotation-checkbox" id="annotation-label">
+                    </label>
+                    <iron-input>
+                      <input
+                        disabled=""
+                        id="annotation-checkbox"
+                        is="iron-input"
+                        type="checkbox"
+                        value=""
+                      />
+                    </iron-input>
+                  </span>
+                </gr-endpoint-decorator>
+              </div>
+            </div>
+            <div class="fileNav mobile">
+              <a class="mobileNavLink" href="/c/test-project/+/42/10/chell.go">
+                <
+              </a>
+              <div class="fullFileName mobile">glados.txt</div>
+              <a
+                class="mobileNavLink"
+                href="/c/test-project/+/42/10/wheatley.md"
+              >
+                >
+              </a>
+            </div>
+          </div>
+          <div class="loading">Loading...</div>
+          <h2 class="assistive-tech-only">Diff view</h2>
+          <gr-diff-host hidden="" id="diffHost"> </gr-diff-host>
+          <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
+          <gr-diff-preferences-dialog id="diffPreferencesDialog">
+          </gr-diff-preferences-dialog>
+          <gr-overlay
+            aria-hidden="true"
+            id="downloadOverlay"
+            style="outline: none; display: none;"
+          >
+            <gr-download-dialog id="downloadDialog" role="dialog">
+            </gr-download-dialog>
+          </gr-overlay>
+        `
+      );
+    });
+
+    test('keyboard shortcuts', async () => {
+      clock = sinon.useFakeTimers();
+      element.changeNum = 42 as NumericChangeId;
+      element.getBrowserModel().setScreenWidth(0);
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 10 as RevisionPatchSetNum,
+      };
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(10),
+        },
+      };
+      element.files = getFilesFromFileList([
+        'chell.go',
+        'glados.txt',
+        'wheatley.md',
+      ]);
+      element.path = 'glados.txt';
+      element.loggedIn = true;
+      await element.updateComplete;
+      setUrlStub.reset();
+
+      pressKey(element, 'u');
+      assert.equal(setUrlStub.callCount, 1);
+      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42');
+      await element.updateComplete;
+
+      pressKey(element, ']');
+      assert.equal(setUrlStub.callCount, 2);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/10/wheatley.md'
+      );
+      element.path = 'wheatley.md';
+      await element.updateComplete;
+
+      assert.isTrue(element.loading);
+
+      pressKey(element, '[');
+      assert.equal(setUrlStub.callCount, 3);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/10/glados.txt'
+      );
+      element.path = 'glados.txt';
+      await element.updateComplete;
+
+      assert.isTrue(element.loading);
+
+      pressKey(element, '[');
+      assert.equal(setUrlStub.callCount, 4);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/10/chell.go'
+      );
+      element.path = 'chell.go';
+      await element.updateComplete;
+
+      assert.isTrue(element.loading);
+
+      pressKey(element, '[');
+      assert.equal(setUrlStub.callCount, 5);
+      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42');
+      await element.updateComplete;
+      assert.isTrue(element.loading);
+
+      assertIsDefined(element.diffPreferencesDialog);
+      const showPrefsStub = sinon
+        .stub(element.diffPreferencesDialog, 'open')
+        .callsFake(() => Promise.resolve());
+
+      pressKey(element, ',');
+      await element.updateComplete;
+      assert(showPrefsStub.calledOnce);
+
+      assertIsDefined(element.cursor);
+      let scrollStub = sinon.stub(element.cursor, 'moveToNextChunk');
+      pressKey(element, 'n');
+      await element.updateComplete;
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sinon.stub(element.cursor, 'moveToPreviousChunk');
+      pressKey(element, 'p');
+      await element.updateComplete;
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sinon.stub(element.cursor, 'moveToNextCommentThread');
+      pressKey(element, 'N');
+      await element.updateComplete;
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sinon.stub(element.cursor, 'moveToPreviousCommentThread');
+      pressKey(element, 'P');
+      await element.updateComplete;
+      assert(scrollStub.calledOnce);
+
+      assertIsDefined(element.diffHost);
+      assertIsDefined(element.diffHost.diffElement);
+      pressKey(element, 'j');
+      await element.updateComplete;
+      assert.equal(
+        element.diffHost.diffElement.viewMode,
+        DiffViewMode.SIDE_BY_SIDE
+      );
+      assert.isTrue(element.diffHost.diffElement.displayLine);
+
+      pressKey(element, Key.ESC);
+      await element.updateComplete;
+      assert.equal(
+        element.diffHost.diffElement.viewMode,
+        DiffViewMode.SIDE_BY_SIDE
+      );
+      assert.isFalse(element.diffHost.diffElement.displayLine);
+
+      // Note that stubbing setReviewed means that the value of the
+      // `element.reviewed` checkbox is not flipped.
+      const setReviewedStub = sinon.stub(element, 'setReviewed');
+      const handleToggleSpy = sinon.spy(element, 'handleToggleFileReviewed');
+      assertIsDefined(element.reviewed);
+      element.reviewed.checked = false;
+      assert.isFalse(handleToggleSpy.called);
+      assert.isFalse(setReviewedStub.called);
+
+      pressKey(element, 'r');
+      assert.isTrue(handleToggleSpy.calledOnce);
+      assert.isTrue(setReviewedStub.calledOnce);
+      assert.equal(setReviewedStub.lastCall.args[0], true);
+
+      // Handler is throttled, so another key press within 500 ms is ignored.
+      clock.tick(100);
+      pressKey(element, 'r');
+      assert.isTrue(handleToggleSpy.calledOnce);
+      assert.isTrue(setReviewedStub.calledOnce);
+
+      clock.tick(1000);
+      pressKey(element, 'r');
+      assert.isTrue(handleToggleSpy.calledTwice);
+      assert.isTrue(setReviewedStub.calledTwice);
+      clock.restore();
+    });
+
+    test('moveToNextCommentThread navigates to next file', async () => {
+      assertIsDefined(element.cursor);
+      sinon.stub(element.cursor, 'isAtEnd').returns(true);
+      element.changeNum = 42 as NumericChangeId;
+      const comment: PathToCommentsInfoMap = {
+        'wheatley.md': [createComment('c2', 21, 10, 'wheatley.md')],
+      };
+      element.changeComments = new ChangeComments(comment);
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 10 as RevisionPatchSetNum,
+      };
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(10),
+        },
+      };
+      element.files = getFilesFromFileList([
+        'chell.go',
+        'glados.txt',
+        'wheatley.md',
+      ]);
+      element.path = 'glados.txt';
+      element.loggedIn = true;
+      await element.updateComplete;
+      setUrlStub.reset();
+
+      pressKey(element, 'N');
+      await element.updateComplete;
+      assert.equal(setUrlStub.callCount, 1);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/10/wheatley.md#21'
+      );
+
+      element.path = 'wheatley.md'; // navigated to next file
+
+      pressKey(element, 'N');
+      await element.updateComplete;
+
+      assert.equal(setUrlStub.callCount, 2);
+      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42');
+    });
+
+    test('shift+x shortcut toggles all diff context', async () => {
+      assertIsDefined(element.diffHost);
+      const toggleStub = sinon.stub(element.diffHost, 'toggleAllContext');
+      pressKey(element, 'X');
+      await element.updateComplete;
+      assert.isTrue(toggleStub.called);
+    });
+
+    test('diff against base', async () => {
+      element.patchRange = {
+        basePatchNum: 5 as BasePatchSetNum,
+        patchNum: 10 as RevisionPatchSetNum,
+      };
+      await element.updateComplete;
+      element.handleDiffAgainstBase();
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/10/some/path.txt'
+      );
+    });
+
+    test('diff against latest', async () => {
+      element.path = 'foo';
+      element.change = {
+        ...createParsedChange(),
+        revisions: createRevisions(12),
+      };
+      element.patchRange = {
+        basePatchNum: 5 as BasePatchSetNum,
+        patchNum: 10 as RevisionPatchSetNum,
+      };
+      await element.updateComplete;
+      element.handleDiffAgainstLatest();
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/5..12/foo'
+      );
+    });
+
+    test('handleDiffBaseAgainstLeft', async () => {
+      element.path = 'foo';
+      element.change = {
+        ...createParsedChange(),
+        revisions: createRevisions(10),
+      };
+      element.patchRange = {
+        patchNum: 3 as RevisionPatchSetNum,
+        basePatchNum: 1 as BasePatchSetNum,
+      };
+      element.viewState = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        patchNum: 3 as RevisionPatchSetNum,
+        basePatchNum: 1 as BasePatchSetNum,
+        path: 'foo',
+      };
+      await element.updateComplete;
+      element.handleDiffBaseAgainstLeft();
+      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1/foo');
+    });
+
+    test('handleDiffBaseAgainstLeft when initially navigating to a comment', () => {
+      element.change = {
+        ...createParsedChange(),
+        revisions: createRevisions(10),
+      };
+      element.patchRange = {
+        patchNum: 3 as RevisionPatchSetNum,
+        basePatchNum: 1 as BasePatchSetNum,
+      };
+      sinon.stub(element, 'viewStateChanged');
+      element.viewState = {
+        commentLink: true,
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+      };
+      element.focusLineNum = 10;
+      element.handleDiffBaseAgainstLeft();
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/1/some/path.txt#10'
+      );
+    });
+
+    test('handleDiffRightAgainstLatest', async () => {
+      element.path = 'foo';
+      element.change = {
+        ...createParsedChange(),
+        revisions: createRevisions(10),
+      };
+      element.patchRange = {
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: 3 as RevisionPatchSetNum,
+      };
+      await element.updateComplete;
+      element.handleDiffRightAgainstLatest();
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/3..10/foo'
+      );
+    });
+
+    test('handleDiffBaseAgainstLatest', async () => {
+      element.change = {
+        ...createParsedChange(),
+        revisions: createRevisions(10),
+      };
+      element.patchRange = {
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: 3 as RevisionPatchSetNum,
+      };
+      await element.updateComplete;
+      element.handleDiffBaseAgainstLatest();
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/10/some/path.txt'
+      );
+    });
+
+    test('A fires an error event when not logged in', async () => {
+      element.loggedIn = false;
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      pressKey(element, 'a');
+      await element.updateComplete;
+      assert.isFalse(setUrlStub.calledOnce);
+      assert.isTrue(loggedInErrorSpy.called);
+    });
+
+    test('A navigates to change with logged in', async () => {
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: 5 as BasePatchSetNum,
+        patchNum: 10 as RevisionPatchSetNum,
+      };
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(10),
+          b: createRevision(5),
+        },
+      };
+      element.loggedIn = true;
+      await element.updateComplete;
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      setUrlStub.reset();
+
+      pressKey(element, 'a');
+
+      await element.updateComplete;
+      assert.equal(setUrlStub.callCount, 1);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/5..10?openReplyDialog=true'
+      );
+      assert.isFalse(loggedInErrorSpy.called);
+    });
+
+    test('A navigates to change with old patch number with logged in', async () => {
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(1),
+          b: createRevision(2),
+        },
+      };
+      element.loggedIn = true;
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      pressKey(element, 'a');
+      await element.updateComplete;
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/1?openReplyDialog=true'
+      );
+      assert.isFalse(loggedInErrorSpy.called);
+    });
+
+    test('keyboard shortcuts with patch range', () => {
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: 5 as BasePatchSetNum,
+        patchNum: 10 as RevisionPatchSetNum,
+      };
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(10),
+          b: createRevision(5),
+        },
+      };
+      element.files = getFilesFromFileList([
+        'chell.go',
+        'glados.txt',
+        'wheatley.md',
+      ]);
+      element.path = 'glados.txt';
+
+      pressKey(element, 'u');
+      assert.equal(setUrlStub.callCount, 1);
+      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/5..10');
+
+      pressKey(element, ']');
+      assert.isTrue(element.loading);
+      assert.equal(setUrlStub.callCount, 2);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/5..10/wheatley.md'
+      );
+      element.path = 'wheatley.md';
+
+      pressKey(element, '[');
+      assert.isTrue(element.loading);
+      assert.equal(setUrlStub.callCount, 3);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/5..10/glados.txt'
+      );
+      element.path = 'glados.txt';
+
+      pressKey(element, '[');
+      assert.isTrue(element.loading);
+      assert.equal(setUrlStub.callCount, 4);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/5..10/chell.go'
+      );
+      element.path = 'chell.go';
+
+      pressKey(element, '[');
+      assert.isTrue(element.loading);
+      assert.equal(setUrlStub.callCount, 5);
+      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/5..10');
+
+      assertIsDefined(element.downloadOverlay);
+      const downloadOverlayStub = sinon
+        .stub(element.downloadOverlay, 'open')
+        .returns(Promise.resolve());
+      pressKey(element, 'd');
+      assert.isTrue(downloadOverlayStub.called);
+    });
+
+    test('keyboard shortcuts with old patch number', () => {
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(1),
+          b: createRevision(2),
+        },
+      };
+      element.files = getFilesFromFileList([
+        'chell.go',
+        'glados.txt',
+        'wheatley.md',
+      ]);
+      element.path = 'glados.txt';
+
+      pressKey(element, 'u');
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
+
+      pressKey(element, ']');
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/1/wheatley.md'
+      );
+      element.path = 'wheatley.md';
+
+      pressKey(element, '[');
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/1/glados.txt'
+      );
+      element.path = 'glados.txt';
+
+      pressKey(element, '[');
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/1/chell.go'
+      );
+      element.path = 'chell.go';
+
+      setUrlStub.reset();
+      pressKey(element, '[');
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
+    });
+
+    test('edit should redirect to edit page', async () => {
+      element.loggedIn = true;
+      element.path = 't.txt';
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        project: 'gerrit' as RepoName,
+        status: ChangeStatus.NEW,
+        revisions: {
+          a: createRevision(1),
+          b: createRevision(2),
+        },
+      };
+      await element.updateComplete;
+      const editBtn = queryAndAssert<GrButton>(
+        element,
+        '.editButton gr-button'
+      );
+      assert.isTrue(!!editBtn);
+      editBtn.click();
+      assert.equal(setUrlStub.callCount, 1);
+      assert.equal(setUrlStub.lastCall.firstArg, '/c/gerrit/+/42/1/t.txt,edit');
+    });
+
+    test('edit should redirect to edit page with line number', async () => {
+      const lineNumber = 42;
+      element.loggedIn = true;
+      element.path = 't.txt';
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        project: 'gerrit' as RepoName,
+        status: ChangeStatus.NEW,
+        revisions: {
+          a: createRevision(1),
+          b: createRevision(2),
+        },
+      };
+      assertIsDefined(element.cursor);
+      sinon
+        .stub(element.cursor, 'getAddress')
+        .returns({number: lineNumber, leftSide: false});
+      await element.updateComplete;
+      const editBtn = queryAndAssert<GrButton>(
+        element,
+        '.editButton gr-button'
+      );
+      assert.isTrue(!!editBtn);
+      editBtn.click();
+      assert.equal(setUrlStub.callCount, 1);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/gerrit/+/42/1/t.txt,edit#42'
+      );
+    });
+
+    async function isEditVisibile({
+      loggedIn,
+      changeStatus,
+    }: {
+      loggedIn: boolean;
+      changeStatus: ChangeStatus;
+    }): Promise<boolean> {
+      element.loggedIn = loggedIn;
+      element.path = 't.txt';
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        status: changeStatus,
+        revisions: {
+          a: createRevision(1),
+          b: createRevision(2),
+        },
+      };
+      await element.updateComplete;
+      const editBtn = query(element, '.editButton gr-button');
+      return !!editBtn;
+    }
+
+    test('edit visible only when logged and status NEW', async () => {
+      for (const changeStatus of Object.keys(ChangeStatus) as ChangeStatus[]) {
+        assert.isFalse(
+          await isEditVisibile({loggedIn: false, changeStatus}),
+          `loggedIn: false, changeStatus: ${changeStatus}`
+        );
+
+        if (changeStatus !== ChangeStatus.NEW) {
+          assert.isFalse(
+            await isEditVisibile({loggedIn: true, changeStatus}),
+            `loggedIn: true, changeStatus: ${changeStatus}`
+          );
+        } else {
+          assert.isTrue(
+            await isEditVisibile({loggedIn: true, changeStatus}),
+            `loggedIn: true, changeStatus: ${changeStatus}`
+          );
+        }
+      }
+    });
+
+    test('edit visible when logged and status NEW', async () => {
+      assert.isTrue(
+        await isEditVisibile({loggedIn: true, changeStatus: ChangeStatus.NEW})
+      );
+    });
+
+    test('edit hidden when logged and status ABANDONED', async () => {
+      assert.isFalse(
+        await isEditVisibile({
+          loggedIn: true,
+          changeStatus: ChangeStatus.ABANDONED,
+        })
+      );
+    });
+
+    test('edit hidden when logged and status MERGED', async () => {
+      assert.isFalse(
+        await isEditVisibile({
+          loggedIn: true,
+          changeStatus: ChangeStatus.MERGED,
+        })
+      );
+    });
+
+    suite('diff prefs hidden', () => {
+      test('when no prefs or logged out', async () => {
+        const getDiffPrefsContainer = () =>
+          query<HTMLSpanElement>(element, '#diffPrefsContainer');
+        element.prefs = undefined;
+        element.loggedIn = false;
+        await element.updateComplete;
+        assert.isNotOk(getDiffPrefsContainer());
+
+        element.loggedIn = true;
+        await element.updateComplete;
+        assert.isNotOk(getDiffPrefsContainer());
+
+        element.loggedIn = false;
+        element.prefs = {...createDefaultDiffPrefs(), font_size: 12};
+        await element.updateComplete;
+        assert.isNotOk(getDiffPrefsContainer());
+
+        element.loggedIn = true;
+        element.prefs = {...createDefaultDiffPrefs(), font_size: 12};
+        await element.updateComplete;
+        assert.isOk(getDiffPrefsContainer());
+      });
+    });
+
+    test('prefsButton opens gr-diff-preferences', () => {
+      const handlePrefsTapSpy = sinon.spy(element, 'handlePrefsTap');
+      assertIsDefined(element.diffPreferencesDialog);
+      const overlayOpenStub = sinon.stub(element.diffPreferencesDialog, 'open');
+      const prefsButton = queryAndAssert<GrButton>(element, '.prefsButton');
+      prefsButton.click();
+
+      assert.isTrue(handlePrefsTapSpy.called);
+      assert.isTrue(overlayOpenStub.called);
+    });
+
+    suite('url parameters', () => {
+      setup(() => {
+        sinon.stub(element, 'fetchFiles');
+      });
+
+      test('_formattedFiles', () => {
+        element.changeNum = 42 as NumericChangeId;
+        element.patchRange = {
+          basePatchNum: PARENT,
+          patchNum: 10 as RevisionPatchSetNum,
+        };
+        element.change = {
+          ...createParsedChange(),
+          _number: 42 as NumericChangeId,
+        };
+        element.files = getFilesFromFileList([
+          'chell.go',
+          'glados.txt',
+          'wheatley.md',
+          '/COMMIT_MSG',
+          '/MERGE_LIST',
+        ]);
+        element.path = 'glados.txt';
+        const expectedFormattedFiles: DropdownItem[] = [
+          {
+            text: 'chell.go',
+            mobileText: 'chell.go',
+            value: 'chell.go',
+            bottomText: '',
+            file: {
+              ...createFileInfo(),
+              __path: 'chell.go',
+            },
+          },
+          {
+            text: 'glados.txt',
+            mobileText: 'glados.txt',
+            value: 'glados.txt',
+            bottomText: '',
+            file: {
+              ...createFileInfo(),
+              __path: 'glados.txt',
+            },
+          },
+          {
+            text: 'wheatley.md',
+            mobileText: 'wheatley.md',
+            value: 'wheatley.md',
+            bottomText: '',
+            file: {
+              ...createFileInfo(),
+              __path: 'wheatley.md',
+            },
+          },
+          {
+            text: 'Commit message',
+            mobileText: 'Commit message',
+            value: '/COMMIT_MSG',
+            bottomText: '',
+            file: {
+              ...createFileInfo(),
+              __path: '/COMMIT_MSG',
+            },
+          },
+          {
+            text: 'Merge list',
+            mobileText: 'Merge list',
+            value: '/MERGE_LIST',
+            bottomText: '',
+            file: {
+              ...createFileInfo(),
+              __path: '/MERGE_LIST',
+            },
+          },
+        ];
+
+        const result = element.formatFilesForDropdown();
+
+        assert.deepEqual(result, expectedFormattedFiles);
+        assert.equal(result[1].value, element.path);
+      });
+
+      test('prev/up/next links', async () => {
+        element.changeNum = 42 as NumericChangeId;
+        element.patchRange = {
+          basePatchNum: PARENT,
+          patchNum: 10 as RevisionPatchSetNum,
+        };
+        element.change = {
+          ...createParsedChange(),
+          _number: 42 as NumericChangeId,
+          revisions: {
+            a: createRevision(10),
+          },
+        };
+        element.files = getFilesFromFileList([
+          'chell.go',
+          'glados.txt',
+          'wheatley.md',
+        ]);
+        element.path = 'glados.txt';
+        await element.updateComplete;
+
+        const linkEls = queryAll(element, '.navLink');
+        assert.equal(linkEls.length, 3);
+        assert.equal(
+          linkEls[0].getAttribute('href'),
+          '/c/test-project/+/42/10/chell.go'
+        );
+        assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
+        assert.equal(
+          linkEls[2].getAttribute('href'),
+          '/c/test-project/+/42/10/wheatley.md'
+        );
+        element.path = 'wheatley.md';
+        await element.updateComplete;
+        assert.equal(
+          linkEls[0].getAttribute('href'),
+          '/c/test-project/+/42/10/glados.txt'
+        );
+        assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
+        assert.equal(linkEls[2].getAttribute('href'), '/c/test-project/+/42');
+        element.path = 'chell.go';
+        await element.updateComplete;
+        assert.equal(linkEls[0].getAttribute('href'), '/c/test-project/+/42');
+        assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
+        assert.equal(
+          linkEls[2].getAttribute('href'),
+          '/c/test-project/+/42/10/glados.txt'
+        );
+        element.path = 'not_a_real_file';
+        await element.updateComplete;
+        assert.equal(
+          linkEls[0].getAttribute('href'),
+          '/c/test-project/+/42/10/wheatley.md'
+        );
+        assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
+        assert.equal(
+          linkEls[2].getAttribute('href'),
+          '/c/test-project/+/42/10/chell.go'
+        );
+      });
+
+      test('prev/up/next links with patch range', async () => {
+        element.changeNum = 42 as NumericChangeId;
+        element.patchRange = {
+          basePatchNum: 5 as BasePatchSetNum,
+          patchNum: 10 as RevisionPatchSetNum,
+        };
+        element.change = {
+          ...createParsedChange(),
+          _number: 42 as NumericChangeId,
+          revisions: {
+            a: createRevision(5),
+            b: createRevision(10),
+          },
+        };
+        element.files = getFilesFromFileList([
+          'chell.go',
+          'glados.txt',
+          'wheatley.md',
+        ]);
+        element.path = 'glados.txt';
+        await element.updateComplete;
+        const linkEls = queryAll(element, '.navLink');
+        assert.equal(linkEls.length, 3);
+        assert.equal(
+          linkEls[0].getAttribute('href'),
+          '/c/test-project/+/42/5..10/chell.go'
+        );
+        assert.equal(
+          linkEls[1].getAttribute('href'),
+          '/c/test-project/+/42/5..10'
+        );
+        assert.equal(
+          linkEls[2].getAttribute('href'),
+          '/c/test-project/+/42/5..10/wheatley.md'
+        );
+        element.path = 'wheatley.md';
+        await element.updateComplete;
+        assert.equal(
+          linkEls[0].getAttribute('href'),
+          '/c/test-project/+/42/5..10/glados.txt'
+        );
+        assert.equal(
+          linkEls[1].getAttribute('href'),
+          '/c/test-project/+/42/5..10'
+        );
+        assert.equal(
+          linkEls[2].getAttribute('href'),
+          '/c/test-project/+/42/5..10'
+        );
+        element.path = 'chell.go';
+        await element.updateComplete;
+        assert.equal(
+          linkEls[0].getAttribute('href'),
+          '/c/test-project/+/42/5..10'
+        );
+        assert.equal(
+          linkEls[1].getAttribute('href'),
+          '/c/test-project/+/42/5..10'
+        );
+        assert.equal(
+          linkEls[2].getAttribute('href'),
+          '/c/test-project/+/42/5..10/glados.txt'
+        );
+      });
+    });
+
+    test('handlePatchChange calls setUrl correctly', async () => {
+      element.change = {
+        ...createParsedChange(),
+        _number: 321 as NumericChangeId,
+        project: 'foo/bar' as RepoName,
+      };
+      element.path = 'path/to/file.txt';
+
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 3 as RevisionPatchSetNum,
+      };
+      await element.updateComplete;
+
+      const detail = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+
+      queryAndAssert(element, '#rangeSelect').dispatchEvent(
+        new CustomEvent('patch-range-change', {detail, bubbles: false})
+      );
+
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/foo/bar/+/321/1/path/to/file.txt'
+      );
+    });
+
+    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');
+
+        assertIsDefined(element.diffHost);
+        sinon.stub(element.diffHost, 'reload');
+        element.loggedIn = true;
+        const diffPreferences = {
+          ...createDefaultDiffPrefs(),
+          manual_review: true,
+        };
+        element.userModel.setDiffPreferences(diffPreferences);
+        element.getChangeModel().setState({
+          change: createParsedChange(),
+          diffPath: '/COMMIT_MSG',
+          reviewedFiles: [],
+          loadingStatus: LoadingStatus.LOADED,
+        });
+
+        element.routerModel.setState({
+          changeNum: TEST_NUMERIC_CHANGE_ID,
+          view: GerritView.DIFF,
+          patchNum: 2 as RevisionPatchSetNum,
+        });
+        element.patchRange = {
+          patchNum: 2 as RevisionPatchSetNum,
+          basePatchNum: 1 as BasePatchSetNum,
+        };
+
+        await waitUntil(() => setReviewedStatusStub.called);
+
+        assert.isFalse(setReviewedFileStatusStub.called);
+
+        // if prefs are updated then the reviewed status should not be set again
+        element.userModel.setDiffPreferences(createDefaultDiffPrefs());
+
+        await element.updateComplete;
+        assert.isFalse(setReviewedFileStatusStub.called);
+      }
+    );
+
+    test('_prefs.manual_review false means set reviewed is called', async () => {
+      const setReviewedFileStatusStub = sinon
+        .stub(element.getChangeModel(), 'setReviewedFilesStatus')
+        .callsFake(() => Promise.resolve());
+
+      assertIsDefined(element.diffHost);
+      sinon.stub(element.diffHost, 'reload');
+      element.loggedIn = true;
+      const diffPreferences = {
+        ...createDefaultDiffPrefs(),
+        manual_review: false,
+      };
+      element.userModel.setDiffPreferences(diffPreferences);
+      element.getChangeModel().setState({
+        change: createParsedChange(),
+        diffPath: '/COMMIT_MSG',
+        reviewedFiles: [],
+        loadingStatus: LoadingStatus.LOADED,
+      });
+
+      element.routerModel.setState({
+        changeNum: TEST_NUMERIC_CHANGE_ID,
+        view: GerritView.DIFF,
+        patchNum: 22 as RevisionPatchSetNum,
+      });
+      element.patchRange = {
+        patchNum: 2 as RevisionPatchSetNum,
+        basePatchNum: 1 as BasePatchSetNum,
+      };
+
+      await waitUntil(() => setReviewedFileStatusStub.called);
+
+      assert.isTrue(setReviewedFileStatusStub.called);
+    });
+
+    test('file review status', async () => {
+      element.getChangeModel().setState({
+        change: createParsedChange(),
+        diffPath: '/COMMIT_MSG',
+        reviewedFiles: [],
+        loadingStatus: LoadingStatus.LOADED,
+      });
+      element.loggedIn = true;
+      const saveReviewedStub = sinon
+        .stub(element.getChangeModel(), 'setReviewedFilesStatus')
+        .callsFake(() => Promise.resolve());
+      assertIsDefined(element.diffHost);
+      sinon.stub(element.diffHost, 'reload');
+
+      element.userModel.setDiffPreferences(createDefaultDiffPrefs());
+
+      element.routerModel.setState({
+        changeNum: TEST_NUMERIC_CHANGE_ID,
+        view: GerritView.DIFF,
+        patchNum: 2 as RevisionPatchSetNum,
+      });
+
+      element.patchRange = {
+        patchNum: 2 as RevisionPatchSetNum,
+        basePatchNum: 1 as BasePatchSetNum,
+      };
+
+      await waitUntil(() => saveReviewedStub.called);
+
+      element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', true);
+      await element.updateComplete;
+
+      const reviewedStatusCheckBox = queryAndAssert<HTMLInputElement>(
+        element,
+        'input[type="checkbox"]'
+      );
+
+      assert.isTrue(reviewedStatusCheckBox.checked);
+      assert.deepEqual(saveReviewedStub.lastCall.args, [
+        42,
+        2,
+        '/COMMIT_MSG',
+        true,
+      ]);
+
+      reviewedStatusCheckBox.click();
+      assert.isFalse(reviewedStatusCheckBox.checked);
+      assert.deepEqual(saveReviewedStub.lastCall.args, [
+        42,
+        2,
+        '/COMMIT_MSG',
+        false,
+      ]);
+
+      element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', false);
+      await element.updateComplete;
+
+      reviewedStatusCheckBox.click();
+      assert.isTrue(reviewedStatusCheckBox.checked);
+      assert.deepEqual(saveReviewedStub.lastCall.args, [
+        42,
+        2,
+        '/COMMIT_MSG',
+        true,
+      ]);
+
+      const callCount = saveReviewedStub.callCount;
+
+      element.viewState = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        project: 'test' as RepoName,
+      };
+      await element.updateComplete;
+
+      // saveReviewedState observer observes viewState, but should not fire when
+      // view !== GerritView.DIFF.
+      assert.equal(saveReviewedStub.callCount, callCount);
+    });
+
+    test('file review status with edit loaded', async () => {
+      const saveReviewedStub = sinon.stub(
+        element.getChangeModel(),
+        'setReviewedFilesStatus'
+      );
+
+      element.patchRange = {
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: EDIT,
+      };
+      await waitEventLoop();
+
+      assert.isTrue(element.computeEditMode());
+      element.setReviewed(true);
+      assert.isFalse(saveReviewedStub.called);
+    });
+
+    test('hash is determined from viewState', async () => {
+      assertIsDefined(element.diffHost);
+      sinon.stub(element.diffHost, 'reload');
+      const initLineStub = sinon.stub(element, 'initLineOfInterestAndCursor');
+
+      element.loggedIn = true;
+      element.viewState = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        patchNum: 2 as RevisionPatchSetNum,
+        basePatchNum: 1 as BasePatchSetNum,
+        path: '/COMMIT_MSG',
+      };
+
+      await element.updateComplete;
+      await waitEventLoop();
+      assert.isTrue(initLineStub.calledOnce);
+    });
+
+    test('diff mode selector correctly toggles the diff', async () => {
+      const select = queryAndAssert<GrDiffModeSelector>(element, '#modeSelect');
+      const diffDisplay = element.diffHost;
+      assertIsDefined(diffDisplay);
+      element.userPrefs = {
+        ...createDefaultPreferences(),
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+      };
+      element.getBrowserModel().setScreenWidth(0);
+
+      const userStub = stubUsers('updatePreferences');
+
+      await element.updateComplete;
+      // The mode selected in the view state reflects the selected option.
+      // 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.
+      element.handleToggleDiffMode();
+      assert.isTrue(
+        userStub.calledWithExactly({
+          diff_view: DiffViewMode.UNIFIED,
+        })
+      );
+    });
+
+    test('diff mode selector should be hidden for binary', async () => {
+      element.diff = {
+        ...createDiff(),
+        binary: true,
+        content: [],
+      };
+
+      await element.updateComplete;
+      const diffModeSelector = queryAndAssert(element, '.diffModeSelector');
+      assert.isTrue(diffModeSelector.classList.contains('hide'));
+    });
+
+    suite('commitRange', () => {
+      const change: ParsedChangeInfo = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          'commit-sha-1': {
+            ...createRevision(1),
+            commit: {
+              ...createCommit(),
+              parents: [{subject: 's1', commit: 'sha-1-parent' as CommitId}],
+            },
+          },
+          'commit-sha-2': createRevision(2),
+          'commit-sha-3': createRevision(3),
+          'commit-sha-4': createRevision(4),
+          'commit-sha-5': {
+            ...createRevision(5),
+            commit: {
+              ...createCommit(),
+              parents: [{subject: 's5', commit: 'sha-5-parent' as CommitId}],
+            },
+          },
+        },
+      };
+      setup(async () => {
+        assertIsDefined(element.diffHost);
+        sinon.stub(element.diffHost, 'reload');
+        sinon.stub(element, 'initCursor');
+        element.change = change;
+        await element.updateComplete;
+        await element.diffHost.updateComplete;
+      });
+
+      test('uses the patchNum and basePatchNum ', async () => {
+        element.viewState = {
+          view: GerritView.DIFF,
+          changeNum: 42 as NumericChangeId,
+          patchNum: 4 as RevisionPatchSetNum,
+          basePatchNum: 2 as BasePatchSetNum,
+          path: '/COMMIT_MSG',
+        };
+        element.change = change;
+        await element.updateComplete;
+        await waitEventLoop();
+        assert.deepEqual(element.commitRange, {
+          baseCommit: 'commit-sha-2' as CommitId,
+          commit: 'commit-sha-4' as CommitId,
+        });
+      });
+
+      test('uses the parent when there is no base patch num ', async () => {
+        element.viewState = {
+          view: GerritView.DIFF,
+          changeNum: 42 as NumericChangeId,
+          patchNum: 5 as RevisionPatchSetNum,
+          path: '/COMMIT_MSG',
+        };
+        element.change = change;
+        await element.updateComplete;
+        await waitEventLoop();
+        assert.deepEqual(element.commitRange, {
+          commit: 'commit-sha-5' as CommitId,
+          baseCommit: 'sha-5-parent' as CommitId,
+        });
+      });
+    });
+
+    test('initCursor', () => {
+      assertIsDefined(element.cursor);
+      assert.isNotOk(element.cursor.initialLineNumber);
+
+      // Does nothing when viewState specify no cursor address:
+      element.initCursor(false);
+      assert.isNotOk(element.cursor.initialLineNumber);
+
+      // Does nothing when viewState specify side but no number:
+      element.initCursor(true);
+      assert.isNotOk(element.cursor.initialLineNumber);
+
+      // Revision hash: specifies lineNum but not side.
+
+      element.focusLineNum = 234;
+      element.initCursor(false);
+      assert.equal(element.cursor.initialLineNumber, 234);
+      assert.equal(element.cursor.side, Side.RIGHT);
+
+      // Base hash: specifies lineNum and side.
+      element.focusLineNum = 345;
+      element.initCursor(true);
+      assert.equal(element.cursor.initialLineNumber, 345);
+      assert.equal(element.cursor.side, Side.LEFT);
+
+      // Specifies right side:
+      element.focusLineNum = 123;
+      element.initCursor(false);
+      assert.equal(element.cursor.initialLineNumber, 123);
+      assert.equal(element.cursor.side, Side.RIGHT);
+    });
+
+    test('getLineOfInterest', () => {
+      assert.isUndefined(element.getLineOfInterest(false));
+
+      element.focusLineNum = 12;
+      let result = element.getLineOfInterest(false);
+      assert.isOk(result);
+      assert.equal(result!.lineNum, 12);
+      assert.equal(result!.side, Side.RIGHT);
+
+      result = element.getLineOfInterest(true);
+      assert.isOk(result);
+      assert.equal(result!.lineNum, 12);
+      assert.equal(result!.side, Side.LEFT);
+    });
+
+    test('onLineSelected', () => {
+      const replaceStateStub = sinon.stub(history, 'replaceState');
+      assertIsDefined(element.cursor);
+      sinon
+        .stub(element.cursor, 'getAddress')
+        .returns({number: 123, leftSide: false});
+
+      element.changeNum = 321 as NumericChangeId;
+      element.change = {
+        ...createParsedChange(),
+        _number: 321 as NumericChangeId,
+        project: 'foo/bar' as RepoName,
+      };
+      element.patchRange = {
+        basePatchNum: 3 as BasePatchSetNum,
+        patchNum: 5 as RevisionPatchSetNum,
+      };
+      const e = {detail: {number: 123, side: Side.RIGHT}} as CustomEvent;
+
+      element.onLineSelected(e);
+
+      assert.isTrue(replaceStateStub.called);
+    });
+
+    test('line selected on left side', () => {
+      const replaceStateStub = sinon.stub(history, 'replaceState');
+      assertIsDefined(element.cursor);
+      sinon
+        .stub(element.cursor, 'getAddress')
+        .returns({number: 123, leftSide: true});
+
+      element.changeNum = 321 as NumericChangeId;
+      element.change = {
+        ...createParsedChange(),
+        _number: 321 as NumericChangeId,
+        project: 'foo/bar' as RepoName,
+      };
+      element.patchRange = {
+        basePatchNum: 3 as BasePatchSetNum,
+        patchNum: 5 as RevisionPatchSetNum,
+      };
+      const e = {detail: {number: 123, side: Side.LEFT}} as CustomEvent;
+
+      element.onLineSelected(e);
+
+      assert.isTrue(replaceStateStub.called);
+    });
+
+    test('handleToggleDiffMode', () => {
+      const userStub = stubUsers('updatePreferences');
+      element.userPrefs = {
+        ...createDefaultPreferences(),
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+      };
+
+      element.handleToggleDiffMode();
+      assert.deepEqual(userStub.lastCall.args[0], {
+        diff_view: DiffViewMode.UNIFIED,
+      });
+
+      element.userPrefs = {
+        ...createDefaultPreferences(),
+        diff_view: DiffViewMode.UNIFIED,
+      };
+
+      element.handleToggleDiffMode();
+      assert.deepEqual(userStub.lastCall.args[0], {
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+      });
+    });
+
+    suite('initPatchRange', () => {
+      setup(async () => {
+        getDiffRestApiStub.returns(Promise.resolve(createDiff()));
+        element.viewState = {
+          view: GerritView.DIFF,
+          changeNum: 42 as NumericChangeId,
+          patchNum: 3 as RevisionPatchSetNum,
+          path: 'abcd',
+        };
+        await element.updateComplete;
+      });
+      test('empty', () => {
+        sinon.stub(element, 'getPaths').returns({});
+        element.initPatchRange();
+        assert.equal(Object.keys(element.commentMap ?? {}).length, 0);
+      });
+
+      test('has paths', () => {
+        sinon.stub(element, 'fetchFiles');
+        sinon.stub(element, 'getPaths').returns({
+          'path/to/file/one.cpp': true,
+          'path-to/file/two.py': true,
+        });
+        element.changeNum = 42 as NumericChangeId;
+        element.patchRange = {
+          basePatchNum: 3 as BasePatchSetNum,
+          patchNum: 5 as RevisionPatchSetNum,
+        };
+        element.initPatchRange();
+        assert.deepEqual(Object.keys(element.commentMap ?? {}), [
+          'path/to/file/one.cpp',
+          'path-to/file/two.py',
+        ]);
+      });
+    });
+
+    suite('computeCommentSkips', () => {
+      test('empty file list', () => {
+        const commentMap = {
+          'path/one.jpg': true,
+          'path/three.wav': true,
+        };
+        const path = 'path/two.m4v';
+        const result = element.computeCommentSkips(commentMap, [], path);
+        assert.isOk(result);
+        assert.isNotOk(result!.previous);
+        assert.isNotOk(result!.next);
+      });
+
+      test('finds skips', () => {
+        const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
+        let path = fileList[1];
+        const commentMap: CommentMap = {};
+        commentMap[fileList[0]] = true;
+        commentMap[fileList[1]] = false;
+        commentMap[fileList[2]] = true;
+
+        let result = element.computeCommentSkips(commentMap, fileList, path);
+        assert.isOk(result);
+        assert.equal(result!.previous, fileList[0]);
+        assert.equal(result!.next, fileList[2]);
+
+        commentMap[fileList[1]] = true;
+
+        result = element.computeCommentSkips(commentMap, fileList, path);
+        assert.isOk(result);
+        assert.equal(result!.previous, fileList[0]);
+        assert.equal(result!.next, fileList[2]);
+
+        path = fileList[0];
+
+        result = element.computeCommentSkips(commentMap, fileList, path);
+        assert.isOk(result);
+        assert.isNull(result!.previous);
+        assert.equal(result!.next, fileList[1]);
+
+        path = fileList[2];
+
+        result = element.computeCommentSkips(commentMap, fileList, path);
+        assert.isOk(result);
+        assert.equal(result!.previous, fileList[1]);
+        assert.isNull(result!.next);
+      });
+
+      suite('skip next/previous', () => {
+        let navToChangeStub: SinonStub;
+
+        setup(() => {
+          navToChangeStub = sinon.stub(element, 'navToChangeView');
+          element.files = getFilesFromFileList([
+            'path/one.jpg',
+            'path/two.m4v',
+            'path/three.wav',
+          ]);
+          element.patchRange = {
+            patchNum: 2 as RevisionPatchSetNum,
+            basePatchNum: 1 as BasePatchSetNum,
+          };
+        });
+
+        suite('moveToPreviousFileWithComment', () => {
+          test('no skips', () => {
+            element.moveToPreviousFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isFalse(setUrlStub.called);
+          });
+
+          test('no previous', async () => {
+            const commentMap: CommentMap = {};
+            commentMap[element.files.sortedFileList[0]!] = false;
+            commentMap[element.files.sortedFileList[1]!] = false;
+            commentMap[element.files.sortedFileList[2]!] = true;
+            element.commentMap = commentMap;
+            element.path = element.files.sortedFileList[1];
+            await element.updateComplete;
+
+            element.moveToPreviousFileWithComment();
+            assert.isTrue(navToChangeStub.calledOnce);
+            assert.isFalse(setUrlStub.called);
+          });
+
+          test('w/ previous', async () => {
+            const commentMap: CommentMap = {};
+            commentMap[element.files.sortedFileList[0]!] = true;
+            commentMap[element.files.sortedFileList[1]!] = false;
+            commentMap[element.files.sortedFileList[2]!] = true;
+            element.commentMap = commentMap;
+            element.path = element.files.sortedFileList[1];
+            await element.updateComplete;
+
+            element.moveToPreviousFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isTrue(setUrlStub.calledOnce);
+          });
+        });
+
+        suite('moveToNextFileWithComment', () => {
+          test('no skips', () => {
+            element.moveToNextFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isFalse(setUrlStub.called);
+          });
+
+          test('no previous', async () => {
+            const commentMap: CommentMap = {};
+            commentMap[element.files.sortedFileList[0]!] = true;
+            commentMap[element.files.sortedFileList[1]!] = false;
+            commentMap[element.files.sortedFileList[2]!] = false;
+            element.commentMap = commentMap;
+            element.path = element.files.sortedFileList[1];
+            await element.updateComplete;
+
+            element.moveToNextFileWithComment();
+            assert.isTrue(navToChangeStub.calledOnce);
+            assert.isFalse(setUrlStub.called);
+          });
+
+          test('w/ previous', async () => {
+            const commentMap: CommentMap = {};
+            commentMap[element.files.sortedFileList[0]!] = true;
+            commentMap[element.files.sortedFileList[1]!] = false;
+            commentMap[element.files.sortedFileList[2]!] = true;
+            element.commentMap = commentMap;
+            element.path = element.files.sortedFileList[1];
+            await element.updateComplete;
+
+            element.moveToNextFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isTrue(setUrlStub.calledOnce);
+          });
+        });
+      });
+    });
+
+    test('_computeEditMode', () => {
+      const callCompute = (range: PatchRange) => {
+        element.patchRange = range;
+        return element.computeEditMode();
+      };
+      assert.isFalse(
+        callCompute({
+          basePatchNum: PARENT,
+          patchNum: 1 as RevisionPatchSetNum,
+        })
+      );
+      assert.isTrue(
+        callCompute({
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: EDIT,
+        })
+      );
+    });
+
+    test('computeFileNum', () => {
+      element.path = '/foo';
+      assert.equal(
+        element.computeFileNum([
+          {text: '/foo', value: '/foo'},
+          {text: '/bar', value: '/bar'},
+        ]),
+        1
+      );
+      element.path = '/bar';
+      assert.equal(
+        element.computeFileNum([
+          {text: '/foo', value: '/foo'},
+          {text: '/bar', value: '/bar'},
+        ]),
+        2
+      );
+    });
+
+    test('computeFileNumClass', () => {
+      assert.equal(element.computeFileNumClass(0, []), '');
+      assert.equal(
+        element.computeFileNumClass(1, [
+          {text: '/foo', value: '/foo'},
+          {text: '/bar', value: '/bar'},
+        ]),
+        'show'
+      );
+    });
+
+    test('f open file dropdown', async () => {
+      assertIsDefined(element.dropdown);
+      assertIsDefined(element.dropdown.dropdown);
+      assert.isFalse(element.dropdown.dropdown.opened);
+      pressKey(element, 'f');
+      await element.updateComplete;
+      assert.isTrue(element.dropdown.dropdown.opened);
+    });
+
+    suite('blame', () => {
+      test('toggle blame with button', () => {
+        assertIsDefined(element.diffHost);
+        const toggleBlame = sinon
+          .stub(element.diffHost, 'loadBlame')
+          .callsFake(() => Promise.resolve([]));
+        queryAndAssert<GrButton>(element, '#toggleBlame').click();
+        assert.isTrue(toggleBlame.calledOnce);
+      });
+      test('toggle blame with shortcut', () => {
+        assertIsDefined(element.diffHost);
+        const toggleBlame = sinon
+          .stub(element.diffHost, 'loadBlame')
+          .callsFake(() => Promise.resolve([]));
+        pressKey(element, 'b');
+        assert.isTrue(toggleBlame.calledOnce);
+      });
+    });
+
+    suite('editMode behavior', () => {
+      setup(async () => {
+        element.loggedIn = true;
+        await element.updateComplete;
+      });
+
+      test('reviewed checkbox', async () => {
+        sinon.stub(element, 'handlePatchChange');
+        element.patchRange = createPatchRange();
+        await element.updateComplete;
+        assertIsDefined(element.reviewed);
+        // Reviewed checkbox should be shown.
+        assert.isTrue(isVisible(element.reviewed));
+        element.patchRange = {...element.patchRange, patchNum: EDIT};
+        await element.updateComplete;
+
+        assert.isFalse(isVisible(element.reviewed));
+      });
+    });
+
+    suite('switching files', () => {
+      let dispatchEventStub: SinonStub;
+      let navToFileStub: SinonStub;
+      let moveToPreviousChunkStub: SinonStub;
+      let moveToNextChunkStub: SinonStub;
+      let isAtStartStub: SinonStub;
+      let isAtEndStub: SinonStub;
+      let nowStub: SinonStub;
+
+      setup(() => {
+        dispatchEventStub = sinon.stub(element, 'dispatchEvent').callThrough();
+        navToFileStub = sinon.stub(element, 'navToFile');
+        assertIsDefined(element.cursor);
+        moveToPreviousChunkStub = sinon.stub(
+          element.cursor,
+          'moveToPreviousChunk'
+        );
+        moveToNextChunkStub = sinon.stub(element.cursor, 'moveToNextChunk');
+        isAtStartStub = sinon.stub(element.cursor, 'isAtStart');
+        isAtEndStub = sinon.stub(element.cursor, 'isAtEnd');
+        nowStub = sinon.stub(Date, 'now');
+      });
+
+      test('shows toast when at the end of file', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        pressKey(element, 'n');
+
+        assert.isTrue(moveToNextChunkStub.called);
+        assert.equal(
+          dispatchEventStub.lastCall.args[0].type,
+          EventType.SHOW_ALERT
+        );
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('navigates to next file when n is tapped again', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
+        element.reviewedFiles = new Set(['file2']);
+        element.path = 'file1';
+
+        nowStub.returns(5);
+        pressKey(element, 'n');
+        nowStub.returns(10);
+        pressKey(element, 'n');
+
+        assert.isTrue(navToFileStub.called);
+        assert.deepEqual(navToFileStub.lastCall.args, [['file1', 'file3'], 1]);
+      });
+
+      test('does not navigate if n is tapped twice too slow', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        nowStub.returns(5);
+        pressKey(element, 'n');
+        nowStub.returns(6000);
+        pressKey(element, 'n');
+
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('shows toast when at the start of file', () => {
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        pressKey(element, 'p');
+
+        assert.isTrue(moveToPreviousChunkStub.called);
+        assert.equal(
+          dispatchEventStub.lastCall.args[0].type,
+          EventType.SHOW_ALERT
+        );
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('navigates to prev file when p is tapped again', () => {
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
+        element.reviewedFiles = new Set(['file2']);
+        element.path = 'file3';
+
+        nowStub.returns(5);
+        pressKey(element, 'p');
+        nowStub.returns(10);
+        pressKey(element, 'p');
+
+        assert.isTrue(navToFileStub.called);
+        assert.deepEqual(navToFileStub.lastCall.args, [['file1', 'file3'], -1]);
+      });
+
+      test('does not navigate if p is tapped twice too slow', () => {
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        nowStub.returns(5);
+        pressKey(element, 'p');
+        nowStub.returns(6000);
+        pressKey(element, 'p');
+
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('does not navigate when tapping n then p', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        nowStub.returns(5);
+        pressKey(element, 'n');
+
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        nowStub.returns(10);
+        pressKey(element, 'p');
+
+        assert.isFalse(navToFileStub.called);
+      });
+    });
+
+    test('shift+m navigates to next unreviewed file', async () => {
+      element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
+      element.reviewedFiles = new Set(['file1', 'file2']);
+      element.path = 'file1';
+      const reviewedStub = sinon.stub(element, 'setReviewed');
+      const navStub = sinon.stub(element, 'navToFile');
+      pressKey(element, 'M');
+      await waitEventLoop();
+
+      assert.isTrue(reviewedStub.lastCall.args[0]);
+      assert.deepEqual(navStub.lastCall.args, [['file1', 'file3'], 1]);
+    });
+
+    test('File change should trigger setUrl once', async () => {
+      element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
+      sinon.stub(element, 'initLineOfInterestAndCursor');
+
+      // Load file1
+      element.viewState = {
+        view: GerritView.DIFF,
+        patchNum: 1 as RevisionPatchSetNum,
+        changeNum: 101 as NumericChangeId,
+        project: 'test-project' as RepoName,
+        path: 'file1',
+      };
+      element.patchRange = {
+        patchNum: 1 as RevisionPatchSetNum,
+        basePatchNum: PARENT,
+      };
+      element.change = {
+        ...createParsedChange(),
+        revisions: createRevisions(1),
+      };
+      await element.updateComplete;
+      assert.isFalse(setUrlStub.called);
+
+      // Switch to file2
+      element.handleFileChange(
+        new CustomEvent('value-change', {detail: {value: 'file2'}})
+      );
+      assert.isTrue(setUrlStub.calledOnce);
+
+      // This is to mock the param change triggered by above navigate
+      element.viewState = {
+        view: GerritView.DIFF,
+        patchNum: 1 as RevisionPatchSetNum,
+        changeNum: 101 as NumericChangeId,
+        project: 'test-project' as RepoName,
+        path: 'file2',
+      };
+      element.patchRange = {
+        patchNum: 1 as RevisionPatchSetNum,
+        basePatchNum: PARENT,
+      };
+
+      // No extra call
+      assert.isTrue(setUrlStub.calledOnce);
+    });
+
+    test('_computeDownloadDropdownLinks', () => {
+      const downloadLinks = [
+        {
+          url: '/changes/test~12/revisions/1/patch?zip&path=index.php',
+          name: 'Patch',
+        },
+        {
+          url: '/changes/test~12/revisions/1/files/index.php/download?parent=1',
+          name: 'Left Content',
+        },
+        {
+          url: '/changes/test~12/revisions/1/files/index.php/download',
+          name: 'Right Content',
+        },
+      ];
+
+      element.change = createParsedChange();
+      element.change.project = 'test' as RepoName;
+      element.changeNum = 12 as NumericChangeId;
+      element.patchRange = {
+        patchNum: 1 as RevisionPatchSetNum,
+        basePatchNum: PARENT,
+      };
+      element.path = 'index.php';
+      element.diff = createDiff();
+      assert.deepEqual(element.computeDownloadDropdownLinks(), downloadLinks);
+    });
+
+    test('_computeDownloadDropdownLinks diff returns renamed', () => {
+      const downloadLinks = [
+        {
+          url: '/changes/test~12/revisions/3/patch?zip&path=index.php',
+          name: 'Patch',
+        },
+        {
+          url: '/changes/test~12/revisions/2/files/index2.php/download',
+          name: 'Left Content',
+        },
+        {
+          url: '/changes/test~12/revisions/3/files/index.php/download',
+          name: 'Right Content',
+        },
+      ];
+
+      const diff = createDiff();
+      diff.change_type = 'RENAMED';
+      diff.meta_a!.name = 'index2.php';
+
+      element.change = createParsedChange();
+      element.change.project = 'test' as RepoName;
+      element.changeNum = 12 as NumericChangeId;
+      element.patchRange = {
+        patchNum: 3 as RevisionPatchSetNum,
+        basePatchNum: 2 as BasePatchSetNum,
+      };
+      element.path = 'index.php';
+      element.diff = diff;
+      assert.deepEqual(element.computeDownloadDropdownLinks(), downloadLinks);
+    });
+
+    test('computeDownloadFileLink', () => {
+      assert.equal(
+        element.computeDownloadFileLink(
+          'test' as RepoName,
+          12 as NumericChangeId,
+          {patchNum: 1 as PatchSetNumber, basePatchNum: PARENT},
+          'index.php',
+          true
+        ),
+        '/changes/test~12/revisions/1/files/index.php/download?parent=1'
+      );
+
+      assert.equal(
+        element.computeDownloadFileLink(
+          'test' as RepoName,
+          12 as NumericChangeId,
+          {patchNum: 1 as PatchSetNumber, basePatchNum: -2 as PatchSetNumber},
+          'index.php',
+          true
+        ),
+        '/changes/test~12/revisions/1/files/index.php/download?parent=2'
+      );
+
+      assert.equal(
+        element.computeDownloadFileLink(
+          'test' as RepoName,
+          12 as NumericChangeId,
+          {patchNum: 3 as PatchSetNumber, basePatchNum: 2 as PatchSetNumber},
+          'index.php',
+          true
+        ),
+        '/changes/test~12/revisions/2/files/index.php/download'
+      );
+
+      assert.equal(
+        element.computeDownloadFileLink(
+          'test' as RepoName,
+          12 as NumericChangeId,
+          {patchNum: 3 as PatchSetNumber, basePatchNum: 2 as PatchSetNumber},
+          'index.php',
+          false
+        ),
+        '/changes/test~12/revisions/3/files/index.php/download'
+      );
+    });
+
+    test('computeDownloadPatchLink', () => {
+      assert.equal(
+        element.computeDownloadPatchLink(
+          'test' as RepoName,
+          12 as NumericChangeId,
+          {basePatchNum: PARENT, patchNum: 1 as RevisionPatchSetNum},
+          'index.php'
+        ),
+        '/changes/test~12/revisions/1/patch?zip&path=index.php'
+      );
+    });
+  });
+
+  suite('unmodified files with comments', () => {
+    let element: GrDiffView;
+
+    setup(async () => {
+      const changedFiles = {
+        'file1.txt': createFileInfo(),
+        'a/b/test.c': createFileInfo(),
+      };
+      stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
+      stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+      stubRestApi('getChangeFiles').returns(Promise.resolve(changedFiles));
+      stubRestApi('saveFileReviewed').returns(Promise.resolve(new Response()));
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubRestApi('getReviewedFiles').returns(Promise.resolve([]));
+      element = await fixture(html`<gr-diff-view></gr-diff-view>`);
+      element.changeNum = 42 as NumericChangeId;
+    });
+
+    test('fetchFiles add files with comments without changes', () => {
+      element.patchRange = {
+        basePatchNum: 5 as BasePatchSetNum,
+        patchNum: 10 as RevisionPatchSetNum,
+      };
+      element.changeComments = {
+        getPaths: sinon.stub().returns({
+          'file2.txt': {},
+          'file1.txt': {},
+        }),
+      } as unknown as ChangeComments;
+      element.changeNum = 23 as NumericChangeId;
+      return element.fetchFiles().then(() => {
+        assert.deepEqual(element.files, {
+          sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
+          changeFilesByPath: {
+            'file1.txt': createFileInfo(),
+            'file2.txt': {status: 'U'} as FileInfo,
+            'a/b/test.c': createFileInfo(),
+          },
+        });
+      });
+    });
+  });
+});
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 1656f5f..d9f88f7 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
@@ -1,18 +1,7 @@
 /**
  * @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 '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-select/gr-select';
@@ -29,31 +18,32 @@
   convertToPatchSetNum,
 } from '../../../utils/patch-set-util';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   BasePatchSetNum,
-  ParentPatchSetNum,
+  EDIT,
+  PARENT,
   PatchSetNum,
   RevisionInfo,
+  RevisionPatchSetNum,
   Timestamp,
 } from '../../../types/common';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {
   DropdownItem,
-  DropDownValueChangeEvent,
   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 {customElement, property, query, state} from 'lit/decorators.js';
 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';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {GeneratedWebLink} from '../../../utils/weblink-util';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -75,9 +65,6 @@
 }
 
 declare global {
-  interface HTMLElementEventMap {
-    'value-change': DropDownValueChangeEvent;
-  }
   interface HTMLElementTagNameMap {
     'gr-patch-range-select': GrPatchRangeSelect;
   }
@@ -106,7 +93,7 @@
   filesWeblinks?: FilesWebLinks;
 
   @property({type: String})
-  patchNum?: PatchSetNum;
+  patchNum?: RevisionPatchSetNum;
 
   @property({type: String})
   basePatchNum?: BasePatchSetNum;
@@ -131,11 +118,11 @@
 
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getCommentsModel().changeComments$,
+      () => this.getCommentsModel().changeComments$,
       x => (this.changeComments = x)
     );
   }
@@ -164,10 +151,12 @@
           .filesWeblinks {
             display: none;
           }
+          /* prettier formatter removes semi-colons after css mixins. */
+          /* prettier-ignore */
           gr-dropdown-list {
             --native-select-style: {
               max-width: 5.25em;
-            }
+            };
           }
         }
       `,
@@ -231,12 +220,9 @@
       return [];
     }
 
-    const parentCounts = this.revisionInfo.getParentCountMap();
-    const currentParentCount = hasOwnProperty(parentCounts, this.patchNum)
-      ? parentCounts[this.patchNum as number]
-      : 1;
     const maxParents = this.revisionInfo.getMaxParents();
-    const isMerge = currentParentCount > 1;
+    const isMerge = this.revisionInfo.isMergeCommit(this.patchNum);
+    const parentCount = this.revisionInfo.getParentCount(this.patchNum);
 
     const dropdownContent: DropdownItem[] = [];
     for (const basePatch of this.availablePatches) {
@@ -254,12 +240,12 @@
 
     dropdownContent.push({
       text: isMerge ? 'Auto Merge' : 'Base',
-      value: 'PARENT',
+      value: PARENT,
     });
 
     for (let idx = 0; isMerge && idx < maxParents; idx++) {
       dropdownContent.push({
-        disabled: idx >= currentParentCount,
+        disabled: idx >= parentCount,
         triggerText: `Parent ${idx + 1}`,
         text: `Parent ${idx + 1}`,
         mobileText: `Parent ${idx + 1}`,
@@ -293,7 +279,7 @@
       const patchNum = patch.num;
       const entry = this.createDropdownEntry(
         patchNum,
-        patchNum === 'edit' ? '' : 'Patchset ',
+        patchNum === EDIT ? '' : 'Patchset ',
         getShaForPatch(patch)
       );
       dropdownContent.push({
@@ -356,7 +342,7 @@
    * is sorted in reverse order (higher patchset nums first), invalid patch
    * nums have an index greater than the index of basePatchNum.
    *
-   * In addition, if the current basePatchNum is 'PARENT', all patchNums are
+   * In addition, if the current basePatchNum is PARENT, all patchNums are
    * valid.
    *
    * If the current basePatchNum is a parent index, then only patches that have
@@ -368,10 +354,10 @@
    * @param patchNum The possible patch num.
    */
   computeRightDisabled(
-    basePatchNum: PatchSetNum,
-    patchNum: PatchSetNum
+    basePatchNum: BasePatchSetNum,
+    patchNum: RevisionPatchSetNum
   ): boolean {
-    if (basePatchNum === ParentPatchSetNum) {
+    if (basePatchNum === PARENT) {
       return false;
     }
 
@@ -444,7 +430,7 @@
    * Catches value-change events from the patchset dropdowns and determines
    * whether or not a patch change event should be fired.
    */
-  private handlePatchChange(e: DropDownValueChangeEvent) {
+  private handlePatchChange(e: ValueChangedEvent<string>) {
     const detail: PatchRangeChangeDetail = {
       patchNum: this.patchNum,
       basePatchNum: this.basePatchNum,
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index c4e710d..c90992f 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -1,33 +1,22 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import '../gr-comment-api/gr-comment-api';
+import '../../../test/common-test-setup';
 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,
+  EDIT,
+  RevisionPatchSetNum,
+  PARENT,
   PatchSetNum,
+  PatchSetNumber,
   RevisionInfo,
   Timestamp,
   UrlEncodedCommentId,
@@ -46,8 +35,7 @@
 } 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');
+import {fixture, html, assert} from '@open-wc/testing';
 
 type RevIdToRevisionInfo = {
   [revisionId: string]: RevisionInfo | EditRevisionInfo;
@@ -71,7 +59,9 @@
 
     // Element must be wrapped in an element with direct access to the
     // comment API.
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<gr-patch-range-select></gr-patch-range-select>`
+    );
 
     // Stub methods on the changeComments object after changeComments has
     // been initialized.
@@ -79,6 +69,22 @@
     await element.updateComplete;
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <h3 class="assistive-tech-only">Patchset Range Selection</h3>
+        <span aria-label="patch range starts with" class="patchRange">
+          <gr-dropdown-list id="basePatchDropdown"> </gr-dropdown-list>
+        </span>
+        <span aria-hidden="true" class="arrow"> → </span>
+        <span aria-label="patch range ends with" class="patchRange">
+          <gr-dropdown-list id="patchNumDropdown"> </gr-dropdown-list>
+        </span>
+      `
+    );
+  });
+
   test('enabled/disabled options', async () => {
     element.revisions = [
       createRevision(3),
@@ -88,12 +94,9 @@
     ];
     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)
+        element.computeRightDisabled(PARENT, patchNum as PatchSetNumber)
       );
     }
     for (const basePatchNum of [1, 2]) {
@@ -107,15 +110,11 @@
     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: EDIT, sha: '1'} as PatchSet,
       {num: 3, sha: '2'} as PatchSet,
       {num: 2, sha: '3'} as PatchSet,
       {num: 1, sha: '4'} as PatchSet,
@@ -132,9 +131,9 @@
         disabled: true,
         triggerText: 'Patchset edit',
         text: 'Patchset edit | 1',
-        mobileText: 'edit',
+        mobileText: EDIT,
         bottomText: '',
-        value: 'edit',
+        value: EDIT,
       },
       {
         disabled: true,
@@ -165,11 +164,11 @@
       } as DropdownItem,
       {
         text: 'Base',
-        value: 'PARENT',
+        value: PARENT,
       } as DropdownItem,
     ];
-    element.patchNum = 1 as PatchSetNum;
-    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    element.patchNum = 1 as PatchSetNumber;
+    element.basePatchNum = PARENT;
     await element.updateComplete;
 
     assert.deepEqual(element.computeBaseDropdownContent(), expectedResult);
@@ -187,16 +186,16 @@
       {num: 1, sha: '1'} as PatchSet,
       {num: 2, sha: '2'} as PatchSet,
       {num: 3, sha: '3'} as PatchSet,
-      {num: 'edit', sha: '4'} as PatchSet,
+      {num: EDIT, sha: '4'} as PatchSet,
     ];
-    element.patchNum = 2 as PatchSetNum;
-    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    element.patchNum = 2 as PatchSetNumber;
+    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;
+    element.patchNum = 1 as PatchSetNumber;
     await element.updateComplete;
     assert.equal(baseDropDownStub.callCount, 1);
   });
@@ -214,8 +213,8 @@
       {num: 2, sha: '3'} as PatchSet,
       {num: 1, sha: '4'} as PatchSet,
     ];
-    element.patchNum = 2 as PatchSetNum;
-    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    element.patchNum = 2 as PatchSetNumber;
+    element.basePatchNum = PARENT as BasePatchSetNum;
     await element.updateComplete;
 
     // Should be recomputed for each available patch
@@ -238,10 +237,10 @@
       {num: 1, sha: '1'} as PatchSet,
       {num: 2, sha: '2'} as PatchSet,
       {num: 3, sha: '3'} as PatchSet,
-      {num: 'edit', sha: '4'} as PatchSet,
+      {num: EDIT, sha: '4'} as PatchSet,
     ];
-    element.patchNum = 2 as PatchSetNum;
-    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    element.patchNum = 2 as PatchSetNumber;
+    element.basePatchNum = PARENT as BasePatchSetNum;
     await element.updateComplete;
 
     // Should be recomputed for each available patch
@@ -253,7 +252,7 @@
 
   test('computePatchDropdownContent', async () => {
     element.availablePatches = [
-      {num: 'edit', sha: '1'} as PatchSet,
+      {num: EDIT, sha: '1'} as PatchSet,
       {num: 3, sha: '2'} as PatchSet,
       {num: 2, sha: '3'} as PatchSet,
       {num: 1, sha: '4'} as PatchSet,
@@ -270,11 +269,11 @@
     const expectedResult: DropdownItem[] = [
       {
         disabled: false,
-        triggerText: 'edit',
+        triggerText: EDIT,
         text: 'edit | 1',
-        mobileText: 'edit',
+        mobileText: EDIT,
         bottomText: '',
-        value: 'edit',
+        value: EDIT,
       },
       {
         disabled: false,
@@ -341,7 +340,7 @@
         {
           id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
           message: 'test',
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           unresolved: true,
           updated: '2017-10-11 20:48:40.000000000' as Timestamp,
         },
@@ -350,13 +349,13 @@
         {
           id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
           message: 'test',
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           updated: '2017-10-12 20:48:40.000000000' as Timestamp,
         },
         {
           id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
           message: 'test',
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           updated: '2017-10-13 20:48:40.000000000' as Timestamp,
         },
       ],
@@ -366,7 +365,7 @@
         {
           id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
           message: 'test',
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           unresolved: true,
           updated: '2017-10-11 20:48:40.000000000' as Timestamp,
         },
@@ -393,36 +392,12 @@
     assert.equal(element.computePatchSetCommentsString(1 as PatchSetNum), '');
   });
 
-  test('patch-range-change fires', () => {
+  test('patch-range-change fires', async () => {
     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.patchNum = 3 as PatchSetNumber;
     element.availablePatches = [
-      {num: 'edit', sha: '1'} as PatchSet,
+      {num: EDIT, sha: '1'} as PatchSet,
       {num: 3, sha: '2'} as PatchSet,
       {num: 2, sha: '3'} as PatchSet,
       {num: 1, sha: '4'} as PatchSet,
@@ -434,8 +409,50 @@
       createRevision(4),
     ];
     element.revisionInfo = getInfo(element.revisions);
-    element.patchNum = 1 as PatchSetNum;
-    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    await element.updateComplete;
+
+    element.addEventListener('patch-range-change', handler);
+    const basePatchDropdown = queryAndAssert<GrDropdownList>(
+      element,
+      '#basePatchDropdown'
+    );
+    basePatchDropdown.value = '2';
+    await basePatchDropdown.updateComplete;
+    assert.equal(handler.callCount, 1);
+    assert.deepEqual(handler.lastCall.args[0].detail, {
+      basePatchNum: 2,
+      patchNum: 3,
+    });
+
+    // BasePatchNum should not have changed, due to one-way data binding.
+    const patchNumDropdown = queryAndAssert<GrDropdownList>(
+      element,
+      '#patchNumDropdown'
+    );
+    patchNumDropdown.value = EDIT;
+    await patchNumDropdown.updateComplete;
+    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 PatchSetNumber;
+    element.basePatchNum = PARENT;
     await element.updateComplete;
 
     const stub = stubReporting('reportInteraction');
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 281b7e54..6dbdca6 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
@@ -1,48 +1,48 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-list-view/gr-list-view';
 import {getBaseUrl} from '../../../utils/url-util';
 import {DocResult} from '../../../types/common';
 import {fireTitleChange} from '../../../utils/event-util';
 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';
+import {LitElement, html} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {documentationViewModelToken} from '../../../models/views/documentation';
 
 @customElement('gr-documentation-search')
 export class GrDocumentationSearch extends LitElement {
-  /**
-   * URL params passed from the router.
-   */
-  @property({type: Object})
-  params?: ListViewParams;
-
   // private but used in test
   @state() documentationSearches?: DocResult[];
 
   // private but used in test
   @state() loading = true;
 
-  @state() private filter = '';
+  // private but used in test
+  @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getViewModel = resolve(this, documentationViewModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      x => {
+        this.filter = x?.filter ?? '';
+        if (x !== undefined) this.getDocumentationSearches();
+      }
+    );
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Documentation Search');
@@ -91,21 +91,9 @@
     `;
   }
 
-  override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('params')) {
-      this.paramsChanged();
-    }
-  }
-
-  // private but used in test
-  paramsChanged() {
+  getDocumentationSearches() {
+    const filter = this.filter;
     this.loading = true;
-    this.filter = this.params?.filter ?? '';
-
-    return this.getDocumentationSearches(this.filter);
-  }
-
-  private getDocumentationSearches(filter: string) {
     this.documentationSearches = [];
     return this.restApiService
       .getDocumentationSearches(filter)
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 4bba9cd..0092193 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
@@ -1,28 +1,15 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-documentation-search';
 import {GrDocumentationSearch} from './gr-documentation-search';
 import {page} from '../../../utils/page-wrapper-utils';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {DocResult} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-documentation-search');
+import {fixture, html, assert} from '@open-wc/testing';
 
 function documentationGenerator(counter: number) {
   return {
@@ -45,8 +32,9 @@
 
   setup(async () => {
     sinon.stub(page, 'show');
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-documentation-search></gr-documentation-search>`
+    );
   });
 
   suite('list with searches for documentation', () => {
@@ -55,7 +43,7 @@
       stubRestApi('getDocumentationSearches').returns(
         Promise.resolve(documentationSearches)
       );
-      await element.paramsChanged();
+      await element.getDocumentationSearches();
       await element.updateComplete;
     });
 
@@ -69,6 +57,264 @@
         'Documentation/dev-rest-api.html'
       );
     });
+
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-list-view>
+            <table class="genericList" id="list">
+              <tbody>
+                <tr class="headerRow">
+                  <th class="name topHeader">Name</th>
+                  <th class="name topHeader"></th>
+                  <th class="name topHeader"></th>
+                </tr>
+                <tr class="loadingMsg" id="loading">
+                  <td>Loading...</td>
+                </tr>
+              </tbody>
+              <tbody>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes0
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes1
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes2
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes3
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes4
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes5
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes6
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes7
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes8
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes9
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes10
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes11
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes12
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes13
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes14
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes15
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes16
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes17
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes18
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes19
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes20
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes21
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes22
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes23
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes24
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes25
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+              </tbody>
+            </table>
+          </gr-list-view>
+        `
+      );
+    });
   });
 
   suite('filter', () => {
@@ -80,8 +326,8 @@
       const stub = stubRestApi('getDocumentationSearches').returns(
         Promise.resolve(documentationSearches)
       );
-      element.params = {filter: 'test'};
-      await element.paramsChanged();
+      element.filter = 'test';
+      await element.getDocumentationSearches();
       assert.isTrue(stub.lastCall.calledWithExactly('test'));
     });
   });
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 8b8a615..7229c63 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
@@ -1,22 +1,11 @@
 /**
  * @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 {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.ts
index 6b7ce34..39b70ec 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.ts
@@ -1,34 +1,32 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-default-editor';
 import {GrDefaultEditor} from './gr-default-editor';
-import {mockPromise, queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-default-editor');
+import {
+  mockPromise,
+  queryAndAssert,
+  waitEventLoop,
+} from '../../../test/test-utils';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-default-editor tests', () => {
   let element: GrDefaultEditor;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-default-editor></gr-default-editor>`);
     element.fileContent = '';
-    await flush();
+    await waitEventLoop();
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ' <textarea id="textarea"></textarea> '
+    );
   });
 
   test('fires content-change event', async () => {
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.ts b/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
index af3fbb2..f94b885 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-constants.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
  */
 
 export interface GrEditAction {
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 f081037..a273a3e 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
@@ -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 '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
@@ -22,8 +10,8 @@
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-overlay/gr-overlay';
 import {GrEditAction, GrEditConstants} from '../gr-edit-constants';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {ChangeInfo, PatchSetNum} from '../../../types/common';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {ChangeInfo, RevisionPatchSetNum} from '../../../types/common';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {
   AutocompleteQuery,
@@ -39,10 +27,12 @@
 } 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 {customElement, property, query, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
+import {createEditUrl} from '../../../models/views/edit';
+import {resolve} from '../../../models/dependency';
 
 @customElement('gr-edit-controls')
 export class GrEditControls extends LitElement {
@@ -67,7 +57,7 @@
   change?: ChangeInfo;
 
   @property({type: String})
-  patchNum?: PatchSetNum;
+  patchNum?: RevisionPatchSetNum;
 
   @property({type: Array})
   hiddenActions: string[] = [GrEditConstants.Actions.RESTORE.id];
@@ -86,6 +76,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   static override get styles() {
     return [
       sharedStyles,
@@ -434,12 +426,15 @@
       this.closeDialog(this.openDialog);
       return;
     }
-    const url = GerritNav.getEditUrlForDiff(
-      this.change,
-      this.path,
-      this.patchNum
-    );
-    GerritNav.navigateToRelativeUrl(url);
+    assertIsDefined(this.patchNum, 'patchset number');
+    const url = createEditUrl({
+      changeNum: this.change._number,
+      project: this.change.project,
+      path: this.path,
+      patchNum: this.patchNum,
+    });
+
+    this.getNavigation().setUrl(url);
     this.closeDialog(this.getDialogFromEvent(e));
   };
 
@@ -579,17 +574,17 @@
   };
 
   private readonly handleTextChanged = (e: BindValueChangeEvent) => {
-    this.path = e.detail.value;
+    this.path = e.detail.value ?? '';
   };
 
   private readonly handleBindValueChangedNewPath = (
     e: BindValueChangeEvent
   ) => {
-    this.newPath = e.detail.value;
+    this.newPath = e.detail.value ?? '';
   };
 
   private readonly handleBindValueChangedPath = (e: BindValueChangeEvent) => {
-    this.path = e.detail.value;
+    this.path = e.detail.value ?? '';
   };
 }
 
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 8376d34..b4469db 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
@@ -1,34 +1,28 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-edit-controls';
 import {GrEditControls} from './gr-edit-controls';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 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 {
+  CommitId,
+  NumericChangeId,
+  PatchSetNumber,
+  RevisionPatchSetNum,
+} 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';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-edit-controls tests', () => {
   let element: GrEditControls;
@@ -43,7 +37,7 @@
       <gr-edit-controls></gr-edit-controls>
     `);
     element.change = createChange();
-    element.patchNum = 1 as PatchSetNum;
+    element.patchNum = 1 as RevisionPatchSetNum;
     showDialogSpy = sinon.spy(element, 'showDialog');
     closeDialogSpy = sinon.spy(element, 'closeDialog');
     hideDialogStub = sinon.stub(element, 'hideAllDialogs');
@@ -51,6 +45,146 @@
     await element.updateComplete;
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-button
+          aria-disabled="false"
+          id="open"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Add/Open/Upload
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          id="delete"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Delete
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          id="rename"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Rename
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          class="invisible"
+          id="restore"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Restore
+        </gr-button>
+        <gr-overlay
+          aria-hidden="true"
+          id="overlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-dialog
+            class="dialog invisible"
+            confirm-label="Confirm"
+            confirm-on-enter=""
+            disabled=""
+            id="openDialog"
+            role="dialog"
+          >
+            <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."
+              >
+              </gr-autocomplete>
+              <div contenteditable="true" id="dragDropArea">
+                <p>Drag and drop a file here</p>
+                <p>or</p>
+                <p>
+                  <iron-input>
+                    <input
+                      hidden=""
+                      id="fileUploadInput"
+                      multiple=""
+                      type="file"
+                    />
+                  </iron-input>
+                  <label for="fileUploadInput">
+                    <gr-button
+                      aria-disabled="false"
+                      id="fileUploadBrowse"
+                      role="button"
+                      tabindex="0"
+                    >
+                      Browse
+                    </gr-button>
+                  </label>
+                </p>
+              </div>
+            </div>
+          </gr-dialog>
+          <gr-dialog
+            class="dialog invisible"
+            confirm-label="Delete"
+            confirm-on-enter=""
+            disabled=""
+            id="deleteDialog"
+            role="dialog"
+          >
+            <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.">
+              </gr-autocomplete>
+            </div>
+          </gr-dialog>
+          <gr-dialog
+            class="dialog invisible"
+            confirm-label="Rename"
+            confirm-on-enter=""
+            disabled=""
+            id="renameDialog"
+            role="dialog"
+          >
+            <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.">
+              </gr-autocomplete>
+              <iron-input id="newPathIronInput">
+                <input id="newPathInput" placeholder="Enter the new path." />
+              </iron-input>
+            </div>
+          </gr-dialog>
+          <gr-dialog
+            class="dialog invisible"
+            confirm-label="Restore"
+            confirm-on-enter=""
+            id="restoreDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Restore this file?</div>
+            <div class="main" slot="main">
+              <iron-input>
+                <input />
+              </iron-input>
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+      `
+    );
+  });
+
   test('all actions exist', () => {
     // We take 1 away from the total found, due to an extra button being
     // added for the file uploads (browse).
@@ -61,13 +195,11 @@
   });
 
   suite('edit button CUJ', () => {
-    let editDiffStub: sinon.SinonStub;
-    let navStub: sinon.SinonStub;
+    let setUrlStub: sinon.SinonStub;
     let openAutoComplete: GrAutocomplete;
 
     setup(() => {
-      editDiffStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
-      navStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
       openAutoComplete = queryAndAssert<GrAutocomplete>(
         element.openDialog,
         'gr-autocomplete'
@@ -84,14 +216,14 @@
 
     test('open', async () => {
       assert.isFalse(hideDialogStub.called);
-      MockInteractions.tap(queryAndAssert(element, '#open'));
-      element.patchNum = 1 as PatchSetNum;
+      queryAndAssert<GrButton>(element, '#open').click();
+      element.patchNum = 1 as RevisionPatchSetNum;
       await showDialogSpy.lastCall.returnValue;
       assert.isTrue(hideDialogStub.called);
       assert.isTrue(element.openDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup focused manually - in headless mode Chrome sometimes don't
-      // setup focus. flush and/or flushAsynchronousOperations don't help
+      // Setup focused manually - in headless mode Chrome sometimes doesn't
+      // setup focus. waitEventLoop() doesn't help.
       openAutoComplete.focused = true;
       openAutoComplete.noDebounce = true;
       openAutoComplete.text = 'src/test.cpp';
@@ -102,30 +234,21 @@
         element.openDialog,
         'gr-button[primary]'
       ).click();
-      await waitUntil(() => editDiffStub.called);
 
-      assert.isTrue(navStub.called);
-      assert.deepEqual(editDiffStub.lastCall.args, [
-        element.change,
-        'src/test.cpp',
-        element.patchNum,
-      ]);
+      assert.isTrue(setUrlStub.called);
       assert.isTrue(closeDialogSpy.called);
     });
 
     test('cancel', async () => {
-      MockInteractions.tap(queryAndAssert(element, '#open'));
+      queryAndAssert<GrButton>(element, '#open').click();
       return showDialogSpy.lastCall.returnValue.then(async () => {
         assert.isTrue(element.openDialog!.disabled);
         openAutoComplete.noDebounce = true;
         openAutoComplete.text = 'src/test.cpp';
         await element.updateComplete;
         await waitUntil(() => !element.openDialog!.disabled);
-        MockInteractions.tap(
-          queryAndAssert<GrButton>(element.openDialog, 'gr-button')
-        );
-        assert.isFalse(editDiffStub.called);
-        assert.isFalse(navStub.called);
+        queryAndAssert<GrButton>(element.openDialog, 'gr-button').click();
+        assert.isFalse(setUrlStub.called);
         await waitUntil(() => closeDialogSpy.called);
         assert.equal(element.path, '');
       });
@@ -149,21 +272,22 @@
 
     test('delete', async () => {
       deleteStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(queryAndAssert(element, '#delete'));
+      queryAndAssert<GrButton>(element, '#delete').click();
       await showDialogSpy.lastCall.returnValue;
       assert.isTrue(element.deleteDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup focused manually - in headless mode Chrome sometimes don't
-      // setup focus. flush and/or flushAsynchronousOperations don't help
+      // Setup focused manually - in headless mode Chrome sometimes doesn't
+      // setup focus. waitEventLoop() doesn't help.
       deleteAutocomplete.focused = true;
       deleteAutocomplete.noDebounce = true;
       deleteAutocomplete.text = 'src/test.cpp';
       await element.updateComplete;
       assert.isTrue(queryStub.called);
       await waitUntil(() => !element.deleteDialog!.disabled);
-      MockInteractions.tap(
-        queryAndAssert(element.deleteDialog, 'gr-button[primary]')
-      );
+      queryAndAssert<GrButton>(
+        element.deleteDialog,
+        'gr-button[primary]'
+      ).click();
       await element.updateComplete;
 
       assert.isTrue(deleteStub.called);
@@ -175,21 +299,22 @@
 
     test('delete fails', async () => {
       deleteStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(queryAndAssert(element, '#delete'));
+      queryAndAssert<GrButton>(element, '#delete').click();
       await showDialogSpy.lastCall.returnValue;
       assert.isTrue(element.deleteDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup focused manually - in headless mode Chrome sometimes don't
-      // setup focus. flush and/or flushAsynchronousOperations don't help
+      // Setup focused manually - in headless mode Chrome sometimes doesn't
+      // setup focus. waitEventLoop() doesn't help.
       deleteAutocomplete.focused = true;
       deleteAutocomplete.noDebounce = true;
       deleteAutocomplete.text = 'src/test.cpp';
       await element.updateComplete;
       assert.isTrue(queryStub.called);
       await waitUntil(() => !element.deleteDialog!.disabled);
-      MockInteractions.tap(
-        queryAndAssert(element.deleteDialog, 'gr-button[primary]')
-      );
+      queryAndAssert<GrButton>(
+        element.deleteDialog,
+        'gr-button[primary]'
+      ).click();
       await element.updateComplete;
 
       assert.isTrue(deleteStub.called);
@@ -200,7 +325,7 @@
     });
 
     test('cancel', () => {
-      MockInteractions.tap(queryAndAssert(element, '#delete'));
+      queryAndAssert<GrButton>(element, '#delete').click();
       return showDialogSpy.lastCall.returnValue.then(async () => {
         assert.isTrue(element.deleteDialog!.disabled);
         queryAndAssert<GrAutocomplete>(
@@ -209,7 +334,7 @@
         ).text = 'src/test.cpp';
         await element.updateComplete;
         await waitUntil(() => !element.deleteDialog!.disabled);
-        MockInteractions.tap(queryAndAssert(element.deleteDialog, 'gr-button'));
+        queryAndAssert<GrButton>(element.deleteDialog, 'gr-button').click();
         assert.isFalse(eventStub.called);
         assert.isTrue(closeDialogSpy.called);
         await waitUntil(() => element.path === '');
@@ -234,12 +359,12 @@
 
     test('rename', async () => {
       renameStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(queryAndAssert(element, '#rename'));
+      queryAndAssert<GrButton>(element, '#rename').click();
       await showDialogSpy.lastCall.returnValue;
       assert.isTrue(element.renameDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup focused manually - in headless mode Chrome sometimes don't
-      // setup focus. flush and/or flushAsynchronousOperations don't help
+      // Setup focused manually - in headless mode Chrome sometimes doesn't
+      // setup focus. waitEventLoop() doesn't help.
       renameAutocomplete.focused = true;
       renameAutocomplete.noDebounce = true;
       renameAutocomplete.text = 'src/test.cpp';
@@ -251,9 +376,10 @@
       await element.updateComplete;
 
       assert.isFalse(element.renameDialog!.disabled);
-      MockInteractions.tap(
-        queryAndAssert(element.renameDialog, 'gr-button[primary]')
-      );
+      queryAndAssert<GrButton>(
+        element.renameDialog,
+        'gr-button[primary]'
+      ).click();
       await element.updateComplete;
       assert.isTrue(renameStub.called);
 
@@ -265,12 +391,12 @@
 
     test('rename fails', async () => {
       renameStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(queryAndAssert(element, '#rename'));
+      queryAndAssert<GrButton>(element, '#rename').click();
       await showDialogSpy.lastCall.returnValue;
       assert.isTrue(element.renameDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup focused manually - in headless mode Chrome sometimes don't
-      // setup focus. flush and/or flushAsynchronousOperations don't help
+      // Setup focused manually - in headless mode Chrome sometimes doesn't
+      // setup focus. waitEventLoop() doesn't help.
       renameAutocomplete.focused = true;
       renameAutocomplete.noDebounce = true;
       renameAutocomplete.text = 'src/test.cpp';
@@ -282,9 +408,10 @@
       await element.updateComplete;
 
       assert.isFalse(element.renameDialog!.disabled);
-      MockInteractions.tap(
-        queryAndAssert(element.renameDialog, 'gr-button[primary]')
-      );
+      queryAndAssert<GrButton>(
+        element.renameDialog,
+        'gr-button[primary]'
+      ).click();
       await element.updateComplete;
 
       assert.isTrue(renameStub.called);
@@ -295,7 +422,7 @@
     });
 
     test('cancel', () => {
-      MockInteractions.tap(queryAndAssert(element, '#rename'));
+      queryAndAssert<GrButton>(element, '#rename').click();
       return showDialogSpy.lastCall.returnValue.then(async () => {
         assert.isTrue(element.renameDialog!.disabled);
         queryAndAssert<GrAutocomplete>(
@@ -305,7 +432,7 @@
         element.newPathIronInput!.bindValue = 'src/test.newPath';
         await element.updateComplete;
         assert.isFalse(element.renameDialog!.disabled);
-        MockInteractions.tap(queryAndAssert(element.renameDialog, 'gr-button'));
+        queryAndAssert<GrButton>(element.renameDialog, 'gr-button').click();
         assert.isFalse(eventStub.called);
         assert.isTrue(closeDialogSpy.called);
         await waitUntil(() => element.path === '');
@@ -331,11 +458,12 @@
     test('restore', () => {
       restoreStub.returns(Promise.resolve({ok: true}));
       element.path = 'src/test.cpp';
-      MockInteractions.tap(queryAndAssert(element, '#restore'));
+      queryAndAssert<GrButton>(element, '#restore').click();
       return showDialogSpy.lastCall.returnValue.then(async () => {
-        MockInteractions.tap(
-          queryAndAssert(element.restoreDialog, 'gr-button[primary]')
-        );
+        queryAndAssert<GrButton>(
+          element.restoreDialog,
+          'gr-button[primary]'
+        ).click();
         await element.updateComplete;
 
         assert.isTrue(restoreStub.called);
@@ -351,11 +479,12 @@
     test('restore fails', () => {
       restoreStub.returns(Promise.resolve({ok: false}));
       element.path = 'src/test.cpp';
-      MockInteractions.tap(queryAndAssert(element, '#restore'));
+      queryAndAssert<GrButton>(element, '#restore').click();
       return showDialogSpy.lastCall.returnValue.then(async () => {
-        MockInteractions.tap(
-          queryAndAssert(element.restoreDialog, 'gr-button[primary]')
-        );
+        queryAndAssert<GrButton>(
+          element.restoreDialog,
+          'gr-button[primary]'
+        ).click();
         await element.updateComplete;
 
         assert.isTrue(restoreStub.called);
@@ -369,11 +498,9 @@
 
     test('cancel', () => {
       element.path = 'src/test.cpp';
-      MockInteractions.tap(queryAndAssert(element, '#restore'));
+      queryAndAssert<GrButton>(element, '#restore').click();
       return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(
-          queryAndAssert(element.restoreDialog, 'gr-button')
-        );
+        queryAndAssert<GrButton>(element.restoreDialog, 'gr-button').click();
         assert.isFalse(eventStub.called);
         assert.isTrue(closeDialogSpy.called);
         assert.equal(element.path, '');
@@ -382,15 +509,13 @@
   });
 
   suite('save file upload', () => {
-    let navStub: sinon.SinonStub;
     let fileStub: sinon.SinonStub;
 
     setup(() => {
-      navStub = sinon.stub(GerritNav, 'navigateToChange');
       fileStub = stubRestApi('saveFileUploadChangeEdit');
     });
 
-    test('handleUploadConfirm', () => {
+    test('handleUploadConfirm', async () => {
       fileStub.returns(Promise.resolve({ok: true}));
 
       element.change = {
@@ -400,19 +525,23 @@
         revisions: {
           abcd: {
             ...createRevision(1),
-            _number: 1 as PatchSetNum,
+            _number: 1 as PatchSetNumber,
           },
           efgh: {
             ...createRevision(2),
-            _number: 2 as PatchSetNum,
+            _number: 2 as PatchSetNumber,
           },
         },
         current_revision: 'efgh' as CommitId,
       };
 
-      element.handleUploadConfirm('test.php', 'base64').then(() => {
-        assert.isTrue(navStub.calledWithExactly(1 as NumericChangeId));
-      });
+      element.handleUploadConfirm('test.php', 'base64');
+
+      assert.isTrue(fileStub.calledOnce);
+      assert.equal(fileStub.lastCall.args[0], 1);
+      assert.equal(fileStub.lastCall.args[1], 'test.php');
+      assert.equal(fileStub.lastCall.args[2], 'base64');
+      await waitForEventOnce(element, 'reload');
     });
   });
 
@@ -430,21 +559,23 @@
     const spy = sinon.spy(element, 'getDialogFromEvent');
     element.addEventListener('tap', element.getDialogFromEvent);
 
-    MockInteractions.tap(element.openDialog!);
+    element.openDialog!.click();
     await element.updateComplete;
     assert.equal(spy.lastCall.returnValue!.id, 'openDialog');
 
-    MockInteractions.tap(element.deleteDialog!);
+    element.deleteDialog!.click();
     await element.updateComplete;
     assert.equal(spy.lastCall.returnValue!.id, 'deleteDialog');
 
-    MockInteractions.tap(
-      queryAndAssert<GrAutocomplete>(element.deleteDialog, 'gr-autocomplete')
-    );
+    queryAndAssert<GrAutocomplete>(
+      element.deleteDialog,
+      'gr-autocomplete'
+    ).click();
+
     await element.updateComplete;
     assert.equal(spy.lastCall.returnValue!.id, 'deleteDialog');
 
-    MockInteractions.tap(element);
+    element.click();
     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 a770d5b..c442aa6 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
@@ -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 '../../shared/gr-dropdown/gr-dropdown';
 import {GrEditConstants} from '../gr-edit-constants';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 interface EditAction {
   label: string;
@@ -52,6 +40,9 @@
         gr-dropdown {
           --gr-dropdown-item-color: var(--link-color);
           --gr-button-padding: var(--spacing-xs) var(--spacing-s);
+          --gr-dropdown-item-background-color: transparent;
+          --gr-dropdown-item-border: none;
+          --gr-dropdown-item-text-transform: uppercase;
         }
         #actions {
           margin-right: var(--spacing-l);
@@ -61,31 +52,16 @@
   }
 
   override render() {
-    // To pass CSS mixins for @apply to Polymer components, they need to appear
-    // in <style> inside the template.
-    /* eslint-disable lit/prefer-static-styles */
-    const customStyle = html`
-      <style>
-        gr-dropdown {
-          --gr-dropdown-item: {
-            background-color: transparent;
-            border: none;
-            text-transform: uppercase;
-          }
-        }
-      </style>
-    `;
     const fileActions = this._computeFileActions(this._allFileActions);
-    return html`${customStyle}
-      <gr-dropdown
-        id="actions"
-        .items=${fileActions}
-        down-arrow=""
-        vertical-offset="20"
-        @tap-item=${this._handleActionTap}
-        link=""
-        >Actions</gr-dropdown
-      >`;
+    return html` <gr-dropdown
+      id="actions"
+      .items=${fileActions}
+      down-arrow=""
+      vertical-offset="20"
+      @tap-item=${this._handleActionTap}
+      link=""
+      >Actions</gr-dropdown
+    >`;
   }
 
   _handleActionTap(e: CustomEvent) {
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
index 049c187..bd27660 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
@@ -1,29 +1,15 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-edit-file-controls';
 import {GrEditFileControls} from './gr-edit-file-controls';
 import {GrEditConstants} from '../gr-edit-constants';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-edit-file-controls');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-edit-file-controls tests', () => {
   let element: GrEditFileControls;
@@ -31,20 +17,33 @@
   let fileActionHandler: sinon.SinonStub;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<gr-edit-file-controls></gr-edit-file-controls>`
+    );
     fileActionHandler = sinon.stub();
     element.addEventListener('file-action-tap', fileActionHandler);
-    await flush();
+    await element.updateComplete;
   });
 
-  test('open tap emits event', () => {
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dropdown down-arrow="" id="actions" link="" vertical-offset="20">
+          Actions
+        </gr-dropdown>
+      `
+    );
+  });
+
+  test('open tap emits event', async () => {
     const actions = queryAndAssert<GrDropdown>(element, '#actions');
     element.filePath = 'foo';
-    actions._open();
-    flush();
+    actions.open();
+    await actions.updateComplete;
 
-    const row = queryAndAssert(actions, 'li [data-id="open"]');
-    MockInteractions.tap(row);
+    const row = queryAndAssert<HTMLSpanElement>(actions, 'li [data-id="open"]');
+    row.click();
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
       action: GrEditConstants.Actions.OPEN.id,
@@ -52,14 +51,17 @@
     });
   });
 
-  test('delete tap emits event', () => {
+  test('delete tap emits event', async () => {
     const actions = queryAndAssert<GrDropdown>(element, '#actions');
     element.filePath = 'foo';
-    actions._open();
-    flush();
+    actions.open();
+    await actions.updateComplete;
 
-    const row = queryAndAssert(actions, 'li [data-id="delete"]');
-    MockInteractions.tap(row);
+    const row = queryAndAssert<HTMLSpanElement>(
+      actions,
+      'li [data-id="delete"]'
+    );
+    row.click();
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
       action: GrEditConstants.Actions.DELETE.id,
@@ -67,14 +69,17 @@
     });
   });
 
-  test('restore tap emits event', () => {
+  test('restore tap emits event', async () => {
     const actions = queryAndAssert<GrDropdown>(element, '#actions');
     element.filePath = 'foo';
-    actions._open();
-    flush();
+    actions.open();
+    await actions.updateComplete;
 
-    const row = queryAndAssert(actions, 'li [data-id="restore"]');
-    MockInteractions.tap(row);
+    const row = queryAndAssert<HTMLSpanElement>(
+      actions,
+      'li [data-id="restore"]'
+    );
+    row.click();
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
       action: GrEditConstants.Actions.RESTORE.id,
@@ -82,14 +87,17 @@
     });
   });
 
-  test('rename tap emits event', () => {
+  test('rename tap emits event', async () => {
     const actions = queryAndAssert<GrDropdown>(element, '#actions');
     element.filePath = 'foo';
-    actions._open();
-    flush();
+    actions.open();
+    await actions.updateComplete;
 
-    const row = queryAndAssert(actions, 'li [data-id="rename"]');
-    MockInteractions.tap(row);
+    const row = queryAndAssert<HTMLSpanElement>(
+      actions,
+      'li [data-id="rename"]'
+    );
+    row.click();
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
       action: GrEditConstants.Actions.RENAME.id,
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 dc8e7a6..a15a575 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
@@ -1,36 +1,19 @@
 /**
  * @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 '../../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 {
-  GerritNav,
-  GenerateUrlEditViewParameters,
-} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {computeTruncatedPath} from '../../../utils/path-list-util';
 import {
-  PatchSetNum,
   EditPreferencesInfo,
   Base64FileContent,
-  NumericChangeId,
-  EditPatchSetNum,
+  PatchSetNumber,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
 import {HttpMethod, NotifyType} from '../../../constants/constants';
@@ -40,11 +23,16 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {changeIsMerged, changeIsAbandoned} from '../../../utils/change-util';
-import {addShortcut, Modifier} from '../../../utils/dom-util';
+import {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 {LitElement, PropertyValues, html, css, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {editViewModelToken, EditViewState} from '../../../models/views/edit';
+import {createChangeUrl} from '../../../models/views/change';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -70,21 +58,12 @@
    */
 
   @property({type: Object})
-  params?: GenerateUrlEditViewParameters;
+  viewState?: EditViewState;
 
   // private but used in test
   @state() change?: ParsedChangeInfo;
 
   // private but used in test
-  @state() changeNum?: NumericChangeId;
-
-  // private but used in test
-  @state() patchNum?: PatchSetNum;
-
-  // private but used in test
-  @state() path?: string;
-
-  // private but used in test
   @state() type?: string;
 
   // private but used in test
@@ -101,7 +80,8 @@
 
   @state() private editPrefs?: EditPreferencesInfo;
 
-  @state() private lineNum?: number;
+  // private but used in test
+  @state() latestPatchsetNumber?: PatchSetNumber;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -111,40 +91,54 @@
 
   private readonly userModel = getAppContext().userModel;
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getEditViewModel = resolve(this, editViewModelToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  private readonly shortcuts = new ShortcutController(this);
+
   // Tests use this so needs to be non private
   storeTask?: DelayedTask;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
-
   constructor() {
     super();
     this.addEventListener('content-change', e => {
       this.handleContentChange(e as CustomEvent<{value: string}>);
     });
+    subscribe(
+      this,
+      () => this.userModel.editPreferences$,
+      editPreferences => (this.editPrefs = editPreferences)
+    );
+    subscribe(
+      this,
+      () => this.getEditViewModel().state$,
+      state => {
+        this.viewState = state;
+        this.viewStateChanged();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchsetNumber = x)
+    );
+    this.shortcuts.addLocal({key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
+      this.handleSaveShortcut()
+    );
+    this.shortcuts.addLocal({key: 's', modifiers: [Modifier.META_KEY]}, () =>
+      this.handleSaveShortcut()
+    );
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    subscribe(this, this.userModel.editPreferences$, editPreferences => {
-      this.editPrefs = editPreferences;
-    });
-    this.cleanups.push(
-      addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
-        this.handleSaveShortcut()
-      )
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, () =>
-        this.handleSaveShortcut()
-      )
-    );
   }
 
   override disconnectedCallback() {
-    this.storeTask?.cancel();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
+    this.storeTask?.flush();
     super.disconnectedCallback();
   }
 
@@ -202,11 +196,15 @@
         .rightControls {
           justify-content: flex-end;
         }
+        .warning {
+          color: var(--error-text-color);
+        }
       `,
     ];
   }
 
   override render() {
+    if (!this.viewState) return;
     return html` ${this.renderHeader()} ${this.renderEndpoint()} `;
   }
 
@@ -216,10 +214,11 @@
         <header>
           <span class="controlGroup">
             <span>Edit mode</span>
+            ${this.renderEditingOldPatchsetWarning()}
             <span class="separator"></span>
             <gr-editable-label
               labelText="File path"
-              .value=${this.path}
+              .value=${this.viewState?.path}
               placeholder="File path..."
               @changed=${this.handlePathChanged}
             ></gr-editable-label>
@@ -252,6 +251,12 @@
     `;
   }
 
+  private renderEditingOldPatchsetWarning() {
+    const patchset = this.viewState?.patchNum;
+    if (patchset === this.latestPatchsetNumber) return nothing;
+    return html`<span class="warning">&nbsp;(Old Patchset)</span>`;
+  }
+
   private renderEndpoint() {
     return html`
       <div class="textareaWrapper">
@@ -270,7 +275,7 @@
           ></gr-endpoint-param>
           <gr-endpoint-param
             name="lineNum"
-            .value=${this.lineNum}
+            .value=${this.viewState?.lineNum}
           ></gr-endpoint-param>
           <gr-default-editor
             id="file"
@@ -282,56 +287,40 @@
   }
 
   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.viewState?.changeNum}_ps${this.viewState?.patchNum}_${this.viewState?.path}`;
   }
 
   // private but used in test
-  paramsChanged() {
-    if (!this.params) return;
-
-    if (this.params.view !== GerritNav.View.EDIT) {
-      return;
-    }
-
-    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;
+  viewStateChanged() {
+    if (!this.viewState) return;
 
     // 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(() => {
-      if (!this.params) return;
-      const title = `Editing ${computeTruncatedPath(this.params.path)}`;
+      if (!this.viewState) return;
+      const title = `Editing ${computeTruncatedPath(this.viewState.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());
+    promises.push(this.getFileData());
     return Promise.all(promises);
   }
 
-  private async getChangeDetail(changeNum: NumericChangeId) {
+  private async getChangeDetail() {
+    const changeNum = this.viewState?.changeNum;
+    assertIsDefined(changeNum, 'change number');
     this.change = await this.restApiService.getChangeDetail(changeNum);
   }
 
@@ -342,7 +331,7 @@
       this,
       'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
     );
-    GerritNav.navigateToChange(this.change);
+    this.getNavigation().setUrl(createChangeUrl({change: this.change}));
   }
 
   private navigateToChangeIfEditType() {
@@ -350,21 +339,22 @@
 
     // Prevent editing binary files
     fireAlert(this, 'You cannot edit binary files within the inline editor.');
-    GerritNav.navigateToChange(this.change);
+    this.getNavigation().setUrl(createChangeUrl({change: this.change}));
   }
 
   // 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) {
-      throw new Error('changeNum or path undefined');
-    }
-    const path = e.detail;
-    if (path === this.path) return;
+    const changeNum = this.viewState?.changeNum;
+    const currentPath = this.viewState?.path;
+    assertIsDefined(changeNum, 'change number');
+    assertIsDefined(currentPath, 'path');
+
+    const newPath = e.detail;
+    if (newPath === currentPath) return;
     const res = await this.restApiService.renameFileInChangeEdit(
-      this.changeNum,
-      this.path,
-      path
+      changeNum,
+      currentPath,
+      newPath
     );
     if (!res?.ok) return;
 
@@ -374,22 +364,21 @@
 
   // private but used in test
   viewEditInChangeView() {
-    if (this.change)
-      GerritNav.navigateToChange(this.change, {
-        isEdit: true,
-        forceReload: true,
-      });
+    if (!this.change) return;
+    this.getNavigation().setUrl(
+      createChangeUrl({change: this.change, edit: true, forceReload: true})
+    );
   }
 
   // private but used in test
-  getFileData(
-    changeNum: NumericChangeId,
-    path: string,
-    patchNum?: PatchSetNum
-  ) {
-    if (patchNum === undefined) {
-      return Promise.reject(new Error('patchNum undefined'));
-    }
+  getFileData() {
+    const changeNum = this.viewState?.changeNum;
+    const patchNum = this.viewState?.patchNum;
+    const path = this.viewState?.path;
+    assertIsDefined(changeNum, 'change number');
+    assertIsDefined(patchNum, 'patchset number');
+    assertIsDefined(path, 'path');
+
     const storedContent = this.storage.getEditableContentItem(this.storageKey);
 
     return this.restApiService
@@ -422,16 +411,18 @@
 
   // private but used in test
   saveEdit() {
-    if (this.changeNum === undefined || !this.path) {
-      return Promise.reject(new Error('changeNum or path undefined'));
-    }
+    const changeNum = this.viewState?.changeNum;
+    const path = this.viewState?.path;
+    assertIsDefined(changeNum, 'change number');
+    assertIsDefined(path, 'path');
+
     this.saving = true;
     this.showAlert(SAVING_MESSAGE);
     this.storage.eraseEditableContentItem(this.storageKey);
     if (!this.newContent)
       return Promise.reject(new Error('new content undefined'));
     return this.restApiService
-      .saveChangeEdit(this.changeNum, this.path, this.newContent)
+      .saveChangeEdit(changeNum, path, this.newContent)
       .then(res => {
         this.saving = false;
         this.showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
@@ -455,9 +446,7 @@
       return true;
     }
 
-    if (this.saving) {
-      return true;
-    }
+    if (this.saving) return true;
     return this.content === this.newContent;
   }
 
@@ -474,13 +463,13 @@
   };
 
   private handlePublishTap = () => {
-    assertIsDefined(this.changeNum, 'changeNum');
+    const changeNum = this.viewState?.changeNum;
+    assertIsDefined(changeNum, 'change number');
 
-    const changeNum = this.changeNum;
     this.saveEdit().then(() => {
       const handleError: ErrorCallback = response => {
         this.showAlert(PUBLISH_FAILED_MSG);
-        this.reporting.error(new Error(response?.statusText));
+        this.reporting.error('/edit:publish', new Error(response?.statusText));
       };
 
       this.showAlert(PUBLISHING_EDIT_MSG);
@@ -496,7 +485,9 @@
         )
         .then(() => {
           assertIsDefined(this.change, 'change');
-          GerritNav.navigateToChange(this.change, {forceReload: true});
+          this.getNavigation().setUrl(
+            createChangeUrl({change: this.change, forceReload: true})
+          );
         });
     });
   };
@@ -519,9 +510,7 @@
 
   // private but used in test
   handleSaveShortcut() {
-    if (!this.computeSaveDisabled()) {
-      this.saveEdit();
-    }
+    if (!this.computeSaveDisabled()) this.saveEdit();
   }
 }
 
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 a449e53..52581ed 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
@@ -1,46 +1,37 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-editor-view';
 import {GrEditorView} from './gr-editor-view';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {HttpMethod} from '../../../constants/constants';
 import {
   mockPromise,
+  pressKey,
   query,
   stubRestApi,
   stubStorage,
 } from '../../../test/test-utils';
 import {
-  EditPatchSetNum,
+  EDIT,
   NumericChangeId,
-  PatchSetNum,
+  PatchSetNumber,
+  RevisionPatchSetNum,
 } from '../../../types/common';
 import {
   createChangeViewChange,
-  createGenerateUrlEditViewParameters,
+  createEditViewState,
 } 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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
+import {Modifier} from '../../../utils/dom-util';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-editor-view tests', () => {
   let element: GrEditorView;
@@ -51,16 +42,89 @@
   let navigateStub: sinon.SinonStub;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-editor-view></gr-editor-view>`);
     savePathStub = stubRestApi('renameFileInChangeEdit');
     saveFileStub = stubRestApi('saveChangeEdit');
     changeDetailStub = stubRestApi('getChangeDetail');
     navigateStub = sinon.stub(element, 'viewEditInChangeView');
+    element.viewState = {
+      ...createEditViewState(),
+      patchNum: 1 as PatchSetNumber,
+    };
+    element.latestPatchsetNumber = 1 as PatchSetNumber;
     await element.updateComplete;
   });
 
-  suite('paramsChanged', () => {
-    test('good params proceed', async () => {
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="stickyHeader">
+          <header>
+            <span class="controlGroup">
+              <span> Edit mode </span>
+              <span class="separator"> </span>
+              <gr-editable-label
+                id="global"
+                labeltext="File path"
+                placeholder="File path..."
+                tabindex="0"
+                title="${element.viewState?.path}"
+              >
+              </gr-editable-label>
+            </span>
+            <span class="controlGroup rightControls">
+              <gr-button
+                aria-disabled="false"
+                id="close"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Cancel
+              </gr-button>
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                id="save"
+                link=""
+                primary=""
+                role="button"
+                tabindex="-1"
+                title="Save and Close the file"
+              >
+                Save
+              </gr-button>
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                id="publish"
+                link=""
+                primary=""
+                role="button"
+                tabindex="-1"
+                title="Publish your edit. A new patchset will be created."
+              >
+                Save & Publish
+              </gr-button>
+            </span>
+          </header>
+        </div>
+        <div class="textareaWrapper">
+          <gr-endpoint-decorator id="editorEndpoint" name="editor">
+            <gr-endpoint-param name="fileContent"> </gr-endpoint-param>
+            <gr-endpoint-param name="prefs"> </gr-endpoint-param>
+            <gr-endpoint-param name="fileType"> </gr-endpoint-param>
+            <gr-endpoint-param name="lineNum"> </gr-endpoint-param>
+            <gr-default-editor id="file"> </gr-default-editor>
+          </gr-endpoint-decorator>
+        </div>
+      `
+    );
+  });
+
+  suite('viewStateChanged', () => {
+    test('good view state proceed', async () => {
       changeDetailStub.returns(Promise.resolve({}));
       const fileStub = sinon.stub(element, 'getFileData').callsFake(() => {
         element.content = 'text';
@@ -69,20 +133,14 @@
         return Promise.resolve();
       });
 
-      element.params = {...createGenerateUrlEditViewParameters()};
-      const promises = element.paramsChanged();
+      element.viewState = {...createEditViewState()};
+      const promises = element.viewStateChanged();
 
       await element.updateComplete;
 
       const changeNum = 42 as NumericChangeId;
-      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,
-        'foo/bar.baz',
-        EditPatchSetNum as PatchSetNum,
-      ]);
+      assert.isTrue(fileStub.called);
 
       return promises?.then(() => {
         assert.equal(element.content, 'text');
@@ -93,8 +151,7 @@
   });
 
   test('edit file path', () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.path = 'foo/bar.baz';
+    element.viewState = {...createEditViewState()};
     savePathStub.onFirstCall().returns(Promise.resolve({}));
     savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
 
@@ -144,8 +201,7 @@
     const newText = 'file text changed';
 
     setup(async () => {
-      element.changeNum = 42 as NumericChangeId;
-      element.path = 'foo/bar.baz';
+      element.viewState = {...createEditViewState()};
       element.content = originalText;
       element.newContent = originalText;
       await element.updateComplete;
@@ -174,7 +230,7 @@
       );
       assert.isFalse(element.saving);
 
-      MockInteractions.tap(query<GrButton>(element, '#save')!);
+      query<GrButton>(element, '#save')!.click();
       assert.isTrue(saveSpy.called);
       assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
       assert.isTrue(element.saving);
@@ -213,7 +269,7 @@
         query<GrButton>(element, '#save')!.hasAttribute('disabled')
       );
 
-      MockInteractions.tap(query<GrButton>(element, '#save')!);
+      query<GrButton>(element, '#save')!.click();
       assert.isTrue(saveSpy.called);
       assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
       assert.isTrue(element.saving);
@@ -248,7 +304,7 @@
         query<GrButton>(element, '#save')!.hasAttribute('disabled')
       );
 
-      MockInteractions.tap(query<GrButton>(element, '#publish')!);
+      query<GrButton>(element, '#publish')!.click();
       assert.isTrue(saveSpy.called);
       assert.equal(alertStub.getCall(0).args[0], 'Saving changes...');
       assert.isTrue(element.saving);
@@ -287,7 +343,7 @@
         query<GrButton>(element, '#save')!.hasAttribute('disabled')
       );
 
-      MockInteractions.tap(query<GrButton>(element, '#close')!);
+      query<GrButton>(element, '#close')!.click();
       assert.isTrue(closeSpy.called);
       assert.isFalse(saveFileStub.called);
       assert.isTrue(navigateStub.called);
@@ -310,38 +366,38 @@
           content: 'new content',
         })
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: EDIT,
+        path: 'test/path',
+      };
 
       // Ensure no data is set with a bad response.
-      return element
-        .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');
-        });
+      return element.getFileData().then(() => {
+        assert.equal(element.newContent, 'new content');
+        assert.equal(element.content, 'new content');
+        assert.equal(element.type, 'text/javascript');
+      });
     });
 
     test('!res.ok', () => {
       stubRestApi('getFileContent').returns(
         Promise.resolve(new Response(null, {status: 500}))
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: EDIT,
+        path: 'test/path',
+      };
 
       // Ensure no data is set with a bad response.
-      return element
-        .getFileData(
-          1 as NumericChangeId,
-          'test/path',
-          EditPatchSetNum as PatchSetNum
-        )
-        .then(() => {
-          assert.equal(element.newContent, '');
-          assert.equal(element.content, '');
-          assert.equal(element.type, '');
-        });
+      return element.getFileData().then(() => {
+        assert.equal(element.newContent, '');
+        assert.equal(element.content, '');
+        assert.equal(element.type, '');
+      });
     });
 
     test('content is undefined', () => {
@@ -352,42 +408,42 @@
           type: 'text/javascript' as ResponseType,
         })
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: EDIT,
+        path: 'test/path',
+      };
 
-      return element
-        .getFileData(
-          1 as NumericChangeId,
-          'test/path',
-          EditPatchSetNum as PatchSetNum
-        )
-        .then(() => {
-          assert.equal(element.newContent, '');
-          assert.equal(element.content, '');
-          assert.equal(element.type, 'text/javascript');
-        });
+      return element.getFileData().then(() => {
+        assert.equal(element.newContent, '');
+        assert.equal(element.content, '');
+        assert.equal(element.type, 'text/javascript');
+      });
     });
 
     test('content and type is undefined', () => {
       stubRestApi('getFileContent').returns(
         Promise.resolve({...new Response(), ok: true})
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: EDIT,
+        path: 'test/path',
+      };
 
-      return element
-        .getFileData(
-          1 as NumericChangeId,
-          'test/path',
-          EditPatchSetNum as PatchSetNum
-        )
-        .then(() => {
-          assert.equal(element.newContent, '');
-          assert.equal(element.content, '');
-          assert.equal(element.type, '');
-        });
+      return element.getFileData().then(() => {
+        assert.equal(element.newContent, '');
+        assert.equal(element.content, '');
+        assert.equal(element.type, '');
+      });
     });
   });
 
   test('showAlert', async () => {
     const promise = mockPromise();
-    element.addEventListener('show-alert', e => {
+    element.addEventListener(EventType.SHOW_ALERT, e => {
       assert.deepEqual(e.detail, {message: 'test message', showDismiss: true});
       assert.isTrue(e.bubbles);
       promise.resolve();
@@ -400,11 +456,15 @@
   test('viewEditInChangeView', () => {
     element.change = createChangeViewChange();
     navigateStub.restore();
-    const navStub = sinon.stub(GerritNav, 'navigateToChange');
-    element.patchNum = EditPatchSetNum;
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
     element.viewEditInChangeView();
-    assert.equal(navStub.lastCall.args[1]!.patchNum, undefined);
-    assert.equal(navStub.lastCall.args[1]!.isEdit, true);
+
+    assert.isTrue(setUrlStub.called);
+    assert.equal(
+      setUrlStub.lastCall.firstArg,
+      '/c/test-project/+/42,edit?forceReload=true'
+    );
   });
 
   suite('keyboard shortcuts', () => {
@@ -421,13 +481,13 @@
       test('save enabled', async () => {
         element.content = '';
         element.newContent = '_test';
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
+        pressKey(element, 's', Modifier.CTRL_KEY);
         await element.updateComplete;
 
         assert.isTrue(handleSpy.calledOnce);
         assert.isTrue(saveStub.calledOnce);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
+        pressKey(element, 's', Modifier.META_KEY);
         await element.updateComplete;
 
         assert.equal(handleSpy.callCount, 2);
@@ -435,13 +495,13 @@
       });
 
       test('save disabled', async () => {
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
+        pressKey(element, 's', Modifier.CTRL_KEY);
         await element.updateComplete;
 
         assert.isTrue(handleSpy.calledOnce);
         assert.isFalse(saveStub.called);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
+        pressKey(element, 's', Modifier.META_KEY);
         await element.updateComplete;
 
         assert.equal(handleSpy.callCount, 2);
@@ -463,20 +523,24 @@
           content: 'old content',
         })
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: 1 as RevisionPatchSetNum,
+        path: 'test',
+      };
 
       const alertStub = sinon.stub();
-      element.addEventListener('show-alert', alertStub);
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
 
-      return element
-        .getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
-        .then(async () => {
-          await element.updateComplete;
+      return element.getFileData().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.isTrue(alertStub.called);
+        assert.equal(element.newContent, 'pending edit');
+        assert.equal(element.content, 'old content');
+        assert.equal(element.type, 'text/javascript');
+      });
     });
 
     test('local edit exists, is same as remote edit', () => {
@@ -491,26 +555,33 @@
           content: 'pending edit',
         })
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: 1 as RevisionPatchSetNum,
+        path: 'test',
+      };
 
       const alertStub = sinon.stub();
-      element.addEventListener('show-alert', alertStub);
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
 
-      return element
-        .getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
-        .then(async () => {
-          await element.updateComplete;
+      return element.getFileData().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.isFalse(alertStub.called);
+        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.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: 1 as RevisionPatchSetNum,
+        path: 'test',
+      };
       assert.equal(element.storageKey, 'c1_ps1_test');
     });
   });
diff --git a/polygerrit-ui/app/elements/font-roboto-local-loader.ts b/polygerrit-ui/app/elements/font-roboto-local-loader.ts
index 1be72d2..ebd7f6b 100644
--- a/polygerrit-ui/app/elements/font-roboto-local-loader.ts
+++ b/polygerrit-ui/app/elements/font-roboto-local-loader.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 // Place all code related to font-roboto-local here
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index a78e59f..c05e4c4 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -1,23 +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 '../styles/themes/app-theme';
 import '../styles/themes/dark-theme';
-import {applyTheme as applyDarkTheme} from '../styles/themes/dark-theme';
+import {
+  applyTheme as applyDarkTheme,
+  removeTheme as removeDarkTheme,
+} from '../styles/themes/dark-theme';
 import './admin/gr-admin-view/gr-admin-view';
 import './documentation/gr-documentation-search/gr-documentation-search';
 import './change-list/gr-change-list-view/gr-change-list-view';
@@ -38,11 +29,10 @@
 import './settings/gr-registration-dialog/gr-registration-dialog';
 import './settings/gr-settings-view/gr-settings-view';
 import {getBaseUrl} from '../utils/url-util';
-import {Shortcut} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {GerritNav} from './core/gr-navigation/gr-navigation';
+import {navigationToken} from './core/gr-navigation/gr-navigation';
 import {getAppContext} from '../services/app-context';
-import {GrRouter} from './core/gr-router/gr-router';
-import {AccountDetailInfo, ServerInfo} from '../types/common';
+import {routerToken} from './core/gr-router/gr-router';
+import {AccountDetailInfo} from '../types/common';
 import {
   constructServerErrorMsg,
   GrErrorManager,
@@ -52,8 +42,6 @@
 import {
   AppElementJustRegisteredParams,
   AppElementParams,
-  AppElementPluginScreenParams,
-  AppElementSearchParam,
   isAppElementJustRegisteredParams,
 } from './gr-app-types';
 import {GrMainHeader} from './core/gr-main-header/gr-main-header';
@@ -61,25 +49,30 @@
 import {
   DialogChangeEventDetail,
   EventType,
-  LocationChangeEvent,
   PageErrorEventDetail,
   RpcLogEvent,
   TitleChangeEventDetail,
-  ValueChangedEvent,
 } from '../types/events';
-import {ChangeListViewState, ChangeViewState, ViewState} from '../types/types';
 import {GerritView} from '../services/router/router-model';
-import {LifeCycle} from '../constants/reporting';
+import {Execution, 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 {customElement, property, query, state} from 'lit/decorators.js';
+import {Shortcut, ShortcutController} from './lit/shortcut-controller';
+import {cache} from 'lit/directives/cache.js';
 import {assertIsDefined} from '../utils/common-util';
 import './gr-css-mixins';
+import {isDarkTheme, prefersDarkColorScheme} from '../utils/theme-util';
+import {AppTheme} from '../constants/constants';
+import {subscribe} from './lit/subscription-controller';
+import {KnownExperimentId} from '../services/flags/flags';
+import {PluginViewState} from '../models/views/plugin';
+import {createSearchUrl, SearchViewState} from '../models/views/search';
+import {createSettingsUrl} from '../models/views/settings';
+import {createDashboardUrl} from '../models/views/dashboard';
 
 interface ErrorInfo {
   text: string;
@@ -108,46 +101,22 @@
 
   @query('#keyboardShortcuts') keyboardShortcuts?: GrOverlay;
 
-  @query('gr-settings-view') settingdView?: GrSettingsView;
+  @query('gr-settings-view') settingsView?: GrSettingsView;
 
   @property({type: Object})
   params?: AppElementParams;
 
   @state() private account?: AccountDetailInfo;
 
-  @state() private serverConfig?: ServerInfo;
-
   @state() private version?: string;
 
-  @state() private showChangeListView?: boolean;
-
-  @state() private showDashboardView?: boolean;
-
-  @state() private showChangeView?: boolean;
-
-  @state() private showDiffView?: boolean;
-
-  @state() private showSettingsView?: boolean;
-
-  @state() private showAdminView?: boolean;
-
-  @state() private showCLAView?: boolean;
-
-  @state() private showEditorView?: boolean;
-
-  @state() private showPluginScreen?: boolean;
-
-  @state() private showDocumentationSearch?: boolean;
-
-  @state() private viewState?: ViewState;
+  @state() private view?: GerritView;
 
   @state() private lastError?: ErrorInfo;
 
   // private but used in test
   @state() lastSearchPage?: string;
 
-  @state() private path?: string;
-
   @state() private settingsUrl?: string;
 
   @state() private mobileSearch = false;
@@ -174,18 +143,31 @@
   // Triggers dom-if unsetting/setting restamp behaviour in lit
   @state() private invalidateDiffViewCache = false;
 
-  readonly router = new GrRouter();
+  @state() private theme = AppTheme.AUTO;
+
+  @state() private themeEndpoint = 'app-theme-light';
+
+  readonly getRouter = resolve(this, routerToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
 
   private reporting = getAppContext().reportingService;
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly flagsService = getAppContext().flagsService;
+
   private readonly getBrowserModel = resolve(this, browserModelToken);
 
   private readonly shortcuts = new ShortcutController(this);
 
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly routerModel = getAppContext().routerModel;
+
   constructor() {
     super();
+
     document.addEventListener(EventType.PAGE_ERROR, e => {
       this.handlePageError(e);
     });
@@ -195,8 +177,8 @@
     this.addEventListener(EventType.DIALOG_CHANGE, e => {
       this.handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
     });
-    this.addEventListener(EventType.LOCATION_CHANGE, e =>
-      this.handleLocationChange(e)
+    this.addEventListener(EventType.LOCATION_CHANGE, () =>
+      this.handleLocationChange()
     );
     this.addEventListener(EventType.RECREATE_CHANGE_VIEW, () =>
       this.handleRecreateView()
@@ -209,20 +191,45 @@
       this.showKeyboardShortcuts()
     );
     this.shortcuts.addAbstract(Shortcut.GO_TO_USER_DASHBOARD, () =>
-      this.goToUserDashboard()
+      this.getNavigation().setUrl(createDashboardUrl({user: 'self'}))
     );
     this.shortcuts.addAbstract(Shortcut.GO_TO_OPENED_CHANGES, () =>
-      this.goToOpenedChanges()
+      this.getNavigation().setUrl(createSearchUrl({statuses: ['open']}))
     );
     this.shortcuts.addAbstract(Shortcut.GO_TO_MERGED_CHANGES, () =>
-      this.goToMergedChanges()
+      this.getNavigation().setUrl(createSearchUrl({statuses: ['merged']}))
     );
     this.shortcuts.addAbstract(Shortcut.GO_TO_ABANDONED_CHANGES, () =>
-      this.goToAbandonedChanges()
+      this.getNavigation().setUrl(createSearchUrl({statuses: ['abandoned']}))
     );
     this.shortcuts.addAbstract(Shortcut.GO_TO_WATCHED_CHANGES, () =>
-      this.goToWatchedChanges()
+      this.getNavigation().setUrl(
+        createSearchUrl({query: 'is:watched is:open'})
+      )
     );
+
+    subscribe(
+      this,
+      () => this.userModel.preferenceTheme$,
+      theme => {
+        this.theme = theme;
+        this.applyTheme();
+      }
+    );
+    subscribe(
+      this,
+      () => this.routerModel.routerView$,
+      view => {
+        this.view = view;
+        if (view) this.errorView?.classList.remove('show');
+      }
+    );
+
+    prefersDarkColorScheme().addEventListener('change', () => {
+      if (this.theme === AppTheme.AUTO) {
+        this.applyTheme();
+      }
+    });
   }
 
   override connectedCallback() {
@@ -232,7 +239,7 @@
 
     this.updateLoginUrl();
     this.reporting.appStarted();
-    this.router.start();
+    this.getRouter().start();
 
     this.restApiService.getAccount().then(account => {
       this.account = account;
@@ -242,39 +249,32 @@
         this.reporting.reportLifeCycle(LifeCycle.STARTED_AS_GUEST);
       }
     });
-    this.restApiService.getConfig().then(config => {
-      this.serverConfig = config;
-    });
     this.restApiService.getVersion().then(version => {
       this.version = version;
       this.logWelcome();
     });
 
-    const isDarkTheme = !!window.localStorage.getItem('dark-theme');
-    document.documentElement.classList.toggle('darkTheme', isDarkTheme);
-    document.documentElement.classList.toggle('lightTheme', !isDarkTheme);
-    if (isDarkTheme) applyDarkTheme();
+    // TODO(milutin): Remove saving preferences after while. This code is
+    // for migration.
+    if (window.localStorage.getItem('dark-theme')) {
+      this.userModel.updatePreferences({theme: AppTheme.DARK});
+      window.localStorage.removeItem('dark-theme');
+      this.reporting.reportExecution(
+        Execution.REACHABLE_CODE,
+        'Dark theme was migrated from localstorage'
+      );
+    } else if (window.localStorage.getItem('light-theme')) {
+      this.userModel.updatePreferences({theme: AppTheme.LIGHT});
+      window.localStorage.removeItem('light-theme');
+      this.reporting.reportExecution(
+        Execution.REACHABLE_CODE,
+        'Light theme was migrated from localstorage'
+      );
+    }
 
     // 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.viewState = {
-      changeView: {
-        changeNum: null,
-        patchRange: null,
-        selectedFileIndex: 0,
-        showReplyDialog: false,
-        diffMode: null,
-        numFilesShown: null,
-      },
-      changeListView: {
-        query: null,
-        offset: 0,
-        selectedChangeIndex: 0,
-      },
-      dashboardView: {},
-    };
+    this.settingsUrl = createSettingsUrl();
   }
 
   static override get styles() {
@@ -365,9 +365,9 @@
       <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
       <gr-main-header
         id="mainHeader"
-        .searchQuery=${(this.params as AppElementSearchParam)?.query}
+        .searchQuery=${(this.params as SearchViewState)?.query}
         @mobile-search=${this.mobileSearchToggle}
-        @show-keyboard-shortcuts=${this.handleShowKeyboardShortcuts}
+        @show-keyboard-shortcuts=${this.showKeyboardShortcuts}
         .mobileSearchHidden=${!this.mobileSearch}
         .loginUrl=${this.loginUrl}
         ?aria-hidden=${this.footerHeaderAriaHidden}
@@ -409,15 +409,14 @@
         id="errorManager"
         .loginUrl=${this.loginUrl}
       ></gr-error-manager>
-      <gr-plugin-host id="plugins" .config=${this.serverConfig}>
-      </gr-plugin-host>
+      <gr-plugin-host id="plugins"></gr-plugin-host>
       <gr-external-style
         id="externalStyleForAll"
         name="app-theme"
       ></gr-external-style>
       <gr-external-style
         id="externalStyleForTheme"
-        .name=${this.getThemeEndpoint()}
+        name=${this.themeEndpoint}
       ></gr-external-style>
     `;
   }
@@ -428,34 +427,26 @@
       <gr-smart-search
         id="search"
         label="Search for changes"
-        .searchQuery=${(this.params as AppElementSearchParam)?.query}
-        .serverConfig=${this.serverConfig}
+        .searchQuery=${(this.params as SearchViewState)?.query}
       >
       </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>
-    `;
+    return cache(
+      this.view === GerritView.SEARCH
+        ? html` <gr-change-list-view></gr-change-list-view> `
+        : nothing
+    );
   }
 
   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>
-    `;
+    return cache(
+      this.view === GerritView.DASHBOARD
+        ? html`<gr-dashboard-view></gr-dashboard-view>`
+        : nothing
+    );
   }
 
   private renderChangeView() {
@@ -463,24 +454,21 @@
       this.updateComplete.then(() => (this.invalidateChangeViewCache = false));
       return nothing;
     }
-    return cache(this.showChangeView ? this.changeViewTemplate() : nothing);
+    return cache(
+      this.view === GerritView.CHANGE ? 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>
+      <gr-change-view .backPage=${this.lastSearchPage}></gr-change-view>
     `;
   }
 
   private renderEditorView() {
-    if (!this.showEditorView) return nothing;
-    return html`<gr-editor-view .params=${this.params}></gr-editor-view>`;
+    if (this.view !== GerritView.EDIT) return nothing;
+    return html`<gr-editor-view></gr-editor-view>`;
   }
 
   private renderDiffView() {
@@ -488,24 +476,19 @@
       this.updateComplete.then(() => (this.invalidateDiffViewCache = false));
       return nothing;
     }
-    return cache(this.showDiffView ? this.diffViewTemplate() : nothing);
+    return cache(
+      this.view === GerritView.DIFF ? 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>
-    `;
+    return html`<gr-diff-view></gr-diff-view>`;
   }
 
   private renderSettingsView() {
-    if (!this.showSettingsView) return nothing;
+    if (this.view !== GerritView.SETTINGS) return nothing;
     return html`
       <gr-settings-view
-        .params=${this.params}
         @account-detail-update=${this.handleAccountDetailUpdate}
       >
       </gr-settings-view>
@@ -513,35 +496,36 @@
   }
 
   private renderAdminView() {
-    if (!this.showAdminView) return nothing;
-    return html`<gr-admin-view
-      .path=${this.path}
-      .params=${this.params}
-    ></gr-admin-view>`;
+    if (
+      this.view !== GerritView.ADMIN &&
+      this.view !== GerritView.GROUP &&
+      this.view !== GerritView.REPO
+    )
+      return nothing;
+    return html`<gr-admin-view></gr-admin-view>`;
   }
 
   private renderPluginScreen() {
-    if (!this.showPluginScreen) return nothing;
+    if (this.view !== GerritView.PLUGIN_SCREEN) return nothing;
+    const pluginViewState = this.params as PluginViewState;
     return html`
       <gr-endpoint-decorator .name=${this.computePluginScreenName()}>
         <gr-endpoint-param
           name="token"
-          .value=${(this.params as AppElementPluginScreenParams).screen}
+          .value=${pluginViewState.screen}
         ></gr-endpoint-param>
       </gr-endpoint-decorator>
     `;
   }
 
   private renderCLAView() {
-    if (!this.showCLAView) return nothing;
+    if (this.view !== GerritView.AGREEMENTS) 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>
-    `;
+    if (this.view !== GerritView.DOCUMENTATION_SEARCH) return nothing;
+    return html`<gr-documentation-search></gr-documentation-search>`;
   }
 
   private renderKeyboardShortcutsDialog() {
@@ -581,7 +565,6 @@
 
     if (changedProperties.has('params')) {
       this.viewChanged();
-
       this.paramsChanged();
     }
   }
@@ -608,29 +591,6 @@
   }
 
   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 =
-      view === GerritView.ADMIN ||
-      view === GerritView.GROUP ||
-      view === GerritView.REPO;
-    this.showCLAView = view === GerritView.AGREEMENTS;
-    this.showEditorView = view === GerritView.EDIT;
-    const isPluginScreen = view === GerritView.PLUGIN_SCREEN;
-    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.
-    if (isPluginScreen) {
-      setTimeout(() => (this.showPluginScreen = true), 1);
-    }
-    this.showDocumentationSearch = view === GerritView.DOCUMENTATION_SEARCH;
     if (
       this.params &&
       isAppElementJustRegisteredParams(this.params) &&
@@ -640,8 +600,8 @@
       await this.updateComplete;
       assertIsDefined(this.registrationOverlay, 'registrationOverlay');
       assertIsDefined(this.registrationDialog, 'registrationDialog');
-      this.registrationOverlay.open();
-      this.registrationDialog.loadData().then(() => {
+      await this.registrationOverlay.open();
+      await this.registrationDialog.loadData().then(() => {
         this.registrationOverlay!.refit();
       });
     }
@@ -650,20 +610,24 @@
     fireIronAnnounce(this, ' ');
   }
 
-  private handlePageError(e: CustomEvent<PageErrorEventDetail>) {
-    const props = [
-      'showChangeListView',
-      'showDashboardView',
-      'showChangeView',
-      'showDiffView',
-      'showSettingsView',
-      'showAdminView',
-    ];
-    for (const showProp of props) {
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      (this as any)[showProp as any] = false;
+  private applyTheme() {
+    const showDarkTheme = isDarkTheme(
+      this.theme,
+      this.flagsService.isEnabled(KnownExperimentId.AUTO_APP_THEME)
+    );
+    document.documentElement.classList.toggle('darkTheme', showDarkTheme);
+    document.documentElement.classList.toggle('lightTheme', !showDarkTheme);
+    if (showDarkTheme) {
+      this.themeEndpoint = 'app-theme-dark';
+      applyDarkTheme();
+    } else {
+      this.themeEndpoint = 'app-theme-light';
+      removeDarkTheme();
     }
+  }
 
+  private handlePageError(e: CustomEvent<PageErrorEventDetail>) {
+    this.view = undefined;
     this.errorView?.classList.add('show');
     const response = e.detail.response;
     const err: ErrorInfo = {
@@ -691,15 +655,8 @@
     }
   }
 
-  private handleLocationChange(e: LocationChangeEvent) {
+  private handleLocationChange() {
     this.updateLoginUrl();
-
-    const hash = e.detail.hash.substring(1);
-    let pathname = e.detail.pathname;
-    if (pathname.startsWith('/c/') && Number(hash) > 0) {
-      pathname += '@' + hash;
-    }
-    this.path = pathname;
   }
 
   private updateLoginUrl() {
@@ -751,26 +708,18 @@
     }
   }
 
-  private async handleShowKeyboardShortcuts() {
+  private async showKeyboardShortcuts() {
     this.loadKeyboardShortcutsDialog = true;
     await this.updateComplete;
     assertIsDefined(this.keyboardShortcuts, 'keyboardShortcuts');
-    this.keyboardShortcuts.open();
-  }
 
-  private async showKeyboardShortcuts() {
-    // same shortcut should close the dialog if pressed again
-    // when dialog is open
-    this.loadKeyboardShortcutsDialog = true;
-    await this.updateComplete;
-    if (!this.keyboardShortcuts) return;
     if (this.keyboardShortcuts.opened) {
       this.keyboardShortcuts.cancel();
       return;
     }
-    this.keyboardShortcuts.open();
     this.footerHeaderAriaHidden = true;
     this.mainAriaHidden = true;
+    await this.keyboardShortcuts.open();
   }
 
   private handleKeyboardShortcutDialogClose() {
@@ -785,10 +734,7 @@
 
   private handleAccountDetailUpdate() {
     this.mainHeader?.reload();
-    if (this.params?.view === GerritView.SETTINGS) {
-      assertIsDefined(this.settingdView, 'settingdView');
-      this.settingdView.reloadAccountDetail();
-    }
+    this.settingsView?.reloadAccountDetail();
   }
 
   private handleRegistrationDialogClose() {
@@ -799,31 +745,12 @@
     this.registrationOverlay.close();
   }
 
-  private goToOpenedChanges() {
-    GerritNav.navigateToStatusSearch('open');
-  }
-
-  private goToUserDashboard() {
-    GerritNav.navigateToUserDashboard();
-  }
-
-  private goToMergedChanges() {
-    GerritNav.navigateToStatusSearch('merged');
-  }
-
-  private goToAbandonedChanges() {
-    GerritNav.navigateToStatusSearch('abandoned');
-  }
-
-  private goToWatchedChanges() {
-    // The query is hardcoded, and doesn't respect custom menu entries
-    GerritNav.navigateToSearchQuery('is:watched is:open');
-  }
-
   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}`;
+    if (this.view !== GerritView.PLUGIN_SCREEN) return '';
+    if (this.params === undefined) return '';
+    const pluginViewState = this.params as PluginViewState;
+    if (!pluginViewState.plugin || !pluginViewState.screen) return '';
+    return `${pluginViewState.plugin}-screen-${pluginViewState.screen}`;
   }
 
   private logWelcome() {
@@ -848,31 +775,6 @@
   private mobileSearchToggle() {
     this.mobileSearch = !this.mobileSearch;
   }
-
-  getThemeEndpoint() {
-    // For now, we only have dark mode and light mode
-    return window.localStorage.getItem('dark-theme')
-      ? '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-entry-point.ts b/polygerrit-ui/app/elements/gr-app-entry-point.ts
index b1f9621..0ae8942 100644
--- a/polygerrit-ui/app/elements/gr-app-entry-point.ts
+++ b/polygerrit-ui/app/elements/gr-app-entry-point.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // DO NOT EXPORT ANYTHING FROM THIS FILE!
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 243e3d5..4132c0e 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 /**
@@ -23,14 +12,12 @@
  */
 
 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(appContext: AppContext) {
   window.GrAnnotation = GrAnnotation;
-  window.page = page;
   window.GrPluginActionContext = GrPluginActionContext;
   initGerritPluginApi(appContext);
 }
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index bd6b229..3008236 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -1,139 +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 {
-  DashboardSection,
-  GenerateUrlParameters,
-  GroupDetailView,
-  RepoDetailView,
-} from './core/gr-navigation/gr-navigation';
-import {
-  BasePatchSetNum,
-  DashboardId,
-  GroupId,
-  NumericChangeId,
-  RepoName,
-  RevisionPatchSetNum,
-  UrlEncodedCommentId,
-} from '../types/common';
-import {GerritView} from '../services/router/router-model';
+import {SettingsViewState} from '../models/views/settings';
+import {AdminViewState} from '../models/views/admin';
+import {GroupViewState} from '../models/views/group';
+import {RepoViewState} from '../models/views/repo';
+import {AgreementViewState} from '../models/views/agreement';
+import {DocumentationViewState} from '../models/views/documentation';
+import {PluginViewState} from '../models/views/plugin';
+import {SearchViewState} from '../models/views/search';
+import {DashboardViewState} from '../models/views/dashboard';
+import {ChangeViewState} from '../models/views/change';
+import {DiffViewState} from '../models/views/diff';
+import {EditViewState} from '../models/views/edit';
 
 export interface AppElement extends HTMLElement {
-  params: AppElementParams | GenerateUrlParameters;
-}
-
-// TODO(TS): Remove unify AppElementParams with GenerateUrlParameters
-// Seems we can use GenerateUrlParameters instead of AppElementParams,
-// but it require some refactoring
-export interface AppElementDashboardParams {
-  view: GerritView.DASHBOARD;
-  project?: RepoName;
-  dashboard: DashboardId;
-  user?: string;
-  sections?: DashboardSection[];
-  title?: string;
-}
-
-export interface AppElementGroupParams {
-  view: GerritView.GROUP;
-  detail?: GroupDetailView;
-  groupId: GroupId;
-}
-
-export interface ListViewParams {
-  filter?: string | null;
-  offset?: number | string;
-}
-
-export interface AppElementAdminParams extends ListViewParams {
-  view: GerritView.ADMIN;
-  adminView: string;
-  openCreateModal?: boolean;
-}
-
-export interface AppElementRepoParams extends ListViewParams {
-  view: GerritView.REPO;
-  detail?: RepoDetailView;
-  repo: RepoName;
-}
-
-export interface AppElementDocSearchParams {
-  view: GerritView.DOCUMENTATION_SEARCH;
-  filter: string | null;
-}
-
-export interface AppElementPluginScreenParams {
-  view: GerritView.PLUGIN_SCREEN;
-  plugin?: string;
-  screen?: string;
-}
-
-export interface AppElementSearchParam {
-  view: GerritView.SEARCH;
-  query: string;
-  offset: string;
-}
-
-export interface AppElementSettingsParam {
-  view: GerritView.SETTINGS;
-  emailToken?: string;
-}
-
-export interface AppElementAgreementParam {
-  view: GerritView.AGREEMENTS;
-}
-
-export interface AppElementDiffViewParam {
-  view: GerritView.DIFF;
-  changeNum: NumericChangeId;
-  project?: RepoName;
-  commentId?: UrlEncodedCommentId;
-  path?: string;
-  patchNum?: RevisionPatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-  lineNum: number;
-  leftSide?: boolean;
-  commentLink?: boolean;
-}
-
-export interface AppElementDiffEditViewParam {
-  view: GerritView.EDIT;
-  changeNum: NumericChangeId;
-  project: RepoName;
-  path: string;
-  patchNum: RevisionPatchSetNum;
-  lineNum?: number;
-}
-
-export interface AppElementChangeViewParams {
-  view: GerritView.CHANGE;
-  changeNum: NumericChangeId;
-  project: RepoName;
-  edit?: boolean;
-  patchNum?: RevisionPatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-  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;
+  params: AppElementParams;
 }
 
 export interface AppElementJustRegisteredParams {
@@ -147,18 +31,18 @@
 }
 
 export type AppElementParams =
-  | AppElementDashboardParams
-  | AppElementGroupParams
-  | AppElementAdminParams
-  | AppElementChangeViewParams
-  | AppElementRepoParams
-  | AppElementDocSearchParams
-  | AppElementPluginScreenParams
-  | AppElementSearchParam
-  | AppElementSettingsParam
-  | AppElementAgreementParam
-  | AppElementDiffViewParam
-  | AppElementDiffEditViewParam
+  | DashboardViewState
+  | GroupViewState
+  | AdminViewState
+  | ChangeViewState
+  | RepoViewState
+  | DocumentationViewState
+  | PluginViewState
+  | SearchViewState
+  | SettingsViewState
+  | AgreementViewState
+  | DiffViewState
+  | EditViewState
   | AppElementJustRegisteredParams;
 
 export function isAppElementJustRegisteredParams(
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 1d0b1ad..0cec8dd 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -1,20 +1,8 @@
 /**
  * @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 {safeTypesBridge} from '../utils/safe-types-util';
 import './font-roboto-local-loader';
 // Sets up global Polymer variable, because plugins requires it.
@@ -47,16 +35,19 @@
   initVisibilityReporter,
   initPerformanceReporter,
   initErrorReporter,
+  initWebVitals,
 } from '../services/gr-reporting/gr-reporting_impl';
 import {injectAppContext} from '../services/app-context';
 import {html, LitElement} from 'lit';
-import {customElement} from 'lit/decorators';
+import {customElement} from 'lit/decorators.js';
+import {ServiceWorkerInstaller} from '../services/service-worker-installer';
 
 const appContext = createAppContext();
 injectAppContext(appContext);
 const reportingService = appContext.reportingService;
 initVisibilityReporter(reportingService);
 initPerformanceReporter(reportingService);
+initWebVitals(reportingService);
 initErrorReporter(reportingService);
 
 installPolymerResin(safeTypesBridge);
@@ -65,6 +56,8 @@
 export class GrApp extends LitElement {
   private finalizables: Finalizable[] = [];
 
+  private serviceWorkerInstaller?: ServiceWorkerInstaller;
+
   override connectedCallback() {
     super.connectedCallback();
     const dependencies = createAppDependencies(appContext);
@@ -72,6 +65,12 @@
       this.finalizables.push(service);
       provide(this, token, () => service);
     }
+    if (!this.serviceWorkerInstaller) {
+      this.serviceWorkerInstaller = new ServiceWorkerInstaller(
+        appContext.flagsService,
+        appContext.userModel
+      );
+    }
   }
 
   override disconnectedCallback() {
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index 251f3ea..a87973b 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -1,34 +1,21 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 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 {fixture, html, assert} from '@open-wc/testing';
+import {queryAndAssert, stubElement, stubRestApi} from '../test/test-utils';
 import {GrApp} from './gr-app';
 import {
-  createAppElementChangeViewParams,
+  createChangeViewState,
   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', () => {
@@ -39,7 +26,7 @@
 
   setup(async () => {
     appStartedStub = sinon.stub(getAppContext().reportingService, 'appStarted');
-    stub('gr-account-dropdown', '_getTopContent');
+    stubElement('gr-account-dropdown', '_getTopContent');
     routerStartStub = sinon.stub(GrRouter.prototype, 'start');
     stubRestApi('getAccount').returns(Promise.resolve(undefined));
     stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
@@ -60,16 +47,10 @@
     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.params = createChangeViewState();
     grAppElement.paramsChanged();
     assert.notOk(grAppElement.lastSearchPage);
 
diff --git a/polygerrit-ui/app/elements/gr-css-mixins.ts b/polygerrit-ui/app/elements/gr-css-mixins.ts
index 7a57a17..733b386 100644
--- a/polygerrit-ui/app/elements/gr-css-mixins.ts
+++ b/polygerrit-ui/app/elements/gr-css-mixins.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement} from '@polymer/decorators';
 import {html} from '@polymer/polymer/lib/utils/html-tag';
@@ -25,37 +13,70 @@
   static get template() {
     return html`
       <style>
+        /* prettier formatter removes semi-colons after css mixins. */
+        /* prettier-ignore */
         :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
+          /* 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-font-common-base: {
+            font-family: var(--header-font-family);
+            -webkit-font-smoothing: initial;
+          };
           --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;
-          }
+          };
+          --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-item: {
+            min-height: 0;
+            padding: 0px 16px;
+          };
+          --paper-item-focused-before: {
+            background-color: var(--selection-background-color);
+          };
+          --paper-item-focused: {
+            background-color: var(--selection-background-color);
+          };
+          --paper-listbox: {
+            padding: 0;
+          };
         }
       </style>
     `;
diff --git a/polygerrit-ui/app/elements/lit/fit-controller.ts b/polygerrit-ui/app/elements/lit/fit-controller.ts
new file mode 100644
index 0000000..423a4f0
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/fit-controller.ts
@@ -0,0 +1,213 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {ReactiveController, ReactiveControllerHost} from 'lit';
+
+export interface FitControllerHost {
+  /**
+   * This offset will increase or decrease the distance to the left side
+   * of the screen, a negative offset will move the dropdown to the left
+   * a positive one, to the right.
+   *
+   */
+  horizontalOffset: number;
+
+  /**
+   * This offset will increase or decrease the distance to the top
+   * side of the screen: a negative offset will move the dropdown upwards
+   * , a positive one, downwards.
+   *
+   */
+  verticalOffset: number;
+}
+
+export interface PositionStyles {
+  top: string;
+  left: string;
+  position: string;
+  maxWidth: string;
+  maxHeight: string;
+  boxSizing: string;
+}
+
+/**
+ * `FitController` fits an element in another element using `max-height`
+ * and `max-width`.
+ *
+ * FitController overrides all properties defined in PositionStyles for the
+ * host.
+ * The element will only be sized and/or positioned if it has not already been
+ * sized and/or positioned by CSS.
+ *  CSS properties            | Action
+ * --------------------------|-------------------------------------------
+ * `position` set            | Element is not centered horizontally/vertically
+ * `top` or `bottom` set     | Element is not vertically centered
+ * `left` or `right` set     | Element is not horizontally centered
+ * `max-height` set          | Element respects `max-height`
+ * `max-width` set           | Element respects `max-width`
+ *
+ * `FitController` positions an element into another element and gives it
+ * a horizontalAlignment = left and verticalAlignment = top.
+ * This will override the element's css position.
+ *
+ * Use `horizontalOffset, verticalOffset` to offset the element from its
+ * `positionTarget`; `FitController` will collapse these in order to
+ * keep the element within `window` boundaries, while preserving the element's
+ * CSS margin values.
+ *
+ */
+export class FitController implements ReactiveController {
+  host: ReactiveControllerHost & HTMLElement & FitControllerHost;
+
+  private originalStyles?: PositionStyles;
+
+  private positionTarget?: HTMLElement;
+
+  constructor(host: ReactiveControllerHost & HTMLElement & FitControllerHost) {
+    (this.host = host).addController(this);
+  }
+
+  hostConnected() {
+    this.positionTarget = this.getPositionTarget();
+  }
+
+  hostDisconnected() {}
+
+  // private but used in tests
+  getPositionTarget() {
+    let parent = this.host.parentNode;
+
+    if (parent && parent.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
+      parent = (parent as ShadowRoot).host;
+    }
+
+    return parent as HTMLElement;
+  }
+
+  private saveOriginalStyles() {
+    // These properties are changed in position() hence keep the original
+    // values to reset the host styles later.
+    this.originalStyles = {
+      top: this.host.style.top || '',
+      left: this.host.style.left || '',
+      position: this.host.style.position || '',
+      maxWidth: this.host.style.maxWidth || '',
+      maxHeight: this.host.style.maxHeight || '',
+      boxSizing: this.host.style.boxSizing || '',
+    };
+  }
+
+  /**
+   * Reset the host style, and clear the memoized data.
+   */
+  private resetStyles() {
+    // It is necessary to clear the max-width:0px and max-height:0px.
+    // A component may call refit() multiple times, in which case we don't
+    // want the values assigned from the first call which may not be precisely
+    // correct to influence the second call.
+    // Hence we reset the styles here.
+    if (this.originalStyles !== undefined) {
+      Object.assign(this.host.style, this.originalStyles);
+    }
+    this.originalStyles = undefined;
+  }
+
+  setPositionTarget(target: HTMLElement) {
+    this.positionTarget = target;
+  }
+
+  /**
+   * Equivalent to calling `resetStyles()` and `position()`.
+   * Useful to call this after the element or the `window` element has
+   * been resized, or if any of the positioning properties
+   * (e.g. `horizontalOffset, verticalOffset`) are updated.
+   * It preserves the scroll position of the host.
+   */
+  refit() {
+    const scrollLeft = this.host.scrollLeft;
+    const scrollTop = this.host.scrollTop;
+    this.resetStyles();
+    this.position();
+    this.host.scrollLeft = scrollLeft;
+    this.host.scrollTop = scrollTop;
+  }
+
+  private position() {
+    this.saveOriginalStyles();
+
+    this.host.style.position = 'fixed';
+    // Need border-box for margin/padding.
+    this.host.style.boxSizing = 'border-box';
+
+    const hostRect = this.host.getBoundingClientRect();
+    const positionRect = this.getNormalizedRect(this.positionTarget!);
+    const windowRect = this.getNormalizedRect(window);
+
+    this.calculateAndSetPositions(hostRect, positionRect, windowRect);
+  }
+
+  // private but used in tests
+  calculateAndSetPositions(
+    hostRect: DOMRect,
+    positionRect: DOMRect,
+    windowRect: DOMRect
+  ) {
+    const hostStyles = (window as Window).getComputedStyle(this.host);
+    const hostMinWidth = parseInt(hostStyles.minWidth) || 0;
+    const hostMinHeight = parseInt(hostStyles.minHeight) || 0;
+
+    const hostMargin = {
+      top: parseInt(hostStyles.marginTop) || 0,
+      right: parseInt(hostStyles.marginRight) || 0,
+      bottom: parseInt(hostStyles.marginBottom) || 0,
+      left: parseInt(hostStyles.marginLeft) || 0,
+    };
+
+    let leftPosition =
+      positionRect.left + this.host.horizontalOffset + hostMargin.left;
+    let topPosition =
+      positionRect.top + this.host.verticalOffset + hostMargin.top;
+
+    // Limit right/bottom within window respecting the margin.
+    const rightPosition = Math.min(
+      windowRect.right - hostMargin.right,
+      leftPosition + hostRect.width
+    );
+    const bottomPosition = Math.min(
+      windowRect.bottom - hostMargin.bottom,
+      topPosition + hostRect.height
+    );
+
+    // Respect hostMinWidth and hostMinHeight
+    // Current width is rightPosition - leftPosition or hostRect.width
+    //    rightPosition - leftPosition >= hostMinWidth
+    // => leftPosition <= rightPosition - hostMinWidth
+    leftPosition = Math.min(leftPosition, rightPosition - hostMinWidth);
+    topPosition = Math.min(topPosition, bottomPosition - hostMinHeight);
+
+    // Limit left/top within window respecting the margin.
+    leftPosition = Math.max(windowRect.left + hostMargin.left, leftPosition);
+    topPosition = Math.max(windowRect.top + hostMargin.top, topPosition);
+
+    // Use right/bottom to set maxWidth/maxHeight and respect
+    // minWidth/minHeight.
+    const maxWidth = Math.max(rightPosition - leftPosition, hostMinWidth);
+    const maxHeight = Math.max(bottomPosition - topPosition, hostMinHeight);
+
+    this.host.style.maxWidth = `${maxWidth}px`;
+    this.host.style.maxHeight = `${maxHeight}px`;
+
+    this.host.style.left = `${leftPosition}px`;
+    this.host.style.top = `${topPosition}px`;
+  }
+
+  private getNormalizedRect(target: Window | HTMLElement): DOMRect {
+    if (target === document.documentElement || target === window) {
+      return new DOMRect(0, 0, window.innerWidth, window.innerHeight);
+    }
+    return (target as HTMLElement).getBoundingClientRect();
+  }
+}
diff --git a/polygerrit-ui/app/elements/lit/fit-controller_test.ts b/polygerrit-ui/app/elements/lit/fit-controller_test.ts
new file mode 100644
index 0000000..2a60b83
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/fit-controller_test.ts
@@ -0,0 +1,143 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {FitController} from './fit-controller';
+import {LitElement} from 'lit';
+import {customElement} from 'lit/decorators.js';
+
+@customElement('fit-element')
+class FitElement extends LitElement {
+  fitController = new FitController(this);
+
+  horizontalOffset = 0;
+
+  verticalOffset = 0;
+
+  override render() {
+    return html`<div></div>`;
+  }
+}
+
+suite('fit controller', () => {
+  let element: FitElement;
+  setup(async () => {
+    element = await fixture(html`<fit-element></fit-element>`);
+  });
+
+  test('refit positioning', async () => {
+    const hostRect = new DOMRect(0, 0, 50, 50);
+
+    const positionRect = new DOMRect(37, 37, 300, 60);
+
+    const windowRect = new DOMRect(0, 0, 600, 600);
+
+    element.fitController.calculateAndSetPositions(
+      hostRect,
+      positionRect,
+      windowRect
+    );
+
+    assert.equal(element.style.top, '37px');
+    assert.equal(element.style.left, '37px');
+  });
+
+  test('refit positioning with offset', async () => {
+    const elementWithOffset: FitElement = await fixture(
+      html`<fit-element></fit-element>`
+    );
+    elementWithOffset.verticalOffset = 10;
+    elementWithOffset.horizontalOffset = 20;
+
+    const hostRect = new DOMRect(0, 0, 50, 50);
+
+    const positionRect = new DOMRect(37, 37, 300, 60);
+
+    const windowRect = new DOMRect(0, 0, 600, 600);
+
+    elementWithOffset.fitController.calculateAndSetPositions(
+      hostRect,
+      positionRect,
+      windowRect
+    );
+
+    assert.equal(elementWithOffset.style.top, '47px');
+    assert.equal(elementWithOffset.style.left, '57px');
+  });
+
+  test('host margin updates positioning', async () => {
+    const hostRect = new DOMRect(0, 0, 50, 50);
+
+    const positionRect = new DOMRect(37, 37, 300, 60);
+
+    const windowRect = new DOMRect(0, 0, 600, 600);
+
+    element.style.marginLeft = '10px';
+    element.style.marginTop = '10px';
+    element.fitController.calculateAndSetPositions(
+      hostRect,
+      positionRect,
+      windowRect
+    );
+
+    // is 10px extra from the previous test due to host margin
+    assert.equal(element.style.top, '47px');
+    assert.equal(element.style.left, '47px');
+  });
+
+  test('host minWidth, minHeight overrides positioning', async () => {
+    const hostRect = new DOMRect(0, 0, 50, 50);
+
+    const positionRect = new DOMRect(37, 37, 300, 60);
+
+    const windowRect = new DOMRect(0, 0, 600, 600);
+
+    element.style.marginLeft = '10px';
+    element.style.marginTop = '10px';
+
+    element.style.minHeight = '50px';
+    element.style.minWidth = '60px';
+
+    element.fitController.calculateAndSetPositions(
+      hostRect,
+      positionRect,
+      windowRect
+    );
+
+    assert.equal(element.style.top, '47px');
+
+    // Should be 47 like the previous test but that would make it overall
+    // smaller in width than the minWidth defined
+    assert.equal(element.style.left, '37px');
+    assert.equal(element.style.maxWidth, '60px');
+  });
+
+  test('positioning happens within window size ', async () => {
+    const hostRect = new DOMRect(0, 0, 50, 50);
+
+    const positionRect = new DOMRect(37, 37, 300, 60);
+
+    // window size is small hence limits the position
+    const windowRect = new DOMRect(0, 0, 50, 50);
+
+    element.style.marginLeft = '10px';
+    element.style.marginTop = '10px';
+
+    element.fitController.calculateAndSetPositions(
+      hostRect,
+      positionRect,
+      windowRect
+    );
+
+    assert.equal(element.style.top, '47px');
+    assert.equal(element.style.left, '47px');
+    // With the window size being 50, the element is styled with width 3px
+    // width = windowSize - leftPosition = 50 - 47 = 3px
+    // Without the window width restriction, in previous test maxWidth is 60px
+    assert.equal(element.style.maxWidth, '3px');
+  });
+});
diff --git a/polygerrit-ui/app/elements/lit/incremental-repeat.ts b/polygerrit-ui/app/elements/lit/incremental-repeat.ts
new file mode 100644
index 0000000..695290c
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/incremental-repeat.ts
@@ -0,0 +1,123 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {directive, AsyncDirective} from 'lit/async-directive.js';
+import {DirectiveParameters, ChildPart} from 'lit/directive.js';
+import {
+  insertPart,
+  setChildPartValue,
+  removePart,
+} from 'lit/directive-helpers.js';
+
+interface RepeatOptions<T> {
+  values: T[];
+  mapFn?: (val: T, idx: number) => unknown;
+  initialCount: number;
+  targetFrameRate?: number;
+  startAt?: number;
+  // TODO: targetFramerate
+}
+
+interface RepeatState<T> {
+  values: T[];
+  mapFn?: (val: T, idx: number) => unknown;
+  startAt: number;
+  incrementAmount: number;
+  lastRenderedAt: number;
+  targetFrameRate: number;
+}
+
+class IncrementalRepeat<T> extends AsyncDirective {
+  private children: {part: ChildPart; options: RepeatOptions<T>}[] = [];
+
+  private part!: ChildPart;
+
+  private state!: RepeatState<T>;
+
+  render(options: RepeatOptions<T>) {
+    const values = options.values.slice(
+      options.startAt ?? 0,
+      (options.startAt ?? 0) + options.initialCount
+    );
+    if (options.mapFn) {
+      return values.map(options.mapFn);
+    }
+    return values;
+  }
+
+  override update(part: ChildPart, [options]: DirectiveParameters<this>) {
+    if (options.values !== this.state?.values) {
+      if (this.nextScheduledFrameWork !== undefined)
+        cancelAnimationFrame(this.nextScheduledFrameWork);
+      this.part = part;
+      this.clearParts();
+      this.state = {
+        values: options.values,
+        mapFn: options.mapFn,
+        startAt: options.initialCount,
+        incrementAmount: options.initialCount,
+        lastRenderedAt: performance.now(),
+        targetFrameRate: options.targetFrameRate ?? 30,
+      };
+      this.nextScheduledFrameWork = requestAnimationFrame(
+        this.animationFrameHandler
+      );
+    } else {
+      this.updateParts();
+    }
+    return this.render(options);
+  }
+
+  private appendPart(options: RepeatOptions<T>) {
+    const part = insertPart(this.part);
+    this.children.push({part, options});
+    setChildPartValue(part, this.render(options));
+  }
+
+  private clearParts() {
+    for (const child of this.children) {
+      removePart(child.part);
+    }
+    this.children = [];
+  }
+
+  private updateParts() {
+    for (const child of this.children) {
+      setChildPartValue(child.part, this.render(child.options));
+    }
+  }
+
+  private nextScheduledFrameWork: number | undefined;
+
+  private animationFrameHandler = () => {
+    const now = performance.now();
+    const frameRate = 1000 / (now - this.state.lastRenderedAt);
+    if (frameRate < this.state.targetFrameRate) {
+      // https://en.wikipedia.org/wiki/Additive_increase/multiplicative_decrease
+      this.state.incrementAmount = Math.max(
+        1,
+        Math.round(this.state.incrementAmount / 2)
+      );
+    } else {
+      this.state.incrementAmount++;
+    }
+    this.state.lastRenderedAt = now;
+    this.appendPart({
+      mapFn: this.state.mapFn,
+      values: this.state.values,
+      initialCount: this.state.incrementAmount,
+      startAt: this.state.startAt,
+    });
+
+    this.state.startAt += this.state.incrementAmount;
+    if (this.state.startAt < this.state.values.length) {
+      this.nextScheduledFrameWork = requestAnimationFrame(
+        this.animationFrameHandler
+      );
+    }
+  };
+}
+
+export const incrementalRepeat = directive(IncrementalRepeat);
diff --git a/polygerrit-ui/app/elements/lit/shortcut-controller.ts b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
index 4fcc1d2..5d6c536 100644
--- a/polygerrit-ui/app/elements/lit/shortcut-controller.ts
+++ b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
@@ -1,39 +1,34 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 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 {Binding, ShortcutOptions} from '../../utils/dom-util';
+import {shortcutsServiceToken} from '../../services/shortcuts/shortcuts-service';
 import {Shortcut} from '../../services/shortcuts/shortcuts-config';
+import {resolve} from '../../models/dependency';
 
+export {Shortcut};
 interface ShortcutListener {
   binding: Binding;
   listener: (e: KeyboardEvent) => void;
+  options?: ShortcutOptions;
 }
 
 interface AbstractListener {
   shortcut: Shortcut;
   listener: (e: KeyboardEvent) => void;
+  options?: ShortcutOptions;
 }
 
 type Cleanup = () => void;
 
 export class ShortcutController implements ReactiveController {
-  private readonly service: ShortcutsService = getAppContext().shortcutsService;
+  private readonly getShortcutsService = resolve(
+    this.host,
+    shortcutsServiceToken
+  );
 
   private readonly listenersLocal: ShortcutListener[] = [];
 
@@ -50,11 +45,16 @@
   // 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});
+  // Call method in constructor of the component
+  addLocal(
+    binding: Binding,
+    listener: (e: KeyboardEvent) => void,
+    options?: ShortcutOptions
+  ) {
+    this.listenersLocal.push({binding, listener, options});
   }
 
+  // Call method in constructor of the component
   addGlobal(binding: Binding, listener: (e: KeyboardEvent) => void) {
     this.listenersGlobal.push({binding, listener});
   }
@@ -66,24 +66,41 @@
    *
    * Use this method when you are migrating from Polymer to Lit. Call it for
    * each entry of keyboardShortcuts().
+   *
+   * Call method in constructor of the component
    */
-  addAbstract(shortcut: Shortcut, listener: (e: KeyboardEvent) => void) {
-    this.listenersAbstract.push({shortcut, listener});
+  addAbstract(
+    shortcut: Shortcut,
+    listener: (e: KeyboardEvent) => void,
+    options?: ShortcutOptions
+  ) {
+    this.listenersAbstract.push({shortcut, listener, options});
   }
 
   hostConnected() {
-    for (const {binding, listener} of this.listenersLocal) {
-      const cleanup = this.service.addShortcut(this.host, binding, listener, {
-        shouldSuppress: false,
-      });
+    const shortcutsService = this.getShortcutsService();
+    for (const {binding, listener, options} of this.listenersLocal) {
+      const cleanup = shortcutsService.addShortcut(
+        this.host,
+        binding,
+        listener,
+        {
+          shouldSuppress: options?.shouldSuppress ?? false,
+          preventDefault: options?.preventDefault,
+        }
+      );
       this.cleanups.push(cleanup);
     }
-    for (const {shortcut, listener} of this.listenersAbstract) {
-      const cleanup = this.service.addShortcutListener(shortcut, listener);
+    for (const {shortcut, listener, options} of this.listenersAbstract) {
+      const cleanup = shortcutsService.addShortcutListener(
+        shortcut,
+        listener,
+        options
+      );
       this.cleanups.push(cleanup);
     }
     for (const {binding, listener} of this.listenersGlobal) {
-      const cleanup = this.service.addShortcut(
+      const cleanup = shortcutsService.addShortcut(
         document.body,
         binding,
         listener
diff --git a/polygerrit-ui/app/elements/lit/subscription-controller.ts b/polygerrit-ui/app/elements/lit/subscription-controller.ts
index b37a978..fdd24cf 100644
--- a/polygerrit-ui/app/elements/lit/subscription-controller.ts
+++ b/polygerrit-ui/app/elements/lit/subscription-controller.ts
@@ -5,50 +5,46 @@
  */
 import {ReactiveController, ReactiveControllerHost} from 'lit';
 import {Observable, Subscription} from 'rxjs';
+import {Provider} from '../../models/dependency';
 
-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;
+export class SubscriptionError extends Error {
+  constructor(message: string) {
+    super(message);
+  }
 }
 
 /**
  * Enables components to simply hook up a property with an Observable like so:
  *
- * subscribe(this, obs$, x => (this.prop = x));
+ * subscribe(this, () => obs$, x => (this.prop = x));
  */
 export function subscribe<T>(
-  host: ReactiveControllerHost,
-  obs$: Observable<T>,
-  setProp: (t: T) => void
+  host: ReactiveControllerHost & HTMLElement,
+  provider: Provider<Observable<T>>,
+  callback: (t: T) => void
 ) {
-  if (!checkSubscription(host, obs$, setProp)) return;
-  host.addController(new SubscriptionController(obs$, setProp));
+  if (host.isConnected)
+    throw new Error(
+      'Subscriptions should happen before a component is connected'
+    );
+  const controller = new SubscriptionController(provider, callback);
+  host.addController(controller);
 }
+
 export class SubscriptionController<T> implements ReactiveController {
   private sub?: Subscription;
 
   constructor(
-    private readonly obs$: Observable<T>,
-    private readonly setProp: (t: T) => void
+    private readonly provider: Provider<Observable<T>>,
+    private readonly callback: (t: T) => void
   ) {}
 
   hostConnected() {
-    this.sub = this.obs$.subscribe(this.setProp);
+    this.sub = this.provider().subscribe(v => this.update(v));
+  }
+
+  update(value: T) {
+    this.callback(value);
   }
 
   hostDisconnected() {
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
index cf0e23a..da0035e 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {EventType, PluginApi} from '../../../api/plugin';
 import {AdminPluginApi, MenuLink} from '../../../api/admin';
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
index b953885..da7aa9e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
@@ -1,23 +1,12 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
+import {assert} from '@open-wc/testing';
 import {AdminPluginApi} from '../../../api/admin';
 import {PluginApi} from '../../../api/plugin';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
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 0654914..ab71255 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
@@ -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 {AttributeHelperPluginApi} from '../../../api/attribute-helper';
 import {PluginApi} from '../../../api/plugin';
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
deleted file mode 100644
index 94eb292..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_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 {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-
-Polymer({
-  is: 'gr-attribute-helper-some-element',
-  properties: {
-    fooBar: {
-      type: Object,
-      notify: true,
-    },
-  },
-});
-
-const basicFixture = fixtureFromElement('gr-attribute-helper-some-element');
-
-suite('gr-attribute-helper tests', () => {
-  let element;
-  let instance;
-
-  setup(() => {
-    let plugin;
-    window.Gerrit.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    element = basicFixture.instantiate();
-    instance = plugin.attributeHelper(element);
-  });
-
-  test('resolved on value change from undefined', () => {
-    const promise = instance.get('fooBar').then(value => {
-      assert.equal(value, 'foo! bar!');
-    });
-    element.fooBar = 'foo! bar!';
-    return promise;
-  });
-
-  test('resolves to current attribute value', () => {
-    element.fooBar = 'foo-foo-bar';
-    const promise = instance.get('fooBar').then(value => {
-      assert.equal(value, 'foo-foo-bar');
-    });
-    element.fooBar = 'no bar';
-    return promise;
-  });
-
-  test('bind', () => {
-    const stub = sinon.stub();
-    element.fooBar = 'bar foo';
-    const unbind = instance.bind('fooBar', stub);
-    element.fooBar = 'partridge in a foo tree';
-    element.fooBar = 'five gold bars';
-    assert.equal(stub.callCount, 3);
-    assert.deepEqual(stub.args[0], ['bar foo']);
-    assert.deepEqual(stub.args[1], ['partridge in a foo tree']);
-    assert.deepEqual(stub.args[2], ['five gold bars']);
-    stub.reset();
-    unbind();
-    instance.fooBar = 'ladies dancing';
-    assert.isFalse(stub.called);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts
new file mode 100644
index 0000000..5c15816
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn';
+import {fixture, html, assert} from '@open-wc/testing';
+import {PluginApi} from '../../../api/plugin';
+import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
+
+// Attribute helper only works on Polymer notify events, so we cannot use a Lit
+// element for the test.
+Polymer({
+  is: 'foo-bar',
+  properties: {
+    fooBar: {
+      type: Object,
+      notify: true,
+    },
+  },
+});
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'foo-bar': HTMLElement;
+  }
+}
+
+suite('gr-attribute-helper tests', () => {
+  let element: HTMLElement & {fooBar?: string};
+  let instance: AttributeHelperPluginApi;
+
+  setup(async () => {
+    let plugin: PluginApi;
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    element = await fixture(html`<foo-bar></foo-bar>`);
+    instance = plugin!.attributeHelper(element);
+  });
+
+  test('get resolves on value change from undefined', async () => {
+    const fooBarWatch = instance.get('fooBar');
+    element.fooBar = 'foo! bar!';
+    const value = await fooBarWatch;
+
+    assert.equal(value, 'foo! bar!');
+  });
+
+  test('get resolves to current attribute value', async () => {
+    element.fooBar = 'foo-foo-bar';
+    const fooBarWatch = instance.get('fooBar');
+    element.fooBar = 'no bar';
+    const value = await fooBarWatch;
+
+    assert.equal(value, 'foo-foo-bar');
+  });
+
+  test('bind', () => {
+    const stub = sinon.stub();
+    element.fooBar = 'bar foo';
+    const unbind = instance.bind('fooBar', stub);
+    element.fooBar = 'partridge in a foo tree';
+    element.fooBar = 'five gold bars';
+
+    assert.equal(stub.callCount, 3);
+    assert.deepEqual(stub.args[0], ['bar foo']);
+    assert.deepEqual(stub.args[1], ['partridge in a foo tree']);
+    assert.deepEqual(stub.args[2], ['five gold bars']);
+
+    stub.reset();
+    unbind();
+    element.fooBar = 'ladies dancing';
+
+    assert.isFalse(stub.called);
+  });
+});
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 8bd3f15..4d59dc9 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
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Settings
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT 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 {PluginApi} from '../../../api/plugin';
 import {
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 596c54b..011fbbf 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
@@ -1,24 +1,13 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {PluginApi} from '../../../api/plugin';
 import {ChecksPluginApi} from '../../../api/checks';
+import {assert} from '@open-wc/testing';
 
 suite('gr-settings-api tests', () => {
   let checksApi: ChecksPluginApi | undefined;
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
index 6001622..cef6a8b 100644
--- 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
@@ -3,9 +3,10 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {assert} from '@open-wc/testing';
 import {HookApi, PluginElement} from '../../../api/hook';
 import {PluginApi} from '../../../api/plugin';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import {GrDomHook, GrDomHooksManager} from './gr-dom-hooks';
 
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 64548ac..61279cf 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
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {html, LitElement} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {
   getPluginEndpoints,
   ModuleInfo,
@@ -130,12 +130,12 @@
     }
     const expectProperties = this.getEndpointParams().map(paramEl => {
       const helper = plugin.attributeHelper(paramEl);
-      // TODO: this should be replaced by accessing the property directly
-      const paramName = paramEl.getAttribute('name');
+      const paramName = paramEl.name;
       if (!paramName) {
         this.reporting.error(
+          `Plugin '${pluginName}', endpoint '${this.name}'`,
           new Error(
-            `plugin '${pluginName}' endpoint '${this.name}': param is missing a name.`
+            `Plugin '${pluginName}', endpoint '${this.name}': param is missing a name.`
           )
         );
         return;
@@ -156,9 +156,10 @@
         // and the return type is NodeJS.Timeout object
         (timeoutId = window.setTimeout(() => {
           this.reporting.error(
+            `Plugin '${pluginName}', endpoint '${this.name}'`,
             new Error(
-              'Timeout waiting for endpoint properties initialization: ' +
-                `plugin ${pluginName}, endpoint ${this.name}`
+              `Plugin ${pluginName}, endpoint ${this.name}: ` +
+                'Timeout waiting for endpoint properties initialization'
             )
           );
         }, INIT_PROPERTIES_TIMEOUT_MS))
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
index d7acc61..c3e6911 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
@@ -3,11 +3,11 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {fixture, html, assert} from '@open-wc/testing';
 import {
   mockPromise,
   queryAndAssert,
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 5a00c37..e73aad6 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
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
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 d6d1866..ffe9400 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
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
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 24fc613..9915d4c 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
@@ -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 {
   EventHelperPluginApi,
@@ -42,10 +31,10 @@
    */
   onClick(callback: (event: Event) => boolean) {
     this.reporting.trackApi(this.plugin, 'event', 'onClick');
-    return this._listen(this.element, callback);
+    return this.listen(this.element, callback);
   }
 
-  _listen(
+  private listen(
     container: HTMLElement,
     callback: (event: Event) => boolean
   ): UnsubscribeCallback {
@@ -58,8 +47,8 @@
           mayContinue = callback(e);
         } catch (exception: unknown) {
           this.reporting.error(
+            'GrEventHelper',
             new Error('event listener callback error'),
-            undefined,
             exception
           );
         }
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
deleted file mode 100644
index 13bd535..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
+++ /dev/null
@@ -1,75 +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 {addListener} from '@polymer/polymer/lib/utils/gestures.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {mockPromise} from '../../../test/test-utils.js';
-
-Polymer({
-  is: 'gr-event-helper-some-element',
-
-  properties: {
-    fooBar: {
-      type: Object,
-      notify: true,
-    },
-  },
-});
-
-const basicFixture = fixtureFromElement('gr-event-helper-some-element');
-
-suite('gr-event-helper tests', () => {
-  let element;
-  let instance;
-
-  setup(() => {
-    let plugin;
-    window.Gerrit.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    element = basicFixture.instantiate();
-    instance = plugin.eventHelper(element);
-  });
-
-  test('onTap()', async () => {
-    const promise = mockPromise();
-    instance.onTap(() => {
-      promise.resolve();
-    });
-    MockInteractions.tap(element);
-    await promise;
-  });
-
-  test('onTap() cancel', () => {
-    const tapStub = sinon.stub();
-    addListener(element.parentElement, 'tap', tapStub);
-    instance.onTap(() => false);
-    MockInteractions.tap(element);
-    flush();
-    assert.isFalse(tapStub.called);
-  });
-
-  test('onClick() cancel', () => {
-    const tapStub = sinon.stub();
-    element.parentElement.addEventListener('click', tapStub);
-    instance.onTap(() => false);
-    MockInteractions.tap(element);
-    flush();
-    assert.isFalse(tapStub.called);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.ts
new file mode 100644
index 0000000..eaaab5d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.ts
@@ -0,0 +1,88 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+// eslint-disable-next-line import/named
+import {fixture, html, assert} from '@open-wc/testing';
+import {PluginApi} from '../../../api/plugin';
+import {EventHelperPluginApi} from '../../../api/event-helper';
+
+suite('gr-event-helper tests', () => {
+  let element: HTMLDivElement;
+  let eventHelper: EventHelperPluginApi;
+
+  setup(async () => {
+    let plugin: PluginApi;
+    window.Gerrit.install(
+      p => (plugin = p),
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    element = await fixture(html`<div></div>`);
+    eventHelper = plugin!.eventHelper(element);
+  });
+
+  test('listens via onTap', async () => {
+    let parentReceivedClick = false;
+    element.parentElement!.addEventListener(
+      'click',
+      () => (parentReceivedClick = true)
+    );
+    let helperReceivedClick = false;
+
+    eventHelper.onTap(() => {
+      helperReceivedClick = true;
+      return true;
+    });
+    element.click();
+
+    assert.isTrue(helperReceivedClick);
+    assert.isTrue(parentReceivedClick);
+  });
+
+  test('listens via onClick', async () => {
+    let parentReceivedClick = false;
+    element.parentElement!.addEventListener(
+      'click',
+      () => (parentReceivedClick = true)
+    );
+    let helperReceivedClick = false;
+
+    eventHelper.onClick(() => {
+      helperReceivedClick = true;
+      return true;
+    });
+    element.click();
+
+    assert.isTrue(helperReceivedClick);
+    assert.isTrue(parentReceivedClick);
+  });
+
+  test('onTap false blocks event to parent', async () => {
+    let parentReceivedTap = false;
+    element.parentElement!.addEventListener(
+      'tap',
+      () => (parentReceivedTap = true)
+    );
+
+    eventHelper.onTap(() => false);
+    element.click();
+
+    assert.isFalse(parentReceivedTap);
+  });
+
+  test('onClick false blocks event to parent', async () => {
+    let parentReceivedTap = false;
+    element.parentElement!.addEventListener(
+      'tap',
+      () => (parentReceivedTap = true)
+    );
+
+    eventHelper.onClick(() => false);
+    element.click();
+
+    assert.isFalse(parentReceivedTap);
+  });
+});
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 1554e1f..43d9805 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
@@ -1,39 +1,40 @@
 /**
  * @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 {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {LitElement, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {LitElement, html, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
 
 @customElement('gr-external-style')
 export class GrExternalStyle extends LitElement {
   // This is a required value for this component.
-  @property({type: String})
+  @property({type: String, reflect: true})
   name!: string;
 
   // private but used in test
-  @state() stylesApplied: string[] = [];
+  stylesApplied: string[] = [];
+
+  stylesElements: HTMLElement[] = [];
 
   override render() {
     return html`<slot></slot>`;
   }
 
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('name')) {
+      // We remove all styles defined for different name.
+      this.removeStyles();
+      this.importAndApply();
+      getPluginLoader()
+        .awaitPluginsLoaded()
+        .then(() => this.importAndApply());
+    }
+  }
+
   // private but used in test
   applyStyle(name: string) {
     if (this.stylesApplied.includes(name)) {
@@ -44,6 +45,7 @@
     const s = document.createElement('style');
     s.setAttribute('include', name);
     const cs = document.createElement('custom-style');
+    this.stylesElements.push(cs);
     cs.appendChild(s);
     // When using Shadow DOM <custom-style> must be added to the <body>.
     // Within <gr-external-style> itself the styles would have no effect.
@@ -52,20 +54,18 @@
     updateStyles();
   }
 
+  removeStyles() {
+    this.stylesElements.forEach(el => el.remove());
+    this.stylesElements = [];
+    this.stylesApplied = [];
+  }
+
   private importAndApply() {
     const moduleNames = getPluginEndpoints().getModules(this.name);
     for (const name of moduleNames) {
       this.applyStyle(name);
     }
   }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.importAndApply();
-    getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => this.importAndApply());
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.ts
deleted file mode 100644
index 94196df..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_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-external-style/gr-external-style_test.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts
index 2b8444b..ce87acb 100644
--- 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
@@ -1,36 +1,22 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
-import {resetPlugins} from '../../../test/test-utils';
-import './gr-external-style.js';
-import {GrExternalStyle} from './gr-external-style.js';
+import '../../../test/common-test-setup';
+import {mockPromise, MockPromise, resetPlugins} from '../../../test/test-utils';
+import './gr-external-style';
+import {GrExternalStyle} from './gr-external-style';
 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>`
-);
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-external-style integration tests', () => {
   const TEST_URL = 'http://some.com/plugins/url.js';
 
   let element: GrExternalStyle;
   let plugin: PluginApi;
+  let pluginsLoaded: MockPromise<void>;
   let applyStyleSpy: sinon.SinonSpy;
 
   const installPlugin = () => {
@@ -47,33 +33,34 @@
   };
 
   const createElement = async () => {
-    element = basicFixture.instantiate() as GrExternalStyle;
-    applyStyleSpy = sinon.spy(element, 'applyStyle');
+    applyStyleSpy = sinon.spy(GrExternalStyle.prototype, 'applyStyle');
+    element = await fixture(
+      html`<gr-external-style .name=${'foo'}></gr-external-style>`
+    );
     await element.updateComplete;
   };
 
   /**
    * Installs the plugin, creates the element, registers style module.
    */
-  const lateRegister = () => {
+  const lateRegister = async () => {
     installPlugin();
-    createElement();
+    await createElement();
     plugin.registerStyleModule('foo', 'some-module');
   };
 
   /**
    * Installs the plugin, registers style module, creates the element.
    */
-  const earlyRegister = () => {
+  const earlyRegister = async () => {
     installPlugin();
     plugin.registerStyleModule('foo', 'some-module');
-    createElement();
+    await createElement();
   };
 
   setup(() => {
-    sinon
-      .stub(getPluginLoader(), 'awaitPluginsLoaded')
-      .returns(Promise.resolve());
+    pluginsLoaded = mockPromise();
+    sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
   });
 
   teardown(() => {
@@ -84,13 +71,14 @@
   });
 
   test('applies plugin-provided styles', async () => {
-    lateRegister();
+    await lateRegister();
+    pluginsLoaded.resolve();
     await element.updateComplete;
     assert.isTrue(applyStyleSpy.calledWith('some-module'));
   });
 
   test('does not double apply', async () => {
-    earlyRegister();
+    await earlyRegister();
     await element.updateComplete;
     plugin.registerStyleModule('foo', 'some-module');
     await element.updateComplete;
@@ -101,8 +89,36 @@
   });
 
   test('loads and applies preloaded modules', async () => {
-    earlyRegister();
+    await earlyRegister();
     await element.updateComplete;
     assert.isTrue(applyStyleSpy.calledWith('some-module'));
   });
+
+  test('removes old custom-style if name is changed', async () => {
+    installPlugin();
+    plugin.registerStyleModule('bar', 'some-module');
+    await earlyRegister();
+    await element.updateComplete;
+    let customStyles = document.body.querySelectorAll('custom-style');
+    assert.strictEqual(customStyles.length, 1);
+    element.name = 'bar';
+    await element.updateComplete;
+    customStyles = document.body.querySelectorAll('custom-style');
+    assert.strictEqual(customStyles.length, 1);
+    element.name = 'baz';
+    await element.updateComplete;
+    customStyles = document.body.querySelectorAll('custom-style');
+    assert.strictEqual(customStyles.length, 0);
+  });
+
+  test('can apply more than one style', async () => {
+    await earlyRegister();
+    await element.updateComplete;
+    plugin.registerStyleModule('foo', 'some-module2');
+    pluginsLoaded.resolve();
+    await element.updateComplete;
+    assert.strictEqual(element.stylesApplied.length, 2);
+    const customStyles = document.body.querySelectorAll('custom-style');
+    assert.strictEqual(customStyles.length, 2);
+  });
 });
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 9b62743..b0993b9 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
@@ -3,27 +3,37 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {LitElement, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {LitElement} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {ServerInfo} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
 
 @customElement('gr-plugin-host')
 export class GrPluginHost extends LitElement {
-  @property({type: Object})
+  @state()
   config?: ServerInfo;
 
-  _configChanged(config: ServerInfo) {
-    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);
-  }
+  // visible for testing
+  readonly getConfigModel = resolve(this, configModelToken);
 
-  override updated(changedProperties: PropertyValues<GrPluginHost>) {
-    if (changedProperties.has('config') && this.config) {
-      this._configChanged(this.config);
-    }
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        if (!config) return;
+        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);
+      }
+    );
   }
 }
 
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
index 701707e..bb89d12 100644
--- 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
@@ -1,47 +1,39 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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';
+import {fixture, html, assert} from '@open-wc/testing';
+import {SinonStub} from 'sinon';
+import {createServerInfo} from '../../../test/test-data-generators';
 
 suite('gr-plugin-host tests', () => {
   let element: GrPluginHost;
+  let loadPluginsStub: SinonStub;
 
   setup(async () => {
+    loadPluginsStub = sinon.stub(getPluginLoader(), 'loadPlugins');
     element = await fixture<GrPluginHost>(html`
       <gr-plugin-host></gr-plugin-host>
     `);
+    await element.updateComplete;
 
     sinon.stub(document.body, 'appendChild');
   });
 
   test('load plugins should be called', async () => {
-    const loadPluginsStub = sinon.stub(getPluginLoader(), 'loadPlugins');
-    element.config = {
+    loadPluginsStub.reset();
+    element.getConfigModel().updateServerConfig({
+      ...createServerInfo(),
       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([
@@ -53,15 +45,15 @@
   });
 
   test('theme plugins should be loaded if enabled', async () => {
-    const loadPluginsStub = sinon.stub(getPluginLoader(), 'loadPlugins');
-    element.config = {
+    loadPluginsStub.reset();
+    element.getConfigModel().updateServerConfig({
+      ...createServerInfo(),
       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([
@@ -72,4 +64,13 @@
       ])
     );
   });
+
+  test('plugins loaded with instanceId ', async () => {
+    loadPluginsStub.reset();
+    const config = createServerInfo();
+    config.gerrit.instance_id = 'test-id';
+    element.getConfigModel().updateServerConfig(config);
+    assert.isTrue(loadPluginsStub.calledOnce);
+    assert.isTrue(loadPluginsStub.calledWith([], 'test-id'));
+  });
 });
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 e6413b6..135ae51 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
@@ -1,24 +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 '../../shared/gr-overlay/gr-overlay';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html} from 'lit';
-import {customElement, query} from 'lit/decorators';
+import {customElement, query} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
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 032bfac..86243c4 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
@@ -1,36 +1,26 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
+import {stubElement} from '../../../test/test-utils';
 import './gr-plugin-popup';
 import {GrPluginPopup} from './gr-plugin-popup';
 
-const basicFixture = fixtureFromElement('gr-plugin-popup');
-
 suite('gr-plugin-popup tests', () => {
   let element: GrPluginPopup;
   let overlayOpen: sinon.SinonStub;
   let overlayClose: sinon.SinonStub;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-plugin-popup></gr-plugin-popup>`);
     await element.updateComplete;
-    overlayOpen = stub('gr-overlay', 'open').callsFake(() => Promise.resolve());
-    overlayClose = stub('gr-overlay', 'close');
+    overlayOpen = stubElement('gr-overlay', 'open').callsFake(() =>
+      Promise.resolve()
+    );
+    overlayClose = stubElement('gr-overlay', 'close');
   });
 
   test('exists', () => {
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
index ea95f0c..5354ea5 100644
--- 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
@@ -3,14 +3,15 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {queryAndAssert, waitEventLoop} from '../../../test/test-utils';
 import {LitElement, html} from 'lit';
-import {customElement} from 'lit/decorators';
+import {customElement} from 'lit/decorators.js';
+import {fixture, assert} from '@open-wc/testing';
 
 @customElement('gr-user-test-popup')
 class GrUserTestPopupElement extends LitElement {
@@ -24,14 +25,12 @@
   }
 }
 
-const containerFixture = fixtureFromElement('div');
-
 suite('gr-popup-interface tests', () => {
   let container: HTMLElement;
   let instance: GrPopupInterface;
   let plugin: PluginApi;
 
-  setup(() => {
+  setup(async () => {
     window.Gerrit.install(
       p => {
         plugin = p;
@@ -39,7 +38,7 @@
       '0.1',
       'http://test.com/plugins/testplugin/static/test.js'
     );
-    container = containerFixture.instantiate();
+    container = await fixture(html`<div></div>`);
     sinon.stub(plugin, 'hook').returns({
       getLastAttached() {
         return Promise.resolve(container);
@@ -60,7 +59,7 @@
       const popup = instance._getElement();
       assert.isOk(popup);
       popup!.appendChild(manual);
-      await flush();
+      await waitEventLoop();
       assert.equal(
         queryAndAssert(container, '#foobar').textContent,
         'manual content'
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 bb6f256..c9603f4 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
@@ -1,18 +1,7 @@
 /**
  * @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-avatar/gr-avatar';
@@ -24,10 +13,10 @@
 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 {customElement, property, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
-import {when} from 'lit/directives/when';
+import {when} from 'lit/directives/when.js';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 
 @customElement('gr-account-info')
@@ -359,8 +348,7 @@
   }
 
   private handleKeydown(e: KeyboardEvent) {
-    if (e.keyCode === 13) {
-      // Enter
+    if (e.key === 'Enter') {
       e.stopPropagation();
       this.save();
     }
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 03ad8a5..518828a 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-account-info';
 import {query, queryAll, stubRestApi} from '../../../test/test-utils';
 import {GrAccountInfo} from './gr-account-info';
@@ -31,8 +19,7 @@
 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');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-account-info tests', () => {
   let element!: GrAccountInfo;
@@ -66,62 +53,65 @@
     stubRestApi('getConfig').resolves(config);
     stubRestApi('getPreferences').resolves(createPreferences());
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-account-info></gr-account-info>`);
     await element.loadData();
     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>
-    `);
+    assert.shadowDom.equal(
+      element,
+      /* 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', () => {
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 96b8d12..9f4379b 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
@@ -1,27 +1,15 @@
 /**
  * @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 {getBaseUrl} from '../../../utils/url-util';
 import {ContributorAgreementInfo} from '../../../types/common';
 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';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 @customElement('gr-agreements-list')
 export class GrAgreementsList extends LitElement {
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
index f3eeae8..3fe55a5 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
@@ -1,26 +1,14 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-agreements-list';
-import {queryAll, stubRestApi} from '../../../test/test-utils';
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
 import {GrAgreementsList} from './gr-agreements-list';
 import {ContributorAgreementInfo} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-agreements-list');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-agreements-list tests', () => {
   let element: GrAgreementsList;
@@ -36,20 +24,36 @@
 
     stubRestApi('getAccountAgreements').returns(Promise.resolve(agreements));
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-agreements-list></gr-agreements-list>`);
 
     await element.loadData();
-    await flush();
+    await waitEventLoop();
   });
 
   test('renders', () => {
-    const rows = queryAll<HTMLTableRowElement>(element, 'tbody tr') ?? [];
-    assert.equal(rows.length, 1);
-
-    const nameCells = Array.from(rows).map(row =>
-      queryAll<HTMLTableElement>(row, 'td')[0].textContent?.trim()
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <table id="agreements">
+            <thead>
+              <tr>
+                <th class="nameColumn">Name</th>
+                <th class="descriptionColumn">Description</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td class="nameColumn">
+                  <a href="/some url" rel="external"> Agreements 1 </a>
+                </td>
+                <td class="descriptionColumn">Agreements 1 description</td>
+              </tr>
+            </tbody>
+          </table>
+          <a href="/settings/new-agreement"> New Contributor Agreement </a>
+        </div>
+      `
     );
-
-    assert.equal(nameCells[0], 'Agreements 1');
   });
 });
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 34e376b..53548f8 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
@@ -1,31 +1,22 @@
 /**
  * @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 '../../shared/gr-button/gr-button';
 import {ServerInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {PropertyValues} from 'lit';
 import {fire} from '../../../utils/event-util';
 import {ValueChangedEvent} from '../../../types/events';
+import {ColumnNames} from '../../../constants/constants';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
 
 @customElement('gr-change-table-editor')
 export class GrChangeTableEditor extends LitElement {
@@ -35,14 +26,16 @@
   @property({type: Boolean})
   showNumber?: boolean;
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
-
   @property({type: Array})
   defaultColumns: string[] = [];
 
+  @state()
+  serverConfig?: ServerInfo;
+
   private readonly flagsService = getAppContext().flagsService;
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   static override styles = [
     sharedStyles,
     formStyles,
@@ -66,6 +59,17 @@
     `,
   ];
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+  }
+
   override render() {
     return html`<div class="gr-form-styles">
       <table id="changeCols">
@@ -119,7 +123,7 @@
   }
 
   private configChanged() {
-    this.defaultColumns = columnNames.filter(column =>
+    this.defaultColumns = Object.values(ColumnNames).filter(column =>
       this.isColumnEnabled(column)
     );
     if (!this.displayedColumns) return;
@@ -134,16 +138,9 @@
    */
   isColumnEnabled(column: string) {
     if (!this.serverConfig?.change) return true;
-    if (column === 'Comments')
+    if (column === ColumnNames.COMMENTS)
       return this.flagsService.isEnabled('comments-column');
-    if (column === 'Status')
-      return !this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
-    if (column === ' Status ')
-      return this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
+    if (column === ColumnNames.STATUS) return false;
     return true;
   }
 
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 c37c3f9..4d3d3a1 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
@@ -1,25 +1,15 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
+import {ColumnNames} from '../../../constants/constants';
 
 suite('gr-change-table-editor tests', () => {
   let element: GrChangeTableEditor;
@@ -37,8 +27,8 @@
       'Reviewers',
       'Comments',
       'Repo',
-      'Branch',
-      'Updated',
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
     ];
 
     element.displayedColumns = columns;
@@ -48,77 +38,80 @@
   });
 
   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>`);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ` <div class="gr-form-styles">
+        <table id="changeCols">
+          <thead>
+            <tr>
+              <th class="nameHeader">Column</th>
+              <th class="visibleHeader">Visible</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr>
+              <td><label for="numberCheckbox"> Number </label></td>
+              <td class="checkboxContainer">
+                <input id="numberCheckbox" name="number" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Subject"> Subject </label></td>
+              <td class="checkboxContainer">
+                <input checked="" id="Subject" name="Subject" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Owner"> Owner </label></td>
+              <td class="checkboxContainer">
+                <input checked="" id="Owner" name="Owner" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Reviewers"> Reviewers </label></td>
+              <td class="checkboxContainer">
+                <input
+                  checked=""
+                  id="Reviewers"
+                  name="Reviewers"
+                  type="checkbox"
+                />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Repo"> Repo </label></td>
+              <td class="checkboxContainer">
+                <input checked="" id="Repo" name="Repo" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Branch"> Branch </label></td>
+              <td class="checkboxContainer">
+                <input checked="" id="Branch" name="Branch" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Updated"> Updated </label></td>
+              <td class="checkboxContainer">
+                <input checked="" id="Updated" name="Updated" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Size"> Size </label></td>
+              <td class="checkboxContainer">
+                <input id="Size" name="Size" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for=" Status "> Status </label></td>
+              <td class="checkboxContainer">
+                <input id=" Status " name=" Status " type="checkbox" />
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>`
+    );
   });
 
   test('renders', () => {
@@ -150,7 +143,13 @@
   });
 
   test('show item', async () => {
-    element.displayedColumns = ['Status', 'Owner', 'Repo', 'Branch', 'Updated'];
+    element.displayedColumns = [
+      ColumnNames.STATUS,
+      ColumnNames.OWNER,
+      ColumnNames.REPO,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+    ];
     // trigger computation of enabled displayed columns
     element.serverConfig = createServerInfo();
     await element.updateComplete;
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 c8c06c9..2f835f7 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
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
 import {getBaseUrl} from '../../../utils/url-util';
@@ -29,9 +17,9 @@
 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 {customElement, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
-import {ifDefined} from 'lit/directives/if-defined';
+import {ifDefined} from 'lit/directives/if-defined.js';
 
 declare global {
   interface HTMLElementTagNameMap {
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 bac8c75..a321610 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
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-cla-view';
-import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {stubRestApi} from '../../../test/test-utils';
 import {GrClaView} from './gr-cla-view';
 import {
   ContributorAgreementInfo,
@@ -27,7 +16,7 @@
 } from '../../../types/common';
 import {AuthType} from '../../../constants/constants';
 import {createServerInfo} from '../../../test/test-data-generators';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-cla-view tests', () => {
   let element: GrClaView;
@@ -135,18 +124,40 @@
     await element.updateComplete;
   });
 
-  test('renders as expected with signed agreement', () => {
-    const agreementSections = queryAll(element, '.contributorAgreementButton');
-    const agreementSubmittedTexts = queryAll(element, '.alreadySubmittedText');
-    assert.equal(agreementSections.length, 2);
-    assert.isFalse(
-      queryAndAssert<HTMLInputElement>(agreementSections[0], 'input').disabled
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <main>
+          <h1 class="heading-1">New Contributor Agreement</h1>
+          <h3 class="heading-3">Select an agreement type:</h3>
+          <span class="contributorAgreementButton">
+            <input
+              data-name="Individual"
+              data-url="static/cla_individual.html"
+              id="claNewAgreementsInputIndividual"
+              name="claNewAgreementsRadio"
+              type="radio"
+            />
+            <label id="claNewAgreementsLabel"> Individual </label>
+          </span>
+          <div class="agreementsUrl">test-description</div>
+          <span class="contributorAgreementButton">
+            <input
+              data-name="CLA"
+              data-url="static/cla.html"
+              disabled=""
+              id="claNewAgreementsInputCLA"
+              name="claNewAgreementsRadio"
+              type="radio"
+            />
+            <label id="claNewAgreementsLabel"> CLA </label>
+          </span>
+          <div class="alreadySubmittedText">Agreement already submitted.</div>
+          <div class="agreementsUrl">Contributor License Agreement</div>
+        </main>
+      `
     );
-    assert.isOk(agreementSubmittedTexts[0]);
-    assert.isTrue(
-      queryAndAssert<HTMLInputElement>(agreementSections[1], 'input').disabled
-    );
-    assert.isNotOk(agreementSubmittedTexts[1]);
   });
 
   test('disableAgreements', () => {
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 0f7e065..f554ff0 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
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
@@ -24,7 +12,7 @@
 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 {customElement, query, state} from 'lit/decorators.js';
 import {convertToString} from '../../../utils/string-util';
 import {subscribe} from '../../lit/subscription-controller';
 
@@ -60,12 +48,16 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.userModel.editPreferences$, editPreferences => {
-      this.originalEditPrefs = editPreferences;
-      this.editPrefs = {...editPreferences};
-    });
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.editPreferences$,
+      editPreferences => {
+        this.originalEditPrefs = editPreferences;
+        this.editPrefs = {...editPreferences};
+      }
+    );
   }
 
   static override get styles() {
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 894aae2..bd682b8 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
@@ -1,29 +1,16 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-edit-preferences';
 import {queryAll, stubRestApi} from '../../../test/test-utils';
 import {GrEditPreferences} from './gr-edit-preferences';
 import {EditPreferencesInfo, ParsedJSON} from '../../../types/common';
 import {IronInputElement} from '@polymer/iron-input';
 import {createDefaultEditPrefs} from '../../../constants/constants';
-
-const basicFixture = fixtureFromElement('gr-edit-preferences');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-edit-preferences tests', () => {
   let element: GrEditPreferences;
@@ -47,12 +34,116 @@
 
     stubRestApi('getEditPreferences').returns(Promise.resolve(editPreferences));
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-edit-preferences></gr-edit-preferences>`);
 
     await element.updateComplete;
   });
 
   test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <h2 id="EditPreferences">Edit Preferences</h2>
+        <fieldset id="editPreferences">
+          <div class="gr-form-styles" id="editPreferences">
+            <section>
+              <label class="title" for="editTabWidth"> Tab width </label>
+              <span class="value">
+                <iron-input>
+                  <input id="editTabWidth" type="number" />
+                </iron-input>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="editColumns"> Columns </label>
+              <span class="value">
+                <iron-input>
+                  <input id="editColumns" type="number" />
+                </iron-input>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="editIndentUnit"> Indent unit </label>
+              <span class="value">
+                <iron-input>
+                  <input id="editIndentUnit" type="number" />
+                </iron-input>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="editSyntaxHighlighting">
+                Syntax highlighting
+              </label>
+              <span class="value">
+                <input checked="" id="editSyntaxHighlighting" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="editShowTabs"> Show tabs </label>
+              <span class="value">
+                <input checked="" id="editShowTabs" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showTrailingWhitespaceInput">
+                Show trailing whitespace
+              </label>
+              <span class="value">
+                <input
+                  checked=""
+                  id="editShowTrailingWhitespaceInput"
+                  type="checkbox"
+                />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showMatchBrackets">
+                Match brackets
+              </label>
+              <span class="value">
+                <input checked="" id="showMatchBrackets" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="editShowLineWrapping">
+                Line wrapping
+              </label>
+              <span class="value">
+                <input id="editShowLineWrapping" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showIndentWithTabs">
+                Indent with tabs
+              </label>
+              <span class="value">
+                <input id="showIndentWithTabs" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showAutoCloseBrackets">
+                Auto close brackets
+              </label>
+              <span class="value">
+                <input id="showAutoCloseBrackets" type="checkbox" />
+              </span>
+            </section>
+          </div>
+          <gr-button
+            aria-disabled="true"
+            disabled=""
+            id="saveEditPrefs"
+            role="button"
+            tabindex="-1"
+          >
+            Save changes
+          </gr-button>
+        </fieldset>
+      `
+    );
+  });
+
+  test('input values match preferences', () => {
     // Rendered with the expected preferences selected.
     const tabWidthInput = valueOf('Tab width', 'editPreferences')
       .firstElementChild as IronInputElement;
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 40c0690..b9f59bf 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
@@ -1,25 +1,14 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
 import {EmailInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {ValueChangedEvent} from '../../../types/events';
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 8ac101d..25c9b97 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
@@ -1,25 +1,13 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-email-editor';
 import {GrEmailEditor} from './gr-email-editor';
 import {spyRestApi, stubRestApi} from '../../../test/test-utils';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-email-editor tests', () => {
   let element: GrEmailEditor;
@@ -42,93 +30,96 @@
   });
 
   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>`);
+    assert.shadowDom.equal(
+      element,
+      /* 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', () => {
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 97804946..b2f8acd 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
@@ -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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-button/gr-button';
@@ -24,7 +13,7 @@
 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 {customElement, property, query, state} from 'lit/decorators.js';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {assertIsDefined} from '../../../utils/common-util';
@@ -216,7 +205,7 @@
   }
 
   private handleNewKeyChanged(e: BindValueChangeEvent) {
-    this.newKey = e.detail.value;
+    this.newKey = e.detail.value ?? '';
   }
 
   private handleDeleteKey(index: number) {
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
index 0f775b80..8e653fc 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-gpg-editor';
 import {
   mockPromise,
@@ -31,8 +19,7 @@
   OpenPgpUserIds,
 } from '../../../api/rest-api';
 import {GrButton} from '../../shared/gr-button/gr-button';
-
-const basicFixture = fixtureFromElement('gr-gpg-editor');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-gpg-editor tests', () => {
   let element: GrGpgEditor;
@@ -66,141 +53,149 @@
 
     stubRestApi('getAccountGPGKeys').returns(Promise.resolve(keys));
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-gpg-editor></gr-gpg-editor>`);
 
     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"
+    assert.shadowDom.equal(
+      element,
+      /* 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=""
           >
-            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
+            <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"
-              autocomplete="on"
-              id="newKey"
-              placeholder="New GPG Key"
+              class="closeButton"
+              role="button"
+              tabindex="0"
             >
-            </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> `);
+              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', () => {
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 2b8a1e9..68a2293 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
@@ -1,27 +1,15 @@
 /**
  * @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 {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GroupInfo, GroupId} from '../../../types/common';
 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';
-import {customElement, state} from 'lit/decorators';
+import {customElement, state} from 'lit/decorators.js';
+import {createGroupUrl} from '../../../models/views/group';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -76,7 +64,7 @@
         </thead>
         <tbody>
           ${(this._groups ?? []).map(group => {
-            const href = this._computeGroupPath(group);
+            const href = this._computeGroupPath(group) ?? '';
             return html`
               <tr>
                 <td class="nameColumn">
@@ -94,13 +82,12 @@
     </div>`;
   }
 
-  _computeGroupPath(group: GroupInfo) {
-    if (!group || !group.id) {
-      return;
-    }
+  _computeGroupPath(group?: GroupInfo) {
+    if (!group?.id) return;
 
     // Group ID is already encoded from the API
     // Decode it here to match with our router encoding behavior
-    return GerritNav.getUrlForGroup(decodeURIComponent(group.id) as GroupId);
+    const decodedGroupId = decodeURIComponent(group.id) as GroupId;
+    return createGroupUrl({groupId: decodedGroupId});
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.ts
index a1534af..08ce11c 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.ts
@@ -1,28 +1,14 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-group-list';
 import {GrGroupList} from './gr-group-list';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {queryAll, stubRestApi} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-group-list');
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-group-list tests', () => {
   let element: GrGroupList;
@@ -56,52 +42,51 @@
 
     stubRestApi('getAccountGroups').returns(Promise.resolve(groups));
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-group-list></gr-group-list>`);
 
     await element.loadData();
-    await flush();
+    await waitEventLoop();
   });
 
-  test('renders', async () => {
-    await flush();
-
-    const rows = Array.from(queryAll(element, 'tbody tr'));
-
-    assert.equal(rows.length, 3);
-
-    const nameCells = rows.map(row =>
-      queryAll(row, 'td a')[0].textContent!.trim()
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <table id="groups">
+            <thead>
+              <tr>
+                <th class="nameHeader">Name</th>
+                <th class="descriptionHeader">Description</th>
+                <th class="visibleCell">Visible to all</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td class="nameColumn">
+                  <a href="/admin/groups/abc"> Group 1 </a>
+                </td>
+                <td>Group 1 description</td>
+                <td class="visibleCell">No</td>
+              </tr>
+              <tr>
+                <td class="nameColumn">
+                  <a href="/admin/groups/456"> Group 2 </a>
+                </td>
+                <td></td>
+                <td class="visibleCell">Yes</td>
+              </tr>
+              <tr>
+                <td class="nameColumn">
+                  <a href="/admin/groups/789"> Group 3 </a>
+                </td>
+                <td></td>
+                <td class="visibleCell">No</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      `
     );
-
-    assert.equal(nameCells[0], 'Group 1');
-    assert.equal(nameCells[1], 'Group 2');
-    assert.equal(nameCells[2], 'Group 3');
-  });
-
-  test('_computeGroupPath', () => {
-    let urlStub = sinon
-      .stub(GerritNav, 'getUrlForGroup')
-      .callsFake(
-        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5'
-      );
-
-    let group = {
-      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5' as GroupId,
-    };
-    assert.equal(
-      element._computeGroupPath(group),
-      '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5'
-    );
-
-    urlStub.restore();
-
-    urlStub = sinon
-      .stub(GerritNav, 'getUrlForGroup')
-      .callsFake(() => '/admin/groups/user/test');
-
-    group = {
-      id: 'user%2Ftest' as GroupId,
-    };
-    assert.equal(element._computeGroupPath(group), '/admin/groups/user/test');
   });
 });
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 73fc55f..9595391 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
@@ -1,18 +1,7 @@
 /**
  * @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 '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
@@ -22,7 +11,7 @@
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
index eab8d2e..116d349 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
@@ -1,25 +1,12 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-http-password';
 import {GrHttpPassword} from './gr-http-password';
-import {stubRestApi} from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
 import {
   createAccountDetailWithId,
   createServerInfo,
@@ -27,8 +14,7 @@
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../../shared/gr-button/gr-button';
-
-const basicFixture = fixtureFromElement('gr-http-password');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-http-password tests', () => {
   let element: GrHttpPassword;
@@ -42,9 +28,71 @@
     stubRestApi('getAccount').returns(Promise.resolve(account));
     stubRestApi('getConfig').returns(Promise.resolve(config));
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-http-password></gr-http-password>`);
     await element.loadData();
-    await flush();
+    await waitEventLoop();
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <div>
+            <section>
+              <span class="title"> Username </span>
+              <span class="value"> user name </span>
+            </section>
+            <gr-button
+              aria-disabled="false"
+              id="generateButton"
+              role="button"
+              tabindex="0"
+            >
+              Generate new password
+            </gr-button>
+          </div>
+          <span hidden="">
+            <a href="" rel="noopener" target="_blank"> Obtain password </a>
+            (opens in a new tab)
+          </span>
+        </div>
+        <gr-overlay
+          aria-hidden="true"
+          id="generatedPasswordOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <div class="gr-form-styles">
+            <section id="generatedPasswordDisplay">
+              <span class="title"> New Password: </span>
+              <span class="value"> </span>
+              <gr-copy-clipboard
+                buttontitle="Copy password to clipboard"
+                hastooltip=""
+                hideinput=""
+              >
+              </gr-copy-clipboard>
+            </section>
+            <section id="passwordWarning">
+              This password will not be displayed again.
+              <br />
+              If you lose it, you will need to generate a new one.
+            </section>
+            <gr-button
+              aria-disabled="false"
+              class="closeButton"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              Close
+            </gr-button>
+          </div>
+        </gr-overlay>
+      `
+    );
   });
 
   test('generate password', () => {
@@ -60,7 +108,7 @@
 
     assert.isNotOk(element._generatedPassword);
 
-    MockInteractions.tap(button);
+    button.click();
 
     assert.isTrue(generateStub.called);
     assert.equal(element._generatedPassword, 'Generating...');
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 d8a7579..4f5411d 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.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 '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import '../../shared/gr-button/gr-button';
@@ -23,10 +12,10 @@
 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 {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
-import {classMap} from 'lit/directives/class-map';
+import {classMap} from 'lit/directives/class-map.js';
 import {when} from 'lit/directives/when.js';
 import {assertIsDefined} from '../../../utils/common-util';
 
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 8ee84bf..84df178 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-identities';
 import {GrIdentities} from './gr-identities';
 import {AuthType} from '../../../constants/constants';
@@ -24,7 +12,7 @@
 import {createServerInfo} from '../../../test/test-data-generators';
 import {queryAll, queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-identities tests', () => {
   let element: GrIdentities;
@@ -58,64 +46,70 @@
   });
 
   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>`);
+    assert.shadowDom.equal(
+      element,
+      /* 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', () => {
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 845b30c..460cc7c 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
@@ -9,14 +9,14 @@
 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 {state, customElement} from 'lit/decorators.js';
 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 {classMap} from 'lit/directives/class-map.js';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 
 @customElement('gr-menu-editor')
@@ -35,12 +35,16 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.userModel.preferences$, prefs => {
-      this.originalPrefs = prefs;
-      this.menuItems = [...prefs.my];
-    });
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
+        this.originalPrefs = prefs;
+        this.menuItems = [...prefs.my];
+      }
+    );
   }
 
   static override styles = [
@@ -227,7 +231,7 @@
   }
 
   private handleInputKeydown(e: KeyboardEvent) {
-    if (e.keyCode === 13) {
+    if (e.key === 'Enter') {
       e.stopPropagation();
       this.handleAddButton();
     }
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
index c6130df..a8ad17c 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
@@ -3,15 +3,14 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {fixture, html, assert} from '@open-wc/testing';
 import {createDefaultPreferences} from '../../../constants/constants';
 
 suite('gr-menu-editor tests', () => {
@@ -38,7 +37,7 @@
       query<HTMLElement>(query<HTMLTableElement>(element, 'tbody'), selector),
       'paper-button'
     );
-    MockInteractions.tap(button!);
+    button!.click();
   }
 
   setup(async () => {
@@ -56,189 +55,192 @@
   });
 
   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>
-    `);
+    assert.shadowDom.equal(
+      element,
+      /* 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 () => {
@@ -331,29 +333,25 @@
     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'
-      )!
-    );
+    query<PaperButtonElement>(
+      query<HTMLElement>(
+        query<HTMLTableElement>(element, 'tbody'),
+        'tr:nth-child(2) .remove-button'
+      ),
+      'paper-button'
+    )!.click();
 
     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'
-        )!
-      );
+      query<PaperButtonElement>(
+        query<HTMLElement>(
+          query<HTMLTableElement>(element, 'tbody'),
+          'tr:first-child .remove-button'
+        ),
+        'paper-button'
+      )!.click();
     }
     assertMenuNamesEqual(element, []);
 
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 6305639..a20c0ee 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
@@ -1,18 +1,7 @@
 /**
  * @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 '../../../styles/gr-form-styles';
@@ -22,11 +11,11 @@
 import {getAppContext} from '../../../services/app-context';
 import {fireEvent} from '../../../utils/event-util';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 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 {when} from 'lit/directives/when.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
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 4118a20..7f0f85d 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
@@ -1,20 +1,9 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-registration-dialog';
 import {GrRegistrationDialog} from './gr-registration-dialog';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
@@ -24,7 +13,7 @@
   createAccountWithId,
   createServerInfo,
 } from '../../../test/test-data-generators';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../../shared/gr-button/gr-button';
 
 suite('gr-registration-dialog tests', () => {
@@ -113,7 +102,9 @@
 
   test('renders', () => {
     // cannot format with /* HTML */, because it breaks test
-    expect(element).shadowDom.to.equal(/* HTML*/ `<div
+    assert.shadowDom.equal(
+      element,
+      /* HTML*/ `<div
       class="container gr-form-styles"
     >
       <header>Please confirm your contact information</header>
@@ -175,7 +166,8 @@
           Save
         </gr-button>
       </footer>
-    </div>`);
+    </div>`
+    );
   });
 
   test('fires the close event on close', async () => {
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 e132128..ea65542 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
@@ -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 {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {formStyles} from '../../../styles/gr-form-styles';
 
 declare global {
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 18c8bea..6c83bea 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
@@ -1,23 +1,12 @@
 /**
  * @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 {pageNavStyles} from '../../../styles/gr-page-nav-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
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 9c448d9..ff2904a 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
@@ -1,18 +1,7 @@
 /**
  * @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 '@polymer/paper-toggle-button/paper-toggle-button';
@@ -34,45 +23,46 @@
 import '../gr-ssh-editor/gr-ssh-editor';
 import '../gr-watched-projects-editor/gr-watched-projects-editor';
 import {getDocsBaseUrl} from '../../../utils/url-util';
-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 {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
-import {PreferencesInput, ServerInfo} from '../../../types/common';
+import {
+  AccountDetailInfo,
+  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 {getAppContext} from '../../../services/app-context';
-import {GerritView} from '../../../services/router/router-model';
 import {
+  ColumnNames,
   DateFormat,
   DefaultBase,
   DiffViewMode,
   EmailFormat,
   EmailStrategy,
+  AppTheme,
   TimeFormat,
 } from '../../../constants/constants';
-import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
-import {windowLocationReload} from '../../../utils/dom-util';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
-import {LitElement, css, html} from 'lit';
-import {
-  customElement,
-  property,
-  query,
-  queryAsync,
-  state,
-} from 'lit/decorators';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, query, queryAsync, state} from 'lit/decorators.js';
 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 {when} from 'lit/directives/when.js';
 import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {settingsViewModelToken} from '../../../models/views/settings';
+import {areNotificationsEnabled} from '../../../utils/worker-util';
 
 const GERRIT_DOCS_BASE_URL =
   'https://gerrit-review.googlesource.com/' + 'Documentation';
@@ -126,6 +116,9 @@
 
   @query('#publishCommentsOnPush') publishCommentsOnPush!: HTMLInputElement;
 
+  @query('#allowBrowserNotifications')
+  allowBrowserNotifications?: HTMLInputElement;
+
   @query('#disableKeyboardShortcuts')
   disableKeyboardShortcuts!: HTMLInputElement;
 
@@ -151,13 +144,14 @@
 
   @query('#diffViewSelect') diffViewSelect!: HTMLInputElement;
 
-  @state() prefs: PreferencesInput = {};
+  @query('#themeSelect') themeSelect!: HTMLInputElement;
 
-  @property({type: Object}) params?: AppElementParams;
+  @state() prefs: PreferencesInput = {};
 
   @state() private accountInfoChanged = false;
 
-  @state() private localPrefs: PreferencesInput = {};
+  // private but used in test
+  @state() localPrefs: PreferencesInput = {};
 
   // private but used in test
   @state() localChangeTableColumns: string[] = [];
@@ -195,51 +189,87 @@
   @state() private emailsChanged = false;
 
   // private but used in test
-  @state() showNumber?: boolean;
+  @state() emailToken?: string;
 
   // private but used in test
-  @state() isDark = false;
+  @state() showNumber?: boolean;
+
+  @state() account?: AccountDetailInfo;
 
   // private but used in test
   public _testOnly_loadingPromise?: Promise<void>;
 
   private readonly restApiService = getAppContext().restApiService;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    // Polymer 2: anchor tag won't work on shadow DOM
-    // we need to manually calling scrollIntoView when hash changed
-    window.addEventListener('location-change', this.handleLocationChange);
-    fireTitleChange(this, 'Settings');
-  }
+  private readonly userModel = getAppContext().userModel;
 
-  override firstUpdated() {
-    this.isDark = !!window.localStorage.getItem('dark-theme');
+  // private but used in test
+  readonly flagsService = getAppContext().flagsService;
 
-    const promises: Array<Promise<unknown>> = [
-      this.accountInfo.loadData(),
-      this.watchedProjectsEditor.loadData(),
-      this.groupList.loadData(),
-      this.identities.loadData(),
-    ];
+  private readonly getViewModel = resolve(this, settingsViewModelToken);
 
-    // TODO(dhruvsri): move this to the service
-    promises.push(
-      this.restApiService.getPreferences().then(prefs => {
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getViewModel().emailToken$,
+      x => {
+        this.emailToken = x;
+        this.confirmEmail();
+      }
+    );
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      acc => {
+        this.account = acc;
+      }
+    );
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
         if (!prefs) {
           throw new Error('getPreferences returned undefined');
         }
         this.prefs = prefs;
         this.showNumber = !!prefs.legacycid_in_change_table;
         this.copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
+        this.prefsChanged = false;
         this.localChangeTableColumns =
           prefs.change_table.length === 0
-            ? columnNames
+            ? Object.values(ColumnNames)
             : prefs.change_table.map(column =>
                 column === 'Project' ? 'Repo' : column
               );
-      })
+      }
     );
+  }
+
+  // private, but used in tests
+  async confirmEmail() {
+    if (!this.emailToken) return;
+    const message = await this.restApiService.confirmEmail(this.emailToken);
+    if (message) fireAlert(this, message);
+    this.getViewModel().clearToken();
+    await this.emailEditor.loadData();
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    // Polymer 2: anchor tag won't work on shadow DOM
+    // we need to manually calling scrollIntoView when hash changed
+    document.addEventListener('location-change', this.handleLocationChange);
+    fireTitleChange(this, 'Settings');
+  }
+
+  override firstUpdated() {
+    const promises: Array<Promise<unknown>> = [
+      this.accountInfo.loadData(),
+      this.watchedProjectsEditor.loadData(),
+      this.groupList.loadData(),
+      this.identities.loadData(),
+    ];
 
     promises.push(
       this.restApiService.getConfig().then(config => {
@@ -268,24 +298,7 @@
       })
     );
 
-    if (
-      this.params &&
-      this.params.view === GerritView.SETTINGS &&
-      this.params.emailToken
-    ) {
-      promises.push(
-        this.restApiService
-          .confirmEmail(this.params.emailToken)
-          .then(message => {
-            if (message) {
-              fireAlert(this, message);
-            }
-            this.emailEditor.loadData();
-          })
-      );
-    } else {
-      promises.push(this.emailEditor.loadData());
-    }
+    promises.push(this.emailEditor.loadData());
 
     this._testOnly_loadingPromise = Promise.all(promises).then(() => {
       this.loading = false;
@@ -318,11 +331,7 @@
       #email {
         margin-bottom: var(--spacing-l);
       }
-      .main section.darkToggle {
-        display: block;
-      }
-      .filters p,
-      .darkToggle p {
+      .filters p {
         margin-bottom: var(--spacing-l);
       }
       .queryExample em {
@@ -377,18 +386,6 @@
         </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)}
@@ -418,279 +415,17 @@
             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>
+            ${this.renderTheme()} ${this.renderChangesPerPages()}
+            ${this.renderDateTimeFormat()} ${this.renderEmailNotification()}
+            ${this.renderEmailFormat()} ${this.renderBrowserNotifications()}
+            ${this.renderDefaultBaseForMerges()}
+            ${this.renderRelativeDateInChangeTable()} ${this.renderDiffView()}
+            ${this.renderShowSizeBarsInFileList()}
+            ${this.renderPublishCommentsOnPush()}
+            ${this.renderWorkInProgressByDefault()}
+            ${this.renderDisableKeyboardShortcuts()}
+            ${this.renderDisableTokenHighlighting()}
+            ${this.renderInsertSignedOff()}
             <gr-button
               id="savePrefs"
               @click=${this.handleSavePreferences}
@@ -737,7 +472,6 @@
                 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;
@@ -996,10 +730,394 @@
   }
 
   override disconnectedCallback() {
-    window.removeEventListener('location-change', this.handleLocationChange);
+    document.removeEventListener('location-change', this.handleLocationChange);
     super.disconnectedCallback();
   }
 
+  private mapTheme(theme: AppTheme) {
+    if (this.flagsService.isEnabled(KnownExperimentId.AUTO_APP_THEME)) {
+      return theme;
+    }
+    if (theme === AppTheme.AUTO) return AppTheme.LIGHT;
+    return theme;
+  }
+
+  private renderTheme() {
+    return html`
+      <section>
+        <label class="title" for="themeSelect">Theme</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.mapTheme(this.localPrefs.theme ?? AppTheme.AUTO)}
+            @change=${() => {
+              this.localPrefs.theme = this.themeSelect.value as AppTheme;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="themeSelect">
+              ${when(
+                this.flagsService.isEnabled(KnownExperimentId.AUTO_APP_THEME),
+                () =>
+                  html`<option value="AUTO">Auto (based on OS prefs)</option>`
+              )}
+              <option value="LIGHT">Light</option>
+              <option value="DARK">Dark</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderChangesPerPages() {
+    return html`
+      <section>
+        <label class="title" for="changesPerPageSelect">Changes per page</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.changes_per_page)}
+            @change=${() => {
+              this.localPrefs.changes_per_page = Number(
+                this.changesPerPageSelect.value
+              ) as 10 | 25 | 50 | 100;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="changesPerPageSelect">
+              <option value="10">10 rows per page</option>
+              <option value="25">25 rows per page</option>
+              <option value="50">50 rows per page</option>
+              <option value="100">100 rows per page</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDateTimeFormat() {
+    return html`
+      <section>
+        <label class="title" for="dateTimeFormatSelect">Date/time format</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.date_format)}
+            @change=${() => {
+              this.localPrefs.date_format = this.dateTimeFormatSelect
+                .value as DateFormat;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="dateTimeFormatSelect">
+              <option value="STD">Jun 3 ; Jun 3, 2016</option>
+              <option value="US">06/03 ; 06/03/16</option>
+              <option value="ISO">06-03 ; 2016-06-03</option>
+              <option value="EURO">3. Jun ; 03.06.2016</option>
+              <option value="UK">03/06 ; 03/06/2016</option>
+            </select>
+          </gr-select>
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.time_format)}
+            aria-label="Time Format"
+            @change=${() => {
+              this.localPrefs.time_format = this.timeFormatSelect
+                .value as TimeFormat;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="timeFormatSelect">
+              <option value="HHMM_12">4:10 PM</option>
+              <option value="HHMM_24">16:10</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEmailNotification() {
+    return html`
+      <section>
+        <label class="title" for="emailNotificationsSelect"
+          >Email notifications</label
+        >
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.email_strategy)}
+            @change=${() => {
+              this.localPrefs.email_strategy = this.emailNotificationsSelect
+                .value as EmailStrategy;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="emailNotificationsSelect">
+              <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+              <option value="ENABLED">Only comments left by others</option>
+              <option value="ATTENTION_SET_ONLY">
+                Only when I am in the attention set
+              </option>
+              <option value="DISABLED">None</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEmailFormat() {
+    if (!this.localPrefs.email_format) return nothing;
+    return html`
+      <section>
+        <label class="title" for="emailFormatSelect">Email format</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.email_format)}
+            @change=${() => {
+              this.localPrefs.email_format = this.emailFormatSelect
+                .value as EmailFormat;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="emailFormatSelect">
+              <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+              <option value="PLAINTEXT">Plaintext only</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderBrowserNotifications() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS))
+      return nothing;
+    if (!areNotificationsEnabled(this.account)) return nothing;
+    return html`
+      <section id="allowBrowserNotificationsSection">
+        <label class="title" for="allowBrowserNotifications"
+          >Allow browser notifications</label
+        >
+        <span class="value">
+          <input
+            id="allowBrowserNotifications"
+            type="checkbox"
+            ?checked=${this.localPrefs.allow_browser_notifications}
+            @change=${() => {
+              this.localPrefs.allow_browser_notifications =
+                this.allowBrowserNotifications!.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDefaultBaseForMerges() {
+    if (!this.localPrefs.default_base_for_merges) return nothing;
+    return nothing;
+    // TODO: Re-enable respecting the default_base_for_merges preference.
+    // See corresponding TODO in change-model.
+    // return html`
+    //   <section>
+    //     <span class="title">Default Base For Merges</span>
+    //     <span class="value">
+    //       <gr-select
+    //         .bindValue=${this.convertToString(
+    //           this.localPrefs.default_base_for_merges
+    //         )}
+    //         @change=${() => {
+    //           this.localPrefs.default_base_for_merges = this
+    //             .defaultBaseForMergesSelect.value as DefaultBase;
+    //           this.prefsChanged = true;
+    //         }}
+    //       >
+    //         <select id="defaultBaseForMergesSelect">
+    //           <option value="AUTO_MERGE">Auto Merge</option>
+    //           <option value="FIRST_PARENT">First Parent</option>
+    //         </select>
+    //       </gr-select>
+    //     </span>
+    //   </section>
+    // `;
+  }
+
+  private renderRelativeDateInChangeTable() {
+    return html`
+      <section>
+        <label class="title" for="relativeDateInChangeTable"
+          >Show Relative Dates In Changes Table</label
+        >
+        <span class="value">
+          <input
+            id="relativeDateInChangeTable"
+            type="checkbox"
+            ?checked=${this.localPrefs.relative_date_in_change_table}
+            @change=${() => {
+              this.localPrefs.relative_date_in_change_table =
+                this.relativeDateInChangeTable.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDiffView() {
+    return html`
+      <section>
+        <span class="title">Diff view</span>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.diff_view)}
+            @change=${() => {
+              this.localPrefs.diff_view = this.diffViewSelect
+                .value as DiffViewMode;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="diffViewSelect">
+              <option value="SIDE_BY_SIDE">Side by side</option>
+              <option value="UNIFIED_DIFF">Unified diff</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderShowSizeBarsInFileList() {
+    return html`
+      <section>
+        <label for="showSizeBarsInFileList" class="title"
+          >Show size bars in file list</label
+        >
+        <span class="value">
+          <input
+            id="showSizeBarsInFileList"
+            type="checkbox"
+            ?checked=${this.localPrefs.size_bar_in_change_table}
+            @change=${() => {
+              this.localPrefs.size_bar_in_change_table =
+                this.showSizeBarsInFileList.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderPublishCommentsOnPush() {
+    return html`
+      <section>
+        <label for="publishCommentsOnPush" class="title"
+          >Publish comments on push</label
+        >
+        <span class="value">
+          <input
+            id="publishCommentsOnPush"
+            type="checkbox"
+            ?checked=${this.localPrefs.publish_comments_on_push}
+            @change=${() => {
+              this.localPrefs.publish_comments_on_push =
+                this.publishCommentsOnPush.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderWorkInProgressByDefault() {
+    return html`
+      <section>
+        <label for="workInProgressByDefault" class="title"
+          >Set new changes to "work in progress" by default</label
+        >
+        <span class="value">
+          <input
+            id="workInProgressByDefault"
+            type="checkbox"
+            ?checked=${this.localPrefs.work_in_progress_by_default}
+            @change=${() => {
+              this.localPrefs.work_in_progress_by_default =
+                this.workInProgressByDefault.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDisableKeyboardShortcuts() {
+    return html`
+      <section>
+        <label for="disableKeyboardShortcuts" class="title"
+          >Disable all keyboard shortcuts</label
+        >
+        <span class="value">
+          <input
+            id="disableKeyboardShortcuts"
+            type="checkbox"
+            ?checked=${this.localPrefs.disable_keyboard_shortcuts}
+            @change=${() => {
+              this.localPrefs.disable_keyboard_shortcuts =
+                this.disableKeyboardShortcuts.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDisableTokenHighlighting() {
+    return html`
+      <section>
+        <label for="disableTokenHighlighting" class="title"
+          >Disable token highlighting on hover</label
+        >
+        <span class="value">
+          <input
+            id="disableTokenHighlighting"
+            type="checkbox"
+            ?checked=${this.localPrefs.disable_token_highlighting}
+            @change=${() => {
+              this.localPrefs.disable_token_highlighting =
+                this.disableTokenHighlighting.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderInsertSignedOff() {
+    return html`
+      <section>
+        <label for="insertSignedOff" class="title">
+          Insert Signed-off-by Footer For Inline Edit Changes
+        </label>
+        <span class="value">
+          <input
+            id="insertSignedOff"
+            type="checkbox"
+            ?checked=${this.localPrefs.signed_off_by}
+            @change=${() => {
+              this.localPrefs.signed_off_by = this.insertSignedOff.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
   private readonly handleLocationChange = () => {
     // Handle anchor tag after dom attached
     const urlHash = window.location.hash;
@@ -1030,11 +1148,7 @@
 
   // private but used in test
   handleSavePreferences() {
-    this.copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
-
-    return this.restApiService.savePreferences(this.prefs).then(() => {
-      this.prefsChanged = false;
-    });
+    return this.userModel.updatePreferences(this.localPrefs);
   }
 
   // private but used in test
@@ -1052,8 +1166,7 @@
 
   // private but used in test
   handleNewEmailKeydown(e: KeyboardEvent) {
-    if (e.keyCode === 13) {
-      // Enter
+    if (e.key === 'Enter') {
       e.stopPropagation();
       this.handleAddEmailButton();
     }
@@ -1100,21 +1213,6 @@
     return base + GERRIT_DOCS_FILTER_PATH;
   }
 
-  private handleToggleDark() {
-    if (this.isDark) {
-      window.localStorage.removeItem('dark-theme');
-    } else {
-      window.localStorage.setItem('dark-theme', 'true');
-    }
-    this.reloadPage();
-  }
-
-  // private but used in test
-  reloadPage() {
-    fireAlert(this, 'Reloading...');
-    windowLocationReload();
-  }
-
   // private but used in test
   showHttpAuth() {
     if (this.serverConfig?.auth?.git_basic_auth_policy) {
@@ -1127,13 +1225,6 @@
   }
 
   /**
-   * Work around a issue on iOS when clicking turns into double tap
-   */
-  private onTapDarkToggle(e: Event) {
-    e.preventDefault();
-  }
-
-  /**
    * bind-value has type string so we have to convert anything inputed
    * to string.
    *
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 6a8f575..0798991 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
@@ -1,25 +1,18 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-settings-view';
 import {GrSettingsView} from './gr-settings-view';
-import {GerritView} from '../../../services/router/router-model';
-import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  queryAll,
+  queryAndAssert,
+  stubFlags,
+  stubRestApi,
+  waitEventLoop,
+} from '../../../test/test-utils';
 import {
   AuthInfo,
   AccountDetailInfo,
@@ -35,19 +28,17 @@
   DiffViewMode,
   EmailFormat,
   EmailStrategy,
+  AppTheme,
   TimeFormat,
 } from '../../../constants/constants';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {
   createAccountDetailWithId,
   createPreferences,
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {GrSelect} from '../../shared/gr-select/gr-select';
-import {AppElementSettingsParam} from '../../gr-app-types';
-
-const basicFixture = fixtureFromElement('gr-settings-view');
-const blankFixture = fixtureFromElement('div');
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
 
 suite('gr-settings-view tests', () => {
   let element: GrSettingsView;
@@ -101,6 +92,7 @@
     preferences = {
       ...createPreferences(),
       changes_per_page: 25,
+      theme: AppTheme.LIGHT,
       date_format: DateFormat.UK,
       time_format: TimeFormat.HHMM_12,
       diff_view: DiffViewMode.UNIFIED,
@@ -121,8 +113,7 @@
     stubRestApi('getPreferences').returns(Promise.resolve(preferences));
     stubRestApi('getAccountEmails').returns(Promise.resolve(undefined));
     stubRestApi('getConfig').returns(Promise.resolve(config));
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-settings-view></gr-settings-view>`);
 
     // Allow the element to render.
     if (element._testOnly_loadingPromise)
@@ -137,7 +128,9 @@
     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
+    assert.shadowDom.equal(
+      element,
+      /* HTML*/ `<div
         class="loading"
         hidden=""
       >
@@ -163,24 +156,6 @@
         </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>
@@ -196,6 +171,19 @@
           <h2 id="Preferences">Preferences</h2>
           <fieldset id="preferences">
             <section>
+              <label class="title" for="themeSelect">
+                Theme
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="themeSelect">
+                    <option value="LIGHT">Light</option>
+                    <option value="DARK">Dark</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
               <label class="title" for="changesPerPageSelect">
                 Changes per page
               </label>
@@ -265,17 +253,6 @@
               </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>
@@ -535,40 +512,39 @@
           <gr-endpoint-decorator name="settings-screen">
           </gr-endpoint-decorator>
         </div>
-      </div>`);
-  });
-
-  test('theme changing', async () => {
-    const reloadStub = sinon.stub(element, 'reloadPage');
-
-    window.localStorage.removeItem('dark-theme');
-    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
-    const themeToggle = queryAndAssert(
-      element,
-      '.darkToggle paper-toggle-button'
+      </div>`
     );
-    MockInteractions.tap(themeToggle);
-    assert.isTrue(window.localStorage.getItem('dark-theme') === 'true');
-    assert.isTrue(reloadStub.calledOnce);
-
-    element.isDark = true;
-    await flush();
-    MockInteractions.tap(themeToggle);
-    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
-    assert.isTrue(reloadStub.calledTwice);
   });
 
-  test('calls the title-change event', () => {
+  test('allow browser notifications', async () => {
+    stubFlags('isEnabled').returns(true);
+    element = await fixture(html`<gr-settings-view></gr-settings-view>`);
+    element.account = createAccountDetailWithId();
+    await element.updateComplete;
+    assert.dom.equal(
+      queryAndAssert(element, '#allowBrowserNotificationsSection'),
+      /* HTML */ `<section id="allowBrowserNotificationsSection">
+        <label class="title" for="allowBrowserNotifications">
+          Allow browser notifications
+        </label>
+        <span class="value">
+          <input checked="" id="allowBrowserNotifications" type="checkbox" />
+        </span>
+      </section>`
+    );
+  });
+
+  test('calls the title-change event', async () => {
     const titleChangedStub = sinon.stub();
 
     // Create a new view.
     const newElement = document.createElement('gr-settings-view');
     newElement.addEventListener('title-change', titleChangedStub);
 
-    const blank = blankFixture.instantiate();
-    blank.appendChild(newElement);
+    const div = await fixture(html`<div></div>`);
+    div.appendChild(newElement);
 
-    flush();
+    await waitEventLoop();
 
     assert.isTrue(titleChangedStub.called);
     assert.equal(titleChangedStub.getCall(0).args[0].detail.title, 'Settings');
@@ -586,6 +562,10 @@
       preferences.changes_per_page
     );
     assert.equal(
+      (valueOf('Theme', 'preferences').firstElementChild as GrSelect).bindValue,
+      preferences.theme
+    );
+    assert.equal(
       (
         valueOf('Date/time format', 'preferences')!
           .firstElementChild as GrSelect
@@ -611,13 +591,6 @@
     );
     assert.equal(
       (
-        valueOf('Default Base For Merges', 'preferences')!
-          .firstElementChild as GrSelect
-      ).bindValue,
-      preferences.default_base_for_merges
-    );
-    assert.equal(
-      (
         valueOf('Show Relative Dates In Changes Table', 'preferences')!
           .firstElementChild as HTMLInputElement
       ).checked,
@@ -670,16 +643,28 @@
 
     assert.isFalse(element.prefsChanged);
 
-    const publishOnPush = valueOf('Publish comments on push', 'preferences')!
-      .firstElementChild!;
+    const themeSelect = valueOf('Theme', 'preferences')
+      .firstElementChild as GrSelect;
+    themeSelect.bindValue = 'DARK';
 
-    MockInteractions.tap(publishOnPush);
+    themeSelect.dispatchEvent(
+      new CustomEvent('change', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+
+    const publishOnPush = valueOf('Publish comments on push', 'preferences')!
+      .firstElementChild! as HTMLSpanElement;
+
+    publishOnPush.click();
 
     assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assertMenusEqual(prefs.my, preferences.my);
       assert.equal(prefs.publish_comments_on_push, true);
+      assert.equal(prefs.theme, AppTheme.DARK);
       return Promise.resolve(createDefaultPreferences());
     });
 
@@ -692,8 +677,8 @@
     const publishCommentsOnPush = valueOf(
       'Publish comments on push',
       'preferences'
-    )!.firstElementChild!;
-    MockInteractions.tap(publishCommentsOnPush);
+    )!.firstElementChild! as HTMLSpanElement;
+    publishCommentsOnPush.click();
 
     assert.isTrue(element.prefsChanged);
 
@@ -711,8 +696,8 @@
     const newChangesWorkInProgress = valueOf(
       'Set new changes to "work in progress" by default',
       'preferences'
-    )!.firstElementChild!;
-    MockInteractions.tap(newChangesWorkInProgress);
+    )!.firstElementChild! as HTMLSpanElement;
+    newChangesWorkInProgress.click();
 
     assert.isTrue(element.prefsChanged);
 
@@ -792,9 +777,6 @@
 
   test('emails are loaded without emailToken', () => {
     const emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
-    element.params = {
-      view: GerritView.SETTINGS,
-    } as AppElementSettingsParam;
     element.firstUpdated();
     assert.isTrue(emailEditorLoadDataStub.calledOnce);
   });
@@ -898,8 +880,8 @@
         })
       );
 
-      element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
-      element.firstUpdated();
+      element.emailToken = 'foo';
+      element.confirmEmail();
     });
 
     test('it is used to confirm email via rest API', () => {
@@ -924,7 +906,7 @@
       await element._testOnly_loadingPromise;
       assert.equal(
         (dispatchEventSpy.lastCall.args[0] as CustomEvent).type,
-        'show-alert'
+        EventType.SHOW_ALERT
       );
       assert.deepEqual(
         (dispatchEventSpy.lastCall.args[0] as CustomEvent).detail,
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 ae20c4e..a73170a 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
@@ -1,18 +1,7 @@
 /**
  * @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-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-button/gr-button';
@@ -24,7 +13,7 @@
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {getAppContext} from '../../../services/app-context';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fire} from '../../../utils/event-util';
@@ -171,7 +160,7 @@
                 placeholder="New SSH Key"
                 .bindValue=${this.newKey}
                 @bind-value-changed=${(e: BindValueChangeEvent) => {
-                  this.newKey = e.detail.value;
+                  this.newKey = e.detail.value ?? '';
                 }}
               ></iron-autogrow-textarea>
             </span>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
index 0f81e9f..c5641ff 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
@@ -1,34 +1,20 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-ssh-editor';
 import {
   mockPromise,
   query,
-  queryAll,
   stubRestApi,
+  waitEventLoop,
 } 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');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-ssh-editor tests', () => {
   let element: GrSshEditor;
@@ -56,22 +42,157 @@
 
     stubRestApi('getAccountSSHKeys').returns(Promise.resolve(keys));
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-ssh-editor></gr-ssh-editor>`);
 
     await element.loadData();
-    await flush();
+    await waitEventLoop();
   });
 
   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);
+    assert.shadowDom.equal(
+      element,
+      /* 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>
+                <tr>
+                  <td class="commentColumn">comment-one@machine-one</td>
+                  <td>Valid</td>
+                  <td>
+                    <gr-button
+                      aria-disabled="false"
+                      data-index="0"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Click to View
+                    </gr-button>
+                  </td>
+                  <td>
+                    <gr-copy-clipboard hastooltip="" hideinput="">
+                    </gr-copy-clipboard>
+                  </td>
+                  <td>
+                    <gr-button
+                      aria-disabled="false"
+                      data-index="0"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Delete
+                    </gr-button>
+                  </td>
+                </tr>
+                <tr>
+                  <td class="commentColumn">comment-two@machine-two</td>
+                  <td>Valid</td>
+                  <td>
+                    <gr-button
+                      aria-disabled="false"
+                      data-index="1"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Click to View
+                    </gr-button>
+                  </td>
+                  <td>
+                    <gr-copy-clipboard hastooltip="" hideinput="">
+                    </gr-copy-clipboard>
+                  </td>
+                  <td>
+                    <gr-button
+                      aria-disabled="false"
+                      data-index="1"
+                      link=""
+                      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"> Algorithm </span>
+                  <span class="value"> </span>
+                </section>
+                <section>
+                  <span class="title"> Public key </span>
+                  <span class="publicKey value"> </span>
+                </section>
+                <section>
+                  <span class="title"> Comment </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 SSH key </span>
+              <span class="value">
+                <iron-autogrow-textarea
+                  aria-disabled="false"
+                  autocomplete="on"
+                  id="newKey"
+                  placeholder="New SSH Key"
+                >
+                </iron-autogrow-textarea>
+              </span>
+            </section>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="addButton"
+              link=""
+              role="button"
+              tabindex="-1"
+            >
+              Add new SSH key
+            </gr-button>
+          </fieldset>
+        </div>
+      `
+    );
   });
 
   test('remove key', async () => {
@@ -90,7 +211,7 @@
       'tbody tr:last-of-type td:nth-child(5) gr-button'
     );
 
-    MockInteractions.tap(button!);
+    button!.click();
 
     assert.equal(element.keys.length, 1);
     assert.equal(element.keysToRemove.length, 1);
@@ -114,7 +235,7 @@
       'tbody tr:last-of-type td:nth-child(3) gr-button'
     );
 
-    MockInteractions.tap(button!);
+    button!.click();
 
     assert.equal(element.keyToView, keys[1]);
     assert.isTrue(openSpy.called);
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 bd3cc22..fb38b59 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
@@ -1,23 +1,12 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
 import {
   AutocompleteQuery,
   GrAutocomplete,
@@ -29,7 +18,7 @@
 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 {when} from 'lit/directives/when.js';
 import {fire} from '../../../utils/event-util';
 import {PropertiesOfType} from '../../../utils/type-util';
 
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 b540be8..1280d6e 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
@@ -1,33 +1,20 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-watched-projects-editor';
 import {GrWatchedProjectsEditor} from './gr-watched-projects-editor';
 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 {queryAndAssert} from '../../../test/test-utils';
 import {IronInputElement} from '@polymer/iron-input';
 import {assertIsDefined} from '../../../utils/common-util';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../../shared/gr-button/gr-button';
 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;
@@ -71,38 +58,212 @@
       }
     });
 
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<gr-watched-projects-editor></gr-watched-projects-editor>`
+    );
 
     await element.loadData();
     await element.updateComplete;
   });
 
   test('renders', () => {
-    const rows = queryAndAssert(element, 'table').querySelectorAll('tbody tr');
-    assert.equal(rows.length, 4);
-
-    function getKeysOfRow(row: number) {
-      const boxes = queryAll(rows[row], 'input[checked]');
-      return Array.prototype.map.call(boxes, e => e.getAttribute('data-key'));
-    }
-
-    let checkedKeys = getKeysOfRow(0);
-    assert.equal(checkedKeys.length, 2);
-    assert.equal(checkedKeys[0], 'notify_submitted_changes');
-    assert.equal(checkedKeys[1], 'notify_abandoned_changes');
-
-    checkedKeys = getKeysOfRow(1);
-    assert.equal(checkedKeys.length, 1);
-    assert.equal(checkedKeys[0], 'notify_new_changes');
-
-    checkedKeys = getKeysOfRow(2);
-    assert.equal(checkedKeys.length, 0);
-
-    checkedKeys = getKeysOfRow(3);
-    assert.equal(checkedKeys.length, 3);
-    assert.equal(checkedKeys[0], 'notify_new_changes');
-    assert.equal(checkedKeys[1], 'notify_new_patch_sets');
-    assert.equal(checkedKeys[2], 'notify_all_comments');
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <table id="watchedProjects">
+            <thead>
+              <tr>
+                <th>Repo</th>
+                <th class="notifType">Changes</th>
+                <th class="notifType">Patches</th>
+                <th class="notifType">Comments</th>
+                <th class="notifType">Submits</th>
+                <th class="notifType">Abandons</th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>project a</td>
+                <td class="notifControl">
+                  <input data-key="notify_new_changes" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_new_patch_sets" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_all_comments" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input
+                    checked=""
+                    data-key="notify_submitted_changes"
+                    type="checkbox"
+                  />
+                </td>
+                <td class="notifControl">
+                  <input
+                    checked=""
+                    data-key="notify_abandoned_changes"
+                    type="checkbox"
+                  />
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td>
+                  project b
+                  <div class="projectFilter">filter 1</div>
+                </td>
+                <td class="notifControl">
+                  <input
+                    checked=""
+                    data-key="notify_new_changes"
+                    type="checkbox"
+                  />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_new_patch_sets" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_all_comments" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_submitted_changes" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_abandoned_changes" type="checkbox" />
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td>
+                  project b
+                  <div class="projectFilter">filter 2</div>
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_new_changes" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_new_patch_sets" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_all_comments" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_submitted_changes" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_abandoned_changes" type="checkbox" />
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td>project c</td>
+                <td class="notifControl">
+                  <input
+                    checked=""
+                    data-key="notify_new_changes"
+                    type="checkbox"
+                  />
+                </td>
+                <td class="notifControl">
+                  <input
+                    checked=""
+                    data-key="notify_new_patch_sets"
+                    type="checkbox"
+                  />
+                </td>
+                <td class="notifControl">
+                  <input
+                    checked=""
+                    data-key="notify_all_comments"
+                    type="checkbox"
+                  />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_submitted_changes" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_abandoned_changes" type="checkbox" />
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+            </tbody>
+            <tfoot>
+              <tr>
+                <th>
+                  <gr-autocomplete
+                    allow-non-suggested-values=""
+                    id="newProject"
+                    placeholder="Repo"
+                    tab-complete=""
+                    threshold="1"
+                  >
+                  </gr-autocomplete>
+                </th>
+                <th colspan="5">
+                  <iron-input class="newFilterInput" id="newFilterInput">
+                    <input
+                      class="newFilterInput"
+                      id="newFilter"
+                      placeholder="branch:name, or other search expression"
+                    />
+                  </iron-input>
+                </th>
+                <th>
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Add
+                  </gr-button>
+                </th>
+              </tr>
+            </tfoot>
+          </table>
+        </div>
+      `
+    );
   });
 
   test('getProjectSuggestions empty', async () => {
@@ -210,11 +371,11 @@
   test('_handleRemoveProject', async () => {
     assert.deepEqual(element.projectsToRemove, []);
 
-    const button = queryAndAssert(
+    const button = queryAndAssert<GrButton>(
       element,
       'table tbody tr:nth-child(2) gr-button'
     );
-    MockInteractions.tap(button);
+    button.click();
 
     await element.updateComplete;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 689a9fb..31e4f5d 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
@@ -1,22 +1,11 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-account-label/gr-account-label';
 import '../gr-button/gr-button';
-import '../gr-icons/gr-icons';
+import '../gr-icon/gr-icon';
 import {
   AccountInfo,
   ApprovalInfo,
@@ -25,9 +14,8 @@
 } from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {ClassInfo, classMap} from 'lit/directives/class-map';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {customElement, property} from 'lit/decorators.js';
+import {ClassInfo, classMap} from 'lit/directives/class-map.js';
 import {getLabelStatus, hasVoted, LabelStatus} from '../../../utils/label-util';
 
 @customElement('gr-account-chip')
@@ -63,9 +51,6 @@
   @property({type: Boolean})
   forceAttention = false;
 
-  @property({type: String})
-  voteableText?: string;
-
   @property({type: Boolean, reflect: true})
   disabled = false;
 
@@ -83,9 +68,6 @@
   @property({type: Boolean, reflect: true})
   showAvatar?: boolean;
 
-  @property({type: Boolean})
-  transparentBackground = false;
-
   @property({type: Object})
   vote?: ApprovalInfo;
 
@@ -94,8 +76,6 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       css`
@@ -130,17 +110,12 @@
         :host:focus gr-button {
           background: #ccc;
         }
-        .transparentBackground,
-        gr-button.transparentBackground {
-          background-color: transparent;
-        }
         :host([disabled]) {
           opacity: 0.6;
           pointer-events: none;
         }
-        iron-icon {
-          height: 1.2rem;
-          width: 1.2rem;
+        gr-icon {
+          font-size: 1.2rem;
         }
         .container gr-account-label::part(gr-account-label-text) {
           color: var(--deemphasized-text-color);
@@ -160,16 +135,6 @@
           --account-label-padding-right: 3px;
           --account-label-circle-padding-right: 3px;
         }
-      `,
-    ];
-  }
-
-  override render() {
-    // To pass CSS mixins for @apply to Polymer components, they need to appear
-    // in <style> inside the template.
-    /* eslint-disable lit/prefer-static-styles */
-    const customStyle = html`
-      <style>
         gr-button.remove::part(paper-button),
         gr-button.remove:hover::part(paper-button),
         gr-button.remove:focus::part(paper-button) {
@@ -186,53 +151,50 @@
           padding: 0 2px 0 1px;
           text-decoration: none;
         }
-      </style>
-    `;
-    return html`${customStyle}
-      <div
-        class=${classMap({
-          ...this.computeVoteClasses(),
-          container: true,
-          transparentBackground: this.transparentBackground,
-          closeShown: this.removable,
-        })}
-      >
-        <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({
-            remove: true,
-            transparentBackground: this.transparentBackground,
-          })}
-          @click=${this._handleRemoveTap}
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<div
+      class=${classMap({
+        ...this.computeVoteClasses(),
+        container: true,
+        closeShown: this.removable,
+      })}
+    >
+      <div>
+        <gr-account-label
+          .account=${this.account}
+          .change=${this.change}
+          ?forceAttention=${this.forceAttention}
+          ?highlightAttention=${this.highlightAttention}
+          clickable
         >
-          <iron-icon icon="gr-icons:close"></iron-icon>
-        </gr-button>
-      </div>`;
+        </gr-account-label>
+      </div>
+      <slot name="vote-chip"></slot>
+      <gr-button
+        id="remove"
+        link=""
+        ?hidden=${!this.removable}
+        aria-label="Remove"
+        class="remove"
+        @click=${this.handleRemoveTap}
+      >
+        <gr-icon icon="close"></gr-icon>
+      </gr-button>
+    </div>`;
   }
 
   constructor() {
     super();
-    this._getHasAvatars().then(hasAvatars => {
+    this.getHasAvatars().then(hasAvatars => {
       this.showAvatar = hasAvatars;
     });
   }
 
-  _handleRemoveTap(e: MouseEvent) {
+  private handleRemoveTap(e: MouseEvent) {
     e.preventDefault();
     this.dispatchEvent(
       new CustomEvent('remove', {
@@ -243,7 +205,7 @@
     );
   }
 
-  _getHasAvatars() {
+  private getHasAvatars() {
     return this.restApiService
       .getConfig()
       .then(cfg =>
@@ -252,12 +214,7 @@
   }
 
   private computeVoteClasses(): ClassInfo {
-    if (
-      !this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) ||
-      !this.label ||
-      !this.account ||
-      !hasVoted(this.label, this.account)
-    ) {
+    if (!this.label || !this.account || !hasVoted(this.label, this.account)) {
       return {};
     }
     const status = getLabelStatus(this.label, this.vote?.value);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_test.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_test.ts
index 0379c8a..8f1e169 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_test.ts
@@ -1,22 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {fixture} from '@open-wc/testing-helpers';
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import './gr-account-chip';
 import {GrAccountChip} from './gr-account-chip';
@@ -37,25 +25,28 @@
   });
 
   test('renders', () => {
-    expect(element).shadowDom.to.equal(/* HTML */ `
-      <div class="container">
-        <div>
-          <gr-account-label clickable="" deselected=""></gr-account-label>
+    assert.shadowDom.equal(
+      element,
+      /* 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"
+          >
+            <gr-icon icon="close"></gr-icon>
+          </gr-button>
         </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 6060826..0509925 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
@@ -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 '../gr-autocomplete/gr-autocomplete';
 import {
   AutocompleteQuery,
@@ -22,8 +10,10 @@
 } 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 {customElement, property, query, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
+import {SuggestedReviewerInfo} from '../../../types/common';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
 
 /**
  * gr-account-entry is an element for entering account
@@ -57,7 +47,8 @@
   placeholder = '';
 
   @property({type: Object})
-  querySuggestions: AutocompleteQuery = () => Promise.resolve([]);
+  querySuggestions: AutocompleteQuery<SuggestedReviewerInfo> = () =>
+    Promise.resolve([]);
 
   @state() private inputText = '';
 
@@ -99,7 +90,7 @@
     }
   }
 
-  get focusStart() {
+  get focusStart(): PaperInputElement | undefined {
     return this.input!.focusStart;
   }
 
@@ -139,7 +130,7 @@
   }
 
   private handleTextChanged(e: BindValueChangeEvent) {
-    this.inputText = e.detail.value;
+    this.inputText = e.detail.value ?? '';
   }
 }
 
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 f08da9e..552e321 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
@@ -1,27 +1,15 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-account-entry';
 import {GrAccountEntry} from './gr-account-entry';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {queryAndAssert, waitUntil} from '../../../test/test-utils';
 import {GrAutocomplete} from '../gr-autocomplete/gr-autocomplete';
-import {PaperInputElementExt} from '../../../types/types';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
 
 suite('gr-account-entry tests', () => {
   let element: GrAccountEntry;
@@ -33,6 +21,21 @@
     await element.updateComplete;
   });
 
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-autocomplete
+          allow-non-suggested-values="false"
+          clear-on-commit=""
+          id="input"
+          warn-uncommitted=""
+        >
+        </gr-autocomplete>
+      `
+    );
+  });
+
   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();
@@ -68,7 +71,7 @@
 
     const input = queryAndAssert<GrAutocomplete>(element, '#input');
     assert.equal(
-      queryAndAssert<PaperInputElementExt>(input, '#input').value,
+      queryAndAssert<PaperInputElement>(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 a505632..aa5fd58e 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
@@ -1,38 +1,27 @@
 /**
  * @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-icon/iron-icon';
 import '../gr-avatar/gr-avatar';
 import '../gr-hovercard-account/gr-hovercard-account';
+import '../gr-icon/gr-icon';
 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 {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {fireEvent} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
-import {ShowAlertEventDetail} from '../../../types/events';
+import {EventType, ShowAlertEventDetail} from '../../../types/events';
 import {LitElement, css, html, TemplateResult} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
-import {classMap} from 'lit/directives/class-map';
+import {customElement, property, state} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
 import {getRemovedByIconClickReason} from '../../../utils/attention-set-util';
-import {ifDefined} from 'lit/directives/if-defined';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {createSearchUrl} from '../../../models/views/search';
 
 @customElement('gr-account-label')
 export class GrAccountLabel extends LitElement {
@@ -50,9 +39,6 @@
   @property({type: Object})
   change?: ChangeInfo;
 
-  @property({type: String})
-  voteableText?: string;
-
   /**
    * Should this user be considered to be in the attention set, regardless
    * of the current state of the change object?
@@ -111,6 +97,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly accountsModel = getAppContext().accountsModel;
+
   static override get styles() {
     return [
       css`
@@ -154,7 +142,7 @@
           border-radius: 8px;
           color: var(--chip-selected-text-color);
         }
-        :host([selected]) iron-icon.attention {
+        :host([selected]) gr-icon.attention {
           color: var(--chip-selected-text-color);
         }
         gr-avatar {
@@ -169,12 +157,12 @@
           /* This negates the 4px horizontal padding, which we appreciate as a
          larger click target, but which we don't want to consume space. :-) */
           margin: 0 -4px 0 -4px;
+          --gr-button-padding: 0 var(--spacing-xs);
           vertical-align: top;
         }
-        iron-icon.attention {
+        gr-icon.attention {
           color: var(--deemphasized-text-color);
-          width: 12px;
-          height: 12px;
+          transform: scaleX(0.8);
         }
         .name {
           display: inline-block;
@@ -200,6 +188,12 @@
     ];
   }
 
+  override async updated() {
+    assertIsDefined(this.account, 'account');
+    const account = await this.accountsModel.fillDetails(this.account);
+    if (account) this.account = account;
+  }
+
   override render() {
     const {account, change, highlightAttention, forceAttention, _config} = this;
     if (!account) return;
@@ -218,7 +212,6 @@
               .account=${account}
               .change=${change}
               .highlightAttention=${highlightAttention}
-              .voteableText=${this.voteableText}
             ></gr-hovercard-account>`
           : ''}
         ${this.attentionIconShown
@@ -251,10 +244,16 @@
                   this.selected,
                   this._selfAccount
                 )}
-                ><iron-icon
-                  class="attention"
-                  icon="gr-icons:attention"
-                ></iron-icon>
+              >
+                <div>
+                  <gr-icon
+                    icon="label_important"
+                    filled
+                    small
+                    class="attention"
+                  >
+                  </gr-icon>
+                </div>
               </gr-button>
             </gr-tooltip-content>`
           : ''}
@@ -300,12 +299,13 @@
 
   private maybeRenderLink(span: TemplateResult) {
     if (!this.clickable || !this.account) return span;
-    const url = GerritNav.getUrlForOwner(
-      this.account.email ||
+    const url = createSearchUrl({
+      owner:
+        this.account.email ||
         this.account.username ||
         this.account.name ||
-        `${this.account._account_id}`
-    );
+        `${this.account._account_id}`,
+    });
     if (!url) return span;
     return html`<a class="ownerLink" href=${url} tabindex="-1">${span}</a>`;
   }
@@ -363,7 +363,7 @@
     if (!this.account._account_id) return;
 
     this.dispatchEvent(
-      new CustomEvent<ShowAlertEventDetail>('show-alert', {
+      new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
         detail: {
           message: 'Saving attention set update ...',
           dismissOnNavigation: true,
@@ -447,8 +447,11 @@
       selected,
       selfAccount
     );
+    const removeFromASTooltip = `Click to remove ${
+      account._account_id === selfAccount?._account_id ? 'yourself' : 'the user'
+    } from the attention set`;
     return enabled
-      ? 'Click to remove the user from the attention set'
+      ? removeFromASTooltip
       : force
       ? 'Disabled. Use "Modify" to make changes.'
       : 'Disabled. Only involved users can 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 d318a7d..e7c0536 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
@@ -1,30 +1,18 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-account-label';
 import {
   query,
   queryAndAssert,
   spyRestApi,
   stubRestApi,
+  waitEventLoop,
 } 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 {
   createAccountDetailWithIdNameAndEmail,
@@ -32,9 +20,8 @@
   createPluginConfig,
   createServerInfo,
 } from '../../../test/test-data-generators';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-account-label');
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
 
 suite('gr-account-label tests', () => {
   let element: GrAccountLabel;
@@ -44,7 +31,6 @@
   };
 
   setup(async () => {
-    sinon.stub(GerritNav, 'getUrlForOwner').callsFake(() => 'test');
     stubRestApi('getAccount').resolves(kermit);
     stubRestApi('getLoggedIn').resolves(false);
     stubRestApi('getConfig').resolves({
@@ -57,47 +43,18 @@
         anonymous_coward_name: 'Anonymous Coward',
       },
     });
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-account-label></gr-account-label>`);
     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">
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <gr-hovercard-account for="hovercardTarget"></gr-hovercard-account>
           <span class="hovercardTargetWrapper">
             <gr-avatar hidden="" imagesize="32"> </gr-avatar>
             <span
@@ -117,9 +74,44 @@
               <span class="rightSidePadding"></span>
             </gr-endpoint-decorator>
           </span>
-        </a>
-      </div>
-    `);
+        </div>
+      `
+    );
+  });
+
+  test('renders clickable', async () => {
+    element.account = kermit;
+    element.clickable = true;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <gr-hovercard-account for="hovercardTarget"></gr-hovercard-account>
+          <a class="ownerLink" href="/q/owner:user-31%2540" 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', () => {
@@ -181,7 +173,7 @@
         owner: kermit,
         reviewers: {},
       };
-      await flush();
+      await waitEventLoop();
     });
 
     test('show attention button', () => {
@@ -192,10 +184,10 @@
 
     test('tap attention button', async () => {
       const apiSpy = spyRestApi('removeFromAttentionSet');
-      const button = queryAndAssert(element, '#attentionButton');
+      const button = queryAndAssert<GrButton>(element, '#attentionButton');
       assert.ok(button);
       assert.isNull(button.getAttribute('disabled'));
-      MockInteractions.tap(button);
+      button.click();
       assert.isTrue(apiSpy.calledOnce);
       assert.equal(apiSpy.lastCall.args[1], 42);
     });
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 1143b4e..bc64647 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
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-account-chip/gr-account-chip';
 import '../gr-account-entry/gr-account-entry';
@@ -25,24 +14,28 @@
   EmailAddress,
   SuggestedReviewerGroupInfo,
   SuggestedReviewerAccountInfo,
+  SuggestedReviewerInfo,
+  isGroup,
 } from '../../../types/common';
 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 {PaperInputElementExt} from '../../../types/types';
 import {fire, fireAlert} from '../../../utils/event-util';
-import {accountOrGroupKey} from '../../../utils/account-util';
+import {getUserId, isAccountNewlyAdded} from '../../../utils/account-util';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {classMap} from 'lit/directives/class-map';
+import {classMap} from 'lit/directives/class-map.js';
 import {
   AutocompleteQuery,
   AutocompleteSuggestion,
   GrAutocomplete,
 } from '../gr-autocomplete/gr-autocomplete';
 import {ValueChangedEvent} from '../../../types/events';
-import {queryAndAssert} from '../../../utils/common-util';
+import {difference, queryAndAssert} from '../../../utils/common-util';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {IronInputElement} from '@polymer/iron-input';
+import {ReviewerState} from '../../../api/rest-api';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
@@ -83,37 +76,18 @@
 
 // Internal input type with account info
 export interface AccountInfoInput extends AccountInfo {
-  _group?: boolean;
   _account?: boolean;
-  _pendingAdd?: boolean;
   confirmed?: boolean;
 }
 
 // Internal input type with group info
 export interface GroupInfoInput extends GroupInfo {
-  _group?: boolean;
   _account?: boolean;
-  _pendingAdd?: boolean;
   confirmed?: boolean;
 }
 
-function isAccountInfoInput(x: AccountInput): x is AccountInfoInput {
-  const input = x as AccountInfoInput;
-  return !!input._account || !!input._account_id || !!input.email;
-}
-
-function isGroupInfoInput(x: AccountInput): x is GroupInfoInput {
-  const input = x as GroupInfoInput;
-  return !!input._group || !!input.id;
-}
-
 export type AccountInput = AccountInfoInput | GroupInfoInput;
 
-export interface AccountAddition {
-  account?: AccountInfoInput;
-  group?: GroupInfoInput;
-}
-
 @customElement('gr-account-list')
 export class GrAccountList extends LitElement {
   /**
@@ -138,6 +112,9 @@
   @property({type: Boolean})
   disabled = false;
 
+  @property({type: String})
+  reviewerState?: ReviewerState;
+
   /**
    * Returns suggestions and convert them to list item
    */
@@ -166,18 +143,13 @@
   @property({type: Array})
   removableValues?: AccountInput[];
 
-  @property({type: Number})
-  maxCount = 0;
-
   /**
    * Returns suggestion items
    */
-  @state() private querySuggestions: AutocompleteQuery;
+  @state() private querySuggestions: AutocompleteQuery<SuggestedReviewerInfo>;
 
   private readonly reporting = getAppContext().reportingService;
 
-  private pendingRemoval: Set<AccountInput> = new Set();
-
   constructor() {
     super();
     this.querySuggestions = input => this.getSuggestions(input);
@@ -202,7 +174,7 @@
       .group {
         --account-label-suffix: ' (group)';
       }
-      .pending-add {
+      .newlyAdded {
         font-style: italic;
       }
       .list {
@@ -219,9 +191,14 @@
           account => html`
             <gr-account-chip
               .account=${account}
+              .change=${this.change}
               class=${classMap({
-                group: !!account._group,
-                pendingAdd: !!account._pendingAdd,
+                group: isGroup(account),
+                newlyAdded: isAccountNewlyAdded(
+                  account,
+                  this.reviewerState,
+                  this.change
+                ),
               })}
               ?removable=${this.computeRemovable(account)}
               @keydown=${this.handleChipKeydown}
@@ -233,8 +210,7 @@
       </div>
       <gr-account-entry
         borderless=""
-        ?hidden=${(this.maxCount && this.maxCount <= this.accounts.length) ||
-        this.readonly}
+        ?hidden=${this.readonly}
         id="entry"
         .placeholder=${this.placeholder}
         @add=${this.handleAdd}
@@ -265,7 +241,9 @@
     return this.entry?.focusStart;
   }
 
-  getSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+  getSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion<SuggestedReviewerInfo>[]> {
     const provider = this.suggestionsProvider;
     if (!provider) return Promise.resolve([]);
     return provider.getSuggestions(input).then(suggestions => {
@@ -294,8 +272,7 @@
     let group;
     let itemTypeAdded = 'unknown';
     if (isAccountObject(item)) {
-      account = {...item.account, _pendingAdd: true};
-      this.removeFromPendingRemoval(account);
+      account = {...item.account};
       this.accounts.push(account);
       itemTypeAdded = 'account';
     } else if (isSuggestedReviewerGroupInfo(item)) {
@@ -303,9 +280,8 @@
         this.pendingConfirmation = item;
         return;
       }
-      group = {...item.group, _pendingAdd: true, _group: true};
+      group = {...item.group};
       this.accounts.push(group);
-      this.removeFromPendingRemoval(group);
       itemTypeAdded = 'group';
     } else if (this.allowAnyInput) {
       if (!item.includes('@')) {
@@ -315,9 +291,8 @@
         fireAlert(this, VALID_EMAIL_ALERT);
         return false;
       } else {
-        account = {email: item as EmailAddress, _pendingAdd: true};
+        account = {email: item as EmailAddress};
         this.accounts.push(account);
-        this.removeFromPendingRemoval(account);
         itemTypeAdded = 'email';
       }
     }
@@ -333,8 +308,6 @@
     this.accounts.push({
       ...group,
       confirmed: true,
-      _pendingAdd: true,
-      _group: true,
     });
     this.pendingConfirmation = null;
     fire(this, 'accounts-changed', {value: this.accounts});
@@ -342,32 +315,17 @@
   }
 
   // private but used in test
-  computeChipClass(account: AccountInput) {
-    const classes = [];
-    if (account._group) {
-      classes.push('group');
-    }
-    if (account._pendingAdd) {
-      classes.push('pendingAdd');
-    }
-    return classes.join(' ');
-  }
-
-  // private but used in test
   computeRemovable(account: AccountInput) {
     if (this.readonly) {
       return false;
     }
     if (this.removableValues) {
       for (let i = 0; i < this.removableValues.length; i++) {
-        if (
-          accountOrGroupKey(this.removableValues[i]) ===
-          accountOrGroupKey(account)
-        ) {
+        if (getUserId(this.removableValues[i]) === getUserId(account)) {
           return true;
         }
       }
-      return !!account._pendingAdd;
+      return isAccountNewlyAdded(account, this.reviewerState, this.change);
     }
     return true;
   }
@@ -380,28 +338,24 @@
 
   removeAccount(toRemove?: AccountInput) {
     if (!toRemove || !this.computeRemovable(toRemove)) {
-      return;
+      return false;
     }
     for (let i = 0; i < this.accounts.length; i++) {
-      if (accountOrGroupKey(toRemove) === accountOrGroupKey(this.accounts[i])) {
+      if (getUserId(toRemove) === getUserId(this.accounts[i])) {
         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;
+        return true;
       }
     }
-    this.reporting.error(
-      new Error(`Received "remove" event for missing account: ${toRemove}`)
-    );
+    return false;
   }
 
   // private but used in test
-  getOwnNativeInput(paperInput: PaperInputElementExt) {
-    // In Polymer 2 inputElement isn't nativeInput anymore
-    return (paperInput.$.nativeInput ||
-      paperInput.inputElement) as HTMLTextAreaElement;
+  getOwnNativeInput(paperInput: PaperInputElement) {
+    return (paperInput.inputElement as IronInputElement)
+      .inputElement as HTMLInputElement;
   }
 
   private handleInputKeydown(e: KeyboardEvent) {
@@ -414,11 +368,11 @@
     ) {
       return;
     }
-    switch (e.keyCode) {
-      case 8: // Backspace
+    switch (e.key) {
+      case 'Backspace':
         this.removeAccount(this.accounts[this.accounts.length - 1]);
         break;
-      case 37: // Left arrow
+      case 'ArrowLeft':
         if (this.accountChips[this.accountChips.length - 1]) {
           this.accountChips[this.accountChips.length - 1].focus();
         }
@@ -430,11 +384,11 @@
     const chip = e.target as GrAccountChip;
     const chips = this.accountChips;
     const index = chips.indexOf(chip);
-    switch (e.keyCode) {
-      case 8: // Backspace
-      case 13: // Enter
-      case 32: // Spacebar
-      case 46: // Delete
+    switch (e.key) {
+      case 'Backspace':
+      case 'Enter':
+      case ' ':
+      case 'Delete':
         this.removeAccount(chip.account);
         // Splice from this array to avoid inconsistent ordering of
         // event handling.
@@ -447,13 +401,13 @@
           this.entry?.focus();
         }
         break;
-      case 37: // Left arrow
+      case 'ArrowLeft':
         if (index > 0) {
           chip.blur();
           chips[index - 1].focus();
         }
         break;
-      case 39: // Right arrow
+      case 'ArrowRight':
         chip.blur();
         if (index < chips.length - 1) {
           chips[index + 1].focus();
@@ -484,37 +438,19 @@
     return wasSubmitted;
   }
 
-  additions(): AccountAddition[] {
-    return this.accounts
-      .filter(account => account._pendingAdd)
-      .map(account => {
-        if (isGroupInfoInput(account)) {
-          return {group: account};
-        } else if (isAccountInfoInput(account)) {
-          return {account};
-        } else {
-          throw new Error('AccountInput must be either Account or Group.');
-        }
-      });
+  additions(): (AccountInfoInput | GroupInfoInput)[] {
+    if (!this.change) return [];
+    return this.accounts.filter(account =>
+      isAccountNewlyAdded(account, this.reviewerState, this.change)
+    );
   }
 
-  removals(): AccountAddition[] {
-    return Array.from(this.pendingRemoval).map(account => {
-      if (isGroupInfoInput(account)) {
-        return {group: account};
-      } else if (isAccountInfoInput(account)) {
-        return {account};
-      } else {
-        throw new Error('AccountInput must be either Account or Group.');
-      }
-    });
-  }
-
-  private removeFromPendingRemoval(account: AccountInput) {
-    this.pendingRemoval.delete(account);
-  }
-
-  clearPendingRemovals() {
-    this.pendingRemoval.clear();
+  removals(): AccountInfo[] {
+    if (!this.reviewerState) return [];
+    return difference(
+      this.change?.reviewers[this.reviewerState] ?? [],
+      this.accounts,
+      (a, b) => getUserId(a) === getUserId(b)
+    );
   }
 }
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 981ad32..3bf7fb0 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
@@ -1,20 +1,9 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-account-list';
 import {
   AccountInfoInput,
@@ -28,15 +17,25 @@
   GroupBaseInfo,
   GroupId,
   GroupName,
+  SuggestedReviewerInfo,
   Suggestion,
 } from '../../../types/common';
-import {queryAll, queryAndAssert, waitUntil} from '../../../test/test-utils';
+import {
+  pressKey,
+  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 {
+  AutocompleteSuggestion,
+  GrAutocomplete,
+} from '../gr-autocomplete/gr-autocomplete';
 import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
-
-const basicFixture = fixtureFromElement('gr-account-list');
+import {createChange} from '../../../test/test-data-generators';
+import {ReviewerState} from '../../../api/rest-api';
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
 
 class MockSuggestionsProvider implements ReviewerSuggestionsProvider {
   init() {}
@@ -45,7 +44,9 @@
     return Promise.resolve([]);
   }
 
-  makeSuggestionItem(_: Suggestion) {
+  makeSuggestionItem(
+    _: Suggestion
+  ): AutocompleteSuggestion<SuggestedReviewerInfo> {
     return {
       name: 'test',
       value: {
@@ -53,7 +54,7 @@
           _account_id: 1 as AccountId,
         } as AccountInfo,
         count: 1,
-      } as unknown as string,
+      },
     };
   }
 }
@@ -70,7 +71,6 @@
     const groupId = `group${++_nextAccountId}`;
     return {
       id: groupId as GroupId,
-      _group: true,
       name: 'abcd' as GroupName,
     };
   };
@@ -97,15 +97,19 @@
     existingAccount1 = makeAccount();
     existingAccount2 = makeAccount();
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-account-list></gr-account-list>`);
     element.accounts = [existingAccount1, existingAccount2];
+    element.reviewerState = ReviewerState.REVIEWER;
+    element.change = {...createChange()};
+    element.change.reviewers[ReviewerState.REVIEWER] = [...element.accounts];
     suggestionsProvider = new MockSuggestionsProvider();
     element.suggestionsProvider = suggestionsProvider;
     await element.updateComplete;
   });
 
   test('renders', () => {
-    expect(element).shadowDom.to.equal(
+    assert.shadowDom.equal(
+      element,
       /* HTML */
       `<div class="list">
           <gr-account-chip removable="" tabindex="-1"> </gr-account-chip>
@@ -135,18 +139,18 @@
     // Existing accounts are listed.
     let chips = getChips();
     assert.equal(chips.length, 2);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    assert.isFalse(chips[1].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
+    assert.isFalse(chips[1].classList.contains('newlyAdded'));
 
-    // New accounts are added to end with pendingAdd class.
+    // New accounts are added to end with newlyAdded class.
     const newAccount = makeAccount();
     handleAdd({account: newAccount, count: 1});
     await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 3);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    assert.isFalse(chips[1].classList.contains('pendingAdd'));
-    assert.isTrue(chips[2].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
+    assert.isFalse(chips[1].classList.contains('newlyAdded'));
+    assert.isTrue(chips[2].classList.contains('newlyAdded'));
 
     // Removed accounts are taken out of the list.
     element.dispatchEvent(
@@ -159,8 +163,8 @@
     await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 2);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    assert.isTrue(chips[1].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
+    assert.isTrue(chips[1].classList.contains('newlyAdded'));
 
     // Invalid remove is ignored.
     element.dispatchEvent(
@@ -180,16 +184,16 @@
     await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 1);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
 
-    // New groups are added to end with pendingAdd and group classes.
+    // New groups are added to end with newlyAdded and group classes.
     const newGroup = makeGroup();
     handleAdd({group: newGroup, confirm: false, count: 1});
     await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 2);
     assert.isTrue(chips[1].classList.contains('group'));
-    assert.isTrue(chips[1].classList.contains('pendingAdd'));
+    assert.isTrue(chips[1].classList.contains('newlyAdded'));
 
     // Removed groups are taken out of the list.
     element.dispatchEvent(
@@ -202,7 +206,7 @@
     await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 1);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
   });
 
   test('getSuggestions uses filter correctly', () => {
@@ -234,7 +238,7 @@
           value: {
             account: suggestion as AccountInfo,
             count: 1,
-          } as unknown as string,
+          },
         };
       });
 
@@ -259,26 +263,14 @@
             value: {
               account: originalSuggestions[0] as AccountInfo,
               count: 1,
-            } as unknown as string,
+            },
           },
         ]);
       });
   });
 
-  test('computeChipClass', () => {
-    const account = makeAccount() as AccountInfoInput;
-    assert.equal(element.computeChipClass(account), '');
-    account._pendingAdd = true;
-    assert.equal(element.computeChipClass(account), 'pendingAdd');
-    account._group = true;
-    assert.equal(element.computeChipClass(account), 'group pendingAdd');
-    account._pendingAdd = false;
-    assert.equal(element.computeChipClass(account), 'group');
-  });
-
   test('computeRemovable', async () => {
     const newAccount = makeAccount() as AccountInfoInput;
-    newAccount._pendingAdd = true;
     element.readonly = false;
     element.removableValues = [];
     element.updateComplete;
@@ -325,7 +317,7 @@
     assert.isTrue(element.submitEntryText());
     assert.isTrue(clearStub.called);
     assert.equal(
-      element.additions()[0].account?.email,
+      (element.additions()[0] as AccountInfo)?.email,
       'test@test' as EmailAddress
     );
   });
@@ -340,18 +332,11 @@
 
     assert.deepEqual(element.additions(), [
       {
-        account: {
-          _account_id: newAccount._account_id,
-          _pendingAdd: true,
-        },
+        _account_id: newAccount._account_id,
       },
       {
-        group: {
-          id: newGroup.id,
-          _group: true,
-          _pendingAdd: true,
-          name: 'abcd' as GroupName,
-        },
+        id: newGroup.id,
+        name: 'abcd' as GroupName,
       },
     ]);
   });
@@ -361,7 +346,7 @@
     assert.deepEqual(element.additions(), []);
 
     const group = makeGroup();
-    const reviewer = {
+    const reviewer: RawAccountInput = {
       group,
       count: 10,
       confirm: true,
@@ -375,13 +360,9 @@
     assert.isNull(element.pendingConfirmation);
     assert.deepEqual(element.additions(), [
       {
-        group: {
-          id: group.id,
-          _group: true,
-          _pendingAdd: true,
-          confirmed: true,
-          name: 'abcd' as GroupName,
-        },
+        id: group.id,
+        name: 'abcd' as GroupName,
+        confirmed: true,
       },
     ]);
   });
@@ -394,16 +375,6 @@
     assert.equal(element.accounts.length, 1);
   });
 
-  test('max-count', async () => {
-    element.maxCount = 1;
-    const acct = makeAccount();
-    handleAdd({account: acct, count: 1});
-    await element.updateComplete;
-    assert.isTrue(
-      queryAndAssert<GrAccountEntry>(element, '#entry').hasAttribute('hidden')
-    );
-  });
-
   test('enter text calls suggestions provider', async () => {
     const suggestions: Suggestion[] = [
       {
@@ -429,7 +400,7 @@
       '#input'
     );
     input.text = 'newTest';
-    MockInteractions.focus(input.input!);
+    input.input!.focus();
     input.noDebounce = true;
     await element.updateComplete;
     assert.isTrue(getSuggestionsStub.calledOnce);
@@ -454,7 +425,7 @@
 
     test('toasts on invalid email', () => {
       const toastHandler = sinon.stub();
-      element.addEventListener('show-alert', toastHandler);
+      element.addEventListener(EventType.SHOW_ALERT, toastHandler);
       handleAdd('test');
       assert.isTrue(toastHandler.called);
     });
@@ -473,20 +444,14 @@
       // on input field update
       assert.equal(element.getOwnNativeInput(input.input!).selectionStart, 0);
       input.text = 'test';
-      MockInteractions.focus(input.input!);
+      input.input!.focus();
       await element.updateComplete;
       assert.equal(element.accounts.length, 2);
-      MockInteractions.pressAndReleaseKeyOn(
-        element.getOwnNativeInput(input.input!),
-        8
-      ); // Backspace
+      pressKey(element.getOwnNativeInput(input.input!), 'Backspace');
       await waitUntil(() => element.accounts.length === 2);
       input.text = '';
       await input.updateComplete;
-      MockInteractions.pressAndReleaseKeyOn(
-        element.getOwnNativeInput(input.input!),
-        8
-      ); // Backspace
+      pressKey(element.getOwnNativeInput(input.input!), 'Backspace');
       await waitUntil(() => element.accounts.length === 1);
     });
 
@@ -498,18 +463,18 @@
       input.text = '';
       element.accounts = [makeAccount(), makeAccount()];
       await element.updateComplete;
-      MockInteractions.focus(input.input!);
+      input.input!.focus();
       await element.updateComplete;
       const chips = element.accountChips;
       const chipsOneSpy = sinon.spy(chips[1], 'focus');
-      MockInteractions.pressAndReleaseKeyOn(input.input!, 37); // Left
+      pressKey(input.input!, 'ArrowLeft');
       assert.isTrue(chipsOneSpy.called);
       const chipsZeroSpy = sinon.spy(chips[0], 'focus');
-      MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
+      pressKey(chips[1], 'ArrowLeft');
       assert.isTrue(chipsZeroSpy.called);
-      MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
+      pressKey(chips[0], 'ArrowLeft');
       assert.isTrue(chipsZeroSpy.calledOnce);
-      MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
+      pressKey(chips[0], 'ArrowRight');
       assert.isTrue(chipsOneSpy.calledTwice);
     });
 
@@ -518,11 +483,11 @@
       await element.updateComplete;
       const focusSpy = sinon.spy(element.accountChips[1], 'focus');
       const removeSpy = sinon.spy(element, 'removeAccount');
-      MockInteractions.pressAndReleaseKeyOn(element.accountChips[0], 8); // Backspace
+      pressKey(element.accountChips[0], 'Backspace');
       assert.isTrue(focusSpy.called);
       assert.isTrue(removeSpy.calledOnce);
 
-      MockInteractions.pressAndReleaseKeyOn(element.accountChips[1], 46); // Delete
+      pressKey(element.accountChips[0], 'Delete');
       assert.isTrue(removeSpy.calledTwice);
     });
   });
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 50ca7a7..9b80282 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -1,26 +1,16 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-button/gr-button';
 import '../../../styles/shared-styles';
 import {getRootElement} from '../../../scripts/rootElement';
 import {ErrorType} from '../../../types/types';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {DependencyRequestEvent} from '../../../models/dependency';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -135,6 +125,9 @@
   @property({type: Boolean})
   showDismiss = false;
 
+  @property({type: Object})
+  owner: HTMLElement | null = null;
+
   @property()
   _boundTransitionEndHandler?: (
     this: HTMLElement,
@@ -148,9 +141,11 @@
     super.connectedCallback();
     this._boundTransitionEndHandler = () => this._handleTransitionEnd();
     this.addEventListener('transitionend', this._boundTransitionEndHandler);
+    this.addEventListener('request-dependency', this.resolveDep);
   }
 
   override disconnectedCallback() {
+    this.removeEventListener('request-dependency', this.resolveDep);
     if (this._boundTransitionEndHandler) {
       this.removeEventListener(
         'transitionend',
@@ -160,6 +155,16 @@
     super.disconnectedCallback();
   }
 
+  /**
+   * 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.owner?.dispatchEvent(
+      new DependencyRequestEvent<unknown>(e.dependency, e.callback)
+    );
+  };
+
   show(text: string, actionText?: string, actionCallback?: () => void) {
     this.text = text;
     this.actionText = actionText;
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
index d0fe563..6908e95 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
@@ -1,29 +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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-alert';
 import {GrAlert} from './gr-alert';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
+import {waitEventLoop} from '../../../test/test-utils';
 
 suite('gr-alert tests', () => {
   let element: GrAlert;
 
   setup(() => {
+    // The gr-alert element attaches itself to the root element on .show(),
+    // rather than existing under a fixture parent.
     element = document.createElement('gr-alert');
   });
 
@@ -33,11 +25,34 @@
     }
   });
 
+  test('render', async () => {
+    element.show('Alert text');
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="content-wrapper">
+          <span class="text"> Alert text </span>
+          <gr-button
+            aria-disabled="false"
+            class="action"
+            hidden=""
+            link=""
+            role="button"
+            tabindex="0"
+          >
+          </gr-button>
+        </div>
+      `
+    );
+  });
+
   test('show/hide', async () => {
     assert.isNull(element.parentNode);
     element.show('Alert text');
     // wait for element to be rendered after being attached to DOM
-    await flush();
+    await waitEventLoop();
     assert.equal(element.parentNode, document.body);
     element.style.setProperty('--gr-alert-transition-duration', '0ms');
     element.hide();
@@ -47,10 +62,10 @@
   test('action event', async () => {
     const spy = sinon.spy();
     element.show('Alert text');
-    await flush();
+    await waitEventLoop();
     element._actionCallback = spy;
     assert.isFalse(spy.called);
-    MockInteractions.tap(element.shadowRoot!.querySelector('.action')!);
+    element.shadowRoot!.querySelector<GrButton>('.action')!.click();
     assert.isTrue(spy.called);
   });
 });
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 97b6be1..3fd1b82 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
@@ -1,37 +1,19 @@
 /**
  * @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 '@polymer/iron-dropdown/iron-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
 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-dropdown_html';
-import {IronFitMixin} from '../../../mixins/iron-fit-mixin/iron-fit-mixin';
-import {customElement, property, observe} from '@polymer/decorators';
-import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
 import {fireEvent} from '../../../utils/event-util';
-import {addShortcut, Key} from '../../../utils/dom-util';
-
-export interface GrAutocompleteDropdown {
-  $: {
-    suggestions: Element;
-  };
-}
+import {Key} from '../../../utils/dom-util';
+import {FitController} from '../../lit/fit-controller';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query} from 'lit/decorators.js';
+import {repeat} from 'lit/directives/repeat.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ShortcutController} from '../../lit/shortcut-controller';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -52,19 +34,8 @@
   selected: HTMLElement | null;
 }
 
-// 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() {
-    return htmlTemplate;
-  }
-
+export class GrAutocompleteDropdown extends LitElement {
   /**
    * Fired when the dropdown is closed.
    *
@@ -80,74 +51,174 @@
   @property({type: Number})
   index: number | null = null;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true, attribute: 'is-hidden'})
   isHidden = true;
 
   @property({type: Number})
-  override verticalOffset: number | null = null;
+  verticalOffset = 0;
 
   @property({type: Number})
-  override horizontalOffset: number | null = null;
+  horizontalOffset = 0;
 
   @property({type: Array})
   suggestions: Item[] = [];
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  @query('#suggestions') suggestionsDiv?: HTMLDivElement;
+
+  private readonly shortcuts = new ShortcutController(this);
 
   // visible for testing
   cursor = new GrCursorManager();
 
+  // visible for testing
+  fitController = new FitController(this);
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          z-index: 100;
+        }
+        :host([is-hidden]) {
+          display: none;
+        }
+        ul {
+          list-style: none;
+        }
+        li {
+          border-bottom: 1px solid var(--border-color);
+          cursor: pointer;
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        li:last-of-type {
+          border: none;
+        }
+        li:focus {
+          outline: none;
+        }
+        li:hover {
+          background-color: var(--hover-background-color);
+        }
+        li.selected {
+          background-color: var(--hover-background-color);
+        }
+        .dropdown-content {
+          background: var(--dropdown-background-color);
+          box-shadow: var(--elevation-level-2);
+          border-radius: var(--border-radius);
+          max-height: 50vh;
+          overflow: auto;
+        }
+        @media only screen and (max-height: 35em) {
+          .dropdown-content {
+            max-height: 80vh;
+          }
+        }
+        .label {
+          color: var(--deemphasized-text-color);
+          padding-left: var(--spacing-l);
+        }
+        .hide {
+          display: none;
+        }
+      `,
+    ];
+  }
+
   constructor() {
     super();
     this.cursor.cursorTargetClass = 'selected';
     this.cursor.focusOnMove = true;
+    this.shortcuts.addLocal({key: Key.UP}, () => this.handleUp());
+    this.shortcuts.addLocal({key: Key.DOWN}, () => this.handleDown());
+    this.shortcuts.addLocal({key: Key.ENTER}, () => this.handleEnter());
+    this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEscape());
+    this.shortcuts.addLocal({key: Key.TAB}, () => this.handleTab());
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.cleanups.push(
-      addShortcut(this, {key: Key.UP}, () => this._handleUp())
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, () => this._handleDown())
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, () => this._handleEnter())
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, () => this._handleEscape())
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, () => this._handleTab())
-    );
   }
 
   override disconnectedCallback() {
     this.cursor.unsetCursor();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
     super.disconnectedCallback();
   }
 
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('index')) {
+      this.setIndex();
+    }
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (
+      changedProperties.has('suggestions') ||
+      changedProperties.has('isHidden')
+    ) {
+      if (!this.isHidden) {
+        this.computeCursorStopsAndRefit();
+      }
+    }
+  }
+
+  override render() {
+    return html`
+      <div
+        class="dropdown-content"
+        slot="dropdown-content"
+        id="suggestions"
+        role="listbox"
+      >
+        <ul>
+          ${repeat(
+            this.suggestions,
+            (item, index) => html`
+              <li
+                data-index=${index}
+                data-value=${item.dataValue ?? ''}
+                tabindex="-1"
+                aria-label=${item.name ?? ''}
+                class="autocompleteOption"
+                role="option"
+                @click=${this.handleClickItem}
+              >
+                <span>${item.text}</span>
+                <span class="label ${this.computeLabelClass(item)}"
+                  >${item.label}</span
+                >
+              </li>
+            `
+          )}
+        </ul>
+      </div>
+    `;
+  }
+
   close() {
     this.isHidden = true;
   }
 
   open() {
     this.isHidden = false;
-    this.onSuggestionsChanged();
   }
 
   getCurrentText() {
     return this.getCursorTarget()?.dataset['value'] || '';
   }
 
-  _handleUp() {
+  setPositionTarget(target: HTMLElement) {
+    this.fitController?.setPositionTarget(target);
+  }
+
+  private handleUp() {
     if (!this.isHidden) this.cursorUp();
   }
 
-  _handleDown() {
+  private handleDown() {
     if (!this.isHidden) this.cursorDown();
   }
 
@@ -159,7 +230,8 @@
     if (!this.isHidden) this.cursor.previous();
   }
 
-  _handleTab() {
+  // private but used in tests
+  handleTab() {
     this.dispatchEvent(
       new CustomEvent<ItemSelectedEvent>('item-selected', {
         detail: {
@@ -172,7 +244,8 @@
     );
   }
 
-  _handleEnter() {
+  // private but used in tests
+  handleEnter() {
     this.dispatchEvent(
       new CustomEvent<ItemSelectedEvent>('item-selected', {
         detail: {
@@ -185,12 +258,12 @@
     );
   }
 
-  _handleEscape() {
-    this._fireClose();
+  private handleEscape() {
+    this.fireClose();
     this.close();
   }
 
-  _handleClickItem(e: Event) {
+  private handleClickItem(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     let selected = e.target! as HTMLElement;
@@ -212,7 +285,7 @@
     );
   }
 
-  _fireClose() {
+  private fireClose() {
     fireEvent(this, 'dropdown-closed');
   }
 
@@ -220,32 +293,27 @@
     return this.cursor.target;
   }
 
-  @observe('suggestions')
-  onSuggestionsChanged() {
+  computeCursorStopsAndRefit() {
     if (this.suggestions.length > 0) {
-      if (!this.isHidden) {
-        flush();
-        this.cursor.stops = Array.from(
-          this.$.suggestions.querySelectorAll('li')
-        );
-        this._resetCursorIndex();
-      }
+      this.cursor.stops = Array.from(
+        this.suggestionsDiv?.querySelectorAll('li') ?? []
+      );
+      this.resetCursorIndex();
     } else {
       this.cursor.stops = [];
     }
-    this.refit();
+    this.fitController?.refit();
   }
 
-  @observe('index')
-  _setIndex() {
+  private setIndex() {
     this.cursor.index = this.index || -1;
   }
 
-  _resetCursorIndex() {
+  private resetCursorIndex() {
     this.cursor.setCursorAtIndex(0);
   }
 
-  _computeLabelClass(item: Item) {
+  private computeLabelClass(item: Item) {
     return item.label ? '' : 'hide';
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts
deleted file mode 100644
index b86e8ec..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_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 {
-      z-index: 100;
-    }
-    :host([is-hidden]) {
-      display: none;
-    }
-    ul {
-      list-style: none;
-    }
-    li {
-      border-bottom: 1px solid var(--border-color);
-      cursor: pointer;
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    li:last-of-type {
-      border: none;
-    }
-    li:focus {
-      outline: none;
-    }
-    li:hover {
-      background-color: var(--hover-background-color);
-    }
-    li.selected {
-      background-color: var(--selection-background-color);
-    }
-    .dropdown-content {
-      background: var(--dropdown-background-color);
-      box-shadow: var(--elevation-level-2);
-      border-radius: var(--border-radius);
-      max-height: 50vh;
-      overflow: auto;
-    }
-    @media only screen and (max-height: 35em) {
-      .dropdown-content {
-        max-height: 80vh;
-      }
-    }
-    .label {
-      color: var(--deemphasized-text-color);
-      padding-left: var(--spacing-l);
-    }
-    .hide {
-      display: none;
-    }
-  </style>
-  <div
-    class="dropdown-content"
-    slot="dropdown-content"
-    id="suggestions"
-    role="listbox"
-  >
-    <ul>
-      <template is="dom-repeat" items="[[suggestions]]">
-        <li
-          data-index$="[[index]]"
-          data-value$="[[item.dataValue]]"
-          tabindex="-1"
-          aria-label$="[[item.name]]"
-          class="autocompleteOption"
-          role="option"
-          on-click="_handleClickItem"
-        >
-          <span>[[item.text]]</span>
-          <span class$="label [[_computeLabelClass(item)]]"
-            >[[item.label]]</span
-          >
-        </li>
-      </template>
-    </ul>
-  </div>
-`;
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 f8478cd..641dd2d 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
@@ -1,27 +1,21 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-autocomplete-dropdown';
 import {GrAutocompleteDropdown} from './gr-autocomplete-dropdown';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {
+  pressKey,
+  queryAll,
+  queryAndAssert,
+  waitEventLoop,
+  waitUntil,
+} from '../../../test/test-utils';
 import {assertIsDefined} from '../../../utils/common-util';
-
-const basicFixture = fixtureFromElement('gr-autocomplete-dropdown');
+import {fixture, html, assert} from '@open-wc/testing';
+import {Key} from '../../../utils/dom-util';
 
 suite('gr-autocomplete-dropdown', () => {
   let element: GrAutocompleteDropdown;
@@ -29,37 +23,78 @@
   const suggestionsEl = () => queryAndAssert(element, '#suggestions');
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
+    );
     element.open();
     element.suggestions = [
       {dataValue: 'test value 1', name: 'test name 1', text: '1', label: 'hi'},
       {dataValue: 'test value 2', name: 'test name 2', text: '2'},
     ];
-    await flush();
+    await waitEventLoop();
   });
 
   teardown(() => {
     element.close();
   });
 
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div
+          class="dropdown-content"
+          id="suggestions"
+          role="listbox"
+          slot="dropdown-content"
+        >
+          <ul>
+            <li
+              aria-label="test name 1"
+              class="autocompleteOption selected"
+              data-index="0"
+              data-value="test value 1"
+              role="option"
+              tabindex="-1"
+            >
+              <span> 1 </span>
+              <span class="label"> hi </span>
+            </li>
+            <li
+              aria-label="test name 2"
+              class="autocompleteOption"
+              data-index="1"
+              data-value="test value 2"
+              role="option"
+              tabindex="-1"
+            >
+              <span> 2 </span>
+              <span class="hide label"> </span>
+            </li>
+          </ul>
+        </div>
+      `
+    );
+  });
+
   test('shows labels', () => {
     const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
     assert.equal(els[0].innerText.trim(), '1\nhi');
     assert.equal(els[1].innerText.trim(), '2');
   });
 
-  test('escape key', () => {
+  test('escape key', async () => {
     const closeSpy = sinon.spy(element, 'close');
-    MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'Escape');
-    flush();
+    pressKey(element, Key.ESC);
+    await waitEventLoop();
     assert.isTrue(closeSpy.called);
   });
 
   test('tab key', () => {
-    const handleTabSpy = sinon.spy(element, '_handleTab');
+    const handleTabSpy = sinon.spy(element, 'handleTab');
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
-    MockInteractions.pressAndReleaseKeyOn(element, 9, null, 'Tab');
+    pressKey(element, Key.TAB);
     assert.isTrue(handleTabSpy.called);
     assert.equal(element.cursor.index, 0);
     assert.isTrue(itemSelectedStub.called);
@@ -70,10 +105,10 @@
   });
 
   test('enter key', () => {
-    const handleEnterSpy = sinon.spy(element, '_handleEnter');
+    const handleEnterSpy = sinon.spy(element, 'handleEnter');
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
-    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
+    pressKey(element, Key.ENTER);
     assert.isTrue(handleEnterSpy.called);
     assert.equal(element.cursor.index, 0);
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
@@ -85,11 +120,11 @@
   test('down key', () => {
     element.isHidden = true;
     const nextSpy = sinon.spy(element.cursor, 'next');
-    MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
+    pressKey(element, 'ArrowDown');
     assert.isFalse(nextSpy.called);
     assert.equal(element.cursor.index, 0);
     element.isHidden = false;
-    MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
+    pressKey(element, 'ArrowDown');
     assert.isTrue(nextSpy.called);
     assert.equal(element.cursor.index, 1);
   });
@@ -97,46 +132,46 @@
   test('up key', () => {
     element.isHidden = true;
     const prevSpy = sinon.spy(element.cursor, 'previous');
-    MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
+    pressKey(element, 'ArrowUp');
     assert.isFalse(prevSpy.called);
     assert.equal(element.cursor.index, 0);
     element.isHidden = false;
     element.cursor.setCursorAtIndex(1);
     assert.equal(element.cursor.index, 1);
-    MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
+    pressKey(element, 'ArrowUp');
     assert.isTrue(prevSpy.called);
     assert.equal(element.cursor.index, 0);
   });
 
-  test('tapping selects item', () => {
+  test('tapping selects item', async () => {
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
 
-    MockInteractions.tap(suggestionsEl().querySelectorAll('li')[1]);
-    flush();
+    suggestionsEl().querySelectorAll('li')[1].click();
+    await waitEventLoop();
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
       trigger: 'click',
       selected: suggestionsEl().querySelectorAll('li')[1],
     });
   });
 
-  test('tapping child still selects item', () => {
+  test('tapping child still selects item', async () => {
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
-    const lastElChild = queryAll<HTMLElement>(suggestionsEl(), 'li')[0]
+    const lastElChild = queryAll<HTMLLIElement>(suggestionsEl(), 'li')[0]
       ?.lastElementChild;
     assertIsDefined(lastElChild);
-    MockInteractions.tap(lastElChild);
-    flush();
+    (lastElChild as HTMLSpanElement).click();
+    await waitEventLoop();
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
       trigger: 'click',
       selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
     });
   });
 
-  test('updated suggestions resets cursor stops', () => {
-    const resetStopsSpy = sinon.spy(element, 'onSuggestionsChanged');
+  test('updated suggestions resets cursor stops', async () => {
+    const resetStopsSpy = sinon.spy(element, 'computeCursorStopsAndRefit');
     element.suggestions = [];
-    assert.isTrue(resetStopsSpy.called);
+    await waitUntil(() => 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 04fdd0f..34e67b9 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -1,34 +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 '@polymer/paper-input/paper-input';
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
-import '../gr-icons/gr-icons';
+import '../gr-icon/gr-icon';
 import '../../../styles/shared-styles';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {PaperInputElementExt} from '../../../types/types';
 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 {customElement, property, query, state} from 'lit/decorators.js';
 import {ValueChangedEvent} from '../../../types/events';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {IronInputElement} from '@polymer/iron-input';
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
@@ -96,7 +85,7 @@
   @property({type: Object})
   query?: AutocompleteQuery = () => Promise.resolve([]);
 
-  @query('#input') input?: PaperInputElementExt;
+  @query('#input') input?: PaperInputElement;
 
   @query('#suggestions') suggestionsDropdown?: GrAutocompleteDropdown;
 
@@ -182,7 +171,9 @@
 
   @state() index: number | null = null;
 
-  @state() disableSuggestions = false;
+  // Enabled to suppress showing/updating suggestions when changing properties
+  // that would normally trigger the update.
+  disableDisplayingSuggestions = false;
 
   // private but used in tests
   focused = false;
@@ -205,9 +196,8 @@
       .searchIcon.showSearchIcon {
         display: inline-block;
       }
-      iron-icon {
+      gr-icon {
         margin: 0 var(--spacing-xs);
-        vertical-align: top;
       }
       paper-input.borderless {
         border: none;
@@ -216,7 +206,7 @@
       paper-input {
         background-color: var(--view-background-color);
         color: var(--primary-text-color);
-        border: 1px solid var(--border-color);
+        border: 1px solid var(--prominent-border-color, var(--border-color));
         border-radius: var(--border-radius);
         padding: var(--spacing-s);
         --paper-input-container_-_padding: 0;
@@ -279,11 +269,8 @@
     ) {
       this.updateSuggestions();
     }
-    if (
-      changedProperties.has('suggestions') ||
-      changedProperties.has('focused')
-    ) {
-      this.maybeOpenDropdown();
+    if (changedProperties.has('suggestions')) {
+      this.updateDropdownVisibility();
     }
     if (changedProperties.has('text')) {
       fire(this, 'text-changed', {value: this.text});
@@ -312,13 +299,12 @@
         .label=${this.label}
       >
         <div slot="prefix">
-          <iron-icon
-            icon="gr-icons:search"
+          <gr-icon
+            icon="search"
             class="searchIcon ${this.computeShowSearchIconClass(
               this.showSearchIcon
             )}"
-          >
-          </iron-icon>
+          ></gr-icon>
         </div>
 
         <div slot="suffix">
@@ -326,11 +312,10 @@
         </div>
       </paper-input>
       <gr-autocomplete-dropdown
-        vertical-align="top"
         .verticalOffset=${this.verticalOffset}
-        horizontal-align="left"
         id="suggestions"
         @item-selected=${this.handleItemSelect}
+        @dropdown-closed=${this.focusWithoutDisplayingSuggestions}
         .suggestions=${this.suggestions}
         role="listbox"
         .index=${this.index}
@@ -347,6 +332,15 @@
     this.nativeInput.focus();
   }
 
+  private focusWithoutDisplayingSuggestions() {
+    this.disableDisplayingSuggestions = true;
+    this.focus();
+
+    this.updateComplete.then(() => {
+      this.disableDisplayingSuggestions = false;
+    });
+  }
+
   selectAll() {
     const nativeInputElement = this.nativeInput;
     if (!this.input?.value) {
@@ -359,16 +353,22 @@
     this.text = '';
   }
 
+  private handleItemSelectEnter(e: CustomEvent | KeyboardEvent) {
+    this.handleInputCommit();
+    e.stopPropagation();
+    e.preventDefault();
+    this.focusWithoutDisplayingSuggestions();
+  }
+
   handleItemSelect(e: CustomEvent) {
     if (e.detail.trigger === 'click') {
       this.selected = e.detail.selected;
       this._commit();
       e.stopPropagation();
       e.preventDefault();
+      this.focusWithoutDisplayingSuggestions();
     } else if (e.detail.trigger === 'enter') {
-      this.handleInputCommit();
-      e.stopPropagation();
-      e.preventDefault();
+      this.handleItemSelectEnter(e);
     } else if (e.detail.trigger === 'tab') {
       if (this.tabComplete) {
         this.handleInputCommit(true);
@@ -386,13 +386,13 @@
    *
    * @param text The new text for the input.
    */
-  async setText(text: string) {
-    this.disableSuggestions = true;
+  setText(text: string) {
+    this.disableDisplayingSuggestions = true;
     this.text = text;
-    // if we disableSuggestions immediately then suggestions are requested in
-    // updateSuggestions
-    await this.updateComplete;
-    this.disableSuggestions = false;
+
+    this.updateComplete.then(() => {
+      this.disableDisplayingSuggestions = false;
+    });
   }
 
   onInputFocus() {
@@ -427,7 +427,7 @@
 
     // TODO(taoalpha): Also skip if text has not changed
 
-    if (this.disableSuggestions) {
+    if (this.disableDisplayingSuggestions) {
       return;
     }
 
@@ -475,10 +475,10 @@
   setFocus(focused: boolean) {
     if (focused === this.focused) return;
     this.focused = focused;
-    this.maybeOpenDropdown();
+    this.updateDropdownVisibility();
   }
 
-  maybeOpenDropdown() {
+  updateDropdownVisibility() {
     if (this.suggestions.length > 0 && this.focused) {
       this.suggestionsDropdown?.open();
       return;
@@ -498,20 +498,20 @@
    */
   handleKeydown(e: KeyboardEvent) {
     this.setFocus(true);
-    switch (e.keyCode) {
-      case 38: // Up
+    switch (e.key) {
+      case 'ArrowUp':
         e.preventDefault();
         this.suggestionsDropdown?.cursorUp();
         break;
-      case 40: // Down
+      case 'ArrowDown':
         e.preventDefault();
         this.suggestionsDropdown?.cursorDown();
         break;
-      case 27: // Escape
+      case 'Escape':
         e.preventDefault();
         this.cancel();
         break;
-      case 9: // Tab
+      case 'Tab':
         if (this.suggestions.length > 0 && this.tabComplete) {
           e.preventDefault();
           this.focus();
@@ -520,12 +520,17 @@
           this.setFocus(false);
         }
         break;
-      case 13: // Enter
+      case 'Enter':
         if (modifierPressed(e)) {
           break;
         }
-        e.preventDefault();
-        this.handleInputCommit();
+        if (this.suggestions.length > 0) {
+          // If suggestions are shown, act as if the keypress is in dropdown.
+          this.handleItemSelectEnter(e);
+        } else {
+          e.preventDefault();
+          this.handleInputCommit();
+        }
         break;
       default:
         // For any normal keypress, return focus to the input to allow for
@@ -540,7 +545,7 @@
     }
     this.dispatchEvent(
       new CustomEvent('input-keydown', {
-        detail: {keyCode: e.keyCode, input: this.input},
+        detail: {key: e.key, input: this.input},
         composed: true,
         bubbles: true,
       })
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 7f66b60..d991180 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
@@ -1,34 +1,22 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-autocomplete';
 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, waitUntil} from '../../../test/test-utils';
+import {pressKey, queryAndAssert, waitUntil} from '../../../test/test-utils';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
+import {Key, Modifier} from '../../../utils/dom-util';
 
 suite('gr-autocomplete tests', () => {
   let element: GrAutocomplete;
 
   const focusOnInput = () => {
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    pressKey(inputEl(), Key.ENTER);
   };
 
   const suggestionsEl = () =>
@@ -40,45 +28,107 @@
     element = await fixture(
       html`<gr-autocomplete no-debounce></gr-autocomplete>`
     );
-    await element.updateComplete;
   });
 
-  test('renders', async () => {
-    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
-    const queryStub = sinon.spy(
-      (input: string) =>
-        (promise = Promise.resolve([
-          {name: input + ' 0', value: '0'},
-          {name: input + ' 1', value: '1'},
-          {name: input + ' 2', value: '2'},
-          {name: input + ' 3', value: '3'},
-          {name: input + ' 4', value: '4'},
-        ] as AutocompleteSuggestion[]))
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <paper-input
+          aria-disabled="false"
+          autocomplete="off"
+          id="input"
+          tabindex="0"
+        >
+          <div slot="prefix">
+            <gr-icon icon="search" class="searchIcon"></gr-icon>
+          </div>
+          <div slot="suffix">
+            <slot name="suffix"> </slot>
+          </div>
+        </paper-input>
+        <gr-autocomplete-dropdown
+          id="suggestions"
+          is-hidden=""
+          role="listbox"
+          style="position: fixed; top: 300px; left: 392.5px; box-sizing: border-box; max-height: 600px; max-width: 785px;"
+        >
+        </gr-autocomplete-dropdown>
+      `,
+      {
+        // gr-autocomplete-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [
+          {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+        ],
+      }
+    );
+  });
+
+  test('renders with suggestions', async () => {
+    const queryStub = sinon.spy((input: string) =>
+      Promise.resolve([
+        {name: input + ' 0', value: '0'},
+        {name: input + ' 1', value: '1'},
+        {name: input + ' 2', value: '2'},
+        {name: input + ' 3', value: '3'},
+        {name: input + ' 4', value: '4'},
+      ] as AutocompleteSuggestion[])
     );
     element.query = queryStub;
-    assert.isTrue(suggestionsEl().isHidden);
+
+    focusOnInput();
+    element.text = 'blah';
+    await waitUntil(() => queryStub.called);
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <paper-input
+          aria-disabled="false"
+          autocomplete="off"
+          id="input"
+          tabindex="0"
+        >
+          <div slot="prefix">
+            <gr-icon icon="search" class="searchIcon"></gr-icon>
+          </div>
+          <div slot="suffix">
+            <slot name="suffix"> </slot>
+          </div>
+        </paper-input>
+        <gr-autocomplete-dropdown id="suggestions" role="listbox">
+        </gr-autocomplete-dropdown>
+      `,
+      {
+        // gr-autocomplete-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [
+          {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+        ],
+      }
+    );
+  });
+
+  test('cursor starts on suggestions', async () => {
+    const queryStub = sinon.spy((input: string) =>
+      Promise.resolve([
+        {name: input + ' 0', value: '0'},
+        {name: input + ' 1', value: '1'},
+        {name: input + ' 2', value: '2'},
+        {name: input + ' 3', value: '3'},
+        {name: input + ' 4', value: '4'},
+      ] as AutocompleteSuggestion[])
+    );
+    element.query = queryStub;
+
     assert.equal(suggestionsEl().cursor.index, -1);
 
     focusOnInput();
     element.text = 'blah';
     await waitUntil(() => queryStub.called);
+    await element.updateComplete;
 
-    assert.isTrue(queryStub.called);
-    element.setFocus(true);
-
-    assertIsDefined(promise);
-    return promise.then(async () => {
-      await element.updateComplete;
-      assert.isFalse(suggestionsEl().isHidden);
-      const suggestions = queryAll<HTMLElement>(suggestionsEl(), 'li');
-      assert.equal(suggestions.length, 5);
-
-      for (let i = 0; i < 5; i++) {
-        assert.equal(suggestions[i].innerText.trim(), `blah ${i}`);
-      }
-
-      assert.notEqual(suggestionsEl().cursor.index, -1);
-    });
+    assert.notEqual(suggestionsEl().cursor.index, -1);
   });
 
   test('selectAll', async () => {
@@ -118,13 +168,13 @@
       const cancelHandler = sinon.spy();
       element.addEventListener('cancel', cancelHandler);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 27, null, 'esc');
+      pressKey(inputEl(), Key.ESC);
       await waitUntil(() => suggestionsEl().isHidden);
 
       assert.isFalse(cancelHandler.called);
       assert.equal(element.suggestions.length, 0);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 27, null, 'esc');
+      pressKey(inputEl(), Key.ESC);
       await element.updateComplete;
 
       assert.isTrue(cancelHandler.called);
@@ -160,22 +210,22 @@
 
       assert.equal(suggestionsEl().cursor.index, 0);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 40, null, 'down');
+      pressKey(inputEl(), 'ArrowDown');
       await element.updateComplete;
 
       assert.equal(suggestionsEl().cursor.index, 1);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 40, null, 'down');
+      pressKey(inputEl(), 'ArrowDown');
       await element.updateComplete;
 
       assert.equal(suggestionsEl().cursor.index, 2);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 38, null, 'up');
+      pressKey(inputEl(), 'ArrowUp');
       await element.updateComplete;
 
       assert.equal(suggestionsEl().cursor.index, 1);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      pressKey(inputEl(), Key.ENTER);
       await element.updateComplete;
 
       assert.equal(element.value, '1');
@@ -204,7 +254,7 @@
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      pressKey(inputEl(), Key.ENTER);
 
       await waitUntil(() => commitHandler.called);
 
@@ -232,7 +282,7 @@
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      pressKey(inputEl(), Key.ENTER);
 
       await waitUntil(() => commitHandler.called);
 
@@ -373,7 +423,7 @@
       element.addEventListener('commit', commitHandler);
       await waitUntil(() => element.suggestionsDropdown?.isHidden === false);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      pressKey(inputEl(), Key.ENTER);
 
       await waitUntil(() => commitHandler.called);
       assert.equal(element.text, 'blah 0');
@@ -390,7 +440,7 @@
 
     element.suggestions = [{text: 'tunnel snakes rule!', name: ''}];
     element.tabComplete = false;
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    pressKey(inputEl(), Key.TAB);
     await element.updateComplete;
 
     assert.isFalse(commitHandler.called);
@@ -401,7 +451,7 @@
     await element.updateComplete;
     element.setFocus(true);
     await element.updateComplete;
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    pressKey(inputEl(), Key.TAB);
 
     await waitUntil(() => commitSpy.called);
     assert.isFalse(commitHandler.called);
@@ -411,24 +461,21 @@
   test('focused flag properly triggered', async () => {
     await element.updateComplete;
     assert.isFalse(element.focused);
-    const input = queryAndAssert<PaperInputElement>(
-      element,
-      'paper-input'
-    ).inputElement;
-    MockInteractions.focus(input);
+    const input = queryAndAssert<PaperInputElement>(element, 'paper-input');
+    input.focus();
     assert.isTrue(element.focused);
   });
 
   test('search icon shows with showSearchIcon property', async () => {
     assert.equal(
-      getComputedStyle(queryAndAssert(element, 'iron-icon')).display,
+      getComputedStyle(queryAndAssert(element, 'gr-icon')).display,
       'none'
     );
     element.showSearchIcon = true;
     await element.updateComplete;
 
     assert.notEqual(
-      getComputedStyle(queryAndAssert(element, 'iron-icon')).display,
+      getComputedStyle(queryAndAssert(element, 'gr-icon')).display,
       'none'
     );
   });
@@ -510,13 +557,13 @@
     element.suggestions = [makeSuggestion('file:'), makeSuggestion('-file:')];
     await element.updateComplete;
 
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 88, null, 'x');
+    pressKey(inputEl(), 'x');
     // Must set the value, because the MockInteraction does not.
     inputEl().value = 'file:x';
 
     assert.isTrue(keydownSpy.calledOnce);
 
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    pressKey(inputEl(), Key.ENTER);
     await element.updateComplete;
     assert.isTrue(keydownSpy.calledTwice);
 
@@ -531,17 +578,33 @@
       commitSpy = sinon.spy(element, '_commit');
     });
 
-    test('enter does not call focus', async () => {
+    test('enter in input does not re-render suggestions', async () => {
       element.suggestions = [{text: 'sugar bombs'}];
 
-      focusSpy = sinon.spy(element, 'focus');
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      pressKey(inputEl(), Key.ENTER);
 
-      // Dropdown is hidden without focus so this should never happen?
       await waitUntil(() => commitSpy.called);
+      await element.updateComplete;
 
-      assert.isFalse(focusSpy.called);
       assert.equal(element.suggestions.length, 0);
+      assert.isTrue(suggestionsEl().isHidden);
+    });
+
+    test('enter in suggestion does not re-render suggestions', async () => {
+      element.suggestions = [{text: 'sugar bombs'}];
+      element.setFocus(true);
+
+      await element.updateComplete;
+      assert.isFalse(suggestionsEl().isHidden);
+
+      focusSpy = sinon.spy(element, 'focus');
+      pressKey(suggestionsEl(), Key.ENTER);
+
+      await waitUntil(() => commitSpy.called);
+      await element.updateComplete;
+
+      assert.equal(element.suggestions.length, 0);
+      assert.isTrue(suggestionsEl().isHidden);
     });
 
     test('tab in input, tabComplete = true', async () => {
@@ -551,7 +614,7 @@
       element.tabComplete = true;
       element.suggestions = [{text: 'tunnel snakes drool'}];
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+      pressKey(inputEl(), Key.TAB);
 
       await waitUntil(() => commitSpy.called);
 
@@ -563,7 +626,7 @@
     test('tab in input, tabComplete = false', async () => {
       element.suggestions = [{text: 'sugar bombs'}];
       focusSpy = sinon.spy(element, 'focus');
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+      pressKey(inputEl(), Key.TAB);
       await element.updateComplete;
 
       assert.isFalse(commitSpy.called);
@@ -583,12 +646,7 @@
 
       assert.isFalse(suggestionsEl().isHidden);
 
-      MockInteractions.pressAndReleaseKeyOn(
-        queryAndAssert(suggestionsEl(), 'li:first-child'),
-        9,
-        null,
-        'Tab'
-      );
+      pressKey(queryAndAssert(suggestionsEl(), 'li:first-child'), Key.TAB);
       await element.updateComplete;
       assert.isFalse(commitSpy.called);
       assert.isFalse(element.focused);
@@ -605,19 +663,14 @@
 
       assert.isFalse(suggestionsEl().isHidden);
 
-      MockInteractions.pressAndReleaseKeyOn(
-        queryAndAssert(suggestionsEl(), 'li:first-child'),
-        9,
-        null,
-        'Tab'
-      );
+      pressKey(queryAndAssert(suggestionsEl(), 'li:first-child'), Key.TAB);
       await element.updateComplete;
 
       assert.isTrue(commitSpy.called);
       assert.isTrue(element.focused);
     });
 
-    test('tap on suggestion commits, does not call focus', async () => {
+    test('tap on suggestion commits, calls focus', async () => {
       focusSpy = sinon.spy(element, 'focus');
       element.setFocus(true);
       element.suggestions = [{name: 'first suggestion'}];
@@ -625,18 +678,36 @@
       await element.updateComplete;
 
       await waitUntil(() => !suggestionsEl().isHidden);
-      MockInteractions.tap(queryAndAssert(suggestionsEl(), 'li:first-child'));
+      queryAndAssert<HTMLLIElement>(suggestionsEl(), 'li:first-child').click();
 
       await waitUntil(() => suggestionsEl().isHidden);
-      assert.isFalse(focusSpy.called);
+      assert.isTrue(focusSpy.called);
       assert.isTrue(commitSpy.called);
     });
+
+    test('esc on suggestion clears suggestions, calls focus', async () => {
+      element.suggestions = [{name: 'sugar bombs'}];
+      element.setFocus(true);
+      focusSpy = sinon.spy(element, 'focus');
+
+      await element.updateComplete;
+
+      assert.isFalse(suggestionsEl().isHidden);
+
+      pressKey(queryAndAssert(suggestionsEl(), 'li:first-child'), Key.ESC);
+
+      await waitUntil(() => suggestionsEl().isHidden);
+      await element.updateComplete;
+
+      assert.isFalse(commitSpy.called);
+      assert.isTrue(focusSpy.called);
+    });
   });
 
   test('input-keydown event fired', async () => {
     const listener = sinon.spy();
     element.addEventListener('input-keydown', listener);
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    pressKey(inputEl(), Key.TAB);
     await element.updateComplete;
     assert.isTrue(listener.called);
   });
@@ -644,22 +715,44 @@
   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');
+    pressKey(inputEl(), Key.ENTER, Modifier.CTRL_KEY);
     await element.updateComplete;
 
     assert.equal(dispatchEventStub.lastCall.args[0].type, 'input-keydown');
     assert.equal(
-      (dispatchEventStub.lastCall.args[0] as CustomEvent).detail.keyCode,
-      13
+      (dispatchEventStub.lastCall.args[0] as CustomEvent).detail.key,
+      Key.ENTER
     );
 
     assert.isFalse(commitStub.called);
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    pressKey(inputEl(), Key.ENTER);
     await element.updateComplete;
 
     assert.isTrue(commitStub.called);
   });
 
+  test('enter with dropdown does not propagate', async () => {
+    const event = new KeyboardEvent('keydown', {key: Key.ENTER});
+    const stopPropagationStub = sinon.stub(event, 'stopPropagation');
+
+    element.suggestions = [{name: 'first suggestion'}];
+
+    inputEl().dispatchEvent(event);
+    await element.updateComplete;
+
+    assert.isTrue(stopPropagationStub.called);
+  });
+
+  test('enter with no dropdown propagates', async () => {
+    const event = new KeyboardEvent('keydown', {key: Key.ENTER});
+    const stopPropagationStub = sinon.stub(event, 'stopPropagation');
+
+    inputEl().dispatchEvent(event);
+    await element.updateComplete;
+
+    assert.isFalse(stopPropagationStub.called);
+  });
+
   suite('warnUncommitted', () => {
     let inputClassList: DOMTokenList;
     setup(() => {
@@ -669,23 +762,23 @@
     test('enabled', () => {
       element.warnUncommitted = true;
       element.text = 'blah blah blah';
-      MockInteractions.blur(inputEl());
+      inputEl().dispatchEvent(new Event('blur'));
       assert.isTrue(inputClassList.contains('warnUncommitted'));
-      MockInteractions.focus(inputEl());
+      inputEl().focus();
       assert.isFalse(inputClassList.contains('warnUncommitted'));
     });
 
     test('disabled', () => {
       element.warnUncommitted = false;
       element.text = 'blah blah blah';
-      MockInteractions.blur(inputEl());
+      inputEl().blur();
       assert.isFalse(inputClassList.contains('warnUncommitted'));
     });
 
     test('no text', () => {
       element.warnUncommitted = true;
       element.text = '';
-      MockInteractions.blur(inputEl());
+      inputEl().blur();
       assert.isFalse(inputClassList.contains('warnUncommitted'));
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
new file mode 100644
index 0000000..4fa716b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import './gr-avatar';
+import {AccountInfo} from '../../../types/common';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {uniqueDefinedAvatar} from '../../../utils/account-util';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {subscribe} from '../../lit/subscription-controller';
+
+/**
+ * This elements draws stack of avatars overlapped with each other.
+ *
+ * If accounts is empty or contains accounts with more than MAX_STACK unique
+ * avatars the fallback slot is rendered instead.
+ *
+ * Style parameters:
+ *   --avatar-size: size of the individual avatars. (Default: 16px)
+ *   --stack-border-color: border of individual avatars in stack.
+ *       (Default: #ffffff)
+ */
+@customElement('gr-avatar-stack')
+export class GrAvatarStack extends LitElement {
+  static readonly MAX_STACK = 4;
+
+  @property({type: Array})
+  accounts: AccountInfo[] = [];
+
+  /**
+   * The size of requested image in px.
+   *
+   * By default this also controls avatarSize.
+   */
+  @property({type: Number})
+  imageSize = 16;
+
+  /**
+   * Reflects plugins.has_avatars value of server configuration.
+   */
+  @state() private hasAvatars = false;
+
+  static override get styles() {
+    return [
+      css`
+        gr-avatar {
+          box-sizing: border-box;
+          vertical-align: top;
+          height: var(--avatar-size, 16px);
+          width: var(--avatar-size, 16px);
+          border: solid 1px var(--stack-border-color, transparent);
+        }
+        gr-avatar:not(:first-child) {
+          margin-left: calc((var(--avatar-size, 16px) / -2));
+        }
+      `,
+    ];
+  }
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.hasAvatars = Boolean(config?.plugin?.has_avatars);
+      }
+    );
+  }
+
+  override render() {
+    const uniqueAvatarAccounts = this.accounts
+      .filter(account => !!account?.avatars?.[0]?.url)
+      .filter(uniqueDefinedAvatar);
+    if (
+      !this.hasAvatars ||
+      uniqueAvatarAccounts.length === 0 ||
+      uniqueAvatarAccounts.length > GrAvatarStack.MAX_STACK
+    ) {
+      return html`<slot name="fallback"></slot>`;
+    }
+    return uniqueAvatarAccounts.map(
+      account =>
+        html`<gr-avatar .account=${account} .imageSize=${this.imageSize}>
+        </gr-avatar>`
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-avatar-stack': GrAvatarStack;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts
new file mode 100644
index 0000000..c186a48
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts
@@ -0,0 +1,139 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-avatar-stack';
+import {
+  createAccountWithId,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {fixture, html, assert} from '@open-wc/testing';
+import {stubRestApi} from '../../../test/test-utils';
+import {LitElement} from 'lit';
+
+suite('gr-avatar tests', () => {
+  suite('config with avatars', () => {
+    setup(() => {
+      // Set up server response, so that gr-avatar is not hidden.
+      stubRestApi('getConfig').resolves({
+        ...createServerInfo(),
+        plugin: {has_avatars: true, js_resource_paths: []},
+      });
+    });
+
+    test('renders avatars', async () => {
+      const accounts = [];
+      for (let i = 0; i < 2; ++i) {
+        accounts.push({
+          ...createAccountWithId(i),
+          avatars: [
+            {
+              url: `https://a.b.c/photo${i}.jpg`,
+              height: 32,
+              width: 32,
+            },
+          ],
+        });
+      }
+      accounts.push({
+        ...createAccountWithId(2),
+        avatars: [
+          {
+            // Account with duplicate avatar will be skipped.
+            url: 'https://a.b.c/photo1.jpg',
+            height: 32,
+            width: 32,
+          },
+        ],
+      });
+
+      const element: LitElement = await fixture(
+        html`<gr-avatar-stack
+          .accounts=${accounts}
+          .imageSize=${32}
+        ></gr-avatar-stack>`
+      );
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<gr-avatar
+            style='background-image: url("https://a.b.c/photo0.jpg");'
+          >
+          </gr-avatar>
+          <gr-avatar style='background-image: url("https://a.b.c/photo1.jpg");'>
+          </gr-avatar> `
+      );
+      // Verify that margins are set correctly.
+      const avatars = element.shadowRoot!.querySelectorAll('gr-avatar');
+      assert.strictEqual(avatars.length, 2);
+      assert.strictEqual(window.getComputedStyle(avatars[0]).marginLeft, '0px');
+      for (let i = 1; i < avatars.length; ++i) {
+        assert.strictEqual(
+          window.getComputedStyle(avatars[i]).marginLeft,
+          '-8px'
+        );
+      }
+    });
+
+    test('renders many accounts fallback', async () => {
+      const accounts = [];
+      for (let i = 0; i < 5; ++i) {
+        accounts.push({
+          ...createAccountWithId(i),
+          avatars: [
+            {
+              url: `https://a.b.c/photo${i}.jpg`,
+              height: 32,
+              width: 32,
+            },
+          ],
+        });
+      }
+
+      const element = await fixture(
+        html`<gr-avatar-stack .accounts=${accounts} .imageSize=${32}>
+          <span slot="fallback">Fall back!</span>
+        </gr-avatar-stack>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ '<slot name="fallback"></slot>'
+      );
+    });
+
+    test('renders no accounts fallback', async () => {
+      // Single account without an avatar.
+      const accounts = [createAccountWithId(1)];
+
+      const element = await fixture(
+        html`<gr-avatar-stack .accounts=${accounts} .imageSize=${32}>
+          <span slot="fallback">Fall back!</span>
+        </gr-avatar-stack>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ '<slot name="fallback"></slot>'
+      );
+    });
+  });
+
+  test('renders no avatars fallback', async () => {
+    // Set up server response, to indicate that no avatars are being served.
+    stubRestApi('getConfig').resolves({
+      ...createServerInfo(),
+      plugin: {has_avatars: false, js_resource_paths: []},
+    });
+    // Single account without an avatar.
+    const accounts = [createAccountWithId(1)];
+
+    const element = await fixture(
+      html`<gr-avatar-stack .accounts=${accounts} .imageSize=${32}>
+        <span slot="fallback">Fall back!</span>
+      </gr-avatar-stack>`
+    );
+    assert.shadowDom.equal(element, /* HTML */ '<slot name="fallback"></slot>');
+  });
+});
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 34c553d..b34724c 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -1,26 +1,19 @@
 /**
  * @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 {getBaseUrl} from '../../../utils/url-util';
 import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
 import {AccountInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 
+/**
+ * The <gr-avatar> component works by updating its own background and visibility
+ * rather than conditionally rendering an image into it's shadow root.
+ */
 @customElement('gr-avatar')
 export class GrAvatar extends LitElement {
   @property({type: Object})
@@ -29,8 +22,7 @@
   @property({type: Number})
   imageSize = 16;
 
-  @property({type: Boolean})
-  _hasAvatars = false;
+  @state() private hasAvatars = false;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -54,46 +46,41 @@
   }
 
   override render() {
-    this._updateAvatarURL();
+    this.updateHostVisibilityAndImage();
     return html``;
   }
 
   override connectedCallback() {
     super.connectedCallback();
     Promise.all([
-      this._getConfig(),
+      this.restApiService.getConfig(),
       getPluginLoader().awaitPluginsLoaded(),
     ]).then(([cfg]) => {
-      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-
-      this._updateAvatarURL();
+      this.hasAvatars = Boolean(cfg?.plugin?.has_avatars);
+      this.updateHostVisibilityAndImage();
     });
   }
 
-  _getConfig() {
-    return this.restApiService.getConfig();
-  }
-
-  _updateAvatarURL() {
-    if (!this._hasAvatars || !this.account) {
+  private updateHostVisibilityAndImage() {
+    if (!this.hasAvatars || !this.account) {
       this.hidden = true;
       return;
     }
     this.hidden = false;
 
-    const url = this._buildAvatarURL(this.account);
+    const url = this.buildAvatarURL(this.account);
     if (url) {
-      this.style.backgroundImage = 'url("' + url + '")';
+      this.style.backgroundImage = `url("${url}")`;
     }
   }
 
-  _getAccounts(account: AccountInfo) {
+  private getAccounts(account: AccountInfo) {
     return (
       account._account_id || account.email || account.username || account.name
     );
   }
 
-  _buildAvatarURL(account?: AccountInfo) {
+  private buildAvatarURL(account?: AccountInfo) {
     if (!account) {
       return '';
     }
@@ -108,15 +95,13 @@
         return avatars[i].url;
       }
     }
-    const accountID = this._getAccounts(account);
-    if (!accountID) {
+    const accountIdentifier = this.getAccounts(account);
+    if (!accountIdentifier) {
       return '';
     }
-    return (
-      `${getBaseUrl()}/accounts/` +
-      encodeURIComponent(`${this._getAccounts(account)}`) +
-      `/avatar?s=${this.imageSize}`
-    );
+    return `${getBaseUrl()}/accounts/${encodeURIComponent(
+      accountIdentifier
+    )}/avatar?s=${this.imageSize}`;
   }
 }
 
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 3aeef3e..aa341ff 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
@@ -1,33 +1,19 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-avatar';
 import {GrAvatar} from './gr-avatar';
-import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
-import {getAppContext, AppContext} from '../../../services/app-context';
 import {AvatarInfo} from '../../../types/common';
 import {
-  createAccountWithEmail,
+  createAccountWithEmailOnly,
   createAccountWithId,
   createServerInfo,
 } from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-avatar');
+import {fixture, html, assert} from '@open-wc/testing';
+import {isVisible, stubRestApi} from '../../../test/test-utils';
 
 suite('gr-avatar tests', () => {
   let element: GrAvatar;
@@ -39,45 +25,125 @@
     },
   ];
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  test('renders hidden when no config is set', async () => {
+    stubRestApi('getConfig').resolves(undefined);
+    const accountWithId = {
+      ...createAccountWithId(123),
+      avatars: defaultAvatars,
+    };
+    element = await fixture(
+      html`<gr-avatar .account=${accountWithId}></gr-avatar>`
+    );
+
+    assert.isFalse(isVisible(element));
   });
 
-  test('account without avatar', () => {
-    assert.equal(element._buildAvatarURL(createAccountWithId(123)), '');
+  test('renders hidden when config does not use avatars', async () => {
+    stubRestApi('getConfig').resolves({
+      ...createServerInfo(),
+      plugin: {has_avatars: false, js_resource_paths: []},
+    });
+    const accountWithId = {
+      ...createAccountWithId(123),
+      avatars: defaultAvatars,
+    };
+    element = await fixture(
+      html`<gr-avatar .account=${accountWithId}></gr-avatar>`
+    );
+
+    assert.isFalse(isVisible(element));
   });
 
-  test('methods', () => {
-    assert.equal(
-      element._buildAvatarURL({
+  suite('config has avatars', () => {
+    setup(async () => {
+      stubRestApi('getConfig').resolves({
+        ...createServerInfo(),
+        plugin: {has_avatars: true, js_resource_paths: []},
+      });
+    });
+
+    test('loads correct size', async () => {
+      const accountWithId = {
         ...createAccountWithId(123),
         avatars: defaultAvatars,
-      }),
-      '/accounts/123/avatar?s=16'
-    );
-    assert.equal(
-      element._buildAvatarURL({
-        ...createAccountWithEmail('test@example.com'),
+      };
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithId} .imageSize=${64}></gr-avatar>`
+      );
+
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("/accounts/123/avatar?s=64")'
+      );
+    });
+
+    test('loads using id', async () => {
+      const accountWithId = {
+        ...createAccountWithId(123),
         avatars: defaultAvatars,
-      }),
-      '/accounts/test%40example.com/avatar?s=16'
-    );
-    assert.equal(
-      element._buildAvatarURL({
+      };
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithId}></gr-avatar>`
+      );
+
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("/accounts/123/avatar?s=16")'
+      );
+    });
+
+    test('loads using email', async () => {
+      const accountWithEmail = {
+        ...createAccountWithEmailOnly('foo@gmail.com'),
+        avatars: defaultAvatars,
+      };
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithEmail}></gr-avatar>`
+      );
+
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("/accounts/foo%40gmail.com/avatar?s=16")'
+      );
+    });
+
+    test('loads using name', async () => {
+      const accountWithName = {
         name: 'John Doe',
         avatars: defaultAvatars,
-      }),
-      '/accounts/John%20Doe/avatar?s=16'
-    );
-    assert.equal(
-      element._buildAvatarURL({
+      };
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithName}></gr-avatar>`
+      );
+
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("/accounts/John%20Doe/avatar?s=16")'
+      );
+    });
+
+    test('loads using username', async () => {
+      const accountWithUsername = {
         username: 'John_Doe',
         avatars: defaultAvatars,
-      }),
-      '/accounts/John_Doe/avatar?s=16'
-    );
-    assert.equal(
-      element._buildAvatarURL({
+      };
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithUsername}></gr-avatar>`
+      );
+
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("/accounts/John_Doe/avatar?s=16")'
+      );
+    });
+
+    test('loads using custom URL from matching height', async () => {
+      const accountWithCustomAvatars = {
         ...createAccountWithId(123),
         avatars: [
           {
@@ -95,12 +161,21 @@
             height: 100,
             width: 0,
           },
-        ] as AvatarInfo[],
-      }),
-      'https://cdn.example.com/s16-p/photo.jpg'
-    );
-    assert.equal(
-      element._buildAvatarURL({
+        ],
+      };
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithCustomAvatars}></gr-avatar>`
+      );
+
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("https://cdn.example.com/s16-p/photo.jpg")'
+      );
+    });
+
+    test('loads using normal URL when no custom URL sizes match', async () => {
+      const accountWithCustomAvatars = {
         ...createAccountWithId(123),
         avatars: [
           {
@@ -108,111 +183,17 @@
             height: 95,
             width: 0,
           },
-        ] as AvatarInfo[],
-      }),
-      '/accounts/123/avatar?s=16'
-    );
-    assert.equal(element._buildAvatarURL(undefined), '');
-  });
-
-  suite('config set', () => {
-    let appContext: AppContext;
-    setup(() => {
-      appContext = getAppContext();
-      const config = {
-        ...createServerInfo(),
-        plugin: {has_avatars: true, js_resource_paths: []},
+        ],
       };
-      stub('gr-avatar', '_getConfig').returns(Promise.resolve(config));
-      element = basicFixture.instantiate();
-    });
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithCustomAvatars}></gr-avatar>`
+      );
 
-    test('dom for existing account', () => {
-      assert.isFalse(element.hasAttribute('hidden'));
-
-      element.imageSize = 64;
-      element.account = {
-        ...createAccountWithId(123),
-        avatars: defaultAvatars,
-      };
-      flush();
-
-      assert.strictEqual(element.style.backgroundImage, '');
-
-      // Emulate plugins loaded.
-      getPluginLoader().loadPlugins([]);
-
-      return Promise.all([
-        appContext!.restApiService.getConfig(),
-        getPluginLoader().awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isFalse(element.hasAttribute('hidden'));
-
-        assert.isTrue(
-          element.style.backgroundImage.includes('/accounts/123/avatar?s=64')
-        );
-      });
-    });
-  });
-
-  suite('plugin has avatars', () => {
-    let appContext: AppContext;
-    setup(() => {
-      appContext = getAppContext();
-      const config = {
-        ...createServerInfo(),
-        plugin: {has_avatars: true, js_resource_paths: []},
-      };
-      stub('gr-avatar', '_getConfig').returns(Promise.resolve(config));
-
-      element = basicFixture.instantiate();
-    });
-
-    test('dom for non available account', () => {
-      assert.isFalse(element.hasAttribute('hidden'));
-
-      // Emulate plugins loaded.
-      getPluginLoader().loadPlugins([]);
-
-      return Promise.all([
-        appContext.restApiService.getConfig(),
-        getPluginLoader().awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isTrue(element.hasAttribute('hidden'));
-
-        assert.strictEqual(element.style.backgroundImage, '');
-      });
-    });
-  });
-
-  suite('config not set', () => {
-    let element: GrAvatar;
-    let appContext: AppContext;
-
-    setup(() => {
-      stub('gr-avatar', '_getConfig').returns(Promise.resolve(undefined));
-      appContext = getAppContext();
-      element = basicFixture.instantiate();
-    });
-
-    test('avatar hidden when account set', async () => {
-      await flush();
-      assert.isTrue(element.hasAttribute('hidden'));
-
-      element.imageSize = 64;
-      element.account = {
-        ...createAccountWithId(123),
-        avatars: defaultAvatars,
-      };
-      // Emulate plugins loaded.
-      getPluginLoader().loadPlugins([]);
-
-      return Promise.all([
-        appContext.restApiService.getConfig(),
-        getPluginLoader().awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isTrue(element.hasAttribute('hidden'));
-      });
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("/accounts/123/avatar?s=16")'
+      );
     });
   });
 });
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 1282666..f33f7d8 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -1,28 +1,16 @@
 /**
  * @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/paper-button/paper-button';
 import {spinnerStyles} from '../../../styles/gr-spinner-styles';
 import {votingStyles} from '../../../styles/gr-voting-styles';
-import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
 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';
+import {classMap} from 'lit/directives/class-map.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -51,7 +39,7 @@
   // after created, the initial value maybe overridden by this
   private initialTabindex?: string;
 
-  @property({type: Boolean, reflect: true, attribute: 'down-arrow'})
+  @property({type: Boolean, attribute: 'down-arrow'})
   downArrow = false;
 
   @property({type: Boolean, reflect: true})
@@ -161,6 +149,11 @@
           cursor: default;
         }
 
+        :host([disabled][flatten]) {
+          --background-color: transparent;
+          --text-color: var(--disabled-foreground);
+        }
+
         /* Styles for link buttons specifically */
         :host([link]) {
           --background-color: transparent;
@@ -172,24 +165,11 @@
         :host([disabled][link]),
         :host([loading][link]) {
           --background-color: transparent;
-          --text-color: var(--deemphasized-text-color);
+          --text-color: var(--disabled-foreground);
           cursor: default;
         }
-
-        /* Styles for the optional down arrow */
-        :host(:not([down-arrow])) .downArrow {
-          display: none;
-        }
-        :host([down-arrow]) .downArrow {
-          border-top: 0.36em solid #ccc;
-          border-left: 0.36em solid transparent;
-          border-right: 0.36em solid transparent;
-          margin-bottom: var(--spacing-xxs);
-          margin-left: var(--spacing-m);
-          transition: border-top-color 200ms;
-        }
-        :host([down-arrow]) paper-button:hover .downArrow {
-          border-top-color: var(--deemphasized-text-color);
+        gr-icon.downArrow {
+          color: inherit;
         }
         .newVoteChip {
           border: 1px solid var(--border-color);
@@ -202,12 +182,6 @@
     ];
   }
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  private readonly isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-
   override render() {
     return html`<paper-button
       ?raised=${!this.link && !this.flatten}
@@ -216,16 +190,20 @@
       tabindex="-1"
       part="paper-button"
       class=${classMap({
-        voteChip: this.voteChip && !this.isSubmitRequirementsUiEnabled,
-        newVoteChip: this.voteChip && this.isSubmitRequirementsUiEnabled,
+        newVoteChip: this.voteChip,
       })}
     >
       ${this.loading ? html`<span class="loadingSpin"></span>` : ''}
       <slot></slot>
-      <i class="downArrow"></i>
+      ${this.renderArrowIcon()}
     </paper-button>`;
   }
 
+  renderArrowIcon() {
+    if (!this.downArrow) return nothing;
+    return html`<gr-icon icon="arrow_drop_down" class="downArrow"></gr-icon>`;
+  }
+
   constructor() {
     super();
     this.initialTabindex = this.getAttribute('tabindex') || '0';
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 737625c..1cf05ab 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
@@ -1,25 +1,12 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-button';
 import {addListener} from '@polymer/polymer/lib/utils/gestures';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from './gr-button';
 import {pressKey, queryAndAssert} from '../../../test/test-utils';
 import {PaperButtonElement} from '@polymer/paper-button';
@@ -44,18 +31,33 @@
   });
 
   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>
-    `);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <paper-button
+          animated=""
+          aria-disabled="false"
+          elevation="1"
+          part="paper-button"
+          raised=""
+          role="button"
+          tabindex="-1"
+          ><slot></slot>
+        </paper-button>
+      `
+    );
+  });
+
+  test('renders arrow icon', async () => {
+    element.downArrow = true;
+    await element.updateComplete;
+    const icon = queryAndAssert(element, 'gr-icon');
+    assert.dom.equal(
+      icon,
+      /* HTML */ `
+        <gr-icon icon="arrow_drop_down" class="downArrow"></gr-icon>
+      `
+    );
   });
 
   test('disabled is set by disabled', async () => {
@@ -84,7 +86,7 @@
       'paper-button'
     );
     assert.isFalse(paperBtn.disabled);
-    MockInteractions.tap(element);
+    element.click();
     await element.updateComplete;
     assert.isTrue(paperBtn.disabled);
     assert.isTrue(element.hasAttribute('loading'));
@@ -132,19 +134,19 @@
   // plugins who didn't move to on-click which is faster and well supported.
   test('dispatches click event', () => {
     const spy = addSpyOn('click');
-    MockInteractions.click(element);
+    element.click();
     assert.isTrue(spy.calledOnce);
   });
 
   test('dispatches tap event', () => {
     const spy = addSpyOn('tap');
-    MockInteractions.tap(element);
+    element.click();
     assert.isTrue(spy.calledOnce);
   });
 
   test('dispatches click from tap event', () => {
     const spy = addSpyOn('click');
-    MockInteractions.tap(element);
+    element.click();
     assert.isTrue(spy.calledOnce);
   });
 
@@ -176,13 +178,13 @@
     for (const eventName of ['tap', 'click']) {
       test('stops ' + eventName + ' event', () => {
         const spy = addSpyOn(eventName);
-        MockInteractions.tap(element);
+        element.click();
         assert.isFalse(spy.called);
       });
     }
 
     for (const key of [Key.ENTER, Key.SPACE]) {
-      test(`stops click event on keycode ${key}`, () => {
+      test(`stops click event on key ${key}`, () => {
         const tapSpy = sinon.spy();
         element.addEventListener('click', tapSpy);
         pressKey(element, key);
@@ -199,11 +201,11 @@
     });
 
     test('report event after click', () => {
-      MockInteractions.click(element);
+      element.click();
       assert.isTrue(reportStub.calledOnce);
       assert.equal(reportStub.lastCall.args[0], 'button-click');
       assert.deepEqual(reportStub.lastCall.args[1], {
-        path: 'html.lightTheme>body>div>gr-button',
+        path: 'html>body>div>gr-button',
       });
     });
 
@@ -213,11 +215,11 @@
           <gr-button class="testBtn"></gr-button>
         </div>
       `);
-      MockInteractions.click(queryAndAssert(nestedElement, 'gr-button'));
+      queryAndAssert<GrButton>(nestedElement, 'gr-button').click();
       assert.isTrue(reportStub.calledOnce);
       assert.equal(reportStub.lastCall.args[0], 'button-click');
       assert.deepEqual(reportStub.lastCall.args[1], {
-        path: 'html.lightTheme>body>div>div#test>gr-button.testBtn',
+        path: 'html>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 560c82c..0bb451c 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
@@ -1,30 +1,19 @@
 /**
  * @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 '../gr-icons/gr-icons';
 import {ChangeInfo} from '../../../types/common';
-import {fireAlert} from '../../../utils/event-util';
 import {
   Shortcut,
   ShortcutSection,
 } from '../../../services/shortcuts/shortcuts-config';
-import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {shortcutsServiceToken} from '../../../services/shortcuts/shortcuts-service';
+import {assertIsDefined} from '../../../utils/common-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -48,7 +37,7 @@
   @property({type: Object})
   change?: ChangeInfo;
 
-  private readonly shortcuts = getAppContext().shortcutsService;
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   static override get styles() {
     return [
@@ -58,20 +47,6 @@
           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;
@@ -84,28 +59,32 @@
     return html`
       <button
         role="checkbox"
-        title=${this.shortcuts.createTitle(
+        title=${this.getShortcutsService().createTitle(
           Shortcut.TOGGLE_CHANGE_STAR,
           ShortcutSection.ACTIONS
         )}
         aria-label=${this.change?.starred
           ? 'Unstar this change'
           : 'Star this change'}
-        @click=${this.toggleStar}
+        @click=${this.handleClick}
       >
-        <iron-icon
+        <gr-icon
+          icon="star"
+          small
+          ?filled=${!!this.change?.starred}
           class=${this.change?.starred ? 'active' : ''}
-          .icon=${`gr-icons:star${this.change?.starred ? '' : '-border'}`}
-        ></iron-icon>
+        ></gr-icon>
       </button>
     `;
   }
 
+  handleClick(e: Event) {
+    e.stopPropagation();
+    this.toggleStar();
+  }
+
   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;
+    assertIsDefined(this.change, 'change');
 
     const newVal = !this.change.starred;
     this.change.starred = newVal;
@@ -114,7 +93,6 @@
       change: this.change,
       starred: newVal,
     };
-    if (newVal) fireAlert(this, 'Starring change...');
     this.dispatchEvent(
       new CustomEvent('toggle-star', {
         bubbles: true,
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 2c5d7a2..75237f4 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
@@ -1,34 +1,20 @@
 /**
  * @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 {IronIconElement} from '@polymer/iron-icon';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrChangeStar} from './gr-change-star';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import './gr-change-star';
 import {createChange} from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-change-star');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-change-star tests', () => {
   let element: GrChangeStar;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-change-star></gr-change-star>`);
     element.change = {
       ...createChange(),
       starred: true,
@@ -36,19 +22,38 @@
     await element.updateComplete;
   });
 
-  test('star visibility states', async () => {
-    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');
+  test('renders starred', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <button
+          aria-label="Unstar this change"
+          role="checkbox"
+          title="Star/unstar change (shortcut: s)"
+        >
+          <gr-icon icon="star" small filled class="active"></gr-icon>
+        </button>
+      `
+    );
+  });
 
+  test('renders unstarred', async () => {
     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');
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <button
+          aria-label="Star this change"
+          role="checkbox"
+          title="Star/unstar change (shortcut: s)"
+        >
+          <gr-icon icon="star" small></gr-icon>
+        </button>
+      `
+    );
   });
 
   test('starring', async () => {
@@ -56,7 +61,7 @@
     await element.updateComplete;
     assert.equal(element.change!.starred, false);
 
-    MockInteractions.tap(queryAndAssert<HTMLButtonElement>(element, 'button'));
+    queryAndAssert<HTMLButtonElement>(element, 'button').click();
     await element.updateComplete;
     assert.equal(element.change!.starred, true);
   });
@@ -66,7 +71,7 @@
     await element.updateComplete;
     assert.equal(element.change!.starred, true);
 
-    MockInteractions.tap(queryAndAssert<HTMLButtonElement>(element, 'button'));
+    queryAndAssert<HTMLButtonElement>(element, 'button').click();
     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 dd6077c..d2b9e2d 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
@@ -1,36 +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 '../gr-icon/gr-icon';
 import '../gr-tooltip-content/gr-tooltip-content';
-import '../gr-icons/gr-icons';
 import '../../../styles/shared-styles';
-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';
+import {customElement, property, state} from 'lit/decorators.js';
+import {createSearchUrl} from '../../../models/views/search';
+import {GeneratedWebLink} from '../../../utils/weblink-util';
 
 export enum ChangeStates {
   ABANDONED = 'Abandoned',
   ACTIVE = 'Active',
   MERGE_CONFLICT = 'Merge Conflict',
+  GIT_CONFLICT = 'Git Conflict',
   MERGED = 'Merged',
   PRIVATE = 'Private',
   READY_TO_SUBMIT = 'Ready to submit',
@@ -41,14 +29,18 @@
 
 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, " +
+  'It will not appear on dashboards unless you are in the attention set, ' +
   'and email notifications will be silenced until the review is started.';
 
 export const MERGE_CONFLICT_TOOLTIP =
   'This change has merge conflicts. ' +
-  'Download the patch and run "git rebase". ' +
+  'Rebase on the upstream branch (e.g. "git pull --rebase"). ' +
   'Upload a new patchset after resolving all merge conflicts.';
 
+export const GIT_CONFLICT_TOOLTIP =
+  'A file contents of the change contain git conflict markers' +
+  'to indicate the conflicts.';
+
 const PRIVATE_TOOLTIP =
   'This change is only visible to its owner and ' +
   'current reviewers (or anyone with "View Private Changes" permission).';
@@ -64,7 +56,8 @@
   @property({type: String})
   status?: ChangeStates;
 
-  @property({type: String})
+  // Private but used in tests.
+  @state()
   tooltipText = '';
 
   @property({type: Object})
@@ -99,7 +92,8 @@
           background-color: var(--status-private);
           color: var(--status-private);
         }
-        :host(.merge-conflict) .chip {
+        :host(.merge-conflict) .chip,
+        :host(.git-conflict) .chip {
           background-color: var(--status-conflict);
           color: var(--status-conflict);
         }
@@ -131,13 +125,9 @@
           padding: 0;
         }
         :host(:not([flat])) .chip,
-        .icon {
+        :host(:not([flat])) .chip gr-icon {
           color: var(--status-text-color);
         }
-        .icon {
-          --iron-icon-height: 18px;
-          --iron-icon-width: 18px;
-        }
       `,
     ];
   }
@@ -169,7 +159,7 @@
         <div class="chip" aria-label="Label: ${this.status}">
           ${this.computeStatusString()}
           ${this.showResolveIcon()
-            ? html`<iron-icon class="icon" icon="gr-icons:edit"></iron-icon>`
+            ? html`<gr-icon icon="edit" filled small></gr-icon>`
             : ''}
         </div>
       </a>
@@ -211,7 +201,7 @@
   // private but used in test
   getStatusLink(): string {
     if (this.revertedChange) {
-      return GerritNav.getUrlForSearchQuery(`${this.revertedChange._number}`);
+      return createSearchUrl({query: `${this.revertedChange._number}`});
     }
     if (
       this.status === ChangeStates.MERGE_CONFLICT &&
@@ -246,6 +236,9 @@
       case ChangeStates.MERGE_CONFLICT:
         this.tooltipText = MERGE_CONFLICT_TOOLTIP;
         break;
+      case ChangeStates.GIT_CONFLICT:
+        this.tooltipText = GIT_CONFLICT_TOOLTIP;
+        break;
       default:
         this.tooltipText = '';
         break;
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 654c574..4a046e7 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
@@ -1,27 +1,17 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
-import {createChange} from '../../../test/test-data-generators';
+import '../../../test/common-test-setup';
+import {
+  createChange,
+  TEST_NUMERIC_CHANGE_ID,
+} from '../../../test/test-data-generators';
 import './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';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {queryAndAssert} from '../../../test/test-utils';
 
 const PRIVATE_TOOLTIP =
@@ -37,6 +27,25 @@
     `);
   });
 
+  test('render', async () => {
+    element.status = ChangeStates.WIP;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-tooltip-content
+          has-tooltip=""
+          max-width="40em"
+          position-below=""
+          title="This change isn't ready to be reviewed or submitted. It will not appear on dashboards unless you are in the attention set, and email notifications will be silenced until the review is started."
+        >
+          <div aria-label="Label: WIP" class="chip">Work in Progress</div>
+        </gr-tooltip-content>
+      `
+    );
+  });
+
   test('WIP', async () => {
     element.status = ChangeStates.WIP;
     await element.updateComplete;
@@ -118,16 +127,14 @@
   });
 
   test('reverted change', () => {
-    const url = 'http://google.com';
     const status = ChangeStates.REVERT_SUBMITTED;
     const revertedChange = createChange();
-    sinon.stub(GerritNav, 'getUrlForSearchQuery').returns(url);
 
     element.revertedChange = revertedChange;
     element.resolveWeblinks = [];
     element.status = status;
     assert.isTrue(element.hasStatusLink());
-    assert.equal(element.getStatusLink(), url);
+    assert.equal(element.getStatusLink(), `/q/${TEST_NUMERIC_CHANGE_ID}`);
   });
 
   test('private', async () => {
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 471ebd6..9dfbc04 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
@@ -1,26 +1,22 @@
 /**
  * @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 '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../gr-comment/gr-comment';
+import '../gr-icon/gr-icon';
 import '../../../embed/diff/gr-diff/gr-diff';
 import '../gr-copy-clipboard/gr-copy-clipboard';
-import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property, query, queryAll, state} from 'lit/decorators';
+import {css, html, nothing, LitElement, PropertyValues} from 'lit';
+import {
+  customElement,
+  property,
+  query,
+  queryAll,
+  state,
+} from 'lit/decorators.js';
 import {
   computeDiffFromContext,
   isDraft,
@@ -34,9 +30,9 @@
   getFirstComment,
   createUnsavedReply,
   isUnsaved,
+  NEWLINE_PATTERN,
 } from '../../../utils/comment-util';
 import {ChangeMessageId} from '../../../api/rest-api';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
 import {
   createDefaultDiffPrefs,
@@ -50,13 +46,13 @@
   RepoName,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {GrComment} from '../gr-comment/gr-comment';
+import {CommentEditingChangedDetail, GrComment} from '../gr-comment/gr-comment';
 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} from '../../../utils/common-util';
-import {fire, fireAlert} from '../../../utils/event-util';
+import {assertIsDefined, copyToClipbard} from '../../../utils/common-util';
+import {fire} 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';
@@ -65,17 +61,19 @@
 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 {repeat} from 'lit/directives/repeat.js';
+import {classMap} from 'lit/directives/class-map.js';
 import {ShortcutController} from '../../lit/shortcut-controller';
-import {ValueChangedEvent} from '../../../types/events';
+import {ReplyToCommentEvent, 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 NEWLINE_PATTERN = /\n/g;
+import {Interaction} from '../../../constants/reporting';
+import {HtmlPatched} from '../../../utils/lit-util';
+import {createDiffUrl} from '../../../models/views/diff';
+import {createChangeUrl} from '../../../models/views/change';
 
 declare global {
   interface HTMLElementEventMap {
@@ -256,43 +254,78 @@
 
   private readonly userModel = getAppContext().userModel;
 
+  private readonly reporting = getAppContext().reportingService;
+
   private readonly shortcuts = new ShortcutController(this);
 
   private readonly syntaxLayer = new GrSyntaxLayerWorker();
 
+  // for COMMENTS_AUTOCLOSE logging purposes only
+  readonly uid = performance.now().toString(36) + Math.random().toString(36);
+
+  private readonly patched = new HtmlPatched(key => {
+    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
+      component: this.tagName,
+      key: key.substring(0, 300),
+    });
+  });
+
   constructor() {
     super();
     this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
     this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
-  }
-
-  override connectedCallback(): void {
-    super.connectedCallback();
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
-    subscribe(this, this.userModel.account$, x => (this.account = x));
-    subscribe(this, this.getChangeModel().repo$, x => (this.repoName = x));
-    subscribe(this, this.userModel.diffPreferences$, x =>
-      this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
     );
-    subscribe(this, this.userModel.preferences$, prefs => {
-      const layers: DiffLayer[] = [this.syntaxLayer];
-      if (!prefs.disable_token_highlighting) {
-        layers.push(new TokenHighlightLayer(this));
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repoName = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      x => this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
+    );
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
+        const layers: DiffLayer[] = [this.syntaxLayer];
+        if (!prefs.disable_token_highlighting) {
+          layers.push(new TokenHighlightLayer(this));
+        }
+        this.layers = layers;
       }
-      this.layers = layers;
-    });
-    subscribe(this, this.userModel.diffPreferences$, prefs => {
-      this.prefs = {
-        ...prefs,
-        // set line_wrapping to true so that the context can take all the
-        // remaining space after comment card has rendered
-        line_wrapping: true,
-      };
-    });
+    );
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      prefs => {
+        this.prefs = {
+          ...prefs,
+          // set line_wrapping to true so that the context can take all the
+          // remaining space after comment card has rendered
+          line_wrapping: true,
+        };
+      }
+    );
+  }
+
+  override disconnectedCallback() {
+    if (this.editing) {
+      this.reporting.reportInteraction(
+        Interaction.COMMENTS_AUTOCLOSE_EDITING_THREAD_DISCONNECTED
+      );
+    }
+    super.disconnectedCallback();
   }
 
   static override get styles() {
@@ -394,6 +427,7 @@
          * height, so the link icon does not need a top:4px in gr-comment_html.
          */
         .link-icon {
+          margin-left: var(--spacing-m);
           position: relative;
           top: 4px;
           cursor: pointer;
@@ -462,42 +496,48 @@
 
   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>
-        `;
-      }
+    const publishedComments = repeat(
+      this.thread.comments.filter(c => !isDraftOrUnsaved(c)),
+      comment => comment.id,
+      comment => this.renderComment(comment)
     );
+    // We are deliberately not including the draft in the repeat directive,
+    // because we ran into spurious issues with <gr-comment> being destroyed
+    // and re-created when an unsaved draft transitions to 'saved' state.
+    const draftComment = this.renderComment(this.getDraftOrUnsaved());
+    return html`${publishedComments}${draftComment}`;
+  }
+
+  private renderComment(comment?: Comment) {
+    if (!comment) return nothing;
+    const robotButtonDisabled = !this.account || this.isDraftOrUnsaved();
+    const initiallyCollapsed =
+      !isDraftOrUnsaved(comment) &&
+      (this.messageId
+        ? comment.change_message_id !== this.messageId
+        : !this.unresolved);
+    return this.patched.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
+        }
+        @reply-to-comment=${this.handleReplyToComment}
+        @copy-comment-link=${this.handleCopyLink}
+        @comment-editing-changed=${(
+          e: CustomEvent<CommentEditingChangedDetail>
+        ) => {
+          if (isDraftOrUnsaved(comment)) this.editing = e.detail.editing;
+        }}
+        @comment-unresolved-changed=${(e: ValueChangedEvent<boolean>) => {
+          if (isDraftOrUnsaved(comment)) this.unresolved = e.detail.value;
+        }}
+      ></gr-comment>
+    `;
   }
 
   renderActions() {
@@ -509,15 +549,7 @@
           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
@@ -556,16 +588,24 @@
                 `
               : ''
           }
+          <gr-icon
+            icon="link"
+            class="link-icon copy"
+            @click=${this.handleCopyLink}
+            title="Copy link to this comment"
+            role="button"
+            tabindex="0"
+          ></gr-icon>
         </div>
       </div>
-      </div>
+    </div>
     `;
   }
 
   renderContextualDiff() {
     if (!this.changeNum || !this.showCommentContext || !this.diff) return;
     if (!this.thread?.path) return;
-    const href = this.getUrlForFileComment();
+    const href = this.getUrlForFileComment() ?? '';
     return html`
       <div class="diff-container">
         <gr-diff
@@ -636,7 +676,13 @@
       whenRendered(this, () => {
         this.expandCollapseComments(false);
         this.commentBox?.focus();
+        // The delay is a hack because we don't know exactly when to
+        // scroll the comment into center.
+        // TODO: Find a better solution without a setTimeout
         this.scrollIntoView({block: 'center'});
+        setTimeout(() => {
+          this.scrollIntoView({block: 'center'});
+        }, 500);
       });
     }
   }
@@ -649,6 +695,12 @@
     return this.isDraft() || this.isUnsaved();
   }
 
+  private getDraftOrUnsaved(): Comment | undefined {
+    if (this.unsavedComment) return this.unsavedComment;
+    if (this.isDraft()) return this.getLastComment();
+    return undefined;
+  }
+
   private isNewThread(): boolean {
     return this.thread?.comments.length === 0;
   }
@@ -687,12 +739,12 @@
       return undefined;
     }
     if (this.isNewThread()) return undefined;
-    return GerritNav.getUrlForDiffById(
-      this.changeNum,
-      this.repoName,
-      this.thread.path,
-      this.thread.patchNum
-    );
+    return createDiffUrl({
+      changeNum: this.changeNum,
+      project: this.repoName,
+      path: this.thread.path,
+      patchNum: this.thread.patchNum,
+    });
   }
 
   private computeHighlightRange() {
@@ -716,11 +768,11 @@
       return undefined;
     }
     assertIsDefined(this.rootId, 'rootId of comment thread');
-    return GerritNav.getUrlForComment(
-      this.changeNum,
-      this.repoName,
-      this.rootId
-    );
+    return createDiffUrl({
+      changeNum: this.changeNum,
+      project: this.repoName,
+      commentId: this.rootId,
+    });
   }
 
   private handleCopyLink() {
@@ -728,13 +780,22 @@
     if (!comment) return;
     assertIsDefined(this.changeNum, 'changeNum');
     assertIsDefined(this.repoName, 'repoName');
-    const url = generateAbsoluteUrl(
-      GerritNav.getUrlForCommentsTab(this.changeNum, this.repoName, comment.id)
-    );
+    let url: string;
+    if (this.isPatchsetLevel()) {
+      url = createChangeUrl({
+        changeNum: this.changeNum,
+        project: this.repoName,
+        commentId: comment.id,
+      });
+    } else {
+      url = createDiffUrl({
+        changeNum: this.changeNum,
+        project: this.repoName,
+        commentId: comment.id,
+      });
+    }
     assertIsDefined(url, 'url for comment');
-    navigator.clipboard.writeText(generateAbsoluteUrl(url)).then(() => {
-      fireAlert(this, 'Link copied to clipboard');
-    });
+    copyToClipbard(generateAbsoluteUrl(url), 'Link');
   }
 
   private getDisplayPath() {
@@ -826,13 +887,9 @@
     this.createReplyComment('Done', false, false);
   }
 
-  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);
+  private handleReplyToComment(e: ReplyToCommentEvent) {
+    const {content, userWantsToEdit, unresolved} = e.detail;
+    this.createReplyComment(content, userWantsToEdit, unresolved);
   }
 
   private computeAriaHeading() {
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 0329a4e..10e54c7 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
@@ -1,20 +1,9 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-comment-thread';
 import {DraftInfo, sortComments} from '../../../utils/comment-util';
 import {GrCommentThread} from './gr-comment-thread';
@@ -36,11 +25,11 @@
   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');
+import {fixture, html, waitUntil, assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
+import {SpecialFilePath} from '../../../constants/constants';
+import {GrIcon} from '../gr-icon/gr-icon';
 
 const c1 = {
   author: {name: 'Kermit'},
@@ -84,7 +73,7 @@
 
   setup(async () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-comment-thread></gr-comment-thread>`);
     element.changeNum = 1 as NumericChangeId;
     element.showFileName = true;
     element.showFilePath = true;
@@ -96,211 +85,223 @@
   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>
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="fileName">
+          <a href="/c/test-repo-name/+/1/1/test-path-comment-thread">
+            test-path-comment-thread
+          </a>
+          <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
         </div>
-      </div>
-    `);
+        <div class="pathInfo">
+          <a href="/c/test-repo-name/+/1/comment/the-root/"> #314 </a>
+        </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>
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="fileName">
+          <span>test-path-comment-thread</span>
+          <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
         </div>
-      </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>
+    assert.dom.equal(
+      queryAndAssert(element, '#container'),
+      /* 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">
+                <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-icon
+                  icon="link"
+                  class="copy link-icon"
+                  role="button"
+                  tabindex="0"
+                  title="Copy link to this comment"
+                ></gr-icon>
+              </div>
             </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>
+    assert.dom.equal(
+      queryAndAssert(element, '#container'),
+      /* 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">
+                <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>
+                <gr-icon
+                  icon="link"
+                  class="copy link-icon"
+                  role="button"
+                  tabindex="0"
+                  title="Copy link to this comment"
+                ></gr-icon>
+              </div>
             </div>
           </div>
         </div>
-      </div>
-    `);
+      `
+    );
   });
 
   test('renders with diff', async () => {
     element.showCommentContext = true;
     element.thread = createThread(commentWithContext);
     await element.updateComplete;
-    expect(queryAndAssert(element, '.diff-container')).dom.to.equal(/* HTML */ `
-      <div class="diff-container">
-        <gr-diff
-          class="disable-context-control-buttons hide-line-length-indicator no-left"
-          id="diff"
-          style="--line-limit-marker:100ch; --content-width:none; --diff-max-width:none; --font-size:12px;"
-        >
-        </gr-diff>
-        <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>
+    assert.dom.equal(
+      queryAndAssert(element, '.diff-container'),
+      /* HTML */ `
+        <div class="diff-container">
+          <gr-diff
+            class="disable-context-control-buttons hide-line-length-indicator no-left"
+            id="diff"
+            style="--line-limit-marker:100ch; --content-width:none; --diff-max-width:none; --font-size:12px;"
+          >
+          </gr-diff>
+          <div class="view-diff-container">
+            <a href="/c/test-repo-name/+/1/comment/the-draft/">
+              <gr-button
+                aria-disabled="false"
+                class="view-diff-button"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                View Diff
+              </gr-button>
+            </a>
+          </div>
         </div>
-      </div>
-    `);
+      `
+    );
   });
 
   suite('action button clicks', () => {
@@ -318,7 +319,7 @@
     });
 
     test('handle Ack', async () => {
-      tap(queryAndAssert(element, '#ackBtn'));
+      queryAndAssert<GrButton>(element, '#ackBtn').click();
       waitUntilCalled(stub, 'saveDraft()');
       assert.equal(stub.lastCall.firstArg.message, 'Ack');
       assert.equal(stub.lastCall.firstArg.unresolved, false);
@@ -330,7 +331,7 @@
     });
 
     test('handle Done', async () => {
-      tap(queryAndAssert(element, '#doneBtn'));
+      queryAndAssert<GrButton>(element, '#doneBtn').click();
       waitUntilCalled(stub, 'saveDraft()');
       assert.equal(stub.lastCall.firstArg.message, 'Done');
       assert.equal(stub.lastCall.firstArg.unresolved, false);
@@ -338,13 +339,13 @@
 
     test('handle Reply', async () => {
       assert.isUndefined(element.unsavedComment);
-      tap(queryAndAssert(element, '#replyBtn'));
+      queryAndAssert<GrButton>(element, '#replyBtn').click();
       assert.equal(element.unsavedComment?.message, '');
     });
 
     test('handle Quote', async () => {
       assert.isUndefined(element.unsavedComment);
-      tap(queryAndAssert(element, '#quoteBtn'));
+      queryAndAssert<GrButton>(element, '#quoteBtn').click();
       assert.equal(element.unsavedComment?.message?.trim(), `> ${c2.message}`);
     });
   });
@@ -353,7 +354,7 @@
     let threadEl: GrCommentThread;
 
     setup(async () => {
-      threadEl = basicFixture.instantiate();
+      threadEl = await fixture(html`<gr-comment-thread></gr-comment-thread>`);
       threadEl.thread = createThread();
     });
 
@@ -367,8 +368,8 @@
       await waitUntil(() => threadEl.editing);
 
       const commentEl = queryAndAssert(threadEl, 'gr-comment');
-      const buttonEl = queryAndAssert(commentEl, 'gr-button.cancel');
-      tap(buttonEl);
+      const buttonEl = queryAndAssert<GrButton>(commentEl, 'gr-button.cancel');
+      buttonEl.click();
 
       await waitUntil(() => !threadEl.editing);
       assert.isNotOk(threadEl.parentElement);
@@ -448,4 +449,35 @@
       },
     ]);
   });
+
+  test('patchset comments link to /comments URL', async () => {
+    const clipboardStub = sinon.stub(navigator.clipboard, 'writeText');
+    element.thread = {
+      ...createThread(c1),
+      path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+    };
+    await element.updateComplete;
+
+    queryAndAssert<GrIcon>(element, 'gr-icon.copy').click();
+
+    assert.equal(1, clipboardStub.callCount);
+    assert.equal(
+      clipboardStub.firstCall.args[0],
+      'http://localhost:9876/c/test-repo-name/+/1/comments/the-root'
+    );
+  });
+
+  test('file comments link to /comment URL', async () => {
+    const clipboardStub = sinon.stub(navigator.clipboard, 'writeText');
+    element.thread = createThread(c1);
+    await element.updateComplete;
+
+    queryAndAssert<GrIcon>(element, 'gr-icon.copy').click();
+
+    assert.equal(1, clipboardStub.callCount);
+    assert.equal(
+      clipboardStub.firstCall.args[0],
+      'http://localhost:9876/c/test-repo-name/+/1/comment/the-root/'
+    );
+  });
 });
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 c460ad7..9e8264c 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -1,18 +1,7 @@
 /**
  * @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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
@@ -21,22 +10,20 @@
 import '../gr-button/gr-button';
 import '../gr-dialog/gr-dialog';
 import '../gr-formatted-text/gr-formatted-text';
-import '../gr-icons/gr-icons';
+import '../gr-icon/gr-icon';
 import '../gr-overlay/gr-overlay';
 import '../gr-textarea/gr-textarea';
 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 {getAppContext} from '../../../services/app-context';
-import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
 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,
   RepoName,
   RobotCommentInfo,
@@ -44,30 +31,39 @@
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {
   Comment,
+  createUserFixSuggestion,
   DraftInfo,
+  getContentInCommentRange,
+  getUserSuggestion,
+  hasUserSuggestion,
   isDraftOrUnsaved,
   isRobot,
   isUnsaved,
+  NEWLINE_PATTERN,
+  USER_SUGGESTION_START_PATTERN,
 } from '../../../utils/comment-util';
 import {
   OpenFixPreviewEventDetail,
+  ReplyToCommentEventDetail,
   ValueChangedEvent,
 } from '../../../types/events';
 import {fire, fireEvent} from '../../../utils/event-util';
-import {assertIsDefined} from '../../../utils/common-util';
+import {assertIsDefined, assert} from '../../../utils/common-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 {classMap} from 'lit/directives/class-map.js';
 import {LineNumber} from '../../../api/diff';
-import {CommentSide} from '../../../constants/constants';
-import {getRandomInt} from '../../../utils/math-util';
+import {CommentSide, SpecialFilePath} from '../../../constants/constants';
 import {Subject} from 'rxjs';
 import {debounceTime} from 'rxjs/operators';
-import {configModelToken} from '../../../models/config/config-model';
 import {changeModelToken} from '../../../models/change/change-model';
+import {Interaction} from '../../../constants/reporting';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {isBase64FileContent} from '../../../api/rest-api';
+import {createDiffUrl} from '../../../models/views/diff';
 
 const UNSAVED_MESSAGE = 'Unable to save draft';
 
@@ -80,8 +76,9 @@
 
 declare global {
   interface HTMLElementEventMap {
-    'comment-editing-changed': CustomEvent<boolean>;
-    'comment-unresolved-changed': CustomEvent<boolean>;
+    'comment-editing-changed': CustomEvent<CommentEditingChangedDetail>;
+    'comment-unresolved-changed': ValueChangedEvent<boolean>;
+    'comment-text-changed': ValueChangedEvent<string>;
     'comment-anchor-tap': CustomEvent<CommentAnchorTapEventDetail>;
   }
 }
@@ -91,16 +88,21 @@
   side?: CommentSide;
 }
 
+export interface CommentEditingChangedDetail {
+  editing: boolean;
+  path: string;
+}
+
 @customElement('gr-comment')
 export class GrComment extends LitElement {
   /**
-   * Fired when the create fix comment action is triggered.
+   * Fired when the parent thread component should create a reply.
    *
-   * @event create-fix-comment
+   * @event reply-to-comment
    */
 
   /**
-   * Fired when the show fix preview action is triggered.
+   * Fired when the open fix preview action is triggered.
    *
    * @event open-fix-preview
    */
@@ -144,6 +146,12 @@
   initiallyCollapsed?: boolean;
 
   /**
+   * Hide the header for patchset level comments used in GrReplyDialog.
+   */
+  @property({type: Boolean, attribute: 'hide-header'})
+  hideHeader = false;
+
+  /**
    * 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.
@@ -154,10 +162,18 @@
   @property({type: Boolean, attribute: 'robot-button-disabled'})
   robotButtonDisabled = false;
 
+  @property({type: String})
+  messagePlaceholder?: string;
+
   /* private, but used in css rules */
   @property({type: Boolean, reflect: true})
   saving = false;
 
+  // GrReplyDialog requires the patchset level comment to always remain
+  // editable.
+  @property({type: Boolean, attribute: 'permanent-editing-mode'})
+  permanentEditingMode = false;
+
   /**
    * `saving` and `autoSaving` are separate and cannot be set at the same time.
    * `saving` affects the UI state (disabled buttons, etc.) and eventually
@@ -174,9 +190,6 @@
   editing = false;
 
   @state()
-  commentLinks: CommentLinks = {};
-
-  @state()
   repoName?: RepoName;
 
   /* The 'dirty' state of the comment.message, which will be saved on demand. */
@@ -205,10 +218,15 @@
   @state()
   isAdmin = false;
 
+  @state()
+  isOwner = false;
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly flagsService = getAppContext().flagsService;
+
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   // Private but used in tests.
@@ -216,8 +234,6 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  private readonly configModel = resolve(this, configModelToken);
-
   private readonly shortcuts = new ShortcutController(this);
 
   /**
@@ -240,35 +256,60 @@
 
   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]) {
-        this.shortcuts.addLocal({key, modifiers: [modifier]}, () => {
+    // Allow the shortcuts to bubble up so that GrReplyDialog can respond to
+    // them as well.
+    this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc(), {
+      preventDefault: false,
+    });
+    for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
+      this.shortcuts.addLocal(
+        {key: Key.ENTER, modifiers: [modifier]},
+        () => {
           this.save();
-        });
-      }
+        },
+        {preventDefault: false}
+      );
     }
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
+    // For Ctrl+s add shorctut with preventDefault so that it does
+    // not bubble up to the browser
+    for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
+      this.shortcuts.addLocal({key: 's', modifiers: [modifier]}, () => {
+        this.save();
+      });
+    }
+    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+      this.messagePlaceholder = 'Mention others with @';
+    }
     subscribe(
       this,
-      this.configModel().repoCommentLinks$,
-      x => (this.commentLinks = x)
+      () => this.userModel.account$,
+      x => (this.account = x)
     );
-    subscribe(this, this.userModel.account$, x => (this.account = x));
-    subscribe(this, this.userModel.isAdmin$, x => (this.isAdmin = x));
-
-    subscribe(this, this.getChangeModel().repo$, x => (this.repoName = x));
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.userModel.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.getChangeModel().isOwner$,
+      x => (this.isOwner = x)
+    );
+    subscribe(
+      this,
+      () =>
+        this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
       () => {
         this.autoSave();
       }
@@ -278,6 +319,11 @@
   override disconnectedCallback() {
     // Clean up emoji dropdown.
     if (this.textarea) this.textarea.closeDropdown();
+    if (this.editing) {
+      this.reporting.reportInteraction(
+        Interaction.COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED
+      );
+    }
     super.disconnectedCallback();
   }
 
@@ -301,13 +347,14 @@
         :host([saving]) .date {
           opacity: 0.5;
         }
-        .body {
-          padding-top: var(--spacing-m);
-        }
         .header {
           align-items: center;
           cursor: pointer;
           display: flex;
+          padding-bottom: var(--spacing-m);
+        }
+        :host([collapsed]) .header {
+          padding-bottom: 0px;
         }
         .headerLeft > span {
           font-weight: var(--font-weight-bold);
@@ -317,11 +364,13 @@
           flex: 1;
           overflow: hidden;
         }
-        .draftLabel,
         .draftTooltip {
-          color: var(--deemphasized-text-color);
+          font-weight: var(--font-weight-bold);
           display: inline;
         }
+        .draftTooltip gr-icon {
+          color: var(--info-foreground);
+        }
         .date {
           justify-content: flex-end;
           text-align: right;
@@ -357,7 +406,7 @@
         }
         .editMessage {
           display: block;
-          margin: var(--spacing-m) 0;
+          margin-bottom: var(--spacing-m);
           width: 100%;
         }
         .show-hide {
@@ -381,7 +430,7 @@
           cursor: pointer;
           display: block;
         }
-        label.show-hide iron-icon {
+        label.show-hide gr-icon {
           vertical-align: top;
         }
         :host([collapsed]) #container .body {
@@ -440,10 +489,15 @@
         .draft gr-account-label {
           width: unset;
         }
+        .draft gr-formatted-text.message {
+          display: block;
+          margin-bottom: var(--spacing-m);
+        }
         .portedMessage {
           margin: 0 var(--spacing-m);
         }
         .link-icon {
+          margin-left: var(--spacing-m);
           cursor: pointer;
         }
       `,
@@ -454,31 +508,47 @@
     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()}
+      <gr-endpoint-decorator name="comment">
+        <gr-endpoint-param name="comment" .value=${this.comment}>
+        </gr-endpoint-param>
+        <gr-endpoint-param name="editing" .value=${this.editing}>
+        </gr-endpoint-param>
+        <div id="container" class=${classMap(classes)}>
+          ${this.renderHeader()}
+          <div class="body">
+            ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
+            ${this.renderCommentMessage()}
+            <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+            ${this.renderHumanActions()} ${this.renderRobotActions()}
+            ${this.renderSuggestEditActions()}
           </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>
+      </gr-endpoint-decorator>
       ${this.renderConfirmDialog()}
     `;
   }
 
+  private renderHeader() {
+    if (this.hideHeader) return nothing;
+    return html`
+      <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>
+    `;
+  }
+
   private renderAuthor() {
+    if (isDraftOrUnsaved(this.comment)) return;
     if (isRobot(this.comment)) {
       const id = this.comment.robot_id;
       return html`<span class="robotName">${id}</span>`;
@@ -507,7 +577,7 @@
 
   private renderDraftLabel() {
     if (!isDraftOrUnsaved(this.comment)) return;
-    let label = 'DRAFT';
+    let label = 'Draft';
     let tooltip =
       'This draft is only visible to you. ' +
       "To publish drafts, click the 'Reply' or 'Start review' button " +
@@ -522,8 +592,8 @@
         has-tooltip
         title=${tooltip}
         max-width="20em"
-        show-icon
       >
+        <gr-icon filled icon="rate_review"></gr-icon>
         <span class="draftLabel">${label}</span>
       </gr-tooltip-content>
     `;
@@ -570,7 +640,7 @@
         class="action delete"
         @click=${this.openDeleteCommentOverlay}
       >
-        <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
+        <gr-icon id="icon" icon="delete" filled></gr-icon>
       </gr-button>
     `;
   }
@@ -597,9 +667,7 @@
   }
 
   private renderToggle() {
-    const icon = this.collapsed
-      ? 'gr-icons:expand-more'
-      : 'gr-icons:expand-less';
+    const icon = this.collapsed ? 'expand_more' : 'expand_less';
     const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
     return html`
       <div class="show-hide" tabindex="0">
@@ -610,7 +678,7 @@
             ?checked=${this.collapsed}
             @change=${() => (this.collapsed = !this.collapsed)}
           />
-          <iron-icon id="icon" icon=${icon}></iron-icon>
+          <gr-icon icon=${icon} id="icon"></gr-icon>
         </label>
       </div>
     `;
@@ -631,6 +699,7 @@
         code=""
         ?disabled=${this.saving}
         rows="4"
+        .placeholder=${this.messagePlaceholder}
         text=${this.messageText}
         @text-changed=${(e: ValueChangedEvent) => {
           // TODO: This is causing a re-render of <gr-comment> on every key
@@ -646,14 +715,14 @@
 
   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)}
+        .markdown=${true}
+        .content=${this.comment?.message ?? ''}
       ></gr-formatted-text>
     `;
   }
@@ -662,15 +731,14 @@
     // 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
+      <gr-icon
+        icon="link"
         class="copy link-icon"
         @click=${this.handleCopyLink}
         title="Copy link to this comment"
-        icon="gr-icons:link"
         role="button"
         tabindex="0"
-      >
-      </iron-icon>
+      ></gr-icon>
     `;
   }
 
@@ -700,15 +768,60 @@
     return html`
       <div class="rightActions">
         ${this.autoSaving ? html`.&nbsp;&nbsp;` : ''}
-        ${this.renderCopyLinkIcon()} ${this.renderDiscardButton()}
-        ${this.renderEditButton()} ${this.renderCancelButton()}
-        ${this.renderSaveButton()}
+        ${this.renderDiscardButton()} ${this.renderSuggestEditButton()}
+        ${this.renderPreviewSuggestEditButton()} ${this.renderEditButton()}
+        ${this.renderCancelButton()} ${this.renderSaveButton()}
+        ${this.renderCopyLinkIcon()}
       </div>
     `;
   }
 
+  private renderPreviewSuggestEditButton() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+      return nothing;
+    }
+    assertIsDefined(this.comment, 'comment');
+    if (!hasUserSuggestion(this.comment)) return nothing;
+    return html`
+      <gr-button
+        link
+        secondary
+        class="action show-fix"
+        ?disabled=${this.saving}
+        @click=${this.handleShowFix}
+      >
+        Preview Fix
+      </gr-button>
+    `;
+  }
+
+  private renderSuggestEditButton() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+      return nothing;
+    }
+    if (
+      this.permanentEditingMode ||
+      this.comment?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
+    ) {
+      return nothing;
+    }
+    assertIsDefined(this.comment, 'comment');
+    if (hasUserSuggestion(this.comment)) return nothing;
+    // TODO(milutin): remove this check once suggesting on commit message is
+    // fixed. Currently diff line doesn't match commit message line, because
+    // of metadata in diff, which aren't in content api request.
+    if (this.comment.path === SpecialFilePath.COMMIT_MESSAGE) return nothing;
+    if (this.isOwner) return nothing;
+    return html`<gr-button
+      link
+      class="action suggestEdit"
+      @click=${this.createSuggestEdit}
+      >Suggest Fix</gr-button
+    >`;
+  }
+
   private renderDiscardButton() {
-    if (this.editing) return;
+    if (this.editing || this.permanentEditingMode) return;
     return html`<gr-button
       link
       ?disabled=${this.saving}
@@ -730,7 +843,7 @@
   }
 
   private renderCancelButton() {
-    if (!this.editing) return;
+    if (!this.editing || this.permanentEditingMode) return;
     return html`
       <gr-button
         link
@@ -749,8 +862,8 @@
         link
         ?disabled=${this.isSaveDisabled()}
         class="action save"
-        @click=${this.save}
-        >Save</gr-button
+        @click=${this.handleSaveButtonClicked}
+        >${this.permanentEditingMode ? 'Preview' : 'Save'}</gr-button
       >
     `;
   }
@@ -771,6 +884,22 @@
     `;
   }
 
+  private renderSuggestEditActions() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+      return nothing;
+    }
+    if (
+      !this.account ||
+      isRobot(this.comment) ||
+      isDraftOrUnsaved(this.comment)
+    ) {
+      return nothing;
+    }
+    return html`
+      <div class="robotActions">${this.renderPreviewSuggestEditButton()}</div>
+    `;
+  }
+
   private renderShowFixButton() {
     if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
     return html`
@@ -793,7 +922,7 @@
         link
         ?disabled=${this.robotButtonDisabled}
         class="action fix"
-        @click=${this.handleFix}
+        @click=${this.handlePleaseFix}
       >
         Please Fix
       </gr-button>
@@ -818,11 +947,11 @@
     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,
-      this.repoName,
-      comment.id
-    );
+    return createDiffUrl({
+      changeNum: this.changeNum,
+      project: this.repoName,
+      commentId: comment.id,
+    });
   }
 
   private firstWillUpdateDone = false;
@@ -830,11 +959,15 @@
   firstWillUpdate() {
     if (this.firstWillUpdateDone) return;
     this.firstWillUpdateDone = true;
-
+    if (this.permanentEditingMode) this.editing = true;
     assertIsDefined(this.comment, 'comment');
     this.unresolved = this.comment.unresolved ?? true;
     if (isUnsaved(this.comment)) this.editing = true;
     if (isDraftOrUnsaved(this.comment)) {
+      this.reporting.reportInteraction(
+        Interaction.COMMENTS_AUTOCLOSE_FIRST_UPDATE,
+        {editing: this.editing, unsaved: isUnsaved(this.comment)}
+      );
       this.collapsed = false;
     } else {
       this.collapsed = !!this.initiallyCollapsed;
@@ -849,7 +982,12 @@
     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);
+      fire(this, 'comment-unresolved-changed', {value: this.unresolved});
+    }
+    if (changed.has('messageText')) {
+      // GrReplyDialog updates it's state when text inside patchset level
+      // comment changes.
+      fire(this, 'comment-text-changed', {value: this.messageText});
     }
   }
 
@@ -861,11 +999,6 @@
     });
   }
 
-  // private, but visible for testing
-  getRandomInt(from: number, to: number) {
-    return getRandomInt(from, to);
-  }
-
   private handleCopyLink() {
     fireEvent(this, 'copy-comment-link');
   }
@@ -889,9 +1022,37 @@
   }
 
   // private, but visible for testing
-  getEventPayload(): OpenFixPreviewEventDetail {
+  async createFixPreview(): Promise<OpenFixPreviewEventDetail> {
     assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
-    return {comment: this.comment, patchNum: this.comment.patch_set};
+    assertIsDefined(this.comment?.path, 'comment.path');
+
+    if (hasUserSuggestion(this.comment)) {
+      const replacement = getUserSuggestion(this.comment);
+      assert(!!replacement, 'malformed user suggestion');
+      const line = await this.getCommentedCode();
+
+      return {
+        fixSuggestions: createUserFixSuggestion(
+          this.comment,
+          line,
+          replacement
+        ),
+        patchNum: this.comment.patch_set,
+      };
+    }
+    if (isRobot(this.comment) && this.comment.fix_suggestions.length > 0) {
+      const id = this.comment.robot_id;
+      return {
+        fixSuggestions: this.comment.fix_suggestions.map(s => {
+          return {
+            ...s,
+            description: `${id ?? ''} - ${s.description ?? ''}`,
+          };
+        }),
+        patchNum: this.comment.patch_set,
+      };
+    }
+    throw new Error('unable to create preview fix event');
   }
 
   private onEditingChanged() {
@@ -906,14 +1067,16 @@
 
     // 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);
+    fire(this, 'comment-editing-changed', {
+      editing: this.editing,
+      path: this.comment?.path ?? '',
+    });
   }
 
   // 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();
   }
 
@@ -931,14 +1094,53 @@
     });
   }
 
-  private handleFix() {
-    // Handled by <gr-comment-thread>.
-    fire(this, 'create-fix-comment', this.getEventPayload());
+  private async handleSaveButtonClicked() {
+    await this.save();
+    if (this.permanentEditingMode) {
+      this.editing = !this.editing;
+    }
   }
 
-  private handleShowFix() {
+  private handlePleaseFix() {
+    const message = this.comment?.message;
+    assert(!!message, 'empty message');
+    const quoted = message.replace(NEWLINE_PATTERN, '\n> ');
+    const eventDetail: ReplyToCommentEventDetail = {
+      content: `> ${quoted}\n\nPlease fix.`,
+      userWantsToEdit: false,
+      unresolved: true,
+    };
+    // Handled by <gr-comment-thread>.
+    fire(this, 'reply-to-comment', eventDetail);
+  }
+
+  private async handleShowFix() {
     // Handled top-level in the diff and change view components.
-    fire(this, 'open-fix-preview', this.getEventPayload());
+    fire(this, 'open-fix-preview', await this.createFixPreview());
+  }
+
+  async createSuggestEdit() {
+    const line = await this.getCommentedCode();
+    this.messageText += `${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`;
+  }
+
+  async getCommentedCode() {
+    assertIsDefined(this.comment, 'comment');
+    assertIsDefined(this.changeNum, 'changeNum');
+    // TODO(milutin): Show a toast while the file is being loaded.
+    // TODO(milutin): This should be moved into a service/model.
+    const file = await this.restApiService.getFileContent(
+      this.changeNum,
+      this.comment.path!,
+      this.comment.patch_set!
+    );
+    assert(
+      !!file && isBase64FileContent(file) && !!file.content,
+      'file content for comment not found'
+    );
+    const line = getContentInCommentRange(file.content, this.comment);
+    assert(!!line, 'file content for comment not found');
+    return line;
   }
 
   // private, but visible for testing
@@ -975,6 +1177,9 @@
 
   async save() {
     if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
+    // If it's an unsaved comment then it does not have a draftID yet which
+    // means sending another save() request will create a new draft
+    if (isUnsaved(this.comment) && this.saving) return;
 
     try {
       this.saving = true;
@@ -999,7 +1204,12 @@
           await this.rawSave(messageToSave, {showToast: true});
         }
       }
-      this.editing = false;
+      this.reporting.reportInteraction(
+        Interaction.COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE
+      );
+      if (!this.permanentEditingMode) {
+        this.editing = false;
+      }
     } catch (e) {
       this.unableToSave = true;
       throw e;
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 bda076d..2293619 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
@@ -1,20 +1,9 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-comment';
 import {AUTO_SAVE_DEBOUNCE_DELAY_MS, GrComment} from './gr-comment';
 import {
@@ -27,6 +16,7 @@
   waitUntilCalled,
   dispatch,
   MockPromise,
+  stubFlags,
 } from '../../../test/test-utils';
 import {
   AccountId,
@@ -36,7 +26,6 @@
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {
   createComment,
   createDraft,
@@ -45,253 +34,295 @@
   createUnsaved,
 } from '../../../test/test-data-generators';
 import {
-  CreateFixCommentEvent,
+  ReplyToCommentEvent,
   OpenFixPreviewEventDetail,
 } from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
-import {DraftInfo} from '../../../utils/comment-util';
+import {
+  DraftInfo,
+  USER_SUGGESTION_START_PATTERN,
+} from '../../../utils/comment-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {Modifier} from '../../../utils/dom-util';
 import {SinonStub} from 'sinon';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
 
 suite('gr-comment tests', () => {
   let element: GrComment;
+  const 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,
+  };
+  const 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(() => {
-    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(async () => {
+    element = await fixture(
+      html`<gr-comment
+        .account=${account}
+        .showPatchset=${true}
+        .comment=${comment}
+      ></gr-comment>`
+    );
   });
 
   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>
+      const initiallyCollapsedElement = await fixture(
+        html`<gr-comment
+          .account=${account}
+          .showPatchset=${true}
+          .comment=${comment}
+          .initiallyCollapsed=${true}
+        ></gr-comment>`
+      );
+      assert.shadowDom.equal(
+        initiallyCollapsedElement,
+        /* HTML */ `
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <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" />
+                    <gr-icon id="icon" icon="expand_more"></gr-icon>
+                  </label>
+                </div>
+              </div>
+              <div class="body">
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+              </div>
             </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>
-      `);
+          </gr-endpoint-decorator>
+        `
+      );
     });
 
     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>
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <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" />
+                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                  </label>
+                </div>
+              </div>
+              <div class="body">
+                <gr-formatted-text class="message"></gr-formatted-text>
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+              </div>
             </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>
-      `);
+          </gr-endpoint-decorator>
+        `
+      );
     });
 
     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>
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <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" />
+                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                  </label>
+                </div>
+              </div>
+              <div class="body">
+                <div class="robotId"></div>
+                <gr-formatted-text class="message"></gr-formatted-text>
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <div class="robotActions">
+                  <gr-icon
+                    icon="link"
+                    class="copy link-icon"
+                    role="button"
+                    tabindex="0"
+                    title="Copy link to this comment"
+                  ></gr-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>
-            <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>
-      `);
+          </gr-endpoint-decorator>
+        `
+      );
     });
 
     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>
-      `);
+      assert.dom.equal(
+        queryAndAssert(element, 'gr-button.delete'),
+        /* HTML */ `
+          <gr-button
+            aria-disabled="false"
+            class="action delete"
+            id="deleteBtn"
+            link=""
+            role="button"
+            tabindex="0"
+            title="Delete Comment"
+          >
+            <gr-icon id="icon" icon="delete" filled></gr-icon>
+          </gr-button>
+        `
+      );
     });
 
     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>
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <div class="container draft" id="container">
+              <div class="header" id="header">
+                <div class="headerLeft">
+                  <gr-tooltip-content
+                    class="draftTooltip"
+                    has-tooltip=""
+                    max-width="20em"
+                    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."
+                  >
+                    <gr-icon filled icon="rate_review"></gr-icon>
+                    <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" />
+                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                  </label>
+                </div>
               </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 class="body">
+                <gr-formatted-text class="message"></gr-formatted-text>
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <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>
-          </div>
-        </div>
-      `);
+          </gr-endpoint-decorator>
+        `
+      );
     });
 
     test('renders draft in editing mode', async () => {
@@ -299,75 +330,82 @@
       (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>
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <div class="container draft" id="container">
+              <div class="header" id="header">
+                <div class="headerLeft">
+                  <gr-tooltip-content
+                    class="draftTooltip"
+                    has-tooltip=""
+                    max-width="20em"
+                    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."
+                  >
+                    <gr-icon filled icon="rate_review"></gr-icon>
+                    <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" />
+                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                  </label>
+                </div>
               </div>
-              <div class="rightActions">
-                <gr-button
-                  aria-disabled="false"
-                  class="action cancel"
-                  link=""
-                  role="button"
-                  tabindex="0"
+              <div class="body">
+                <gr-textarea
+                  autocomplete="on"
+                  class="code editMessage"
+                  code=""
+                  id="editTextarea"
+                  rows="4"
+                  text="This is the test comment message."
                 >
-                  Cancel
-                </gr-button>
-                <gr-button
-                  aria-disabled="false"
-                  class="action save"
-                  link=""
-                  role="button"
-                  tabindex="0"
-                >
-                  Save
-                </gr-button>
+                </gr-textarea>
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <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>
-          </div>
-        </div>
-      `);
+          </gr-endpoint-decorator>
+        `
+      );
     });
   });
 
@@ -376,8 +414,8 @@
     element.addEventListener('comment-anchor-tap', stub);
     await element.updateComplete;
 
-    const dateEl = queryAndAssert(element, '.date');
-    tap(dateEl);
+    const dateEl = queryAndAssert<HTMLSpanElement>(element, '.date');
+    dateEl.click();
 
     assert.isTrue(stub.called);
     assert.deepEqual(stub.lastCall.args[0].detail, {
@@ -441,8 +479,8 @@
     element.isAdmin = true;
     await element.updateComplete;
 
-    const deleteButton = queryAndAssert(element, '.action.delete');
-    tap(deleteButton);
+    const deleteButton = queryAndAssert<GrButton>(element, '.action.delete');
+    deleteButton.click();
     await element.updateComplete;
 
     assertIsDefined(element.confirmDeleteOverlay, 'confirmDeleteOverlay');
@@ -490,9 +528,11 @@
       await element.updateComplete;
       assert.isTrue(element.isSaveDisabled());
 
+      // After changing the 'resolved' state of the comment the 'Save' button
+      // should stay disabled, if the message is empty.
       element.unresolved = false;
       await element.updateComplete;
-      assert.isFalse(element.isSaveDisabled());
+      assert.isTrue(element.isSaveDisabled());
 
       element.saving = true;
       await element.updateComplete;
@@ -538,6 +578,23 @@
       assert.isFalse(element.saving);
     });
 
+    test('previewing formatting triggers save', async () => {
+      element.permanentEditingMode = true;
+
+      const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
+
+      element.comment = createDraft();
+      element.editing = true;
+      element.messageText = 'something, not important';
+      await element.updateComplete;
+
+      assert.isFalse(saveStub.called);
+
+      queryAndAssert<GrButton>(element, '.save').click();
+
+      assert.isTrue(saveStub.called);
+    });
+
     test('save failed', async () => {
       sinon
         .stub(element.getCommentsModel(), 'saveDraft')
@@ -597,7 +654,7 @@
       );
       assert.isTrue(checkbox.checked);
 
-      tap(checkbox);
+      checkbox.click();
       await element.updateComplete;
 
       checkbox = queryAndAssert<HTMLInputElement>(element, '#resolvedCheckbox');
@@ -624,19 +681,21 @@
       assert.isFalse(saveStub.called);
     });
 
-    test('handleFix fires create-fix event', async () => {
-      const listener = listenOnce<CreateFixCommentEvent>(
+    test('handlePleaseFix fires reply-to-comment event', async () => {
+      const listener = listenOnce<ReplyToCommentEvent>(
         element,
-        'create-fix-comment'
+        'reply-to-comment'
       );
       element.comment = createRobotComment();
       element.comments = [element.comment];
       await element.updateComplete;
 
-      tap(queryAndAssert(element, '.fix'));
+      queryAndAssert<GrButton>(element, '.fix').click();
 
       const e = await listener;
-      assert.deepEqual(e.detail, element.getEventPayload());
+      assert.equal(e.detail.unresolved, true);
+      assert.equal(e.detail.userWantsToEdit, false);
+      assert.isTrue(e.detail.content.includes('Please fix.'));
     });
 
     test('do not show Please Fix button if human reply exists', async () => {
@@ -668,10 +727,10 @@
       };
       await element.updateComplete;
 
-      tap(queryAndAssert(element, '.show-fix'));
+      queryAndAssert<GrButton>(element, '.show-fix').click();
 
       const e = await listener;
-      assert.deepEqual(e.detail, element.getEventPayload());
+      assert.deepEqual(e.detail, await element.createFixPreview());
     });
   });
 
@@ -741,4 +800,72 @@
       assert.equal(saveStub.firstCall.firstArg.id, 'exp123');
     });
   });
+
+  suite('suggest edit', () => {
+    let element: GrComment;
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      const comment = {
+        ...createComment(),
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        __draft: true,
+        message: 'hello world',
+      };
+      element = await fixture(
+        html`<gr-comment
+          .account=${account}
+          .showPatchset=${true}
+          .comment=${comment}
+          .initiallyCollapsed=${false}
+        ></gr-comment>`
+      );
+    });
+    test('renders suggest fix button', () => {
+      assert.dom.equal(
+        queryAndAssert(element, 'gr-button.suggestEdit'),
+        /* HTML */ `<gr-button
+          aria-disabled="false"
+          class="action suggestEdit"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Suggest Fix
+        </gr-button> `
+      );
+    });
+
+    test('renders preview suggest fix', async () => {
+      element.comment = {
+        ...createComment(),
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        message: `${USER_SUGGESTION_START_PATTERN}afterSuggestion${'\n```'}`,
+      };
+      await element.updateComplete;
+
+      assert.dom.equal(
+        queryAndAssert(element, 'gr-button.show-fix'),
+        /* HTML */ `<gr-button
+          aria-disabled="false"
+          class="action show-fix"
+          link=""
+          role="button"
+          secondary
+          tabindex="0"
+        >
+          Preview Fix
+        </gr-button> `
+      );
+    });
+  });
 });
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 f7f960f..b6512b3 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
@@ -1,22 +1,12 @@
 /**
  * @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-dialog/gr-dialog';
 import {css, html, LitElement} from 'lit';
-import {property, query, customElement} from 'lit/decorators';
+import {property, query, customElement} from 'lit/decorators.js';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {assertIsDefined} from '../../../utils/common-util';
@@ -102,7 +92,7 @@
           placeholder="&lt;Insert reasoning here&gt;"
           .bindValue=${this.message}
           @bind-value-changed=${(e: BindValueChangeEvent) => {
-            this.message = e.detail.value;
+            this.message = e.detail.value ?? '';
           }}
         ></iron-autogrow-textarea>
       </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
new file mode 100644
index 0000000..bd84ac4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrConfirmDeleteCommentDialog} from './gr-confirm-delete-comment-dialog';
+import './gr-confirm-delete-comment-dialog';
+
+suite('gr-confirm-delete-comment-dialog tests', () => {
+  let element: GrConfirmDeleteCommentDialog;
+
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-delete-comment-dialog></gr-confirm-delete-comment-dialog>`
+    );
+  });
+
+  test('render', () => {
+    // prettier and shadowDom string disagree about wrapping in <p> tag.
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <gr-dialog confirm-label="Delete" role="dialog">
+        <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
+            aria-disabled="false"
+            autocomplete="on"
+            class="message"
+            id="messageInput"
+            placeholder="<Insert reasoning here>"
+          >
+          </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 46bb7d1..0e9b874 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
@@ -1,29 +1,22 @@
 /**
  * @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 '@polymer/iron-input/iron-input';
 import '../gr-button/gr-button';
-import '../gr-icons/gr-icons';
-import {IronIconElement} from '@polymer/iron-icon';
-import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
-import {classMap} from 'lit/directives/class-map';
-import {ifDefined} from 'lit/directives/if-defined';
+import '../gr-icon/gr-icon';
+import {
+  assertIsDefined,
+  copyToClipbard,
+  queryAndAssert,
+} from '../../../utils/common-util';
+import {classMap} from 'lit/directives/class-map.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
 import {GrButton} from '../gr-button/gr-button';
+import {GrIcon} from '../gr-icon/gr-icon';
 
 const COPY_TIMEOUT_MS = 1000;
 
@@ -46,6 +39,9 @@
   @property({type: Boolean})
   hideInput = false;
 
+  @query('#icon')
+  iconEl!: GrIcon;
+
   static override get styles() {
     return [
       css`
@@ -66,27 +62,16 @@
           font-size: var(--font-size-mono);
           line-height: var(--line-height-mono);
           width: 100%;
+          background-color: var(--view-background-color);
+          color: var(--primary-text-color);
         }
-        /*
-         * Typically icons are 20px, which is the normal line-height.
-         * The copy icon is too prominent at 20px, so we choose 16px
-         * here, but add 2x2px padding below, so the entire
-         * component should still fit nicely into a normal inline
-         * layout flow.
-         */
-        #icon {
-          height: 16px;
-          width: 16px;
-        }
-        iron-icon {
+        gr-icon {
           color: var(--deemphasized-text-color);
-          vertical-align: top;
-          --iron-icon-height: 20px;
-          --iron-icon-width: 20px;
         }
         gr-button {
           display: block;
-          --gr-button-padding: 2px;
+          --gr-button-padding: var(--spacing-s);
+          margin: calc(0px - var(--spacing-s));
         }
       `,
     ];
@@ -122,7 +107,9 @@
             @click=${this._copyToClipboard}
             aria-label="Click to copy to clipboard"
           >
-            <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
+            <div>
+              <gr-icon id="icon" icon="content_copy" small></gr-icon>
+            </div>
           </gr-button>
         </gr-tooltip-content>
       </div>
@@ -145,15 +132,8 @@
 
     this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
     assertIsDefined(this.text, 'text');
-    this.iconEl.icon = 'gr-icons:check';
-    navigator.clipboard.writeText(this.text);
-    setTimeout(
-      () => (this.iconEl.icon = 'gr-icons:content-copy'),
-      COPY_TIMEOUT_MS
-    );
-  }
-
-  private get iconEl(): IronIconElement {
-    return queryAndAssert<IronIconElement>(this, '#icon');
+    this.iconEl.icon = 'check';
+    copyToClipbard(this.text, 'Link');
+    setTimeout(() => (this.iconEl.icon = 'content_copy'), COPY_TIMEOUT_MS);
   }
 }
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 6c43c43..6e93803 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
@@ -1,41 +1,66 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-copy-clipboard';
 import {GrCopyClipboard} from './gr-copy-clipboard';
 import {queryAndAssert} from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-copy-clipboard');
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
 
 suite('gr-copy-clipboard tests', () => {
   let element: GrCopyClipboard;
+  let clipboardSpy: sinon.SinonStub;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    clipboardSpy = sinon
+      .stub(navigator.clipboard, 'writeText')
+      .returns(Promise.resolve());
+    sinon.spy(document, 'dispatchEvent');
+    element = await fixture(html`<gr-copy-clipboard></gr-copy-clipboard>`);
     element.text = `git fetch http://gerrit@localhost:8080/a/test-project
         refs/changes/05/5/1 && git checkout FETCH_HEAD`;
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="text">
+          <iron-input class="copyText">
+            <input
+              id="input"
+              is="iron-input"
+              part="text-container-style"
+              readonly=""
+              type="text"
+            />
+          </iron-input>
+          <gr-tooltip-content>
+            <gr-button
+              aria-disabled="false"
+              aria-label="Click to copy to clipboard"
+              class="copyToClipboard"
+              id="copy-clipboard-button"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              <div>
+                <gr-icon icon="content_copy" id="icon" small></gr-icon>
+              </div>
+            </gr-button>
+          </gr-tooltip-content>
+        </div>
+      `
+    );
   });
 
   test('copy to clipboard', () => {
-    const clipboardSpy = sinon.spy(navigator.clipboard, 'writeText');
-    const copyBtn = queryAndAssert(element, '.copyToClipboard');
-    MockInteractions.click(copyBtn);
+    queryAndAssert<GrButton>(element, '.copyToClipboard').click();
     assert.isTrue(clipboardSpy.called);
   });
 
@@ -53,7 +78,7 @@
     assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
 
     const inputElement = queryAndAssert<HTMLInputElement>(element, 'input');
-    MockInteractions.tap(inputElement);
+    inputElement.click();
     assert.equal(inputElement.selectionStart, 0);
     assert.equal(inputElement.selectionEnd, element.text!.length - 1);
   });
@@ -67,7 +92,7 @@
     const input = queryAndAssert(element, 'input');
     assert.notEqual(getComputedStyle(input).display, 'none');
     element.hideInput = true;
-    await flush();
+    await element.updateComplete;
     assert.equal(getComputedStyle(input).display, 'none');
   });
 
@@ -76,8 +101,7 @@
     divParent.appendChild(element);
     const clickStub = sinon.stub();
     divParent.addEventListener('click', clickStub);
-    const copyBtn = queryAndAssert(element, '.copyToClipboard');
-    MockInteractions.tap(copyBtn);
+    queryAndAssert<GrButton>(element, '.copyToClipboard').click();
     assert.isFalse(clickStub.called);
   });
 });
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 81b6fd4..828672b 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
@@ -1,18 +1,7 @@
 /**
  * @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 {BehaviorSubject} from 'rxjs';
 import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
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 47b26b2..4b8157e 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
@@ -1,25 +1,13 @@
 /**
  * @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 './gr-cursor-manager.js';
-import {fixture, html} from '@open-wc/testing-helpers';
-import {AbortStop, CursorMoveResult} from '../../../api/core.js';
-import {GrCursorManager} from './gr-cursor-manager.js';
+import '../../../test/common-test-setup';
+// eslint-disable-next-line import/named
+import {fixture, html, assert} from '@open-wc/testing';
+import {AbortStop, CursorMoveResult} from '../../../api/core';
+import {GrCursorManager} from './gr-cursor-manager';
 
 suite('gr-cursor-manager tests', () => {
   let cursor;
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 497dc94..25ee130 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
@@ -1,22 +1,11 @@
 /**
  * @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 '../gr-tooltip-content/gr-tooltip-content';
 import {css, html, LitElement} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {
   parseDate,
   fromNow,
@@ -91,27 +80,22 @@
   @property({type: Boolean})
   showYesterday = false;
 
-  /** @type {?{short: string, full: string}} */
-  @property({type: Object})
-  private dateFormat?: DateFormatPair;
-
-  @property({type: String})
-  private timeFormat?: string;
-
-  @property({type: Boolean})
-  private relative = false;
-
   @property({type: Boolean})
   forceRelative = false;
 
   @property({type: Boolean})
   relativeOptionNoAgo = false;
 
-  private readonly restApiService = getAppContext().restApiService;
+  @state()
+  dateFormat?: DateFormatPair;
 
-  constructor() {
-    super();
-  }
+  @state()
+  timeFormat?: string;
+
+  @state()
+  relative = false;
+
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -141,12 +125,12 @@
   }
 
   private renderDateString() {
-    return html` <span>${this._computeDateStr()}</span>`;
+    return html` <span>${this.computeDateStr()}</span>`;
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadPreferences();
+    this.loadPreferences();
   }
 
   // private but used by tests
@@ -155,27 +139,25 @@
   }
 
   // private but used by tests
-  _loadPreferences() {
-    return this._getLoggedIn().then(loggedIn => {
-      if (!loggedIn) {
-        this.timeFormat = TimeFormats.TIME_24;
-        this.dateFormat = DateFormats.STD;
-        this.relative = this.forceRelative;
-        return;
-      }
-      return Promise.all([this._loadTimeFormat(), this.loadRelative()]);
-    });
+  async loadPreferences() {
+    const loggedIn = await this.restApiService.getLoggedIn();
+    if (!loggedIn) {
+      this.timeFormat = TimeFormats.TIME_24;
+      this.dateFormat = DateFormats.STD;
+      this.relative = this.forceRelative;
+      return;
+    }
+    await Promise.all([this.loadTimeFormat(), this.loadRelative()]);
   }
 
-  // private but used in gr/file-list_test.js
-  _loadTimeFormat() {
-    return this.getPreferences().then(preferences => {
-      if (!preferences) {
-        throw Error('Preferences is not set');
-      }
-      this.decideTimeFormat(preferences.time_format);
-      this.decideDateFormat(preferences.date_format);
-    });
+  // private but used in gr/file-list_test.ts
+  async loadTimeFormat() {
+    const preferences = await this.restApiService.getPreferences();
+    if (!preferences) {
+      throw Error('Preferences is not set');
+    }
+    this.decideTimeFormat(preferences.time_format);
+    this.decideDateFormat(preferences.date_format);
   }
 
   private decideTimeFormat(timeFormat: TimeFormat) {
@@ -213,24 +195,13 @@
     }
   }
 
-  private loadRelative() {
-    return this.getPreferences().then(prefs => {
-      // prefs.relative_date_in_change_table is not set when false.
-      this.relative =
-        this.forceRelative || !!(prefs && prefs.relative_date_in_change_table);
-    });
+  private async loadRelative() {
+    const prefs = await this.restApiService.getPreferences();
+    this.relative =
+      this.forceRelative || Boolean(prefs?.relative_date_in_change_table);
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  private getPreferences() {
-    return this.restApiService.getPreferences();
-  }
-
-  // private but used by tests
-  _computeDateStr() {
+  private computeDateStr() {
     if (!this.dateStr || !this.timeFormat || !this.dateFormat) {
       return '';
     }
@@ -259,7 +230,6 @@
   }
 
   private computeFullDateStr() {
-    // Polymer 2: check for undefined
     if (
       [this.dateStr, this.timeFormat].includes(undefined) ||
       !this.dateFormat
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
deleted file mode 100644
index 2f446cb..0000000
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
+++ /dev/null
@@ -1,461 +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-date-formatter.js';
-import {parseDate} from '../../../utils/date-util.js';
-import {fixture, html} from '@open-wc/testing-helpers';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicTemplate = html`
-  <gr-date-formatter withTooltip 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;
-
-  setup(() => {
-  });
-
-  /**
-   * Parse server-formatter date and normalize into current timezone.
-   */
-  function normalizedDate(dateStr) {
-    const d = parseDate(dateStr);
-    d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
-    return d;
-  }
-
-  async function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
-      expectedTooltip) {
-    // Normalize and convert the date to mimic server response.
-    dateStr = normalizedDate(dateStr)
-        .toJSON()
-        .replace('T', ' ')
-        .slice(0, -1);
-    sinon.useFakeTimers(normalizedDate(nowStr).getTime());
-    element.dateStr = dateStr;
-    await element.updateComplete;
-    const span = element.shadowRoot.querySelector('span');
-    const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
-    assert.equal(span.textContent.trim(), expected);
-    assert.equal(tooltip.title, expectedTooltip);
-    element.showDateAndTime = true;
-    await element.updateComplete;
-    assert.equal(span.textContent.trim(), expectedWithDateAndTime);
-  }
-
-  function stubRestAPI(preferences) {
-    const loggedInPromise = Promise.resolve(preferences !== null);
-    const preferencesPromise = Promise.resolve(preferences);
-    stubRestApi('getLoggedIn').returns(loggedInPromise);
-    stubRestApi('getPreferences').returns(preferencesPromise);
-    return Promise.all([loggedInPromise, preferencesPromise]);
-  }
-
-  suite('STD + 24 hours time format preference', () => {
-    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('');
-      await element._loadPreferences();
-    });
-
-    test('invalid dates are quietly rejected', () => {
-      assert.notOk((new Date('foo')).valueOf());
-      element.dateStr = 'foo';
-      element.timeFormat = 'h:mm A';
-      assert.equal(element._computeDateStr(), '');
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          'Jul 29, 2015, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          'Jul 28',
-          'Jul 28 20:25',
-          'Jul 28, 2015, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          'Jun 15',
-          'Jun 15 03:25',
-          'Jun 15, 2015, 03:25:14');
-    });
-
-    test('More than six months', async () => {
-      await testDates('2015-09-15 20:34:00.000000000',
-          '2015-01-15 03:25:00.000000000',
-          'Jan 15, 2015',
-          'Jan 15, 2015 03:25',
-          'Jan 15, 2015, 03:25:00');
-    });
-  });
-
-  suite('US + 24 hours time format preference', () => {
-    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('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          '07/29/15, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          '07/28',
-          '07/28 20:25',
-          '07/28/15, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          '06/15',
-          '06/15 03:25',
-          '06/15/15, 03:25:14');
-    });
-  });
-
-  suite('ISO + 24 hours time format preference', () => {
-    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('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          '2015-07-29, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          '07-28',
-          '07-28 20:25',
-          '2015-07-28, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          '06-15',
-          '06-15 03:25',
-          '2015-06-15, 03:25:14');
-    });
-  });
-
-  suite('EURO + 24 hours time format preference', () => {
-    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('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          '29.07.2015, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          '28. Jul',
-          '28. Jul 20:25',
-          '28.07.2015, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          '15. Jun',
-          '15. Jun 03:25',
-          '15.06.2015, 03:25:14');
-    });
-  });
-
-  suite('UK + 24 hours time format preference', () => {
-    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('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          '29/07/2015, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          '28/07',
-          '28/07 20:25',
-          '28/07/2015, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          '15/06',
-          '15/06 03:25',
-          '15/06/2015, 03:25:14');
-    });
-  });
-
-  suite('STD + 12 hours time format preference', () => {
-    setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      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',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          'Jul 29, 2015, 3:34:14 PM');
-    });
-  });
-
-  suite('US + 12 hours time format preference', () => {
-    setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      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',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          '07/29/15, 3:34:14 PM');
-    });
-  });
-
-  suite('ISO + 12 hours time format preference', () => {
-    setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      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',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          '2015-07-29, 3:34:14 PM');
-    });
-  });
-
-  suite('EURO + 12 hours time format preference', () => {
-    setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      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',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          '29.07.2015, 3:34:14 PM');
-    });
-  });
-
-  suite('UK + 12 hours time format preference', () => {
-    setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      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',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          '29/07/2015, 3:34:14 PM');
-    });
-  });
-
-  suite('relative date preference', () => {
-    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',
-          '2015-07-29 15:34:14.985000000',
-          '5 hours ago',
-          '5 hours ago',
-          'Jul 29, 2015, 3:34:14 PM');
-    });
-
-    test('More than six months', async () => {
-      await testDates('2015-09-15 20:34:00.000000000',
-          '2015-01-15 03:25:00.000000000',
-          '8 months ago',
-          '8 months ago',
-          'Jan 15, 2015, 3:25:00 AM');
-    });
-  });
-
-  suite('logged in', () => {
-    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');
-      assert.equal(element.dateFormat.short, 'MM/DD');
-      assert.equal(element.dateFormat.full, 'MM/DD/YY');
-      assert.isTrue(element.relative);
-    });
-  });
-
-  suite('logged out', () => {
-    setup(async () => {
-      await stubRestAPI(null);
-      element = await fixture(basicTemplate);
-      await element._loadPreferences();
-    });
-
-    test('Default preferences are respected', () => {
-      assert.equal(element.timeFormat, 'HH:mm');
-      assert.equal(element.dateFormat.short, 'MMM DD');
-      assert.equal(element.dateFormat.full, 'MMM DD, YYYY');
-      assert.isFalse(element.relative);
-    });
-  });
-
-  suite('with tooltip', () => {
-    setup(async () => {
-      await stubRestAPI(null);
-      element = await fixture(basicTemplate);
-      await element._loadPreferences();
-      await element.updateComplete;
-    });
-
-    test('Tooltip is present', () => {
-      const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
-      assert.isOk(tooltip);
-    });
-  });
-
-  suite('without tooltip', () => {
-    setup(async () => {
-      await stubRestAPI(null);
-      element = await fixture(lightTemplate);
-      await element._loadPreferences();
-      await element.updateComplete;
-    });
-
-    test('Tooltip is absent', () => {
-      const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
-      assert.isNotOk(tooltip);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
new file mode 100644
index 0000000..d7c38df
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
@@ -0,0 +1,529 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-date-formatter';
+import {GrDateFormatter} from './gr-date-formatter';
+import {parseDate} from '../../../utils/date-util';
+import {fixture, html, assert} from '@open-wc/testing';
+import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
+import {Timestamp} from '../../../api/rest-api';
+import {PreferencesInfo} from '../../../types/common';
+import {createPreferences} from '../../../test/test-data-generators';
+import {
+  createDefaultPreferences,
+  DateFormat,
+  TimeFormat,
+} from '../../../constants/constants';
+
+const basicTemplate = html`
+  <gr-date-formatter withTooltip 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: GrDateFormatter;
+
+  /**
+   * Parse server-formatted date and normalize into current timezone.
+   */
+  function normalizedDate(dateStr: Timestamp) {
+    const d = parseDate(dateStr);
+    d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
+    return d;
+  }
+
+  async function testDates(
+    nowStr: string,
+    dateStr: string,
+    expected: string,
+    expectedWithDateAndTime: string,
+    expectedTooltip: string
+  ) {
+    // Normalize and convert the date to mimic server response.
+    const normalizedDateStr = normalizedDate(dateStr as Timestamp)
+      .toJSON()
+      .replace('T', ' ')
+      .slice(0, -1);
+    sinon.useFakeTimers(normalizedDate(nowStr as Timestamp).getTime());
+    element.dateStr = normalizedDateStr;
+    await element.updateComplete;
+    const span = queryAndAssert<HTMLSpanElement>(element, 'span');
+    const tooltip = queryAndAssert<GrTooltipContent>(
+      element,
+      'gr-tooltip-content'
+    );
+    assert.equal(span.textContent?.trim(), expected);
+    assert.equal(tooltip.title, expectedTooltip);
+    element.showDateAndTime = true;
+    await element.updateComplete;
+    assert.equal(span.textContent?.trim(), expectedWithDateAndTime);
+  }
+
+  function stubRestAPI(preferences?: PreferencesInfo) {
+    stubRestApi('getLoggedIn').resolves(preferences !== undefined);
+    stubRestApi('getPreferences').resolves(preferences);
+  }
+
+  suite('STD + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.STD,
+        relative_date_in_change_table: false,
+      });
+
+      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',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        'Jul 29, 2015, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        'Jul 28',
+        'Jul 28 20:25',
+        'Jul 28, 2015, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        'Jun 15',
+        'Jun 15 03:25',
+        'Jun 15, 2015, 03:25:14'
+      );
+    });
+
+    test('More than six months', async () => {
+      await testDates(
+        '2015-09-15 20:34:00.000000000',
+        '2015-01-15 03:25:00.000000000',
+        'Jan 15, 2015',
+        'Jan 15, 2015 03:25',
+        'Jan 15, 2015, 03:25:00'
+      );
+    });
+  });
+
+  suite('US + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.US,
+        relative_date_in_change_table: false,
+      });
+      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',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        '07/29/15, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        '07/28',
+        '07/28 20:25',
+        '07/28/15, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        '06/15',
+        '06/15 03:25',
+        '06/15/15, 03:25:14'
+      );
+    });
+  });
+
+  suite('ISO + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.ISO,
+        relative_date_in_change_table: false,
+      });
+
+      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',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        '2015-07-29, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        '07-28',
+        '07-28 20:25',
+        '2015-07-28, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        '06-15',
+        '06-15 03:25',
+        '2015-06-15, 03:25:14'
+      );
+    });
+  });
+
+  suite('EURO + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.EURO,
+        relative_date_in_change_table: false,
+      });
+
+      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',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        '29.07.2015, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        '28. Jul',
+        '28. Jul 20:25',
+        '28.07.2015, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        '15. Jun',
+        '15. Jun 03:25',
+        '15.06.2015, 03:25:14'
+      );
+    });
+  });
+
+  suite('UK + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.UK,
+        relative_date_in_change_table: false,
+      });
+
+      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',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        '29/07/2015, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        '28/07',
+        '28/07 20:25',
+        '28/07/2015, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        '15/06',
+        '15/06 03:25',
+        '15/06/2015, 03:25:14'
+      );
+    });
+  });
+
+  suite('STD + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.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',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        'Jul 29, 2015, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('US + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.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',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        '07/29/15, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('ISO + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.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',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        '2015-07-29, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('EURO + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.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',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        '29.07.2015, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('UK + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.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',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        '29/07/2015, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('relative date preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.STD,
+        relative_date_in_change_table: true,
+      });
+      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',
+        '2015-07-29 15:34:14.985000000',
+        '5 hours ago',
+        '5 hours ago',
+        'Jul 29, 2015, 3:34:14 PM'
+      );
+    });
+
+    test('More than six months', async () => {
+      await testDates(
+        '2015-09-15 20:34:00.000000000',
+        '2015-01-15 03:25:00.000000000',
+        '8 months ago',
+        '8 months ago',
+        'Jan 15, 2015, 3:25:00 AM'
+      );
+    });
+  });
+
+  suite('logged in', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.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');
+      assert.equal(element.dateFormat?.short, 'MM/DD');
+      assert.equal(element.dateFormat?.full, 'MM/DD/YY');
+      assert.isTrue(element.relative);
+    });
+  });
+
+  suite('logged out', () => {
+    setup(async () => {
+      stubRestAPI(undefined);
+      element = await fixture(basicTemplate);
+      await element.loadPreferences();
+    });
+
+    test('Default preferences are respected', () => {
+      assert.equal(element.timeFormat, 'HH:mm');
+      assert.equal(element.dateFormat?.short, 'MMM DD');
+      assert.equal(element.dateFormat?.full, 'MMM DD, YYYY');
+      assert.isFalse(element.relative);
+    });
+  });
+
+  suite('with tooltip', () => {
+    setup(async () => {
+      stubRestAPI(createDefaultPreferences());
+      element = await fixture(basicTemplate);
+      await element.loadPreferences();
+      await element.updateComplete;
+    });
+
+    test('Tooltip is present', () => {
+      const tooltip = queryAndAssert<GrTooltipContent>(
+        element,
+        'gr-tooltip-content'
+      );
+      assert.isOk(tooltip);
+    });
+  });
+
+  suite('without tooltip', () => {
+    setup(async () => {
+      stubRestAPI(createDefaultPreferences());
+      element = await fixture(lightTemplate);
+      await element.loadPreferences();
+      await element.updateComplete;
+    });
+
+    test('Tooltip is absent', () => {
+      const tooltip = query<GrTooltipContent>(element, 'gr-tooltip-content');
+      assert.isNotOk(tooltip);
+    });
+  });
+});
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 4df53da..dbf75f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -1,25 +1,15 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-button/gr-button';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
 import {GrButton} from '../gr-button/gr-button';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {when} from 'lit/directives/when.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -51,6 +41,12 @@
   @property({type: String, attribute: 'cancel-label'})
   cancelLabel = 'Cancel';
 
+  @property({type: Boolean, attribute: 'loading'})
+  loading = false;
+
+  @property({type: String, attribute: 'loading-label'})
+  loadingLabel = 'Loading...';
+
   // TODO: Add consistent naming after Lit conversion of the codebase
   @property({type: Boolean})
   disabled = false;
@@ -105,15 +101,21 @@
         footer {
           display: flex;
           flex-shrink: 0;
-          justify-content: flex-end;
           padding-top: var(--spacing-xl);
         }
+        .flex-space {
+          flex-grow: 1;
+        }
         gr-button {
           margin-left: var(--spacing-l);
         }
         .hidden {
           display: none;
         }
+        .loadingSpin {
+          width: 18px;
+          height: 18px;
+        }
       `,
     ];
   }
@@ -134,6 +136,18 @@
           </div>
         </main>
         <footer>
+          ${when(
+            this.loading,
+            () => html`
+              <span
+                class="loadingSpin"
+                role="progressbar"
+                aria-label=${this.loadingLabel}
+              ></span>
+              <span class="loadingLabel"> ${this.loadingLabel} </span>
+            `
+          )}
+          <div class="flex-space"></div>
           <gr-button
             id="cancel"
             class=${this.cancelLabel.length ? '' : 'hidden'}
@@ -201,7 +215,7 @@
   }
 
   _handleKeydown(e: KeyboardEvent) {
-    if (this.confirmOnEnter && e.keyCode === 13) {
+    if (this.confirmOnEnter && e.key === 'Enter') {
       this._handleConfirm(e);
     }
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
index 171fc6c..d386c32 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
@@ -1,46 +1,122 @@
 /**
  * @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 * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-dialog';
 import {GrDialog} from './gr-dialog';
-import {isHidden, queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-dialog');
+import {
+  isHidden,
+  pressKey,
+  queryAndAssert,
+  waitEventLoop,
+} from '../../../test/test-utils';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
 
 suite('gr-dialog tests', () => {
   let element: GrDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrDialog>(html` <gr-dialog></gr-dialog> `);
     await element.updateComplete;
   });
 
+  test('renders', async () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div class="container">
+        <header class="heading-3">
+          <slot name="header"> </slot>
+        </header>
+        <main>
+          <div class="overflow-container">
+            <slot name="main"> </slot>
+          </div>
+        </main>
+        <footer>
+          <div class="flex-space"></div>
+          <gr-button
+            aria-disabled="false"
+            id="cancel"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Cancel
+          </gr-button>
+          <gr-button
+            aria-disabled="false"
+            id="confirm"
+            link=""
+            primary=""
+            role="button"
+            tabindex="0"
+            title=""
+          >
+            Confirm
+          </gr-button>
+        </footer>
+      </div> `
+    );
+  });
+
+  test('renders with loading state', async () => {
+    element.loading = true;
+    element.loadingLabel = 'Loading!!';
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div class="container">
+        <header class="heading-3">
+          <slot name="header"> </slot>
+        </header>
+        <main>
+          <div class="overflow-container">
+            <slot name="main"> </slot>
+          </div>
+        </main>
+        <footer>
+          <span class="loadingSpin" aria-label="Loading!!" role="progressbar">
+          </span>
+          <span class="loadingLabel"> Loading!! </span>
+          <div class="flex-space"></div>
+          <gr-button
+            aria-disabled="false"
+            id="cancel"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Cancel
+          </gr-button>
+          <gr-button
+            aria-disabled="false"
+            id="confirm"
+            link=""
+            primary=""
+            role="button"
+            tabindex="0"
+            title=""
+          >
+            Confirm
+          </gr-button>
+        </footer>
+      </div> `
+    );
+  });
+
   test('events', () => {
     const confirm = sinon.stub();
     const cancel = sinon.stub();
     element.addEventListener('confirm', confirm);
     element.addEventListener('cancel', cancel);
 
-    MockInteractions.tap(queryAndAssert(element, 'gr-button[primary]'));
+    queryAndAssert<GrButton>(element, 'gr-button[primary]').click();
     assert.equal(confirm.callCount, 1);
 
-    MockInteractions.tap(queryAndAssert(element, 'gr-button:not([primary])'));
+    queryAndAssert<GrButton>(element, 'gr-button:not([primary])').click();
     assert.equal(cancel.callCount, 1);
   });
 
@@ -49,13 +125,8 @@
     await element.updateComplete;
     const handleConfirmStub = sinon.stub(element, '_handleConfirm');
     const handleKeydownSpy = sinon.spy(element, '_handleKeydown');
-    MockInteractions.keyDownOn(
-      queryAndAssert(element, 'main'),
-      13,
-      null,
-      'enter'
-    );
-    await flush();
+    pressKey(queryAndAssert(element, 'main'), 'Enter');
+    await waitEventLoop();
 
     assert.isTrue(handleKeydownSpy.called);
     assert.isFalse(handleConfirmStub.called);
@@ -63,13 +134,8 @@
     element.confirmOnEnter = true;
     await element.updateComplete;
 
-    MockInteractions.keyDownOn(
-      queryAndAssert(element, 'main'),
-      13,
-      null,
-      'enter'
-    );
-    await flush();
+    pressKey(queryAndAssert(element, 'main'), 'Enter');
+    await waitEventLoop();
 
     assert.isTrue(handleConfirmStub.called);
   });
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 44b6fa2..15d7072 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
@@ -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 '@polymer/iron-input/iron-input';
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
@@ -25,7 +13,7 @@
 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 {customElement, query, state} from 'lit/decorators.js';
 import {convertToString} from '../../../utils/string-util';
 import {fire} from '../../../utils/event-util';
 import {ValueChangedEvent} from '../../../types/events';
@@ -65,13 +53,17 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.userModel.diffPreferences$, diffPreferences => {
-      if (!diffPreferences) return;
-      this.originalDiffPrefs = diffPreferences;
-      this.diffPrefs = {...diffPreferences};
-    });
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      diffPreferences => {
+        if (!diffPreferences) return;
+        this.originalDiffPrefs = diffPreferences;
+        this.diffPrefs = {...diffPreferences};
+      }
+    );
   }
 
   static override get styles() {
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 c32189e..05f5ea1 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-diff-preferences';
 import {GrDiffPreferences} from './gr-diff-preferences';
 import {queryAll, stubRestApi} from '../../../test/test-utils';
@@ -24,8 +12,7 @@
 import {IronInputElement} from '@polymer/iron-input';
 import {GrSelect} from '../gr-select/gr-select';
 import {ParsedJSON} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-diff-preferences');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-diff-preferences tests', () => {
   let element: GrDiffPreferences;
@@ -50,112 +37,116 @@
 
     stubRestApi('getDiffPreferences').returns(Promise.resolve(diffPreferences));
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-diff-preferences></gr-diff-preferences>`);
 
     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>
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ` <div class="gr-form-styles" id="diffPreferences">
+        <section>
+          <label class="title" for="contextLineSelect">Context</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>
+            <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>
-        </div>
-      </section>
-    </div>`);
+        </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', () => {
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 78de898..2d93227 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
@@ -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 {Subscription} from 'rxjs';
 import '@polymer/paper-tabs/paper-tab';
@@ -24,7 +13,7 @@
 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 {customElement, property, state} from 'lit/decorators.js';
 import {fire} from '../../../utils/event-util';
 import {BindValueChangeEvent} from '../../../types/events';
 
@@ -106,6 +95,18 @@
           max-width: 15rem;
           text-transform: uppercase;
           --paper-tab-ink: var(--link-color);
+          --paper-font-common-base_-_font-family: var(--header-font-family);
+          --paper-font-common-base_-_-webkit-font-smoothing: initial;
+          --paper-tab-content_-_margin-bottom: var(--spacing-s);
+          /* paper-tabs uses 700 here, which can look awkward */
+          --paper-tab-content-focused_-_font-weight: var(--font-weight-h3);
+          --paper-tab-content-focused_-_background: var(
+            --gray-background-focus
+          );
+          --paper-tab-content-unselected_-_opacity: 1;
+          --paper-tab-content-unselected_-_color: var(
+            --deemphasized-text-color
+          );
         }
         label,
         input {
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 9efecfd..695c674 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-download-commands';
 import {GrDownloadCommands} from './gr-download-commands';
 import {
@@ -25,12 +13,11 @@
   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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {PaperTabElement} from '@polymer/paper-tabs/paper-tab';
 
 suite('gr-download-commands', () => {
   let element: GrDownloadCommands;
@@ -60,18 +47,68 @@
   ];
   const SELECTED_SCHEME = 'http';
 
-  setup(() => {});
-
   suite('unauthenticated', () => {
     setup(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
+      element = await fixture(
+        html`<gr-download-commands></gr-download-commands>`
+      );
       element.schemes = SCHEMES;
       element.commands = COMMANDS;
       element.selectedScheme = SELECTED_SCHEME;
       await element.updateComplete;
     });
 
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="schemes">
+            <paper-tabs
+              dir="null"
+              id="downloadTabs"
+              role="tablist"
+              tabindex="0"
+            >
+              <paper-tab
+                aria-disabled="false"
+                aria-selected="true"
+                class="iron-selected"
+                data-scheme="http"
+                role="tab"
+                tabindex="0"
+              >
+                http
+              </paper-tab>
+              <paper-tab
+                aria-disabled="false"
+                aria-selected="false"
+                data-scheme="repo"
+                role="tab"
+                tabindex="-1"
+              >
+                repo
+              </paper-tab>
+              <paper-tab
+                aria-disabled="false"
+                aria-selected="false"
+                data-scheme="ssh"
+                role="tab"
+                tabindex="-1"
+              >
+                ssh
+              </paper-tab>
+            </paper-tabs>
+          </div>
+          <div class="commands"></div>
+          <gr-shell-command class="_label_checkout"> </gr-shell-command>
+          <gr-shell-command class="_label_cherrypick"> </gr-shell-command>
+          <gr-shell-command class="_label_formatpatch"> </gr-shell-command>
+          <gr-shell-command class="_label_pull"> </gr-shell-command>
+        `
+      );
+    });
+
     test('focusOnCopy', async () => {
       const focusStub = sinon.stub(
         queryAndAssert<GrShellCommand>(element, 'gr-shell-command'),
@@ -101,7 +138,7 @@
         queryAndAssert<PaperTabsElement>(element, '#downloadTabs').selected,
         '0'
       );
-      MockInteractions.tap(queryAndAssert(element, '[data-scheme="ssh"]'));
+      queryAndAssert<PaperTabElement>(element, '[data-scheme="ssh"]').click();
       await element.updateComplete;
       assert.equal(element.selectedScheme, 'ssh');
       assert.equal(
@@ -118,9 +155,12 @@
 
       await element.updateComplete;
 
-      const repoTab = queryAndAssert(element, 'paper-tab[data-scheme="repo"]');
+      const repoTab = queryAndAssert<PaperTabElement>(
+        element,
+        'paper-tab[data-scheme="repo"]'
+      );
 
-      MockInteractions.tap(repoTab);
+      repoTab.click();
 
       assert.isTrue(savePrefsStub.called);
       assert.equal(
@@ -131,8 +171,9 @@
   });
   suite('authenticated', () => {
     test('loads scheme from preferences', async () => {
-      const element = basicFixture.instantiate();
-      await element.updateComplete;
+      const element: GrDownloadCommands = await fixture(
+        html`<gr-download-commands></gr-download-commands>`
+      );
       element.userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'repo',
@@ -141,8 +182,9 @@
     });
 
     test('normalize scheme from preferences', async () => {
-      const element = basicFixture.instantiate();
-      await element.updateComplete;
+      const element: GrDownloadCommands = await fixture(
+        html`<gr-download-commands></gr-download-commands>`
+      );
       element.userModel.setPreferences({
         ...createPreferences(),
         download_scheme: '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 109482f..b6ca9f5 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
@@ -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 '@polymer/iron-dropdown/iron-dropdown';
 import '@polymer/paper-item/paper-item';
@@ -21,14 +10,19 @@
 import '../gr-button/gr-button';
 import '../gr-date-formatter/gr-date-formatter';
 import '../gr-select/gr-select';
-import '../gr-file-status-chip/gr-file-status-chip';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-dropdown-list_html';
-import {customElement, property, observe} from '@polymer/decorators';
+import '../gr-file-status/gr-file-status';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query} from 'lit/decorators.js';
 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';
+import {assertIsDefined} from '../../../utils/common-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ValueChangedEvent} from '../../../types/events';
+import {incrementalRepeat} from '../../lit/incremental-repeat';
+import {when} from 'lit/directives/when.js';
+import {isMagicPath} from '../../../utils/path-list-util';
 
 /**
  * Required values are text and value. mobileText and triggerText will
@@ -50,31 +44,25 @@
   file?: NormalizedFileInfo;
 }
 
-export interface GrDropdownList {
-  $: {
-    dropdown: IronDropdownElement;
-    trigger: GrButton;
-  };
-}
-
-export interface ValueChangeDetail {
-  value: string;
-}
-
-export type DropDownValueChangeEvent = CustomEvent<ValueChangeDetail>;
-
-@customElement('gr-dropdown-list')
-export class GrDropdownList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementEventMap {
+    'value-change': ValueChangedEvent<string>;
   }
+}
+@customElement('gr-dropdown-list')
+export class GrDropdownList extends LitElement {
+  @query('#dropdown')
+  dropdown?: IronDropdownElement;
+
+  @query('#trigger')
+  trigger?: GrButton;
 
   /**
    * Fired when the selected value changes
    *
    * @event value-change
    *
-   * @property {string|number} value
+   * @property {string} value
    */
 
   @property({type: Number})
@@ -89,27 +77,244 @@
   @property({type: Boolean})
   disabled = false;
 
-  @property({type: String, notify: true})
-  value: string | number = '';
+  @property({type: String})
+  value = '';
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-copy-for-trigger-text'})
   showCopyForTriggerText = false;
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        #triggerText {
+          -moz-user-select: text;
+          -ms-user-select: text;
+          -webkit-user-select: text;
+          user-select: text;
+        }
+        .dropdown-trigger {
+          cursor: pointer;
+          padding: 0;
+        }
+        .dropdown-content {
+          background-color: var(--dropdown-background-color);
+          box-shadow: var(--elevation-level-2);
+          max-height: 70vh;
+          min-width: 266px;
+        }
+        paper-item:hover {
+          background-color: var(--hover-background-color);
+        }
+        paper-item:not(:last-of-type) {
+          border-bottom: 1px solid var(--border-color);
+        }
+        .bottomContent {
+          color: var(--deemphasized-text-color);
+        }
+        .bottomContent,
+        .topContent {
+          display: flex;
+          justify-content: space-between;
+          flex-direction: row;
+          width: 100%;
+        }
+        gr-button {
+          font-family: var(--trigger-style-font-family);
+          --gr-button-text-color: var(--trigger-style-text-color);
+        }
+        gr-date-formatter {
+          color: var(--deemphasized-text-color);
+          margin-left: var(--spacing-xxl);
+          white-space: nowrap;
+        }
+        gr-select {
+          display: none;
+        }
+        /* Because the iron dropdown 'area' includes the trigger, and the entire
+          width of the dropdown, we want to treat tapping the area above the
+          dropdown content as if it is tapping whatever content is underneath
+          it. The next two styles allow this to happen. */
+        iron-dropdown {
+          max-width: none;
+          pointer-events: none;
+        }
+        paper-listbox {
+          pointer-events: auto;
+          --paper-listbox_-_padding: 0;
+        }
+        paper-item {
+          cursor: pointer;
+          flex-direction: column;
+          font-size: inherit;
+          /* This variable was introduced in Dec 2019. We keep both min-height
+            * rules around, because --paper-item-min-height is not yet
+            * upstreamed.
+            */
+          --paper-item-min-height: 0;
+          --paper-item_-_min-height: 0;
+          --paper-item_-_padding: 10px 16px;
+          --paper-item-focused-before_-_background-color: var(
+            --selection-background-color
+          );
+          --paper-item-focused_-_background-color: var(
+            --selection-background-color
+          );
+        }
+        @media only screen and (max-width: 50em) {
+          gr-select {
+            display: var(--gr-select-style-display, inline);
+            width: var(--gr-select-style-width);
+          }
+          gr-button,
+          iron-dropdown {
+            display: none;
+          }
+          select {
+            width: var(--native-select-style-width);
+          }
+        }
+      `,
+    ];
+  }
+
+  protected override willUpdate(changedProperties: PropertyValues): void {
+    if (changedProperties.has('items') || changedProperties.has('value')) {
+      this.handleValueChange();
+    }
+  }
+
+  override render() {
+    return html`
+      <gr-button
+        id="trigger"
+        ?disabled=${this.disabled}
+        down-arrow
+        link
+        class="dropdown-trigger"
+        slot="dropdown-trigger"
+        no-uppercase
+        @click=${this.showDropdownTapHandler}
+      >
+        <span id="triggerText">${this.text}</span>
+        <gr-copy-clipboard
+          ?hidden=${!this.showCopyForTriggerText}
+          hideInput
+          .text=${this.text}
+        ></gr-copy-clipboard>
+      </gr-button>
+      <iron-dropdown
+        id="dropdown"
+        .verticalAlign=${'top'}
+        .horizontalAlign=${'left'}
+        .dynamicAlign=${true}
+        .noOverlap=${true}
+        .allowOutsideScroll=${true}
+        @click=${this.handleDropdownClick}
+      >
+        <paper-listbox
+          class="dropdown-content"
+          slot="dropdown-content"
+          .attrForSelected=${'data-value'}
+          .selected=${this.value}
+          @selected-changed=${this.selectedChanged}
+        >
+          ${incrementalRepeat({
+            values: this.items ?? [],
+            initialCount: this.initialCount,
+            mapFn: item => this.renderPaperItem(item as DropdownItem),
+          })}
+        </paper-listbox>
+      </iron-dropdown>
+      <gr-select
+        .bindValue=${this.value}
+        @bind-value-changed=${this.selectedChanged}
+      >
+        <select>
+          ${this.items?.map(
+            item => html`
+              <option ?disabled=${item.disabled} value=${`${item.value}`}>
+                ${this.computeMobileText(item)}
+              </option>
+            `
+          )}
+        </select>
+      </gr-select>
+    `;
+  }
+
+  private renderPaperItem(item: DropdownItem) {
+    return html`
+      <paper-item ?disabled=${item.disabled} data-value=${item.value}>
+        <div class="topContent">
+          <div>${item.text}</div>
+          ${when(
+            item.date,
+            () => html`
+              <gr-date-formatter .dateStr=${item.date}></gr-date-formatter>
+            `
+          )}
+          ${when(
+            item.file?.status && !isMagicPath(item.file?.__path),
+            () => html`
+              <gr-file-status .status=${item.file?.status}></gr-file-status>
+            `
+          )}
+        </div>
+        ${when(
+          item.bottomText,
+          () => html`
+            <div class="bottomContent">
+              <div>${item.bottomText}</div>
+            </div>
+          `
+        )}
+      </paper-item>
+    `;
+  }
+
+  private selectedChanged(e: ValueChangedEvent<string>) {
+    this.value = e.detail.value;
+  }
+
   /**
    * Handle a click on the iron-dropdown element.
    */
-  _handleDropdownClick() {
+  private handleDropdownClick() {
     // async is needed so that that the click event is fired before the
     // dropdown closes (This was a bug for touch devices).
     setTimeout(() => {
-      this.$.dropdown.close();
+      assertIsDefined(this.dropdown);
+      this.dropdown.close();
     }, 1);
   }
 
+  private handleValueChange() {
+    if (this.value === undefined || this.items === undefined) {
+      return;
+    }
+    const selectedObj = this.items.find(item => `${item.value}` === this.value);
+    if (!selectedObj) {
+      return;
+    }
+    this.text = selectedObj.triggerText
+      ? selectedObj.triggerText
+      : selectedObj.text;
+    this.dispatchEvent(
+      new CustomEvent('value-change', {
+        detail: {value: this.value},
+        bubbles: false,
+      })
+    );
+  }
+
   /**
    * Handle a click on the button to open the dropdown.
    */
-  _showDropdownTapHandler() {
+  private showDropdownTapHandler() {
     this.open();
   }
 
@@ -117,37 +322,14 @@
    * Open the dropdown.
    */
   open() {
-    this.$.dropdown.open();
+    assertIsDefined(this.dropdown);
+    this.dropdown.open();
   }
 
-  _computeMobileText(item: DropdownItem) {
+  // Private but used in tests.
+  computeMobileText(item: DropdownItem) {
     return item.mobileText ? item.mobileText : item.text;
   }
-
-  computeStringValue(val: string | number) {
-    return String(val);
-  }
-
-  @observe('value', 'items')
-  _handleValueChange(value?: string, items?: DropdownItem[]) {
-    if (!value || !items) {
-      return;
-    }
-    const selectedObj = items.find(item => `${item.value}` === `${value}`);
-    if (!selectedObj) {
-      return;
-    }
-    this.text = selectedObj.triggerText
-      ? selectedObj.triggerText
-      : selectedObj.text;
-    const detail: ValueChangeDetail = {value};
-    this.dispatchEvent(
-      new CustomEvent('value-change', {
-        detail,
-        bubbles: false,
-      })
-    );
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
deleted file mode 100644
index 3875871..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
+++ /dev/null
@@ -1,185 +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: inline-block;
-    }
-    #triggerText {
-      -moz-user-select: text;
-      -ms-user-select: text;
-      -webkit-user-select: text;
-      user-select: text;
-    }
-    .dropdown-trigger {
-      cursor: pointer;
-      padding: 0;
-    }
-    .dropdown-content {
-      background-color: var(--dropdown-background-color);
-      box-shadow: var(--elevation-level-2);
-      max-height: 70vh;
-      min-width: 266px;
-    }
-    paper-listbox {
-      --paper-listbox: {
-        padding: 0;
-      }
-    }
-    paper-item {
-      cursor: pointer;
-      flex-direction: column;
-      font-size: inherit;
-      /* This variable was introduced in Dec 2019. We keep both min-height
-         * rules around, because --paper-item-min-height is not yet upstreamed.
-         */
-      --paper-item-min-height: 0;
-      --paper-item: {
-        min-height: 0;
-        padding: 10px 16px;
-      }
-      --paper-item-focused-before: {
-        background-color: var(--selection-background-color);
-      }
-      --paper-item-focused: {
-        background-color: var(--selection-background-color);
-      }
-    }
-    paper-item:hover {
-      background-color: var(--hover-background-color);
-    }
-    paper-item:not(:last-of-type) {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .bottomContent {
-      color: var(--deemphasized-text-color);
-    }
-    .bottomContent,
-    .topContent {
-      display: flex;
-      justify-content: space-between;
-      flex-direction: row;
-      width: 100%;
-    }
-    gr-button {
-      font-family: var(--trigger-style-font-family);
-      --gr-button-text-color: var(--trigger-style-text-color);
-    }
-    gr-date-formatter {
-      color: var(--deemphasized-text-color);
-      margin-left: var(--spacing-xxl);
-      white-space: nowrap;
-    }
-    gr-select {
-      display: none;
-    }
-    /* Because the iron dropdown 'area' includes the trigger, and the entire
-       width of the dropdown, we want to treat tapping the area above the
-       dropdown content as if it is tapping whatever content is underneath it.
-       The next two styles allow this to happen. */
-    iron-dropdown {
-      max-width: none;
-      pointer-events: none;
-    }
-    paper-listbox {
-      pointer-events: auto;
-    }
-    @media only screen and (max-width: 50em) {
-      gr-select {
-        display: inline;
-        @apply --gr-select-style;
-      }
-      gr-button,
-      iron-dropdown {
-        display: none;
-      }
-      select {
-        @apply --native-select-style;
-      }
-    }
-  </style>
-  <gr-button
-    disabled="[[disabled]]"
-    down-arrow=""
-    link=""
-    id="trigger"
-    class="dropdown-trigger"
-    on-click="_showDropdownTapHandler"
-    slot="dropdown-trigger"
-    no-uppercase
-  >
-    <span id="triggerText">[[text]]</span>
-    <gr-copy-clipboard
-      hidden="[[!showCopyForTriggerText]]"
-      hideInput=""
-      text="[[text]]"
-    ></gr-copy-clipboard>
-  </gr-button>
-  <iron-dropdown
-    id="dropdown"
-    vertical-align="top"
-    horizontal-align="left"
-    dynamic-align
-    no-overlap
-    allow-outside-scroll="true"
-    on-click="_handleDropdownClick"
-  >
-    <paper-listbox
-      class="dropdown-content"
-      slot="dropdown-content"
-      attr-for-selected="data-value"
-      selected="{{value}}"
-    >
-      <template
-        is="dom-repeat"
-        items="[[items]]"
-        initial-count="[[initialCount]]"
-      >
-        <paper-item disabled="[[item.disabled]]" data-value$="[[item.value]]">
-          <div class="topContent">
-            <div>[[item.text]]</div>
-            <template is="dom-if" if="[[item.date]]">
-              <gr-date-formatter date-str="[[item.date]]"></gr-date-formatter>
-            </template>
-            <template is="dom-if" if="[[item.file]]">
-              <gr-file-status-chip file="[[item.file]]"></gr-file-status-chip>
-            </template>
-          </div>
-          <template is="dom-if" if="[[item.bottomText]]">
-            <div class="bottomContent">
-              <div>[[item.bottomText]]</div>
-            </div>
-          </template>
-        </paper-item>
-      </template>
-    </paper-listbox>
-  </iron-dropdown>
-  <gr-select bind-value="{{value}}">
-    <select>
-      <template is="dom-repeat" items="[[items]]">
-        <option
-          disabled$="[[item.disabled]]"
-          value="[[computeStringValue(item.value)]]"
-        >
-          [[_computeMobileText(item)]]
-        </option>
-      </template>
-    </select>
-  </gr-select>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
index 0416bf7..b9380cb 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
@@ -1,78 +1,33 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {
+  query,
+  queryAll,
+  queryAndAssert,
+  waitEventLoop,
+} from '../../../test/test-utils';
 import {PaperListboxElement} from '@polymer/paper-listbox';
 import {Timestamp} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-dropdown-list');
+import {assertIsDefined} from '../../../utils/common-util';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-dropdown-list tests', () => {
   let element: GrDropdownList;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture<GrDropdownList>(
+      html`<gr-dropdown-list></gr-dropdown-list>`
+    );
   });
 
-  test('hide copy by default', () => {
-    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 = 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();
-    });
-    assert.isFalse(element.$.dropdown.opened);
-    MockInteractions.tap(element.$.trigger);
-    assert.isTrue(element.$.dropdown.opened);
-  });
-
-  test('_computeMobileText', () => {
-    const item: any = {
-      value: 1,
-      text: 'text',
-    };
-    assert.equal(element._computeMobileText(item), item.text);
-    item.mobileText = 'mobile text';
-    assert.equal(element._computeMobileText(item), item.mobileText);
-  });
-
-  test('options are selected and laid out correctly', async () => {
-    element.value = 2;
+  test('render', async () => {
+    element.value = '2';
     element.items = [
       {
         value: 1,
@@ -95,12 +50,169 @@
         mobileText: 'Mobile Text 3',
       },
     ];
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-button
+          aria-disabled="false"
+          class="dropdown-trigger"
+          down-arrow=""
+          id="trigger"
+          link=""
+          no-uppercase=""
+          role="button"
+          slot="dropdown-trigger"
+          tabindex="0"
+        >
+          <span id="triggerText"> Button Text 2 </span>
+          <gr-copy-clipboard hidden="" hideinput=""> </gr-copy-clipboard>
+        </gr-button>
+        <iron-dropdown
+          aria-disabled="false"
+          aria-hidden="true"
+          horizontal-align="left"
+          id="dropdown"
+          style="outline: none; display: none;"
+          vertical-align="top"
+        >
+          <paper-listbox
+            class="dropdown-content"
+            role="listbox"
+            slot="dropdown-content"
+            tabindex="0"
+          >
+            <paper-item
+              aria-disabled="false"
+              aria-selected="false"
+              data-value="1"
+              role="option"
+              tabindex="-1"
+            >
+              <div class="topContent">
+                <div>Top Text 1</div>
+              </div>
+            </paper-item>
+            <paper-item
+              aria-disabled="false"
+              aria-selected="true"
+              class="iron-selected"
+              data-value="2"
+              role="option"
+              tabindex="0"
+            >
+              <div class="topContent">
+                <div>Top Text 2</div>
+              </div>
+              <div class="bottomContent">
+                <div>Bottom Text 2</div>
+              </div>
+            </paper-item>
+            <paper-item
+              aria-disabled="true"
+              aria-selected="false"
+              data-value="3"
+              disabled=""
+              role="option"
+              style="pointer-events: none;"
+              tabindex="-1"
+            >
+              <div class="topContent">
+                <div>Top Text 3</div>
+                <gr-date-formatter> </gr-date-formatter>
+              </div>
+              <div class="bottomContent">
+                <div>Bottom Text 3</div>
+              </div>
+            </paper-item>
+          </paper-listbox>
+        </iron-dropdown>
+        <gr-select>
+          <select>
+            <option value="1">Top Text 1</option>
+            <option value="2">Mobile Text 2</option>
+            <option disabled="" value="3">Mobile Text 3</option>
+          </select>
+        </gr-select>
+      `
+    );
+  });
+
+  test('hide copy by default', () => {
+    const copyEl = query<HTMLElement>(
+      element,
+      '#triggerText + gr-copy-clipboard'
+    )!;
+    assert.isOk(copyEl);
+    assert.isTrue(copyEl.hidden);
+  });
+
+  test('show copy if enabled', async () => {
+    element.showCopyForTriggerText = true;
+    await element.updateComplete;
+    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(() => {
+      assertIsDefined(element.dropdown);
+      element.dropdown.open();
+    });
+    assertIsDefined(element.dropdown);
+    assert.isFalse(element.dropdown.opened);
+    assertIsDefined(element.trigger);
+    element.trigger.click();
+    assert.isTrue(element.dropdown.opened);
+  });
+
+  test('computeMobileText', () => {
+    const item: any = {
+      value: 1,
+      text: 'text',
+    };
+    assert.equal(element.computeMobileText(item), item.text);
+    item.mobileText = 'mobile text';
+    assert.equal(element.computeMobileText(item), item.mobileText);
+  });
+
+  test('options are selected and laid out correctly', async () => {
+    element.value = '2';
+    element.items = [
+      {
+        value: 1,
+        text: 'Top Text 1',
+      },
+      {
+        value: 2,
+        bottomText: 'Bottom Text 2',
+        triggerText: 'Button Text 2',
+        text: 'Top Text 2',
+        mobileText: 'Mobile Text 2',
+      },
+      {
+        value: 3,
+        disabled: true,
+        bottomText: 'Bottom Text 3',
+        triggerText: 'Button Text 3',
+        date: '2017-08-18 23:11:42.569000000' as Timestamp,
+        text: 'Top Text 3',
+        mobileText: 'Mobile Text 3',
+      },
+    ];
+    await element.updateComplete;
+    await waitEventLoop();
+
     assert.equal(
       queryAndAssert<PaperListboxElement>(element, 'paper-listbox').selected,
       element.value
     );
     assert.equal(element.text, 'Button Text 2');
-    await flush();
 
     const items = queryAll<HTMLInputElement>(element, 'paper-item');
     const mobileItems = queryAll<HTMLOptionElement>(element, 'option');
@@ -169,9 +281,9 @@
     assert.equal(mobileItems[2].text, element.items[2].mobileText);
 
     // Select a new item.
-    MockInteractions.tap(items[0]);
-    flush();
-    assert.equal(element.value, 1);
+    items[0].click();
+    await element.updateComplete;
+    assert.equal(element.value, '1');
     assert.isTrue(items[0].classList.contains('iron-selected'));
     assert.isTrue(mobileItems[0].selected);
 
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 f62278a..3a8946a 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -1,18 +1,7 @@
 /**
  * @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-dropdown/iron-dropdown';
 import '../gr-button/gr-button';
@@ -20,34 +9,31 @@
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/shared-styles';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-dropdown_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
-import {property, customElement, observe} from '@polymer/decorators';
-import {addShortcut, Key} from '../../../utils/dom-util';
+import {property, customElement, query, state} from 'lit/decorators.js';
+import {Key} from '../../../utils/dom-util';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {fire} from '../../../utils/event-util';
+import {ValueChangedEvent} from '../../../types/events';
+import {assertIsDefined} from '../../../utils/common-util';
+import {ShortcutController} from '../../lit/shortcut-controller';
 
 const REL_NOOPENER = 'noopener';
 const REL_EXTERNAL = 'external';
 
 declare global {
   interface HTMLElementEventMap {
-    'opened-changed': CustomEvent;
+    'opened-changed': ValueChangedEvent<boolean>;
   }
   interface HTMLElementTagNameMap {
     'gr-dropdown': GrDropdown;
   }
 }
 
-export interface GrDropdown {
-  $: {
-    dropdown: IronDropdownElement;
-    trigger: GrButton;
-  };
-}
-
 export interface DropdownLink {
   url?: string;
   name?: string;
@@ -58,21 +44,102 @@
   tooltip?: string;
 }
 
-interface DisableIdsRecord {
-  base: string[];
-}
-
 export interface DropdownContent {
   text: string;
   bold?: boolean;
 }
 
 @customElement('gr-dropdown')
-export class GrDropdown extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrDropdown extends LitElement {
+  @query('#dropdown')
+  dropdown?: IronDropdownElement;
 
+  @query('#trigger')
+  trigger?: GrButton;
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        .dropdown-trigger {
+          text-decoration: none;
+          width: 100%;
+        }
+        .dropdown-content {
+          background-color: var(--dropdown-background-color);
+          box-shadow: var(--elevation-level-2);
+          min-width: 112px;
+          max-width: 280px;
+        }
+        gr-button {
+          vertical-align: top;
+        }
+        gr-avatar {
+          height: 2em;
+          width: 2em;
+          vertical-align: middle;
+        }
+        gr-button[link]:focus {
+          outline: 5px auto -webkit-focus-ring-color;
+        }
+        ul {
+          list-style: none;
+        }
+        .topContent,
+        li {
+          border-bottom: 1px solid var(--border-color);
+        }
+        li:last-of-type {
+          border: none;
+        }
+        li .itemAction {
+          cursor: pointer;
+          display: block;
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        li .itemAction {
+          color: var(--gr-dropdown-item-color);
+          background-color: var(--gr-dropdown-item-background-color);
+          border: var(--gr-dropdown-item-border);
+          text-transform: var(--gr-dropdown-item-text-transform);
+        }
+        li .itemAction.disabled {
+          color: var(--deemphasized-text-color);
+          cursor: default;
+        }
+        li .itemAction:link,
+        li .itemAction:visited {
+          text-decoration: none;
+        }
+        li .itemAction:not(.disabled):hover {
+          background-color: var(--hover-background-color);
+        }
+        li:focus,
+        li.selected {
+          background-color: var(--selection-background-color);
+          outline: none;
+        }
+        li:focus .itemAction,
+        li.selected .itemAction {
+          background-color: transparent;
+        }
+        .topContent {
+          display: block;
+          padding: var(--spacing-m) var(--spacing-l);
+          color: var(--gr-dropdown-item-color);
+          background-color: var(--gr-dropdown-item-background-color);
+          border: var(--gr-dropdown-item-border);
+          text-transform: var(--gr-dropdown-item-text-transform);
+        }
+        .bold-text {
+          font-weight: var(--font-weight-bold);
+        }
+      `,
+    ];
+  }
   /**
    * Fired when a non-link dropdown item with the given ID is tapped.
    *
@@ -88,13 +155,13 @@
   @property({type: Array})
   items?: DropdownLink[];
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'down-arrow'})
   downArrow = false;
 
   @property({type: Array})
   topContent?: DropdownContent[];
 
-  @property({type: String})
+  @property({type: String, attribute: 'horizontal-align'})
   horizontalAlign = 'left';
 
   /**
@@ -104,12 +171,11 @@
   @property({type: Boolean})
   link = false;
 
-  @property({type: Number})
+  @property({type: Number, attribute: 'vertical-offset'})
   verticalOffset = 40;
 
-  /** Propagates/Reflects the `opened` property of the <iron-dropdown> */
-  @property({type: Boolean, notify: true})
-  opened = false;
+  @state()
+  private opened = false;
 
   /**
    * List the IDs of dropdown buttons to be disabled. (Note this only
@@ -118,68 +184,157 @@
   @property({type: Array})
   disabledIds: string[] = [];
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
-
   // Used within the tests so needs to be non-private.
   cursor = new GrCursorManager();
 
+  private readonly shortcuts = new ShortcutController(this);
+
   constructor() {
     super();
     this.cursor.cursorTargetClass = 'selected';
     this.cursor.focusOnMove = true;
+    this.shortcuts.addLocal({key: Key.UP}, () => this.handleUp());
+    this.shortcuts.addLocal({key: Key.DOWN}, () => this.handleDown());
+    this.shortcuts.addLocal({key: Key.ENTER}, () => this.handleEnter());
+    this.shortcuts.addLocal({key: Key.SPACE}, () => this.handleEnter());
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.cleanups.push(
-      addShortcut(this, {key: Key.UP}, () => this._handleUp())
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, () => this._handleDown())
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, () => this._handleEnter())
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.SPACE}, () => this._handleEnter())
-    );
   }
 
   override disconnectedCallback() {
     this.cursor.unsetCursor();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
     super.disconnectedCallback();
   }
 
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('opened')) {
+      fire(this, 'opened-changed', {value: this.opened});
+    }
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('items')) {
+      this.resetCursorStops();
+    }
+    if (changedProperties.has('opened') && this.opened) {
+      this.resetCursorStops();
+      this.cursor.setCursorAtIndex(0);
+      if (this.cursor.target !== null) this.cursor.target.focus();
+    }
+  }
+
+  override render() {
+    return html` <gr-button
+        ?link=${this.link}
+        class="dropdown-trigger"
+        id="trigger"
+        ?down-arrow=${this.downArrow}
+        @click=${this.dropdownTriggerTapHandler}
+      >
+        <slot></slot>
+      </gr-button>
+      <iron-dropdown
+        id="dropdown"
+        .verticalAlign=${'top'}
+        .verticalOffset=${this.verticalOffset}
+        allowOutsideScroll
+        .horizontalAlign=${this.horizontalAlign}
+        @click=${() => this.close()}
+        @opened-changed=${(e: CustomEvent) => (this.opened = e.detail.value)}
+      >
+        ${this.renderDropdownContent()}
+      </iron-dropdown>`;
+  }
+
+  private renderDropdownContent() {
+    return html` <div class="dropdown-content" slot="dropdown-content">
+      <ul>
+        ${this.renderTopContent()}
+        ${(this.items ?? []).map(link => this.renderDropdownLink(link))}
+      </ul>
+    </div>`;
+  }
+
+  private renderTopContent() {
+    if (!this.topContent) return nothing;
+    return html`
+      <div class="topContent">
+        ${(this.topContent ?? []).map(item => this.renderTopContentItem(item))}
+      </div>
+    `;
+  }
+
+  private renderTopContentItem(item: DropdownContent) {
+    return html`
+      <div class="${this.getClassIfBold(item.bold)} top-item" tabindex="-1">
+        ${item.text}
+      </div>
+    `;
+  }
+
+  private renderDropdownLink(link: DropdownLink) {
+    const disabledClass = this.computeDisabledClass(link.id);
+    return html`
+      <li tabindex="-1">
+        <gr-tooltip-content
+          ?has-tooltip=${!!link.tooltip}
+          title=${ifDefined(link.tooltip)}
+        >
+          <span
+            class="itemAction ${disabledClass}"
+            data-id=${ifDefined(link.id)}
+            @click=${this.handleItemTap}
+            ?hidden=${!!link.url}
+            tabindex="-1"
+            >${link.name}</span
+          >
+          <a
+            class="itemAction"
+            href=${this.computeLinkURL(link)}
+            ?download=${!!link.download}
+            rel=${ifDefined(this.computeLinkRel(link) ?? undefined)}
+            target=${ifDefined(link.target ?? undefined)}
+            ?hidden=${!link.url}
+            tabindex="-1"
+            >${link.name}</a
+          >
+        </gr-tooltip-content>
+      </li>
+    `;
+  }
+
   /**
    * Handle the up key.
    */
-  _handleUp() {
-    if (this.$.dropdown.opened) {
+  private handleUp() {
+    assertIsDefined(this.dropdown);
+    if (this.dropdown.opened) {
       this.cursor.previous();
     } else {
-      this._open();
+      this.open();
     }
   }
 
   /**
    * Handle the down key.
    */
-  _handleDown() {
-    if (this.$.dropdown.opened) {
+  private handleDown() {
+    assertIsDefined(this.dropdown);
+    if (this.dropdown.opened) {
       this.cursor.next();
     } else {
-      this._open();
+      this.open();
     }
   }
 
   /**
    * Handle the enter key.
    */
-  _handleEnter() {
-    if (this.$.dropdown.opened) {
+  private handleEnter() {
+    assertIsDefined(this.dropdown);
+    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,
       // we have to target `a` inside it.
@@ -190,49 +345,39 @@
         }
       }
     } else {
-      this._open();
+      this.open();
     }
   }
 
   /**
-   * Handle a click on the iron-dropdown element.
-   */
-  _handleDropdownClick() {
-    this._close();
-  }
-
-  handleOpenedChanged(e: CustomEvent) {
-    this.opened = e.detail.value;
-  }
-
-  /**
    * Handle a click on the button to open the dropdown.
    */
-  _dropdownTriggerTapHandler(e: MouseEvent) {
+  private dropdownTriggerTapHandler(e: MouseEvent) {
+    assertIsDefined(this.dropdown);
     e.preventDefault();
     e.stopPropagation();
-    if (this.$.dropdown.opened) {
-      this._close();
+    if (this.dropdown.opened) {
+      this.close();
     } else {
-      this._open();
+      this.open();
     }
   }
 
   /**
    * Open the dropdown and initialize the cursor.
+   * Private but used in tests.
    */
-  _open() {
-    this.$.dropdown.open();
-    this._resetCursorStops();
-    this.cursor.setCursorAtIndex(0);
-    if (this.cursor.target !== null) this.cursor.target.focus();
+  open() {
+    assertIsDefined(this.dropdown);
+    this.dropdown.open();
   }
 
-  _close() {
+  // Private but used in tests.
+  close() {
     // async is needed so that that the click event is fired before the
     // dropdown closes (This was a bug for touch devices).
     setTimeout(() => {
-      this.$.dropdown.close();
+      this.dropdown?.close();
     }, 1);
   }
 
@@ -241,8 +386,10 @@
    *
    * @param bold Whether the item is bold.
    * @return The class for the top-content item.
+   *
+   * Private but used in tests.
    */
-  _getClassIfBold(bold?: boolean) {
+  getClassIfBold(bold?: boolean) {
     return bold ? 'bold-text' : '';
   }
 
@@ -265,30 +412,33 @@
    * @param path The path for the URL.
    * @return The scheme-relative URL.
    */
-  _computeRelativeURL(path: string) {
+  private computeRelativeURL(path: string) {
     const host = window.location.host;
     return this._computeURLHelper(host, path);
   }
 
   /**
    * Compute the URL for a link object.
+   *
+   * Private but used in tests.
    */
-  _computeLinkURL(link: DropdownLink) {
+  computeLinkURL(link: DropdownLink) {
     if (typeof link.url === 'undefined') {
       return '';
     }
     if (link.target || !link.url.startsWith('/')) {
       return link.url;
     }
-    return this._computeRelativeURL(link.url);
+    return this.computeRelativeURL(link.url);
   }
 
   /**
    * Compute the value for the rel attribute of an anchor for the given link
    * object. If the link has a target value, then the rel must be "noopener"
    * for security reasons.
+   * Private but used in tests.
    */
-  _computeLinkRel(link: DropdownLink) {
+  computeLinkRel(link: DropdownLink) {
     // Note: noopener takes precedence over external.
     if (link.target) {
       return REL_NOOPENER;
@@ -302,7 +452,7 @@
   /**
    * Handle a click on an item of the dropdown.
    */
-  _handleItemTap(e: MouseEvent) {
+  private handleItemTap(e: MouseEvent) {
     if (e.target === null || !this.items) {
       return;
     }
@@ -323,33 +473,23 @@
   }
 
   /**
-   * If a dropdown item is shown as a button, get the class for the button.
-   *
-   * @param disabledIdsRecord The change record for the disabled IDs
-   *     list.
-   * @return The class for the item button.
-   */
-  _computeDisabledClass(disabledIdsRecord: DisableIdsRecord, id?: string) {
-    return id && disabledIdsRecord.base.includes(id) ? 'disabled' : '';
-  }
-
-  /**
    * Recompute the stops for the dropdown item cursor.
    */
-  @observe('items')
-  _resetCursorStops() {
-    if (this.items && this.items.length > 0 && this.$.dropdown.opened) {
-      flush();
-      this.cursor.stops =
-        this.root !== null ? Array.from(this.root.querySelectorAll('li')) : [];
+  private resetCursorStops() {
+    assertIsDefined(this.dropdown);
+    if (this.items && this.items.length > 0 && this.dropdown?.opened) {
+      this.cursor.stops = Array.from(
+        this.shadowRoot?.querySelectorAll('li') ?? []
+      );
     }
   }
 
-  _computeHasTooltip(tooltip?: string) {
-    return !!tooltip;
-  }
-
-  _computeIsDownload(link: DropdownLink) {
-    return !!link.download;
+  /**
+   * If a dropdown item is shown as a button, get the class for the button.
+   *
+   * @return The class for the item button.
+   */
+  private computeDisabledClass(id?: string) {
+    return id && this.disabledIds.includes(id) ? 'disabled' : '';
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts
deleted file mode 100644
index 082a10b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts
+++ /dev/null
@@ -1,166 +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: inline-block;
-    }
-    .dropdown-trigger {
-      text-decoration: none;
-      width: 100%;
-    }
-    .dropdown-content {
-      background-color: var(--dropdown-background-color);
-      box-shadow: var(--elevation-level-2);
-      min-width: 112px;
-      max-width: 280px;
-    }
-    gr-button {
-      vertical-align: top;
-    }
-    gr-avatar {
-      height: 2em;
-      width: 2em;
-      vertical-align: middle;
-    }
-    gr-button[link]:focus {
-      outline: 5px auto -webkit-focus-ring-color;
-    }
-    ul {
-      list-style: none;
-    }
-    .topContent,
-    li {
-      border-bottom: 1px solid var(--border-color);
-    }
-    li:last-of-type {
-      border: none;
-    }
-    li .itemAction {
-      cursor: pointer;
-      display: block;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    li .itemAction {
-      color: var(--gr-dropdown-item-color);
-      @apply --gr-dropdown-item;
-    }
-    li .itemAction.disabled {
-      color: var(--deemphasized-text-color);
-      cursor: default;
-    }
-    li .itemAction:link,
-    li .itemAction:visited {
-      text-decoration: none;
-    }
-    li .itemAction:not(.disabled):hover {
-      background-color: var(--hover-background-color);
-    }
-    li:focus,
-    li.selected {
-      background-color: var(--selection-background-color);
-      outline: none;
-    }
-    li:focus .itemAction,
-    li.selected .itemAction {
-      background-color: transparent;
-    }
-    .topContent {
-      display: block;
-      padding: var(--spacing-m) var(--spacing-l);
-      color: var(--gr-dropdown-item-color);
-      @apply --gr-dropdown-item;
-    }
-    .bold-text {
-      font-weight: var(--font-weight-bold);
-    }
-  </style>
-  <gr-button
-    link="[[link]]"
-    class="dropdown-trigger"
-    id="trigger"
-    down-arrow="[[downArrow]]"
-    on-click="_dropdownTriggerTapHandler"
-  >
-    <slot></slot>
-  </gr-button>
-  <iron-dropdown
-    id="dropdown"
-    vertical-align="top"
-    vertical-offset="[[verticalOffset]]"
-    allow-outside-scroll="true"
-    horizontal-align="[[horizontalAlign]]"
-    on-click="_handleDropdownClick"
-    on-opened-changed="handleOpenedChanged"
-  >
-    <div class="dropdown-content" slot="dropdown-content">
-      <ul>
-        <template is="dom-if" if="[[topContent]]">
-          <div class="topContent">
-            <template
-              is="dom-repeat"
-              items="[[topContent]]"
-              as="item"
-              initial-count="75"
-            >
-              <div
-                class$="[[_getClassIfBold(item.bold)]] top-item"
-                tabindex="-1"
-              >
-                [[item.text]]
-              </div>
-            </template>
-          </div>
-        </template>
-        <template
-          is="dom-repeat"
-          items="[[items]]"
-          as="link"
-          initial-count="75"
-        >
-          <li tabindex="-1">
-            <gr-tooltip-content
-              has-tooltip="[[_computeHasTooltip(link.tooltip)]]"
-              title$="[[link.tooltip]]"
-            >
-              <span
-                class$="itemAction [[_computeDisabledClass(disabledIds.*, link.id)]]"
-                data-id$="[[link.id]]"
-                on-click="_handleItemTap"
-                hidden$="[[link.url]]"
-                tabindex="-1"
-                >[[link.name]]</span
-              >
-              <a
-                class="itemAction"
-                href$="[[_computeLinkURL(link)]]"
-                download$="[[_computeIsDownload(link)]]"
-                rel$="[[_computeLinkRel(link)]]"
-                target$="[[link.target]]"
-                hidden$="[[!link.url]]"
-                tabindex="-1"
-                >[[link.name]]</a
-              >
-            </gr-tooltip-content>
-          </li>
-        </template>
-      </ul>
-    </div>
-  </iron-dropdown>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
index 393f44e..fab742f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
@@ -1,55 +1,39 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-dropdown';
 import {DropdownLink, GrDropdown} from './gr-dropdown';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {pressKey, queryAll, queryAndAssert} from '../../../test/test-utils';
 import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
-
-const basicFixture = fixtureFromElement('gr-dropdown');
+import {assertIsDefined} from '../../../utils/common-util';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-dropdown tests', () => {
   let element: GrDropdown;
 
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_computeIsDownload', () => {
-    assert.isTrue(element._computeIsDownload({download: true} as DropdownLink));
-    assert.isFalse(
-      element._computeIsDownload({download: false} as DropdownLink)
-    );
+  setup(async () => {
+    element = await fixture(html`<gr-dropdown></gr-dropdown>`);
   });
 
   test('tap on trigger opens menu, then closes', () => {
-    sinon.stub(element, '_open').callsFake(() => {
-      element.$.dropdown.open();
+    sinon.stub(element, 'open').callsFake(() => {
+      assertIsDefined(element.dropdown);
+      element.dropdown.open();
     });
-    sinon.stub(element, '_close').callsFake(() => {
-      element.$.dropdown.close();
+    sinon.stub(element, 'close').callsFake(() => {
+      assertIsDefined(element.dropdown);
+      element.dropdown.close();
     });
-    assert.isFalse(element.$.dropdown.opened);
-    MockInteractions.tap(element.$.trigger);
-    assert.isTrue(element.$.dropdown.opened);
-    MockInteractions.tap(element.$.trigger);
-    assert.isFalse(element.$.dropdown.opened);
+    assertIsDefined(element.dropdown);
+    assertIsDefined(element.trigger);
+    assert.isFalse(element.dropdown.opened);
+    element.trigger.click();
+    assert.isTrue(element.dropdown.opened);
+    element.trigger.click();
+    assert.isFalse(element.dropdown.opened);
   });
 
   test('_computeURLHelper', () => {
@@ -61,71 +45,69 @@
 
   test('link URLs', () => {
     assert.equal(
-      element._computeLinkURL({url: 'http://example.com/test'}),
+      element.computeLinkURL({url: 'http://example.com/test'}),
       'http://example.com/test'
     );
     assert.equal(
-      element._computeLinkURL({url: 'https://example.com/test'}),
+      element.computeLinkURL({url: 'https://example.com/test'}),
       'https://example.com/test'
     );
     assert.equal(
-      element._computeLinkURL({url: '/test'}),
+      element.computeLinkURL({url: '/test'}),
       '//' + window.location.host + '/test'
     );
     assert.equal(
-      element._computeLinkURL({url: '/test', target: '_blank'}),
+      element.computeLinkURL({url: '/test', target: '_blank'}),
       '/test'
     );
   });
 
   test('link rel', () => {
     let link: DropdownLink = {url: '/test'};
-    assert.isNull(element._computeLinkRel(link));
+    assert.isNull(element.computeLinkRel(link));
 
     link = {url: '/test', target: '_blank'};
-    assert.equal(element._computeLinkRel(link), 'noopener');
+    assert.equal(element.computeLinkRel(link), 'noopener');
 
     link = {url: '/test', external: true};
-    assert.equal(element._computeLinkRel(link), 'external');
+    assert.equal(element.computeLinkRel(link), 'external');
 
     link = {url: '/test', target: '_blank', external: true};
-    assert.equal(element._computeLinkRel(link), 'noopener');
+    assert.equal(element.computeLinkRel(link), 'noopener');
   });
 
-  test('_getClassIfBold', () => {
+  test('getClassIfBold', () => {
     let bold = true;
-    assert.equal(element._getClassIfBold(bold), 'bold-text');
+    assert.equal(element.getClassIfBold(bold), 'bold-text');
 
     bold = false;
-    assert.equal(element._getClassIfBold(bold), '');
+    assert.equal(element.getClassIfBold(bold), '');
   });
 
-  test('Top text exists and is bolded correctly', () => {
+  test('Top text exists and is bolded correctly', async () => {
     element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
-    flush();
+    await element.updateComplete;
     const topItems = queryAll<HTMLDivElement>(element, '.top-item');
     assert.equal(topItems.length, 2);
     assert.isTrue(topItems[0].classList.contains('bold-text'));
     assert.isFalse(topItems[1].classList.contains('bold-text'));
   });
 
-  test('non link items', () => {
+  test('non link items', async () => {
     const item0 = {name: 'item one', id: 'foo'};
     element.items = [item0, {name: 'item two', id: 'bar'}];
     const fooTapped = sinon.stub();
     const tapped = sinon.stub();
     element.addEventListener('tap-item-foo', fooTapped);
     element.addEventListener('tap-item', tapped);
-    flush();
-    MockInteractions.tap(
-      queryAndAssert<HTMLSpanElement>(element, '.itemAction')
-    );
+    await element.updateComplete;
+    queryAndAssert<HTMLSpanElement>(element, '.itemAction').click();
     assert.isTrue(fooTapped.called);
     assert.isTrue(tapped.called);
     assert.deepEqual(tapped.lastCall.args[0].detail, item0);
   });
 
-  test('disabled non link item', () => {
+  test('disabled non link item', async () => {
     element.items = [{name: 'item one', id: 'foo'}];
     element.disabledIds = ['foo'];
 
@@ -133,21 +115,19 @@
     const tapped = sinon.stub();
     element.addEventListener('tap-item-foo', stub);
     element.addEventListener('tap-item', tapped);
-    flush();
-    MockInteractions.tap(
-      queryAndAssert<HTMLSpanElement>(element, '.itemAction')
-    );
+    await element.updateComplete;
+    queryAndAssert<HTMLSpanElement>(element, '.itemAction').click();
     assert.isFalse(stub.called);
     assert.isFalse(tapped.called);
   });
 
-  test('properly sets tooltips', () => {
+  test('properly sets tooltips', async () => {
     element.items = [
       {name: 'item one', id: 'foo', tooltip: 'hello'},
       {name: 'item two', id: 'bar'},
     ];
     element.disabledIds = [];
-    flush();
+    await element.updateComplete;
     const tooltipContents = queryAll<GrTooltipContent>(
       element,
       'iron-dropdown li gr-tooltip-content'
@@ -158,46 +138,131 @@
     assert.isFalse(tooltipContents[1].hasTooltip);
   });
 
+  test('render', async () => {
+    element.items = [
+      {name: 'item one', id: 'foo', tooltip: 'hello'},
+      {name: 'item two', id: 'bar', url: 'http://bar'},
+    ];
+    element.disabledIds = [];
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+      <gr-button
+        aria-disabled="false"
+        class="dropdown-trigger"
+        id="trigger"
+        role="button"
+        tabindex="0"
+      >
+        <slot>
+        </slot>
+      </gr-button>
+      <iron-dropdown
+        allowoutsidescroll=""
+        aria-disabled="false"
+        aria-hidden="true"
+        horizontal-align="left"
+        id="dropdown"
+        style="outline: none; display: none;"
+        vertical-align="top"
+      >
+        <div
+          class="dropdown-content"
+          slot="dropdown-content"
+        >
+          <ul>
+            <li tabindex="-1">
+              <gr-tooltip-content
+                has-tooltip=""
+                title="hello"
+              >
+                <span
+                  class="itemAction"
+                  data-id="foo"
+                  tabindex="-1"
+                >
+                  item one
+                </span>
+                <a
+                  class="itemAction"
+                  hidden=""
+                  href=""
+                  tabindex="-1"
+                >
+                  item one
+                </a>
+              </gr-tooltip-content>
+            </li>
+            <li tabindex="-1">
+              <gr-tooltip-content>
+                <span
+                  class="itemAction"
+                  data-id="bar"
+                  hidden=""
+                  tabindex="-1"
+                >
+                  item two
+                </span>
+                <a
+                  class="itemAction"
+                  href="http://bar"
+                  tabindex="-1"
+                >
+                  item two
+                </a>
+              </gr-tooltip-content>
+            </li>
+        </div>
+          </ul>
+      </iron-dropdown>`
+    );
+  });
+
   suite('keyboard navigation', () => {
-    setup(() => {
+    setup(async () => {
       element.items = [
-        {name: 'item one', id: 'foo'},
-        {name: 'item two', id: 'bar'},
+        {name: 'item one', id: 'foo', url: 'http://foo'},
+        {name: 'item two', id: 'bar', url: 'http://bar'},
       ];
-      flush();
+      await element.updateComplete;
     });
 
     test('down', () => {
       const stub = sinon.stub(element.cursor, 'next');
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
-      assert.isTrue(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
+      assertIsDefined(element.dropdown);
+      assert.isFalse(element.dropdown.opened);
+      pressKey(element, 'ArrowDown');
+      assert.isTrue(element.dropdown.opened);
+      pressKey(element, 'ArrowDown');
       assert.isTrue(stub.called);
     });
 
     test('up', () => {
+      assertIsDefined(element.dropdown);
       const stub = sinon.stub(element.cursor, 'previous');
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
-      assert.isTrue(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
+      assert.isFalse(element.dropdown.opened);
+      pressKey(element, 'ArrowUp');
+      assert.isTrue(element.dropdown.opened);
+      pressKey(element, 'ArrowUp');
       assert.isTrue(stub.called);
     });
 
-    test('enter/space', () => {
+    test('enter/space', async () => {
+      assertIsDefined(element.dropdown);
       // Because enter and space are handled by the same fn, we need only to
       // test one.
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 32, null, ' ');
-      assert.isTrue(element.$.dropdown.opened);
+      assert.isFalse(element.dropdown.opened);
+      pressKey(element, ' ');
+      await element.updateComplete;
+      assert.isTrue(element.dropdown.opened);
 
       const el = queryAndAssert<HTMLAnchorElement>(
         element.cursor.target as HTMLElement,
         ':not([hidden]) a'
       );
       const stub = sinon.stub(el, 'click');
-      MockInteractions.pressAndReleaseKeyOn(element, 32, null, ' ');
+      pressKey(element, ' ');
       assert.isTrue(stub.called);
     });
   });
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 5d0f52a..d6a4d94 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
@@ -1,22 +1,12 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
+import '../gr-icon/gr-icon';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
@@ -27,14 +17,15 @@
 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 {customElement, property, state} from 'lit/decorators.js';
 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';
+import {classMap} from 'lit/directives/class-map.js';
+import {when} from 'lit/directives/when.js';
+import {fontStyles} from '../../../styles/gr-font-styles';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -107,7 +98,7 @@
   storeTask?: DelayedTask;
 
   override disconnectedCallback() {
-    this.storeTask?.cancel();
+    this.storeTask?.flush();
     super.disconnectedCallback();
   }
 
@@ -120,6 +111,7 @@
   static override get styles() {
     return [
       sharedStyles,
+      fontStyles,
       css`
         :host {
           display: block;
@@ -171,13 +163,11 @@
           box-shadow: none;
           border: 1px solid var(--border-color);
         }
-        .show-all-container .show-all-button {
-          margin-right: auto;
+        .flex-space {
+          flex-grow: 1;
         }
-        .show-all-container iron-icon {
+        .show-all-container gr-icon {
           color: inherit;
-          --iron-icon-height: 18px;
-          --iron-icon-width: 18px;
         }
         .cancel-button {
           margin-right: var(--spacing-l);
@@ -186,8 +176,6 @@
           margin-right: var(--spacing-xs);
         }
         gr-button {
-          font-family: var(--font-family);
-          line-height: var(--line-height-normal);
           padding: var(--spacing-xs);
         }
       `,
@@ -231,7 +219,7 @@
             .bindValue=${this.newContent}
             ?disabled=${this.disabled}
             @bind-value-changed=${(e: BindValueChangeEvent) => {
-              this.newContent = e.detail.value;
+              this.newContent = e.detail.value ?? '';
             }}
           ></iron-autogrow-textarea>
         </div>
@@ -244,7 +232,7 @@
       return nothing;
 
     return html`
-      <div class="show-all-container">
+      <div class="show-all-container font-normal">
         ${when(
           this.commitCollapsible && !this.editing,
           () => html`
@@ -253,20 +241,19 @@
               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'}
+              <div>
+                ${when(
+                  !this.commitCollapsed,
+                  () => html`<gr-icon icon="expand_less" small></gr-icon>`
+                )}
+                ${when(
+                  this.commitCollapsed,
+                  () => html`<gr-icon icon="expand_more" small></gr-icon>`
+                )}
+                <span>${this.commitCollapsed ? 'Show all' : 'Show less'}</span>
+              </div>
             </gr-button>
+            <div class="flex-space"></div>
           `
         )}
         ${when(
@@ -277,7 +264,10 @@
               class="edit-commit-message"
               title="Edit commit message"
               @click=${this.handleEditCommitMessage}
-              ><iron-icon icon="gr-icons:edit"></iron-icon> Edit</gr-button
+              ><div>
+                <gr-icon icon="edit" filled small></gr-icon>
+                <span>Edit</span>
+              </div></gr-button
             >
           `
         )}
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 9b30591..fec347c6 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
@@ -1,27 +1,15 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-editable-content';
 import {GrEditableContent} from './gr-editable-content';
 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';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
 
 suite('gr-editable-content tests', () => {
   let element: GrEditableContent;
@@ -32,38 +20,44 @@
   });
 
   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> `);
+    assert.shadowDom.equal(
+      element,
+      /* 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 font-normal">
+          <gr-button
+            aria-disabled="false"
+            class="show-all-button"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            <div>
+              <gr-icon icon="expand_more" small></gr-icon>
+              <span>Show all</span>
+            </div>
+          </gr-button>
+          <div class="flex-space"></div>
+          <gr-button
+            aria-disabled="false"
+            class="edit-commit-message"
+            link=""
+            role="button"
+            tabindex="0"
+            title="Edit commit message"
+          >
+            <div>
+              <gr-icon icon="edit" filled small></gr-icon>
+              <span>Edit</span>
+            </div>
+          </gr-button>
+        </div>
+        <gr-endpoint-slot name="above-actions"> </gr-endpoint-slot>
+      </gr-endpoint-decorator> `
+    );
   });
 
   test('show-all-container visibility', async () => {
@@ -117,7 +111,7 @@
     await element.updateComplete;
     element.addEventListener('editable-content-cancel', handler);
 
-    MockInteractions.tap(queryAndAssert(element, 'gr-button.cancel-button'));
+    queryAndAssert<GrButton>(element, 'gr-button.cancel-button').click();
 
     assert.isTrue(handler.called);
   });
@@ -146,25 +140,6 @@
     assert.equal(element.newContent, 'stale content');
   });
 
-  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;
-
-    // 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(async () => {
       element.content = 'current content';
@@ -210,7 +185,7 @@
       await element.updateComplete;
       assert.equal(element.newContent, 'stored content');
       assert.isTrue(dispatchSpy.called);
-      assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
+      assert.equal(dispatchSpy.lastCall.args[0].type, EventType.SHOW_ALERT);
     });
 
     test('editing toggled to true, has no stored data', async () => {
@@ -229,7 +204,7 @@
       element.editing = true;
 
       // Needed because editingChanged resets newContent
-      // We want ediingChanged() to finish before triggering newContentChanged
+      // We want editingChanged() to finish before triggering newContentChanged
       await element.updateComplete;
 
       element.newContent = 'new content';
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 cd020a9..bf8209b 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
@@ -1,35 +1,26 @@
 /**
  * @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-dropdown/iron-dropdown';
 import '@polymer/paper-input/paper-input';
-import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
+import '../gr-icon/gr-icon';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
-import {PaperInputElementExt} from '../../../types/types';
 import {
   AutocompleteQuery,
   GrAutocomplete,
 } from '../gr-autocomplete/gr-autocomplete';
-import {addShortcut, Key} from '../../../utils/dom-util';
+import {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 {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {IronInputElement} from '@polymer/iron-input';
+import {ShortcutController} from '../../lit/shortcut-controller';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -72,14 +63,12 @@
   @property({type: Number})
   maxLength?: number;
 
+  @property({type: String})
+  confirmLabel = 'Save';
+
   /* 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.
-  @state() readonly verticalOffset = -30;
-
   @property({type: Boolean})
   showAsEditPencil = false;
 
@@ -89,6 +78,14 @@
   @property({type: Object})
   query: AutocompleteQuery = () => Promise.resolve([]);
 
+  @query('#input')
+  input?: PaperInputElement;
+
+  @query('#autocomplete')
+  grAutocomplete?: GrAutocomplete;
+
+  private readonly shortcuts = new ShortcutController(this);
+
   static override get styles() {
     return [
       sharedStyles,
@@ -122,32 +119,32 @@
           background-color: var(--dialog-background-color);
           padding: var(--spacing-m);
         }
-        .buttons {
-          display: flex;
-          justify-content: flex-end;
-          padding-top: var(--spacing-l);
-          width: 100%;
+        /* This makes inputContainer on one line. */
+        .inputContainer gr-autocomplete,
+        .inputContainer .buttons {
+          display: inline-block;
         }
         .buttons gr-button {
           margin-left: var(--spacing-m);
         }
+        /* prettier formatter removes semi-colons after css mixins. */
+        /* prettier-ignore */
         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 {
+        gr-button gr-icon {
           color: inherit;
-          --iron-icon-height: 18px;
-          --iron-icon-width: 18px;
         }
         gr-button.pencil {
-          --gr-button-padding: 0px 0px;
+          --gr-button-padding: var(--spacing-s);
+          --margin: calc(0px - var(--spacing-s));
         }
       `,
     ];
@@ -160,20 +157,18 @@
         id="dropdown"
         .verticalAlign=${'auto'}
         .horizontalAlign=${'auto'}
-        .verticalOffset=${this.verticalOffset}
-        allowOutsideScroll
-        @iron-overlay-canceled=${this.cancel}
+        .allowOutsideScroll=${true}
+        .noCancelOnEscKey=${true}
+        .noCancelOnOutsideClick=${true}
       >
         <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 primary id="saveBtn" @click=${this.save}
+                >${this.confirmLabel}</gr-button
               >
-              <gr-button link="" id="saveBtn" @click=${this.save}
-                >save</gr-button
-              >
+              <gr-button id="cancelBtn" @click=${this.cancel}>cancel</gr-button>
             </div>
           </div>
         </div>
@@ -187,8 +182,11 @@
         class="pencil ${this.computeLabelClass()}"
         @click=${this.showDropdown}
         title=${this.computeLabel()}
-        ><iron-icon icon="gr-icons:edit"></iron-icon
-      ></gr-button>`;
+      >
+        <div>
+          <gr-icon icon="edit" filled small></gr-icon>
+        </div>
+      </gr-button>`;
     } else {
       return html`<label
         class=${this.computeLabelClass()}
@@ -208,7 +206,7 @@
         id="autocomplete"
         .text=${this.inputText}
         .query=${this.query}
-        @commit=${this.handleCommit}
+        @cancel=${this.cancel}
         @text-changed=${(e: CustomEvent) => {
           this.inputText = e.detail.value;
         }}
@@ -224,13 +222,14 @@
     }
   }
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  constructor() {
+    super();
+    this.shortcuts.addLocal({key: Key.ENTER}, e => this.handleEnter(e));
+    this.shortcuts.addLocal({key: Key.ESC}, e => this.handleEsc(e));
+  }
 
   override disconnectedCallback() {
     super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
   }
 
   override connectedCallback() {
@@ -241,12 +240,6 @@
     if (!this.getAttribute('id')) {
       this.setAttribute('id', 'global');
     }
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this.handleEnter(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, e => this.handleEsc(e))
-    );
   }
 
   private usePlaceholder(value?: string, placeholder?: string) {
@@ -265,9 +258,8 @@
     if (this.readOnly || this.editing) return;
     return this.openDropdown().then(() => {
       this.nativeInput.focus();
-      const input = this.getInput();
-      if (!input?.value) return;
-      this.nativeInput.setSelectionRange(0, input.value.length);
+      if (!this.input?.value) return;
+      this.nativeInput.setSelectionRange(0, this.input.value.length);
     });
   }
 
@@ -310,9 +302,8 @@
       return;
     }
     this.dropdown?.close();
-    const input = this.getInput();
-    if (input) {
-      this.value = input.value ?? undefined;
+    if (this.input) {
+      this.value = this.input.value ?? undefined;
     } else {
       this.value = this.inputText || '';
     }
@@ -336,16 +327,15 @@
   }
 
   private get nativeInput(): HTMLInputElement {
-    return (this.getInput()?.$.nativeInput ||
-      this.getInput()?.inputElement ||
-      this.getGrAutocomplete()) as HTMLInputElement;
+    if (this.autocomplete) {
+      return this.grAutocomplete!.nativeInput;
+    } else {
+      return (this.input!.inputElement as IronInputElement)
+        .inputElement as HTMLInputElement;
+    }
   }
 
   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()
@@ -356,6 +346,10 @@
   }
 
   private handleEsc(event: KeyboardEvent) {
+    // If autocomplete is used, it's handling the ESC instead.
+    if (this.autocomplete) {
+      return;
+    }
     const inputContainer = queryAndAssert(this, '.inputContainer');
     const isEventFromInput = event
       .composedPath()
@@ -365,10 +359,6 @@
     }
   }
 
-  private handleCommit() {
-    this.getInput()?.focus();
-  }
-
   private computeLabelClass() {
     const {readOnly, value, placeholder} = this;
     const classes = [];
@@ -380,12 +370,4 @@
     }
     return classes.join(' ');
   }
-
-  getInput(): PaperInputElementExt | null {
-    return this.shadowRoot!.querySelector<PaperInputElementExt>('#input');
-  }
-
-  getGrAutocomplete(): GrAutocomplete | null {
-    return this.shadowRoot!.querySelector<GrAutocomplete>('#autocomplete');
-  }
 }
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
index a439d05..d916118 100644
--- 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
@@ -1,29 +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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {fixture, html, assert} from '@open-wc/testing';
 import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {
+  AutocompleteSuggestion,
+  GrAutocomplete,
+} from '../gr-autocomplete/gr-autocomplete';
+import {Key} from '../../../utils/dom-util';
+import {pressKey, waitEventLoop, waitUntil} from '../../../test/test-utils';
+import {IronInputElement} from '@polymer/iron-input';
 
 suite('gr-editable-label tests', () => {
   let element: GrEditableLabel;
@@ -38,19 +32,20 @@
         placeholder="label text"
       ></gr-editable-label>
     `);
+    label = queryAndAssert<HTMLLabelElement>(element, '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;
+    input = (paperInput.inputElement as IronInputElement)
+      .inputElement as HTMLInputElement;
   });
 
   test('renders', () => {
-    expect(element).shadowDom.to.equal(`<label
+    assert.shadowDom.equal(
+      element,
+      `<label
       aria-label="value text"
       class="editable"
       part="label"
@@ -59,7 +54,6 @@
       value text
     </label>
     <iron-dropdown
-      allowoutsidescroll=""
       aria-disabled="false"
       aria-hidden="true"
       horizontal-align="auto"
@@ -75,28 +69,28 @@
             tabindex="0"
           ></paper-input>
           <div class="buttons">
+          <gr-button
+              aria-disabled="false"
+              id="saveBtn"
+              primary
+              role="button"
+              tabindex="0"
+            >
+              Save
+            </gr-button>
             <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>`);
+    </iron-dropdown>`
+    );
   });
 
   test('element render', async () => {
@@ -125,7 +119,7 @@
     assert.equal(elementNoPlaceholder.title, '');
     element.value = 'value text';
 
-    await flush();
+    await waitEventLoop();
     assert.equal(element.title, 'value text');
   });
 
@@ -134,16 +128,15 @@
     element.addEventListener('changed', editedSpy);
     assert.isFalse(element.editing);
 
-    MockInteractions.tap(label);
-    await flush();
+    label.click();
+    await waitEventLoop();
 
     assert.isTrue(element.editing);
     assert.isFalse(editedSpy.called);
 
     element.inputText = 'new text';
-    // Press enter:
-    MockInteractions.keyDownOn(input, 13, null, 'Enter');
-    await flush();
+    pressKey(input, Key.ENTER);
+    await waitEventLoop();
 
     assert.isTrue(editedSpy.called);
     assert.equal(input.value, 'new text');
@@ -155,21 +148,15 @@
     element.addEventListener('changed', editedSpy);
     assert.isFalse(element.editing);
 
-    MockInteractions.tap(label);
-    await flush();
+    label.click();
+    await waitEventLoop();
 
     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();
+    pressKey(queryAndAssert<GrButton>(element, '#saveBtn'), Key.ENTER);
+    await waitEventLoop();
 
     assert.isTrue(editedSpy.called);
     assert.equal(input.value, 'new text');
@@ -181,16 +168,15 @@
     element.addEventListener('changed', editedSpy);
     assert.isFalse(element.editing);
 
-    MockInteractions.tap(label);
-    await flush();
+    label.click();
+    await waitEventLoop();
 
     assert.isTrue(element.editing);
     assert.isFalse(editedSpy.called);
 
     element.inputText = 'new text';
-    // Press escape:
-    MockInteractions.keyDownOn(input, 27, null, 'Escape');
-    await flush();
+    pressKey(input, Key.ESC);
+    await waitEventLoop();
 
     assert.isFalse(editedSpy.called);
     // Text changes should be discarded.
@@ -203,16 +189,16 @@
     element.addEventListener('changed', editedSpy);
     assert.isFalse(element.editing);
 
-    MockInteractions.tap(label);
-    await flush();
+    label.click();
+    await waitEventLoop();
 
     assert.isTrue(element.editing);
     assert.isFalse(editedSpy.called);
 
     element.inputText = 'new text';
     // Press escape:
-    MockInteractions.tap(queryAndAssert<GrButton>(element, '#cancelBtn'));
-    await flush();
+    queryAndAssert<GrButton>(element, '#cancelBtn').click();
+    await waitEventLoop();
 
     assert.isFalse(editedSpy.called);
     // Text changes should be discarded.
@@ -254,4 +240,91 @@
       assert.isFalse(label.classList.contains('editable'));
     });
   });
+
+  suite('autocomplete tests', () => {
+    let element: GrEditableLabel;
+    let autocomplete: GrAutocomplete;
+    let suggestions: Array<AutocompleteSuggestion>;
+    let labelSaved = false;
+
+    setup(async () => {
+      element = await fixture<GrEditableLabel>(html`
+        <gr-editable-label
+          autocomplete
+          value="value text"
+          .query=${() => Promise.resolve(suggestions)}
+          @changed=${() => {
+            labelSaved = true;
+          }}
+        ></gr-editable-label>
+      `);
+
+      autocomplete = element.grAutocomplete!;
+    });
+
+    test('autocomplete suggestions shown esc closes suggestions', async () => {
+      suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
+      await element.open();
+
+      await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+
+      pressKey(autocomplete.input!, Key.ESC);
+
+      await waitUntil(() => autocomplete.suggestionsDropdown!.isHidden);
+      assert.isTrue(element.dropdown?.opened);
+    });
+
+    test('autocomplete suggestions closed esc closes dialogue', async () => {
+      suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
+      await element.open();
+      await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+      // Press esc to close suggestions.
+      pressKey(autocomplete.input!, Key.ESC);
+      await waitUntil(() => autocomplete.suggestionsDropdown!.isHidden);
+
+      pressKey(autocomplete.input!, Key.ESC);
+
+      await element.updateComplete;
+      // Dialogue is closed, save not triggered.
+      assert.isTrue(autocomplete.suggestionsDropdown?.isHidden);
+      assert.isFalse(element.dropdown?.opened);
+      assert.isFalse(labelSaved);
+    });
+
+    test('autocomplete suggestions shown enter chooses suggestions', async () => {
+      suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
+      await element.open();
+
+      await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+
+      pressKey(autocomplete.input!, Key.ENTER);
+
+      await waitUntil(() => autocomplete.suggestionsDropdown!.isHidden);
+      await element.updateComplete;
+      // The value was picked from suggestions, suggestions are hidden, dialogue
+      // is shown, save has not been triggered.
+      assert.strictEqual(element.inputText, 'value text 1');
+      assert.isTrue(autocomplete.suggestionsDropdown?.isHidden);
+      assert.isTrue(element.dropdown?.opened);
+      assert.isFalse(labelSaved);
+    });
+
+    test('autocomplete suggestions closed enter saves suggestion', async () => {
+      suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
+      await element.open();
+      await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+      // Press enter to close suggestions.
+      pressKey(autocomplete.input!, Key.ENTER);
+
+      await waitUntil(() => autocomplete.suggestionsDropdown!.isHidden);
+
+      pressKey(autocomplete.input!, Key.ENTER);
+
+      await element.updateComplete;
+      // Dialogue is closed, save triggered.
+      assert.isTrue(autocomplete.suggestionsDropdown?.isHidden);
+      assert.isFalse(element.dropdown?.opened);
+      assert.isTrue(labelSaved);
+    });
+  });
 });
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
deleted file mode 100644
index a2088cc..0000000
--- a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
+++ /dev/null
@@ -1,121 +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 {SpecialFilePath} from '../../../constants/constants';
-import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-
-const FileStatus = {
-  A: 'Added',
-  C: 'Copied',
-  D: 'Deleted',
-  M: 'Modified',
-  R: 'Renamed',
-  W: 'Rewritten',
-  U: 'Unchanged',
-};
-
-@customElement('gr-file-status-chip')
-export class GrFileStatusChip extends LitElement {
-  @property({type: Object})
-  file?: NormalizedFileInfo;
-
-  static override get styles() {
-    return [
-      sharedStyles,
-      css`
-        .status {
-          display: inline-block;
-          border-radius: var(--border-radius);
-          margin-left: var(--spacing-s);
-          padding: 0 var(--spacing-m);
-          color: var(--primary-text-color);
-          font-size: var(--font-size-small);
-          background-color: var(--file-status-added);
-        }
-        .status.invisible,
-        .status.M {
-          display: none;
-        }
-        .status.D,
-        .status.R,
-        .status.W {
-          background-color: var(--file-status-changed);
-        }
-        .status.U {
-          background-color: var(--file-status-unchanged);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    return html` <span
-      class=${this._computeStatusClass(this.file)}
-      tabindex="0"
-      title=${this._computeFileStatusLabel(this.file?.status)}
-      aria-label=${this._computeFileStatusLabel(this.file?.status)}
-    >
-      ${this._computeFileStatusLabel(this.file?.status)}
-    </span>`;
-  }
-
-  /**
-   * Get a descriptive label for use in the status indicator's tooltip and
-   * ARIA label.
-   */
-  _computeFileStatusLabel(status?: keyof typeof FileStatus) {
-    const statusCode = this._computeFileStatus(status);
-    return hasOwnProperty(FileStatus, statusCode)
-      ? FileStatus[statusCode]
-      : 'Status Unknown';
-  }
-
-  _computeClass(baseClass?: string, path?: string) {
-    const classes = [];
-    if (baseClass) {
-      classes.push(baseClass);
-    }
-    if (
-      path === SpecialFilePath.COMMIT_MESSAGE ||
-      path === SpecialFilePath.MERGE_LIST
-    ) {
-      classes.push('invisible');
-    }
-    return classes.join(' ');
-  }
-
-  _computeFileStatus(
-    status?: keyof typeof FileStatus
-  ): keyof typeof FileStatus {
-    return status || 'M';
-  }
-
-  _computeStatusClass(file?: NormalizedFileInfo) {
-    if (!file) return '';
-    const classStr = this._computeClass('status', file.__path);
-    return `${classStr} ${this._computeFileStatus(file.status)}`;
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-file-status-chip': GrFileStatusChip;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_test.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_test.ts
deleted file mode 100644
index 0abc85f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_test.ts
+++ /dev/null
@@ -1,46 +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';
-import './gr-file-status-chip';
-import {GrFileStatusChip} from './gr-file-status-chip';
-
-const fixture = fixtureFromElement('gr-file-status-chip');
-
-suite('gr-file-status-chip tests', () => {
-  let element: GrFileStatusChip;
-
-  setup(() => {
-    element = fixture.instantiate();
-  });
-
-  test('computed properties', () => {
-    assert.equal(element._computeFileStatus('A'), 'A');
-    assert.equal(element._computeFileStatus(undefined), 'M');
-
-    assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
-    assert.equal(
-      element._computeClass('clazz', '/COMMIT_MSG'),
-      'clazz invisible'
-    );
-  });
-
-  test('_computeFileStatusLabel', () => {
-    assert.equal(element._computeFileStatusLabel('A'), 'Added');
-    assert.equal(element._computeFileStatusLabel('M'), 'Modified');
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts
new file mode 100644
index 0000000..578eda4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts
@@ -0,0 +1,158 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {FileInfoStatus} from '../../../constants/constants';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {assertNever} from '../../../utils/common-util';
+import '../gr-tooltip-content/gr-tooltip-content';
+import '../gr-icon/gr-icon';
+
+function statusString(status: FileInfoStatus) {
+  if (!status) return '';
+  switch (status) {
+    case FileInfoStatus.ADDED:
+      return 'Added';
+    case FileInfoStatus.COPIED:
+      return 'Copied';
+    case FileInfoStatus.DELETED:
+      return 'Deleted';
+    case FileInfoStatus.MODIFIED:
+      return 'Modified';
+    case FileInfoStatus.RENAMED:
+      return 'Renamed';
+    case FileInfoStatus.REWRITTEN:
+      return 'Rewritten';
+    case FileInfoStatus.UNMODIFIED:
+      return 'Unchanged';
+    case FileInfoStatus.REVERTED:
+      return 'Reverted';
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+/**
+ * This is a square colored box with a single letter, which can be used as a
+ * prefix column before file names.
+ *
+ * It can also show an additional "new" icon for indicating that a file was
+ * newly changed in a patchset.
+ */
+@customElement('gr-file-status')
+export class GrFileStatus extends LitElement {
+  @property({type: String})
+  status?: FileInfoStatus;
+
+  /**
+   * Show an additional "new" icon for indicating that a file was newly changed
+   * in a patchset.
+   */
+  @property({type: Boolean})
+  newlyChanged = false;
+
+  /**
+   * What postfix should the tooltip have? For example you can set
+   * ' in ps 5', such that the 'Added' tooltip becomes 'Added in ps 5'.
+   */
+  @property({type: String})
+  labelPostfix = '';
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+        }
+        div.status {
+          display: inline-block;
+          line-height: var(--line-height-normal);
+          width: var(--line-height-normal);
+          text-align: center;
+          border-radius: var(--border-radius);
+          background-color: transparent;
+          color: var(--file-status-font-color);
+        }
+        div.status gr-icon {
+          color: var(--file-status-font-color);
+        }
+        div.status.M {
+          border: 1px solid var(--border-color);
+          line-height: calc(var(--line-height-normal) - 2px);
+          width: calc(var(--line-height-normal) - 2px);
+          color: var(--deemphasized-text-color);
+        }
+        div.status.A {
+          background-color: var(--file-status-added);
+        }
+        div.status.D {
+          background-color: var(--file-status-deleted);
+        }
+        div.status.R,
+        div.status.W {
+          background-color: var(--file-status-renamed);
+        }
+        div.status.U {
+          background-color: var(--file-status-unchanged);
+        }
+        div.status.X {
+          background-color: var(--file-status-reverted);
+        }
+        .size-16 {
+          font-size: 16px;
+          position: relative;
+          top: 2px;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`${this.renderNewlyChanged()}${this.renderStatus()}`;
+  }
+
+  private renderStatus() {
+    const classes = ['status', this.status];
+    return html`<gr-tooltip-content title=${this.computeLabel()} has-tooltip>
+      <div
+        class=${classes.join(' ')}
+        tabindex="0"
+        aria-label=${this.computeLabel()}
+      >
+        ${this.renderIconOrLetter()}
+      </div>
+    </gr-tooltip-content>`;
+  }
+
+  private renderIconOrLetter() {
+    if (this.status === FileInfoStatus.REVERTED) {
+      return html`<gr-icon small icon="undo"></gr-icon>`;
+    }
+    return html`<span>${this.status ?? ''}</span>`;
+  }
+
+  private renderNewlyChanged() {
+    if (!this.newlyChanged) return;
+    return html`<gr-tooltip-content title=${this.computeLabel()} has-tooltip>
+      <gr-icon
+        icon="new_releases"
+        class="size-16"
+        aria-label=${this.computeLabel()}
+      ></gr-icon>
+    </gr-tooltip-content>`;
+  }
+
+  private computeLabel() {
+    if (!this.status) return '';
+    const prefix = this.newlyChanged ? 'Newly ' : '';
+    return `${prefix}${statusString(this.status)}${this.labelPostfix}`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-file-status': GrFileStatus;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts
new file mode 100644
index 0000000..3bf877e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts
@@ -0,0 +1,73 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-file-status';
+import {GrFileStatus} from './gr-file-status';
+import {fixture, assert} from '@open-wc/testing';
+import {FileInfoStatus} from '../../../api/rest-api';
+
+suite('gr-file-status tests', () => {
+  let element: GrFileStatus;
+
+  setup(async () => {
+    element = await fixture<GrFileStatus>('<gr-file-status></gr-file-status>');
+    await setStatus();
+  });
+
+  const setStatus = async (status?: FileInfoStatus, newly = false) => {
+    element.status = status;
+    element.newlyChanged = newly;
+    await element.updateComplete;
+  };
+
+  suite('semantic dom diff tests', () => {
+    test('empty status', async () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-tooltip-content has-tooltip="" title="">
+            <div class="status" aria-label="" tabindex="0"><span></span></div>
+          </gr-tooltip-content>
+        `
+      );
+    });
+
+    test('added', async () => {
+      await setStatus(FileInfoStatus.ADDED);
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-tooltip-content has-tooltip="" title="Added">
+            <div class="A status" aria-label="Added" tabindex="0">
+              <span>A</span>
+            </div>
+          </gr-tooltip-content>
+        `
+      );
+    });
+
+    test('newly added', async () => {
+      await setStatus(FileInfoStatus.ADDED, true);
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-tooltip-content has-tooltip="" title="Newly Added">
+            <gr-icon
+              icon="new_releases"
+              class="size-16"
+              aria-label="Newly Added"
+            ></gr-icon>
+          </gr-tooltip-content>
+          <gr-tooltip-content has-tooltip="" title="Newly Added">
+            <div class="A status" aria-label="Newly Added" tabindex="0">
+              <span>A</span>
+            </div>
+          </gr-tooltip-content>
+        `
+      );
+    });
+  });
+});
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 44a176d..5a1db30 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
@@ -1,70 +1,231 @@
 /**
  * @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 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../gr-linked-text/gr-linked-text';
-import {CommentLinks} from '../../../types/common';
-import {LitElement, css, html, TemplateResult} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+  htmlEscape,
+  sanitizeHtml,
+  sanitizeHtmlToFragment,
+} from '../../../utils/inner-html-util';
+import {unescapeHTML} from '../../../utils/syntax-util';
+import '@polymer/marked-element';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {CommentLinks, EmailAddress} from '../../../api/rest-api';
+import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
+import '../gr-account-chip/gr-account-chip';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {getAppContext} from '../../../services/app-context';
 
-const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
-const INLINE_PATTERN = /(\[.+?\]\(.+?\)|`[^`]+?`)/;
-const EXTRACT_LINK_PATTERN = /\[(.+?)\]\((.+?)\)/;
+/**
+ * This element optionally renders markdown and also applies some regex
+ * replacements to linkify key parts of the text defined by the host's config.
+ */
+@customElement('gr-formatted-text')
+export class GrFormattedText extends LitElement {
+  @property({type: String})
+  content = '';
 
-export type Block = ListBlock | QuoteBlock | Paragraph | CodeBlock | PreBlock;
-export interface ListBlock {
-  type: 'list';
-  items: ListItem[];
-}
-export interface ListItem {
-  spans: InlineItem[];
-}
+  @property({type: Boolean})
+  markdown = false;
 
-export interface QuoteBlock {
-  type: 'quote';
-  blocks: Block[];
-}
-export interface Paragraph {
-  type: 'paragraph';
-  spans: InlineItem[];
-}
-export interface CodeBlock {
-  type: 'code';
-  text: string;
-}
-export interface PreBlock {
-  type: 'pre';
-  text: string;
-}
+  @state()
+  private repoCommentLinks: CommentLinks = {};
 
-export type InlineItem = TextSpan | LinkSpan | CodeSpan;
+  private readonly flagsService = getAppContext().flagsService;
 
-export interface TextSpan {
-  type: 'text';
-  text: string;
-}
+  private readonly getConfigModel = resolve(this, configModelToken);
 
-export interface LinkSpan {
-  type: 'link';
-  text: string;
-  url: string;
-}
+  /**
+   * Note: Do not use sharedStyles or other styles here that should not affect
+   * the generated HTML of the markdown.
+   */
+  static override styles = [
+    css`
+      a {
+        color: var(--link-color);
+      }
+      p,
+      ul,
+      code,
+      blockquote {
+        margin: 0 0 var(--spacing-m) 0;
+        max-width: var(--gr-formatted-text-prose-max-width, none);
+      }
+      p:last-child,
+      ul:last-child,
+      blockquote:last-child,
+      pre:last-child {
+        margin: 0;
+      }
+      blockquote {
+        border-left: var(--spacing-xxs) solid var(--comment-quote-marker-color);
+        padding: 0 var(--spacing-m);
+      }
+      code {
+        background-color: var(--background-color-secondary);
+        border: var(--spacing-xxs) solid var(--border-color);
+        display: block;
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        line-height: var(--line-height-mono);
+        margin: var(--spacing-m) 0;
+        padding: var(--spacing-xxs) var(--spacing-s);
+        overflow-x: auto;
+        /* Pre will preserve whitespace and line breaks but not wrap */
+        white-space: pre;
+      }
+      /* Non-multiline code elements need display:inline to shrink and not take
+         a whole row */
+      :not(pre) > code {
+        display: inline;
+      }
+      p {
+        /* prose will automatically wrap but inline <code> blocks won't and we
+           should overflow in that case rather than wrapping or leaking out */
+        overflow-x: auto;
+      }
+      li {
+        margin-left: var(--spacing-xl);
+      }
+      gr-account-chip {
+        display: inline;
+      }
+      .plaintext {
+        font: inherit;
+        white-space: var(--linked-text-white-space, pre-wrap);
+        word-wrap: var(--linked-text-word-wrap, break-word);
+      }
+    `,
+  ];
 
-export interface CodeSpan {
-  type: 'code';
-  text: string;
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().repoCommentLinks$,
+      repoCommentLinks => (this.repoCommentLinks = repoCommentLinks)
+    );
+  }
+
+  override render() {
+    if (this.markdown) {
+      return this.renderAsMarkdown();
+    } else {
+      return this.renderAsPlaintext();
+    }
+  }
+
+  private renderAsPlaintext() {
+    const linkedText = linkifyUrlsAndApplyRewrite(
+      htmlEscape(this.content).toString(),
+      this.repoCommentLinks
+    );
+
+    return html`
+      <pre class="plaintext">${sanitizeHtmlToFragment(linkedText)}</pre>
+    `;
+  }
+
+  private renderAsMarkdown() {
+    // <marked-element> internals will be in charge of calling our custom
+    // renderer so we wrap 'this.rewriteText' so that 'this' is preserved via
+    // closure.
+    const boundRewriteText = (text: string) =>
+      linkifyUrlsAndApplyRewrite(text, this.repoCommentLinks);
+
+    // We are overriding some marked-element renderers for a few reasons:
+    // 1. Disable inline images as a design/policy choice.
+    // 2. Inline code blocks ("codespan") do not unescape HTML characters when
+    //    rendering without <pre> and so we must do this manually.
+    //    <marked-element> is already escaping these internally. See test
+    //    covering this.
+    // 3. Multiline code blocks ("code") is similarly handling escaped
+    //    characters using <pre>. The convention is to only use <pre> for multi-
+    //    line code blocks so it is not used for inline code blocks. See test
+    //    for this.
+    // 4. Rewrite plain text ("text") to apply linking and other config-based
+    //    rewrites. Text within code blocks is not passed here.
+    // 5. Open links in a new tab by rendering with target="_blank" attribute.
+    function customRenderer(renderer: {[type: string]: Function}) {
+      renderer['link'] = (href: string, title: string, text: string) =>
+        /* HTML */
+        `<a
+          href="${href}"
+          target="_blank"
+          ${title ? `title="${title}"` : ''}
+          rel="noopener"
+          >${text}</a
+        >`;
+      renderer['image'] = (href: string, _title: string, text: string) =>
+        `![${text}](${href})`;
+      renderer['codespan'] = (text: string) =>
+        `<code>${unescapeHTML(text)}</code>`;
+      renderer['code'] = (text: string) => `<pre><code>${text}</code></pre>`;
+      renderer['text'] = boundRewriteText;
+    }
+
+    // The child with slot is optional but allows us control over the styling.
+    // The `callback` property lets us do a final sanitization of the output
+    // HTML string before it is rendered by `<marked-element>` in case any
+    // rewrites have been abused to attempt an XSS attack.
+    return html`
+      <marked-element
+        .markdown=${this.escapeAllButBlockQuotes(this.content)}
+        .breaks=${true}
+        .renderer=${customRenderer}
+        .callback=${(_error: string | null, contents: string) =>
+          sanitizeHtml(contents)}
+      >
+        <div slot="markdown-html"></div>
+      </marked-element>
+    `;
+  }
+
+  private escapeAllButBlockQuotes(text: string) {
+    // Escaping the message should be done first to make sure user's literal
+    // input does not get rendered without affecting html added in later steps.
+    text = htmlEscape(text).toString();
+    // Unescape block quotes '>'. This is slightly dangerous as '>' can be used
+    // in HTML fragments, but it is insufficient on it's own.
+    text = text.replace(/(^|\n)&gt;/g, '$1>');
+
+    return text;
+  }
+
+  override updated() {
+    // Look for @mentions and replace them with an account-label chip.
+    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+      this.convertEmailsToAccountChips();
+    }
+  }
+
+  private convertEmailsToAccountChips() {
+    for (const emailLink of this.renderRoot.querySelectorAll(
+      'a[href^="mailto"]'
+    )) {
+      const previous = emailLink.previousSibling;
+      // This Regexp matches the beginning of the MENTIONS_REGEX at the end of
+      // an element.
+      if (
+        previous?.nodeName === '#text' &&
+        previous?.textContent?.match(/(^|\s)@$/)
+      ) {
+        const accountChip = document.createElement('gr-account-chip');
+        accountChip.account = {
+          email: emailLink.textContent as EmailAddress,
+        };
+        accountChip.removable = false;
+        // Remove the trailing @ from the previous element.
+        previous.textContent = previous.textContent.slice(0, -1);
+        emailLink.parentNode?.replaceChild(accountChip, emailLink);
+      }
+    }
+  }
 }
 
 declare global {
@@ -72,350 +233,3 @@
     'gr-formatted-text': GrFormattedText;
   }
 }
-@customElement('gr-formatted-text')
-export class GrFormattedText extends LitElement {
-  @property({type: String})
-  content?: string;
-
-  @property({type: Object})
-  config?: CommentLinks;
-
-  @property({type: Boolean, reflect: true})
-  noTrailingMargin = false;
-
-  static override get styles() {
-    return [
-      css`
-        :host {
-          display: block;
-          font-family: var(--font-family);
-        }
-        a {
-          color: var(--link-color);
-        }
-        p,
-        ul,
-        code,
-        blockquote,
-        gr-linked-text.pre {
-          margin: 0 0 var(--spacing-m) 0;
-        }
-        p,
-        ul,
-        code,
-        blockquote {
-          max-width: var(--gr-formatted-text-prose-max-width, none);
-        }
-        :host([noTrailingMargin]) p:last-child,
-        :host([noTrailingMargin]) ul:last-child,
-        :host([noTrailingMargin]) blockquote:last-child,
-        :host([noTrailingMargin]) gr-linked-text.pre:last-child {
-          margin: 0;
-        }
-        blockquote {
-          border-left: 1px solid #aaa;
-          padding: 0 var(--spacing-m);
-        }
-        code {
-          display: block;
-          white-space: pre-wrap;
-          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);
-        }
-        .inline-code,
-        code {
-          font-family: var(--monospace-font-family);
-          font-size: var(--font-size-code);
-          line-height: var(--line-height-mono);
-          background-color: var(--background-color-secondary);
-          border: 1px solid var(--border-color);
-          padding: 1px var(--spacing-s);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    if (!this.content) return;
-    const blocks = this._computeBlocks(this.content);
-    return html`${blocks.map(block => this.renderBlock(block))}`;
-  }
-
-  /**
-   * 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 of regular text)
-   * * 'quote' (Block quote.)
-   * * 'pre' (Pre-formatted text.)
-   * * 'list' (Unordered list.)
-   * * 'code' (code blocks.)
-   *
-   * 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
-   * list of strings representing the list items.
-   *
-   * For blocks of type 'quote', there is a `blocks` property that maps to a
-   * list of blocks contained in the quote.
-   *
-   * NOTE: Strings appearing in all block objects are NOT escaped.
-   */
-  _computeBlocks(content: string): Block[] {
-    const result: Block[] = [];
-    const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n');
-
-    for (let i = 0; i < lines.length; i++) {
-      if (!lines[i].length) {
-        continue;
-      }
-
-      if (this.isCodeMarkLine(lines[i])) {
-        const startOfCode = i + 1;
-        const endOfCode = this.getEndOfSection(
-          lines,
-          startOfCode,
-          line => !this.isCodeMarkLine(line)
-        );
-        // If the code extends to the end then there is no closing``` and the
-        // opening``` should not be counted as a multiline code block.
-        const lineAfterCode = lines[endOfCode];
-        if (lineAfterCode && this.isCodeMarkLine(lineAfterCode)) {
-          result.push({
-            type: 'code',
-            // Does not include either of the ``` lines
-            text: lines.slice(startOfCode, endOfCode).join('\n'),
-          });
-          i = endOfCode; // advances past the closing```
-          continue;
-        }
-      }
-      if (this.isSingleLineCode(lines[i])) {
-        // no guard check as _isSingleLineCode tested on the pattern
-        const codeContent = lines[i].match(CODE_MARKER_PATTERN)![2];
-        result.push({type: 'code', text: codeContent});
-      } else if (this.isList(lines[i])) {
-        const endOfList = this.getEndOfSection(lines, i + 1, line =>
-          this.isList(line)
-        );
-        result.push(this.makeList(lines.slice(i, endOfList)));
-        i = endOfList - 1;
-      } else if (this.isQuote(lines[i])) {
-        const endOfQuote = this.getEndOfSection(lines, i + 1, line =>
-          this.isQuote(line)
-        );
-        const blockLines = lines
-          .slice(i, endOfQuote)
-          .map(l => l.replace(/^[ ]?>[ ]?/, ''));
-        result.push({
-          type: 'quote',
-          blocks: this._computeBlocks(blockLines.join('\n')),
-        });
-        i = endOfQuote - 1;
-      } else if (this.isPreFormat(lines[i])) {
-        // include pre or all regular lines but stop at next new line
-        const predicate = (line: string) =>
-          this.isPreFormat(line) ||
-          (this.isRegularLine(line) &&
-            !this.isWhitespaceLine(line) &&
-            line.length > 0);
-        const endOfPre = this.getEndOfSection(lines, i + 1, predicate);
-        result.push({
-          type: 'pre',
-          text: lines.slice(i, endOfPre).join('\n'),
-        });
-        i = endOfPre - 1;
-      } else {
-        const endOfRegularLines = this.getEndOfSection(lines, i + 1, line =>
-          this.isRegularLine(line)
-        );
-        result.push({
-          type: 'paragraph',
-          spans: this.computeInlineItems(
-            lines.slice(i, endOfRegularLines).join('\n')
-          ),
-        });
-        i = endOfRegularLines - 1;
-      }
-    }
-
-    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,
-    sectionPredicate: (line: string) => boolean
-  ) {
-    const index = lines
-      .slice(startIndex)
-      .findIndex(line => !sectionPredicate(line));
-    return index === -1 ? lines.length : index + startIndex;
-  }
-
-  /**
-   * Take a block of comment text that contains a list, generate appropriate
-   * block objects and append them to the output list.
-   *
-   * * Item one.
-   * * Item two.
-   * * item three.
-   *
-   * TODO(taoalpha): maybe we should also support nested list
-   *
-   * @param lines The block containing the list.
-   */
-  private makeList(lines: string[]): Block {
-    return {
-      type: 'list',
-      items: lines.map(line => {
-        return {
-          spans: this.computeInlineItems(line.substring(1).trim()),
-        };
-      }),
-    };
-  }
-
-  private isRegularLine(line: string): boolean {
-    return (
-      !this.isQuote(line) &&
-      !this.isCodeMarkLine(line) &&
-      !this.isSingleLineCode(line) &&
-      !this.isList(line) &&
-      !this.isPreFormat(line)
-    );
-  }
-
-  private isQuote(line: string): boolean {
-    return line.startsWith('> ') || line.startsWith(' > ');
-  }
-
-  private isCodeMarkLine(line: string): boolean {
-    return line.trim() === '```';
-  }
-
-  private isSingleLineCode(line: string): boolean {
-    return CODE_MARKER_PATTERN.test(line);
-  }
-
-  private isPreFormat(line: string): boolean {
-    return /^[ \t]/.test(line) && !this.isWhitespaceLine(line);
-  }
-
-  private isList(line: string): boolean {
-    return /^[-*] /.test(line);
-  }
-
-  private isWhitespaceLine(line: string): boolean {
-    return /^\s+$/.test(line);
-  }
-
-  private renderInlineText(content: string): TemplateResult {
-    return html`
-      <gr-linked-text
-        .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>
-          ${block.spans.map(item => this.renderInlineItem(item))}
-        </p>`;
-      case 'quote':
-        return html`
-          <blockquote>
-            ${block.blocks.map(subBlock => this.renderBlock(subBlock))}
-          </blockquote>
-        `;
-      case 'code':
-        return html`<code>${block.text}</code>`;
-      case 'pre':
-        return html`<pre><code>${block.text}</code></pre>`;
-      case 'list':
-        return html`
-          <ul>
-            ${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 3a23a38..ca498318 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
@@ -1,521 +1,579 @@
 /**
  * @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 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import './gr-formatted-text';
+import '../../../test/common-test-setup';
+import {assert, fixture, html} from '@open-wc/testing';
+import {changeModelToken} from '../../../models/change/change-model';
 import {
-  GrFormattedText,
-  Block,
-  ListBlock,
-  Paragraph,
-  QuoteBlock,
-  PreBlock,
-  CodeBlock,
-  InlineItem,
-  ListItem,
-  TextSpan,
-  LinkSpan,
-} from './gr-formatted-text';
-
-const basicFixture = fixtureFromElement('gr-formatted-text');
+  ConfigModel,
+  configModelToken,
+} from '../../../models/config/config-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import './gr-formatted-text';
+import {GrFormattedText} from './gr-formatted-text';
+import {createConfig} from '../../../test/test-data-generators';
+import {
+  queryAndAssert,
+  stubFlags,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {CommentLinks, EmailAddress} from '../../../api/rest-api';
+import {testResolver} from '../../../test/common-test-setup';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 
 suite('gr-formatted-text tests', () => {
   let element: GrFormattedText;
+  let configModel: ConfigModel;
 
-  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 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);
-  }
-
-  function assertQuoteBlock(block: Block): QuoteBlock {
-    assert.equal(block.type, 'quote');
-    return block as QuoteBlock;
-  }
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('parse empty', () => {
-    assert.lengthOf(element._computeBlocks(''), 0);
-  });
-
-  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], [{type: 'link', text: 'text', url: 'url'}]);
-  });
-
-  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], [{type: 'link', text: 'text', url: ''}]);
-  });
-
-  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], [{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', () => {
-    const comment = '> Quote text';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(quoteBlock.blocks[0], 'Quote text');
-  });
-
-  test('parse quote lead space', () => {
-    const comment = ' > Quote text';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(quoteBlock.blocks[0], 'Quote text');
-  });
-
-  test('parse multiline quote', () => {
-    const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(
-      quoteBlock.blocks[0],
-      'Quote line 1\nQuote line 2\nQuote line 3'
+  async function setCommentLinks(commentlinks: CommentLinks) {
+    configModel.updateRepoConfig({...createConfig(), commentlinks});
+    await waitUntilObserved(
+      configModel.repoCommentLinks$,
+      links => links === commentlinks
     );
-  });
+  }
 
-  test('parse pre', () => {
-    const comment = '    Four space indent.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    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);
-    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);
-    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], [
-      {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], [
-      {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], [
-      {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', () => {
-    const comment =
-      'Paragraph\nacross\na\nfew\nlines.' +
-      '\n\n' +
-      '> Quote\n> across\n> not many lines.' +
-      '\n\n' +
-      'Another paragraph' +
-      '\n\n' +
-      '* Series\n* of\n* list\n* items' +
-      '\n\n' +
-      'Yet another paragraph' +
-      '\n\n' +
-      '\tPreformatted text.' +
-      '\n\n' +
-      'Parting words.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 7);
-    assertSimpleTextBlock(result[0], 'Paragraph\nacross\na\nfew\nlines.\n');
-
-    const quoteBlock = assertQuoteBlock(result[1]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(
-      quoteBlock.blocks[0],
-      'Quote\nacross\nnot many lines.'
+  setup(async () => {
+    configModel = new ConfigModel(
+      testResolver(changeModelToken),
+      getAppContext().restApiService
     );
-
-    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);
-    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);
-    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);
-    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], [
-      {spans: [{type: 'text', text: 'line 1'}]},
-      {spans: [{type: 'text', text: '2nd line'}]},
-    ]);
-    assertSimpleTextBlock(result[1], 'B');
-  });
-
-  test('bullet list 5', () => {
-    const comment =
-      'To see this bug, you have to:\n' +
-      '* Be on IMAP or EAS (not on POP)\n' +
-      '* Be very unlucky\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'To see this bug, you have to:');
-    assertListBlock(result[1], [
-      {spans: [{type: 'text', text: 'Be on IMAP or EAS (not on POP)'}]},
-      {spans: [{type: 'text', text: 'Be very unlucky'}]},
-    ]);
-  });
-
-  test('bullet list 6', () => {
-    const comment =
-      'To see this bug,\n' +
-      'you have to:\n' +
-      '* Be on IMAP or EAS (not on POP)\n' +
-      '* Be very unlucky\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'To see this bug,\nyou have to:');
-    assertListBlock(result[1], [
-      {spans: [{type: 'text', text: 'Be on IMAP or EAS (not on POP)'}]},
-      {spans: [{type: 'text', text: 'Be very unlucky'}]},
-    ]);
-  });
-
-  test('dash list 1', () => {
-    const comment = 'A\n- line 1\n- 2nd line';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    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);
-    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], [
-      {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'}],
+    await setCommentLinks({
+      customLinkRewrite: {
+        match: '(LinkRewriteMe)',
+        link: 'http://google.com/$1',
       },
-      {spans: [{type: 'text', text: '2nd line'}]},
-    ]);
+      customHtmlRewrite: {
+        match: 'HTMLRewriteMe',
+        html: '<div>HTMLRewritten</div>',
+      },
+      complexLinkRewrite: {
+        match: '(^|\\s)A Link (\\d+)($|\\s)',
+        link: '/page?id=$2',
+        text: 'Link $2',
+        prefix: '$1A ',
+        suffix: '$3',
+      },
+    });
+    self.CANONICAL_PATH = 'http://localhost';
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-formatted-text></gr-formatted-text>`,
+          configModelToken,
+          configModel
+        )
+      )
+    ).querySelector('gr-formatted-text')!;
   });
 
-  test('nested list will NOT be recognized', () => {
-    // will be rendered as two separate lists
-    const comment = '- line 1\n  - line with indentation\n- line 2';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertListBlock(result[0], [{spans: [{type: 'text', text: 'line 1'}]}]);
-    assertPreBlock(result[1], '  - line with indentation');
-    assertListBlock(result[2], [{spans: [{type: 'text', text: 'line 2'}]}]);
+  suite('as plaintext', () => {
+    setup(async () => {
+      element.markdown = false;
+      await element.updateComplete;
+    });
+
+    test('does not apply rewrites within links', async () => {
+      element.content = 'google.com/LinkRewriteMe';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <pre class="plaintext">
+            <a
+              href="http://google.com/LinkRewriteMe"
+              rel="noopener"
+              target="_blank"
+            >
+              google.com/LinkRewriteMe
+            </a>
+          </pre>
+        `
+      );
+    });
+
+    test('does not apply rewrites on rewritten text', async () => {
+      await setCommentLinks({
+        capitalizeFoo: {
+          match: 'foo',
+          html: 'FOO',
+        },
+        lowercaseFoo: {
+          match: 'FOO',
+          html: 'foo',
+        },
+      });
+      element.content = 'foo';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <pre class="plaintext">
+          FOO
+        </pre
+          >
+        `
+      );
+    });
+
+    test('supports overlapping rewrites', async () => {
+      await setCommentLinks({
+        bracketNum: {
+          match: '(Start:) ([0-9]+)',
+          html: '$1 [$2]',
+        },
+        bracketNum2: {
+          match: '(Start: [0-9]+) ([0-9]+)',
+          html: '$1 [$2]',
+        },
+      });
+      element.content = 'Start: 123 456';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <pre class="plaintext">
+            Start: [123] [456]
+          </pre
+          >
+        `
+      );
+    });
+
+    test('renders text with links and rewrites', async () => {
+      element.content = `text with plain link: google.com
+        \ntext with config link: LinkRewriteMe
+        \ntext with complex link: A Link 12
+        \ntext with config html: HTMLRewriteMe`;
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <pre class="plaintext">
+            text with plain link:
+            <a href="http://google.com" rel="noopener" target="_blank">
+              google.com
+            </a>
+            text with config link:
+            <a
+              href="http://google.com/LinkRewriteMe"
+              rel="noopener"
+              target="_blank"
+            >
+              LinkRewriteMe
+            </a>
+            text with complex link: A
+            <a
+              href="http://localhost/page?id=12"
+              rel="noopener"
+              target="_blank"
+            >
+              Link 12
+            </a>
+            text with config html:
+            <div>HTMLRewritten</div>
+          </pre>
+        `
+      );
+    });
+
+    test('does not render typed html', async () => {
+      element.content = 'plain text <div>foo</div>';
+      await element.updateComplete;
+
+      const escapedDiv = '&lt;div&gt;foo&lt;/div&gt;';
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<pre class="plaintext">plain text ${escapedDiv}</pre>`
+      );
+    });
+
+    test('does not render markdown', async () => {
+      element.content = '# A Markdown Heading';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ '<pre class="plaintext"># A Markdown Heading</pre>'
+      );
+    });
   });
 
-  test('pre format 1', () => {
-    const comment = 'A\n  This is pre\n  formatted';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'A');
-    assertPreBlock(result[1], '  This is pre\n  formatted');
-  });
+  suite('as markdown', () => {
+    setup(async () => {
+      element.markdown = true;
+      await element.updateComplete;
+    });
+    test('renders text with links and rewrites', async () => {
+      element.content = `text
+        \ntext with plain link: google.com
+        \ntext with config link: LinkRewriteMe
+        \ntext without a link: NotA Link 15 cats
+        \ntext with complex link: A Link 12
+        \ntext with config html: HTMLRewriteMe`;
+      await element.updateComplete;
 
-  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);
-    assertSimpleTextBlock(result[0], 'A');
-    assertPreBlock(result[1], '  This is pre\n  formatted');
-    assertSimpleTextBlock(result[2], 'but this is not');
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>text</p>
+              <p>
+                text with plain link:
+                <a href="http://google.com" rel="noopener" target="_blank">
+                  google.com
+                </a>
+              </p>
+              <p>
+                text with config link:
+                <a
+                  href="http://google.com/LinkRewriteMe"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  LinkRewriteMe
+                </a>
+              </p>
+              <p>text without a link: NotA Link 15 cats</p>
+              <p>
+                text with complex link: A
+                <a
+                  href="http://localhost/page?id=12"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  Link 12
+                </a>
+              </p>
+              <p>text with config html:</p>
+              <div>HTMLRewritten</div>
+              <p></p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('pre format 3', () => {
-    const comment = 'A\n  Q\n    <R>\n  S\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertSimpleTextBlock(result[0], 'A');
-    assertPreBlock(result[1], '  Q\n    <R>\n  S');
-    assertSimpleTextBlock(result[2], 'B');
-  });
+    test('renders headings with links and rewrites', async () => {
+      element.content = `# h1-heading
+        \n## h2-heading
+        \n### h3-heading
+        \n#### h4-heading
+        \n##### h5-heading
+        \n###### h6-heading
+        \n# heading with plain link: google.com
+        \n# heading with config link: LinkRewriteMe
+        \n# heading with config html: HTMLRewriteMe`;
+      await element.updateComplete;
 
-  test('pre format 4', () => {
-    const comment = '  Q\n    <R>\n  S\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertPreBlock(result[0], '  Q\n    <R>\n  S');
-    assertSimpleTextBlock(result[1], 'B');
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <h1>h1-heading</h1>
+              <h2>h2-heading</h2>
+              <h3>h3-heading</h3>
+              <h4>h4-heading</h4>
+              <h5>h5-heading</h5>
+              <h6>h6-heading</h6>
+              <h1>
+                heading with plain link:
+                <a href="http://google.com" rel="noopener" target="_blank">
+                  google.com
+                </a>
+              </h1>
+              <h1>
+                heading with config link:
+                <a
+                  href="http://google.com/LinkRewriteMe"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  LinkRewriteMe
+                </a>
+              </h1>
+              <h1>
+                heading with config html:
+                <div>HTMLRewritten</div>
+              </h1>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('pre format 5', () => {
-    const comment = '  Q\n    <R>\n  S\n \nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertPreBlock(result[0], '  Q\n    <R>\n  S');
-    assertSimpleTextBlock(result[1], ' \nB');
-  });
+    test('renders inline-code without linking or rewriting', async () => {
+      element.content = `\`inline code\`
+        \n\`inline code with plain link: google.com\`
+        \n\`inline code with config link: LinkRewriteMe\`
+        \n\`inline code with config html: HTMLRewriteMe\``;
+      await element.updateComplete;
 
-  test('quote 1', () => {
-    const comment = "> I'm happy with quotes!!";
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(quoteBlock.blocks[0], "I'm happy with quotes!!");
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>
+                <code>inline code</code>
+              </p>
+              <p>
+                <code>inline code with plain link: google.com</code>
+              </p>
+              <p>
+                <code>inline code with config link: LinkRewriteMe</code>
+              </p>
+              <p>
+                <code>inline code with config html: HTMLRewriteMe</code>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('quote 2', () => {
-    const comment = "> I'm happy\n > with quotes!\n\nSee above.";
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(quoteBlock.blocks[0], "I'm happy\nwith quotes!");
-    assertSimpleTextBlock(result[1], 'See above.');
-  });
+    test('renders multiline-code without linking or rewriting', async () => {
+      element.content = `\`\`\`\nmultiline code\n\`\`\`
+        \n\`\`\`\nmultiline code with plain link: google.com\n\`\`\`
+        \n\`\`\`\nmultiline code with config link: LinkRewriteMe\n\`\`\`
+        \n\`\`\`\nmultiline code with config html: HTMLRewriteMe\n\`\`\``;
+      await element.updateComplete;
 
-  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);
-    assertSimpleTextBlock(result[0], 'See this said:');
-    const quoteBlock = assertQuoteBlock(result[1]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertSimpleTextBlock(quoteBlock.blocks[0], 'a quoted\nstring block');
-    assertSimpleTextBlock(result[2], 'OK?');
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <pre>
+              <code>multiline code</code>
+            </pre>
+              <pre>
+              <code>multiline code with plain link: google.com</code>
+            </pre>
+              <pre>
+              <code>multiline code with config link: LinkRewriteMe</code>
+            </pre>
+              <pre>
+              <code>multiline code with config html: HTMLRewriteMe</code>
+            </pre>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('nested quotes', () => {
-    const comment = ' > > prior\n > \n > next\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const outerQuoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(outerQuoteBlock.blocks, 2);
-    const nestedQuoteBlock = assertQuoteBlock(outerQuoteBlock.blocks[0]);
-    assert.lengthOf(nestedQuoteBlock.blocks, 1);
-    assertSimpleTextBlock(nestedQuoteBlock.blocks[0], 'prior');
-    assertSimpleTextBlock(outerQuoteBlock.blocks[1], 'next');
-  });
+    test('does not render inline images into <img> tags', async () => {
+      element.content = '![img](google.com/img.png)';
+      await element.updateComplete;
 
-  test('code 1', () => {
-    const comment = '```\n// test code\n```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertCodeBlock(result[0], '// test code');
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>![img](google.com/img.png)</p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('code 2', () => {
-    const comment = 'test code\n```// test code```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'test code');
-    assertCodeBlock(result[1], '// test code');
-  });
+    test('does not handle @mentions if not enabled', async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.MENTION_USERS)
+        .returns(false);
+      element.content = '@someone@google.com';
+      await element.updateComplete;
 
-  test('not a code block', () => {
-    const comment = 'test code\n```// test code';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertSimpleTextBlock(result[0], 'test code\n```// test code');
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>
+                @
+                <a
+                  href="mailto:someone@google.com"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  someone@google.com
+                </a>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('not a code block 2', () => {
-    const comment = 'test code\n```\n// test code';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'test code');
-    assertSimpleTextBlock(result[1], '```\n// test code');
-  });
+    test('handles @mentions if enabled', async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.MENTION_USERS)
+        .returns(true);
+      element.content = '@someone@google.com';
+      await element.updateComplete;
 
-  test('not a code block 3', () => {
-    const comment = 'test code\n```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertSimpleTextBlock(result[0], 'test code');
-    assertSimpleTextBlock(result[1], '```');
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>
+                <gr-account-chip></gr-account-chip>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+      const accountChip = queryAndAssert<GrAccountChip>(
+        element,
+        'gr-account-chip'
+      );
+      assert.equal(
+        accountChip.account?.email,
+        'someone@google.com' as EmailAddress
+      );
+    });
 
-  test('mix all 1', () => {
-    const comment =
-      ' bullets:\n- bullet 1\n- bullet 2\n\ncode example:\n' +
-      '```// test code```\n\n> reference is here';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 5);
-    assert.equal(result[0].type, 'pre');
-    assert.equal(result[1].type, 'list');
-    assert.equal(result[2].type, 'paragraph');
-    assert.equal(result[3].type, 'code');
-    assert.equal(result[4].type, 'quote');
+    test('does not handle @mentions that is part of a code block', async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.MENTION_USERS)
+        .returns(true);
+      element.content = '`@`someone@google.com';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>
+                <code>@</code>
+                <a
+                  href="mailto:someone@google.com"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  someone@google.com
+                </a>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+
+    test('renders inline links into <a> tags', async () => {
+      element.content = '[myLink](https://www.google.com)';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>
+                <a href="https://www.google.com" rel="noopener" target="_blank"
+                  >myLink</a
+                >
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+
+    test('renders block quotes with links and rewrites', async () => {
+      element.content = `> block quote
+        \n> block quote with plain link: google.com
+        \n> block quote with config link: LinkRewriteMe
+        \n> block quote with config html: HTMLRewriteMe`;
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <blockquote>
+                <p>block quote</p>
+              </blockquote>
+              <blockquote>
+                <p>
+                  block quote with plain link:
+                  <a href="http://google.com" rel="noopener" target="_blank">
+                    google.com
+                  </a>
+                </p>
+              </blockquote>
+              <blockquote>
+                <p>
+                  block quote with config link:
+                  <a
+                    href="http://google.com/LinkRewriteMe"
+                    rel="noopener"
+                    target="_blank"
+                  >
+                    LinkRewriteMe
+                  </a>
+                </p>
+              </blockquote>
+              <blockquote>
+                <p>block quote with config html:</p>
+                <div>HTMLRewritten</div>
+                <p></p>
+              </blockquote>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+
+    test('never renders typed html', async () => {
+      element.content = `plain text <div>foo</div>
+        \n\`inline code <div>foo</div>\`
+        \n\`\`\`\nmultiline code <div>foo</div>\`\`\`
+        \n> block quote <div>foo</div>
+        \n[inline link <div>foo</div>](http://google.com)`;
+      await element.updateComplete;
+
+      const escapedDiv = '&lt;div&gt;foo&lt;/div&gt;';
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>plain text ${escapedDiv}</p>
+              <p>
+                <code>inline code ${escapedDiv}</code>
+              </p>
+              <pre>
+              <code>
+                multiline code ${escapedDiv}
+              </code>
+            </pre>
+              <blockquote>
+                <p>block quote ${escapedDiv}</p>
+              </blockquote>
+              <p>
+                <a href="http://google.com" rel="noopener" target="_blank"
+                  >inline link ${escapedDiv}</a
+                >
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
   });
 });
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 4493e8d..9647141 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
@@ -1,28 +1,21 @@
 /**
  * @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 '@polymer/iron-icon/iron-icon';
 import '../gr-avatar/gr-avatar';
 import '../gr-button/gr-button';
+import '../gr-icon/gr-icon';
 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, state} from 'lit/decorators';
+import {
+  accountKey,
+  computeVoteableText,
+  isAccountEmailOnly,
+  isSelf,
+} from '../../../utils/account-util';
+import {customElement, property, state} from 'lit/decorators.js';
 import {
   AccountInfo,
   ChangeInfo,
@@ -42,10 +35,15 @@
 import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
 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 {css, html, LitElement, nothing} from 'lit';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {EventType} from '../../../types/events';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {createSearchUrl} from '../../../models/views/search';
+import {createDashboardUrl} from '../../../models/views/dashboard';
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = HovercardMixin(LitElement);
@@ -56,7 +54,7 @@
   account!: AccountInfo;
 
   @state()
-  _selfAccount?: AccountInfo;
+  selfAccount?: AccountInfo;
 
   /**
    * Optional ChangeInfo object, typically comes from the change page or
@@ -67,13 +65,6 @@
   change?: ChangeInfo;
 
   /**
-   * Explains which labels the user can vote on and which score they can
-   * give.
-   */
-  @property({type: String})
-  voteableText?: string;
-
-  /**
    * 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.
@@ -81,21 +72,32 @@
   @property({type: Boolean})
   highlightAttention = false;
 
-  @property({type: Object})
-  _config?: ServerInfo;
+  @state()
+  serverConfig?: ServerInfo;
 
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getConfig().then(config => {
-      this._config = config;
-    });
-    this.restApiService.getAccount().then(account => {
-      this._selfAccount = account;
-    });
+  // private but used in tests
+  readonly userModel = getAppContext().userModel;
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.selfAccount = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
   }
 
   static override get styles() {
@@ -137,25 +139,16 @@
         .attention a {
           text-decoration: none;
         }
-        iron-icon {
-          vertical-align: top;
-        }
-        .status iron-icon {
-          width: 14px;
-          height: 14px;
+        .status gr-icon {
+          font-size: 14px;
           position: relative;
           top: 2px;
         }
-        iron-icon.attentionIcon {
-          width: 14px;
-          height: 14px;
-          position: relative;
-          top: 3px;
+        gr-icon.attentionIcon {
+          transform: scaleX(0.8);
         }
-        iron-icon.linkIcon {
-          width: var(--line-height-normal, 20px);
-          height: var(--line-height-normal, 20px);
-          vertical-align: top;
+        gr-icon.linkIcon {
+          font-size: var(--line-height-normal, 20px);
           color: var(--deemphasized-text-color);
           padding-right: 12px;
         }
@@ -191,12 +184,21 @@
         </div>
       </div>
       ${this.renderAccountStatusPlugins()} ${this.renderAccountStatus()}
-      ${this.renderLinks()}
-      ${this.voteableText
+      ${this.renderLinks()} ${this.renderChangeRelatedInfoAndActions()}
+    `;
+  }
+
+  private renderChangeRelatedInfoAndActions() {
+    if (this.change === undefined) {
+      return;
+    }
+    const voteableText = computeVoteableText(this.change, this.account);
+    return html`
+      ${voteableText
         ? html`
             <div class="voteable">
               <span class="title">Voteable:</span>
-              <span class="value">${this.voteableText}</span>
+              <span class="value">${voteableText}</span>
             </div>
           `
         : ''}
@@ -206,7 +208,7 @@
   }
 
   private renderReviewerOrCcActions() {
-    if (!this._selfAccount || !isRemovableReviewer(this.change, this.account))
+    if (!this.selfAccount || !isRemovableReviewer(this.change, this.account))
       return;
     return html`
       <div class="action">
@@ -244,8 +246,9 @@
   }
 
   private renderLinks() {
+    if (!this.account || isAccountEmailOnly(this.account)) return nothing;
     return html` <div class="links">
-      <iron-icon class="linkIcon" icon="gr-icons:link"></iron-icon
+      <gr-icon icon="link" class="linkIcon"></gr-icon
       ><a
         href=${ifDefined(this.computeOwnerChangesLink())}
         @click=${() => {
@@ -288,25 +291,24 @@
     return html`
       <div class="attention">
         <div>
-          <iron-icon
+          <gr-icon
+            icon="label_important"
+            filled
+            small
             class="attentionIcon"
-            icon="gr-icons:attention"
-          ></iron-icon>
-          <span> ${this.computePronoun()} turn to take this action. </span>
+          ></gr-icon>
+          <span> ${this.computePronoun()} turn to take action. </span>
           <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>
+            <gr-icon icon="help" title="read documentation"></gr-icon>
           </a>
         </div>
         <div class="reason">
           <span class="title">Reason:</span>
           <span class="value">
-            ${getReason(this._config, this.account, this.change)}
+            ${getReason(this.serverConfig, this.account, this.change)}
           </span>
           ${lastUpdate
             ? html` (<gr-date-formatter
@@ -354,26 +356,27 @@
 
   // private but used by tests
   computePronoun() {
-    if (!this.account || !this._selfAccount) return '';
-    return isSelf(this.account, this._selfAccount) ? 'Your' : 'Their';
+    if (!this.account || !this.selfAccount) return '';
+    return isSelf(this.account, this.selfAccount) ? 'Your' : 'Their';
   }
 
   computeOwnerChangesLink() {
     if (!this.account) return undefined;
-    return GerritNav.getUrlForOwner(
-      this.account.email ||
+    return createSearchUrl({
+      owner:
+        this.account.email ||
         this.account.username ||
         this.account.name ||
-        `${this.account._account_id}`
-    );
+        `${this.account._account_id}`,
+    });
   }
 
   computeOwnerDashboardLink() {
     if (!this.account) return undefined;
     if (this.account._account_id)
-      return GerritNav.getUrlForUserDashboard(`${this.account._account_id}`);
+      return createDashboardUrl({user: `${this.account._account_id}`});
     if (this.account.email)
-      return GerritNav.getUrlForUserDashboard(this.account.email);
+      return createDashboardUrl({user: this.account.email});
     return undefined;
   }
 
@@ -420,7 +423,7 @@
     // accountKey() throws an error if _account_id & email is not found, which
     // we want to check before showing reloading toast
     const _accountKey = accountKey(this.account);
-    this.dispatchEventThroughTarget('show-alert', {
+    this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
       message: 'Reloading page...',
     });
     const reviewInput: Partial<ReviewInput> = {};
@@ -449,7 +452,7 @@
   private handleRemoveReviewerOrCC() {
     if (!this.change || !(this.account?._account_id || this.account?.email))
       throw new Error('Missing change or account.');
-    this.dispatchEventThroughTarget('show-alert', {
+    this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
       message: 'Reloading page...',
     });
     this.restApiService
@@ -468,34 +471,34 @@
 
   private computeShowActionAddToAttentionSet() {
     const involvedOrSelf =
-      isInvolved(this.change, this._selfAccount) ||
-      isSelf(this.account, this._selfAccount);
+      isInvolved(this.change, this.selfAccount) ||
+      isSelf(this.account, this.selfAccount);
     return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention;
   }
 
   private computeShowActionRemoveFromAttentionSet() {
     const involvedOrSelf =
-      isInvolved(this.change, this._selfAccount) ||
-      isSelf(this.account, this._selfAccount);
+      isInvolved(this.change, this.selfAccount) ||
+      isSelf(this.account, this.selfAccount);
     return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention;
   }
 
   private handleClickAddToAttentionSet(e: MouseEvent) {
     if (!this.change || !this.account._account_id) return;
-    this.dispatchEventThroughTarget('show-alert', {
+    this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
       message: 'Saving attention set update ...',
       dismissOnNavigation: true,
     });
 
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
-    const reason = getAddedByReason(this._selfAccount, this._config);
+    const reason = getAddedByReason(this.selfAccount, this.serverConfig);
 
     if (!this.change.attention_set) this.change.attention_set = {};
     this.change.attention_set[this.account._account_id] = {
       account: this.account,
       reason,
-      reason_account: this._selfAccount,
+      reason_account: this.selfAccount,
     };
     this.dispatchEventThroughTarget('attention-set-updated');
 
@@ -513,7 +516,7 @@
 
   private handleClickRemoveFromAttentionSet(e: MouseEvent) {
     if (!this.change || !this.account._account_id) return;
-    this.dispatchEventThroughTarget('show-alert', {
+    this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
       message: 'Saving attention set update ...',
       dismissOnNavigation: true,
     });
@@ -521,7 +524,7 @@
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
 
-    const reason = getRemovedByReason(this._selfAccount, this._config);
+    const reason = getRemovedByReason(this.selfAccount, this.serverConfig);
     if (this.change.attention_set)
       delete this.change.attention_set[this.account._account_id];
     this.dispatchEventThroughTarget('attention-set-updated');
@@ -546,7 +549,7 @@
     const targetId = this.account._account_id;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
-    const selfId = (this._selfAccount && this._selfAccount._account_id) || -1;
+    const selfId = (this.selfAccount && this.selfAccount._account_id) || -1;
     const reviewers =
       this.change && this.change.reviewers && this.change.reviewers.REVIEWER
         ? [...this.change.reviewers.REVIEWER]
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
index 77fc4f6..89bc043 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
@@ -1,22 +1,10 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {fixture} from '@open-wc/testing-helpers';
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import './gr-hovercard-account';
 import {GrHovercardAccount} from './gr-hovercard-account';
@@ -31,12 +19,14 @@
   AccountId,
   EmailAddress,
   ReviewerState,
-} from '../../../api/rest-api.js';
+} from '../../../api/rest-api';
 import {
   createAccountDetailWithId,
   createChange,
-} from '../../../test/test-data-generators.js';
-import {GrButton} from '../gr-button/gr-button.js';
+  createDetailedLabelInfo,
+} from '../../../test/test-data-generators';
+import {GrButton} from '../gr-button/gr-button';
+import {EventType} from '../../../types/events';
 
 suite('gr-hovercard-account tests', () => {
   let element: GrHovercardAccount;
@@ -51,7 +41,6 @@
   };
 
   setup(async () => {
-    stubRestApi('getAccount').returns(Promise.resolve({...ACCOUNT}));
     const change = {
       ...createChange(),
       attention_set: {},
@@ -67,6 +56,7 @@
       </gr-hovercard-account>`
     );
     await element.show({});
+    element.userModel.setAccount({...ACCOUNT});
     await element.updateComplete;
   });
 
@@ -76,30 +66,74 @@
   });
 
   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>
+    assert.shadowDom.equal(
+      element,
+      /* 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>
-          <div class="account">
-            <h3 class="heading-3 name">Kermit The Frog</h3>
-            <div class="email">kermit@gmail.com</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">
+            <gr-icon icon="link" class="linkIcon"></gr-icon>
+            <a href="/q/owner:kermit%2540gmail.com">Changes</a>
+            ·
+            <a href="/dashboard/31415926535">Dashboard</a>
           </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>
+      `
+    );
+  });
+
+  test('renders without change data', async () => {
+    const elementWithoutChange = await fixture<GrHovercardAccount>(
+      html`<gr-hovercard-account class="hovered" .account=${ACCOUNT}>
+      </gr-hovercard-account>`
+    );
+    await elementWithoutChange.show({});
+    assert.shadowDom.equal(
+      elementWithoutChange,
+      /* 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">
+            <gr-icon class="linkIcon" icon="link"> </gr-icon>
+            <a href="/q/owner:kermit%2540gmail.com"> Changes </a>
+            ·
+            <a href="/dashboard/31415926535"> Dashboard </a>
+          </div>
         </div>
-        <div class="links">
-          <iron-icon class="linkIcon" icon="gr-icons:link"></iron-icon>
-          <a href="">Changes</a>·<a href="">Dashboard</a>
-        </div>
-      </div>
-    `);
+      `
+    );
+    elementWithoutChange.mouseHide(new MouseEvent('click'));
+    await elementWithoutChange.updateComplete;
   });
 
   test('account name is shown', () => {
@@ -109,7 +143,7 @@
 
   test('computePronoun', async () => {
     element.account = createAccountDetailWithId(1);
-    element._selfAccount = createAccountDetailWithId(1);
+    element.selfAccount = createAccountDetailWithId(1);
     await element.updateComplete;
     assert.equal(element.computePronoun(), 'Your');
     element.account = createAccountDetailWithId(2);
@@ -133,13 +167,49 @@
   });
 
   test('voteable div is displayed', async () => {
-    element.voteableText = 'CodeReview: +2';
+    element.change = {
+      ...createChange(),
+      labels: {
+        Foo: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 2, min: 0},
+            },
+          ],
+        },
+        Bar: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createAccountDetailWithId(1),
+              permitted_voting_range: {max: 1, min: 0},
+            },
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 1, min: 0},
+            },
+          ],
+        },
+        FooBar: {
+          ...createDetailedLabelInfo(),
+          all: [{_account_id: 7 as AccountId, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+    element.account = createAccountDetailWithId(1);
+
     await element.updateComplete;
     const voteableEl = queryAndAssert<HTMLSpanElement>(
       element,
       '.voteable .value'
     );
-    assert.equal(voteableEl.innerText, element.voteableText);
+    assert.equal(voteableEl.innerText, 'Bar: +1');
   });
 
   test('remove reviewer', async () => {
@@ -253,7 +323,7 @@
     const showAlertListener = sinon.spy();
     const hideAlertListener = sinon.spy();
     const updatedListener = sinon.spy();
-    element._target.addEventListener('show-alert', showAlertListener);
+    element._target.addEventListener(EventType.SHOW_ALERT, showAlertListener);
     element._target.addEventListener('hide-alert', hideAlertListener);
     element._target.addEventListener('attention-set-updated', updatedListener);
 
@@ -307,7 +377,7 @@
     const showAlertListener = sinon.spy();
     const hideAlertListener = sinon.spy();
     const updatedListener = sinon.spy();
-    element._target.addEventListener('show-alert', showAlertListener);
+    element._target.addEventListener(EventType.SHOW_ALERT, showAlertListener);
     element._target.addEventListener('hide-alert', hideAlertListener);
     element._target.addEventListener('attention-set-updated', updatedListener);
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
index bf35c06..b15a49e 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {customElement} from 'lit/decorators';
+import {customElement} from 'lit/decorators.js';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
 import {css, html, LitElement} from 'lit';
 
diff --git a/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
new file mode 100644
index 0000000..a2d70bc3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {css, html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-icon': GrIcon;
+  }
+}
+
+/**
+ * A material icon.  The advantage of using gr-icon over a native span is that
+ * gr-icon uses :host::before trick to avoid that the icon name shows up in
+ * chrome search.
+ * TODO: Improve type-checking by restricting which strings can be passed into
+ * `icon`.
+ *
+ * @attr {String} icon - the icon to display
+ * @attr {Boolean} filled - whether the icon should be filled
+ * @attr {Boolean} small - whether the icon should be smaller than usual
+ */
+@customElement('gr-icon')
+export class GrIcon extends LitElement {
+  @property({type: String, reflect: true})
+  icon?: string;
+
+  @property({type: Boolean, reflect: true})
+  filled?: boolean;
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          /* Fallback rule for color */
+          color: var(--deemphasized-text-color);
+          font-family: var(--icon-font-family, 'Material Symbols Outlined');
+          font-weight: normal;
+          font-style: normal;
+          font-size: 20px;
+          line-height: 1;
+          letter-spacing: normal;
+          text-transform: none;
+          display: inline-block;
+          white-space: nowrap;
+          word-wrap: normal;
+          direction: ltr;
+          -webkit-font-feature-settings: 'liga';
+          -webkit-font-smoothing: antialiased;
+          font-variation-settings: 'FILL' 0;
+          vertical-align: top;
+        }
+        :host([small]) {
+          font-size: 16px;
+          position: relative;
+          top: 2px;
+        }
+        :host([filled]) {
+          font-variation-settings: 'FILL' 1;
+        }
+        /* This is the trick such that the name of the icon doesn't appear in
+         * search
+         */
+        :host::before {
+          content: attr(icon);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html``;
+  }
+}
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 dc2cbc7..65b1778 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.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 '@polymer/iron-icon/iron-icon';
 import '@polymer/iron-iconset-svg/iron-iconset-svg';
@@ -174,6 +163,12 @@
       <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>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:new_releases -->
+      <g id="new"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M23 12l-2.44-2.78.34-3.68-3.61-.82-1.89-3.18L12 3 8.6 1.54 6.71 4.72l-3.61.81.34 3.68L1 12l2.44 2.78-.34 3.69 3.61.82 1.89 3.18L12 21l3.4 1.46 1.89-3.18 3.61-.82-.34-3.68L23 12zm-4.51 2.11l.26 2.79-2.74.62-1.43 2.41L12 18.82l-2.58 1.11-1.43-2.41-2.74-.62.26-2.8L3.66 12l1.85-2.12-.26-2.78 2.74-.61 1.43-2.41L12 5.18l2.58-1.11 1.43 2.41 2.74.62-.26 2.79L20.34 12l-1.85 2.11zM11 15h2v2h-2zm0-8h2v6h-2z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:arrow_right_alt -->
+      <g id="arrow-right"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14 6l-1.41 1.41L16.17 11H4v2h12.17l-3.58 3.59L14 18l6-6z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:cancel -->
+      <g id="cancel"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z"/></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 3add34d..7ab4689 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
@@ -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 {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {Side} from '../../../constants/constants';
@@ -43,7 +32,10 @@
     this.reporting.trackApi(this.plugin, 'annotation', 'setCoverageProvider');
     if (this.coverageProvider) {
       this.reporting.error(
-        new Error(`Overwriting cov provider: ${this.plugin.getPluginName()}`)
+        'Annotation Plugin',
+        new Error(
+          `Overwriting coverage provider: ${this.plugin.getPluginName()}`
+        )
       );
     }
     this.coverageProvider = coverageProvider;
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 1088b27..f103b60 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
@@ -1,22 +1,11 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma.js';
-import '../../change/gr-change-actions/gr-change-actions.js';
+import '../../../test/common-test-setup';
+import '../../change/gr-change-actions/gr-change-actions';
+import {assert} from '@open-wc/testing';
 
 suite('gr-annotation-actions-js-api tests', () => {
   let annotationActions;
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 f919898..0bd491c 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
@@ -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 {getBaseUrl} from '../../../utils/url-util';
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.ts
index 865aa20..9646bcd 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.ts
@@ -1,23 +1,12 @@
 /**
  * @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 '../../../test/common-test-setup';
 import './gr-js-api-interface';
 import {getPluginNameFromUrl} from './gr-api-utils';
+import {assert} from '@open-wc/testing';
 
 suite('gr-api-utils tests', () => {
   suite('test getPluginNameFromUrl', () => {
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 82528cd..b8bfd21 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
@@ -1,18 +1,7 @@
 /**
  * @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 {PluginApi, TargetElement} from '../../../api/plugin';
 import {ActionInfo, RequireProperties} from '../../../types/common';
@@ -33,6 +22,7 @@
   __primary?: boolean;
   __type: ActionType;
   icon?: string;
+  filled?: boolean;
 }
 
 // This interface is required to avoid circular dependencies between files;
@@ -87,7 +77,10 @@
    */
   private setEl(el?: GrChangeActionsElement) {
     if (!el) {
-      this.reporting.error(new Error('changeActions() API is not ready'));
+      this.reporting.error(
+        'GrChangeActionsInterface',
+        new Error('changeActions() API is not ready')
+      );
       return;
     }
     this.el = el;
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
index 4dfc638..df9adc9 100644
--- 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import '../../change/gr-change-actions/gr-change-actions';
 import {
   query,
@@ -25,7 +13,7 @@
 } 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 {fixture, html, assert} from '@open-wc/testing';
 import {PluginApi} from '../../../api/plugin';
 import {
   ActionType,
@@ -33,9 +21,9 @@
   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';
+import {GrIcon} from '../gr-icon/gr-icon';
 
 suite('gr-change-actions-js-api-interface tests', () => {
   let element: GrChangeActions;
@@ -150,15 +138,12 @@
       changeActions.setLabel(key, 'Yo');
       changeActions.setTitle(key, 'Yo hint');
       changeActions.setEnabled(key, false);
-      changeActions.setIcon(key, 'pupper');
+      changeActions.setIcon(key, 'hive');
       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'
-      );
+      assert.equal(queryAndAssert<GrIcon>(button, 'gr-icon').icon, 'hive');
     });
 
     test('hide action buttons', async () => {
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 b93bc4a..5277d90 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
@@ -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 {GrReplyDialog} from '../../change/gr-reply-dialog/gr-reply-dialog';
 import {PluginApi, TargetElement} from '../../../api/plugin';
 import {JsApiService} from './gr-js-api-types';
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
deleted file mode 100644
index 52d6ab3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
+++ /dev/null
@@ -1,87 +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-reply-dialog/gr-reply-dialog.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-reply-dialog');
-
-suite('gr-change-reply-js-api tests', () => {
-  let element;
-  let changeReply;
-  let plugin;
-
-  setup(() => {
-    stubRestApi('getAccount').returns(Promise.resolve(null));
-  });
-
-  suite('early init', () => {
-    setup(() => {
-      window.Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeReply = plugin.changeReply();
-      element = basicFixture.instantiate();
-    });
-
-    teardown(() => {
-      changeReply = null;
-    });
-
-    test('works', () => {
-      sinon.stub(element, 'getLabelValue').returns('+123');
-      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
-
-      sinon.stub(element, 'setLabelValue');
-      changeReply.setLabelValue('My-Label', '+1337');
-      assert.isTrue(
-          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
-
-      sinon.stub(element, 'setPluginMessage');
-      changeReply.showMessage('foobar');
-      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
-    });
-  });
-
-  suite('normal init', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      window.Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeReply = plugin.changeReply();
-    });
-
-    teardown(() => {
-      changeReply = null;
-    });
-
-    test('works', () => {
-      sinon.stub(element, 'getLabelValue').returns('+123');
-      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
-
-      sinon.stub(element, 'setLabelValue');
-      changeReply.setLabelValue('My-Label', '+1337');
-      assert.isTrue(
-          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
-
-      sinon.stub(element, 'setPluginMessage');
-      changeReply.showMessage('foobar');
-      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts
new file mode 100644
index 0000000..1d47d37
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../../change/gr-reply-dialog/gr-reply-dialog';
+import {stubElement} from '../../../test/test-utils';
+import {assert} from '@open-wc/testing';
+import {PluginApi} from '../../../api/plugin';
+import {ChangeReplyPluginApi} from '../../../api/change-reply';
+
+suite('gr-change-reply-js-api tests', () => {
+  let changeReply: ChangeReplyPluginApi;
+  let plugin: PluginApi;
+
+  suite('early init', () => {
+    setup(async () => {
+      window.Gerrit.install(
+        p => {
+          plugin = p;
+        },
+        '0.1',
+        'http://test.com/plugins/testplugin/static/test.js'
+      );
+      changeReply = plugin.changeReply();
+    });
+
+    test('works', () => {
+      stubElement('gr-reply-dialog', 'getLabelValue').returns('+123');
+      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+      const setLabelValueStub = stubElement('gr-reply-dialog', 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert.isTrue(setLabelValueStub.calledWithExactly('My-Label', '+1337'));
+
+      const setPluginMessageStub = stubElement(
+        'gr-reply-dialog',
+        'setPluginMessage'
+      );
+      changeReply.showMessage('foobar');
+      assert.isTrue(setPluginMessageStub.calledWithExactly('foobar'));
+    });
+  });
+
+  suite('normal init', () => {
+    setup(async () => {
+      window.Gerrit.install(
+        p => {
+          plugin = p;
+        },
+        '0.1',
+        'http://test.com/plugins/testplugin/static/test.js'
+      );
+      changeReply = plugin.changeReply();
+    });
+
+    test('works', () => {
+      stubElement('gr-reply-dialog', 'getLabelValue').returns('+123');
+      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+      const setLabelValueStub = stubElement('gr-reply-dialog', 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert.isTrue(setLabelValueStub.calledWithExactly('My-Label', '+1337'));
+
+      const setPluginMessageStub = stubElement(
+        'gr-reply-dialog',
+        'setPluginMessage'
+      );
+      changeReply.showMessage('foobar');
+      assert.isTrue(setPluginMessageStub.calledWithExactly('foobar'));
+    });
+  });
+});
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 0cce83c..4900ed5 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
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 /**
@@ -32,7 +21,6 @@
   EventCallback,
   EventEmitterService,
 } from '../../../services/gr-event-interface/gr-event-interface';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {Gerrit} from '../../../api/gerrit';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
@@ -41,6 +29,7 @@
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {assertIsDefined} from '../../../utils/common-util';
+import {iconStyles} from '../../../styles/gr-icon-styles';
 
 /**
  * These are the methods and properties that are exposed explicitly in the
@@ -77,7 +66,6 @@
   _customStyleSheet?: CSSStyleSheet;
 
   // exposed methods
-  Nav: typeof GerritNav;
   Auth: AuthService;
 }
 
@@ -122,8 +110,6 @@
 class GerritImpl implements GerritInternal {
   _customStyleSheet?: CSSStyleSheet;
 
-  public readonly Nav = GerritNav;
-
   public readonly Auth: AuthService;
 
   private readonly reportingService: ReportingService;
@@ -135,6 +121,7 @@
   public readonly styles = {
     font: fontStyles,
     form: formStyles,
+    icon: iconStyles,
     menuPage: menuPageStyles,
     spinner: spinnerStyles,
     subPage: subpageStyles,
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
index fbd5107..b906891 100644
--- 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
@@ -1,21 +1,9 @@
 /**
  * @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 '../../../test/common-test-setup';
 import {getPluginLoader} from './gr-plugin-loader';
 import {resetPlugins} from '../../../test/test-utils';
 import {
@@ -27,6 +15,7 @@
 import {GrJsApiInterface} from './gr-js-api-interface-element';
 import {SinonFakeTimers} from 'sinon';
 import {Timestamp} from '../../../api/rest-api';
+import {assert} from '@open-wc/testing';
 
 suite('gr-gerrit tests', () => {
   let element: GrJsApiInterface;
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 3fba6f5..353fc2a 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
@@ -1,18 +1,7 @@
 /**
  * @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 {getPluginLoader} from './gr-plugin-loader';
 import {hasOwnProperty} from '../../../utils/common-util';
@@ -100,8 +89,8 @@
         return callback(change, revision) === false;
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('canSubmitChange callback error'),
-          undefined,
           err
         );
       }
@@ -125,8 +114,8 @@
         cb(detail.path);
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('handleHistory callback error'),
-          undefined,
           err
         );
       }
@@ -170,8 +159,8 @@
         cb(change, revision, info);
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('showChange callback error'),
-          undefined,
           err
         );
       }
@@ -187,8 +176,8 @@
         cb(detail.revisionActions, detail.change);
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('showRevisionActions callback error'),
-          undefined,
           err
         );
       }
@@ -201,8 +190,8 @@
         cb(change, msg);
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('commitMessage callback error'),
-          undefined,
           err
         );
       }
@@ -216,8 +205,8 @@
         cb(detail.node);
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('comment callback error'),
-          undefined,
           err
         );
       }
@@ -230,8 +219,8 @@
         cb(detail.change);
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('labelChange callback error'),
-          undefined,
           err
         );
       }
@@ -244,8 +233,8 @@
         cb(detail.hljs);
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('HighlightjsLoaded callback error'),
-          undefined,
           err
         );
       }
@@ -258,8 +247,8 @@
         revertMsg = cb(change, revertMsg, origMsg) as string;
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('modifyRevertMsg callback error'),
-          undefined,
           err
         );
       }
@@ -281,8 +270,8 @@
         ) as string;
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('modifyRevertSubmissionMsg callback error'),
-          undefined,
           err
         );
       }
@@ -299,8 +288,8 @@
         if (layer) layers.push(layer);
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('getDiffLayers callback error'),
-          undefined,
           err
         );
       }
@@ -315,8 +304,8 @@
         annotationApi.disposeLayer(path);
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('disposeDiffLayers callback error'),
-          undefined,
           err
         );
       }
@@ -365,8 +354,8 @@
         }
       } catch (err: unknown) {
         this.reporting.error(
+          'GrJsApiInterface',
           new Error('getReviewPostRevert callback error'),
-          undefined,
           err
         );
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
index a05e7e1..240bc0b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import './gr-js-api-interface-element';
 import './gr-public-js-api';
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 c45bbf5..4fc403d 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
@@ -1,29 +1,21 @@
 /**
  * @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 './gr-js-api-interface.js';
-import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
-import {EventType} from '../../../api/plugin.js';
-import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
-import {getPluginLoader} from './gr-plugin-loader.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {getAppContext} from '../../../services/app-context.js';
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
+import {EventType} from '../../../api/plugin';
+import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils';
+import {getPluginLoader} from './gr-plugin-loader';
+import {
+  stubRestApi,
+  stubBaseUrl,
+  waitEventLoop,
+} from '../../../test/test-utils';
+import {getAppContext} from '../../../services/app-context';
+import {assert} from '@open-wc/testing';
 
 suite('GrJsApiInterface tests', () => {
   let element;
@@ -145,7 +137,7 @@
     // Timeout on loading plugins
     clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
 
-    await flush();
+    await waitEventLoop();
     assert.isTrue(spy.called);
   });
 
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 7e6a0c7..7aad2f0 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
@@ -1,18 +1,7 @@
 /**
  * @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 {
   ActionInfo,
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 2f9bbcf..0628d2f 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
@@ -1,22 +1,10 @@
 /**
  * @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 {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
-import {ShowAlertEventDetail} from '../../../types/events';
+import {EventType, ShowAlertEventDetail} from '../../../types/events';
 import {PluginApi} from '../../../api/plugin';
 import {UIActionInfo} from './gr-change-actions-js-api';
 import {windowLocationReload} from '../../../utils/dom-util';
@@ -112,6 +100,7 @@
     if (!this.action.method) return;
     if (!this.action.__url) {
       this.reporting.error(
+        'GrPluginActionContext',
         new Error(`Unable to ${this.action.method} to ${this.action.__key}!`)
       );
       return;
@@ -122,7 +111,7 @@
       .then(onSuccess)
       .catch((error: unknown) => {
         document.dispatchEvent(
-          new CustomEvent<ShowAlertEventDetail>('show-alert', {
+          new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
             detail: {
               message: `Plugin network error: ${error}`,
             },
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 d4b93a7..a401351 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
@@ -1,24 +1,14 @@
 /**
  * @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 '../../../test/common-test-setup-karma.js';
-import './gr-js-api-interface.js';
-import {GrPluginActionContext} from './gr-plugin-action-context.js';
-import {addListenerForTest} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {GrPluginActionContext} from './gr-plugin-action-context';
+import {addListenerForTest, waitEventLoop} from '../../../test/test-utils';
+import {EventType} from '../../../types/events';
+import {assert} from '@open-wc/testing';
 
 suite('gr-plugin-action-context tests', () => {
   let instance;
@@ -39,7 +29,7 @@
     sinon.stub(plugin, 'popup').returns(Promise.resolve(popupApiStub));
     const el = document.createElement('span');
     instance.popup(el);
-    await flush();
+    await waitEventLoop();
     assert.isTrue(popupApiStub._getElement.called);
     instance.hide();
     assert.isTrue(popupApiStub.close.called);
@@ -80,9 +70,9 @@
       document.body.appendChild(button);
     });
 
-    test('click', () => {
-      MockInteractions.tap(button);
-      flush();
+    test('click', async () => {
+      button.click();
+      await waitEventLoop();
       assert.isTrue(clickStub.called);
       assert.equal(button.textContent, 'foo');
     });
@@ -135,9 +125,9 @@
       send: sendStub,
     });
     const errorStub = sinon.stub();
-    addListenerForTest(document, 'show-alert', errorStub);
+    addListenerForTest(document, EventType.SHOW_ALERT, errorStub);
     instance.call();
-    await flush();
+    await waitEventLoop();
     assert.isTrue(errorStub.calledOnce);
     assert.equal(errorStub.args[0][0].detail.message,
         'Plugin network error: Error: boom');
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 bb0287a..9ced917 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
@@ -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 {PluginApi} from '../../../api/plugin';
 import {notUndefined} from '../../../types/types';
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 16846f4..15e19e6 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
@@ -1,25 +1,15 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {resetPlugins} from '../../../test/test-utils';
 import './gr-js-api-interface';
 import {GrPluginEndpoints} from './gr-plugin-endpoints';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
+import {assert} from '@open-wc/testing';
 
 export class MockHook<T extends PluginElement> implements HookApi<T> {
   handleInstanceDetached(_: T) {}
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 52022b7..4a90314 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
@@ -1,18 +1,7 @@
 /**
  * @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 {getAppContext} from '../../../services/app-context';
 import {
@@ -143,7 +132,11 @@
       try {
         url = new URL(url);
       } catch (e: unknown) {
-        this._getReporting().error(new Error('url parse error'), undefined, e);
+        this._getReporting().error(
+          'GrPluginLoader',
+          new Error('url parse error'),
+          e
+        );
         return false;
       }
     }
@@ -195,8 +188,8 @@
         this._failToLoad(`${e.name}: ${e.message}`, src);
       } else {
         this._getReporting().error(
+          'GrPluginLoader',
           new Error('plugin callback error'),
-          undefined,
           e
         );
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
index 4bfa0eb..3005c37 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
@@ -1,28 +1,22 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {
+  resetPlugins,
+  stubBaseUrl,
+  waitEventLoop,
+} 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';
+import {EventType} from '../../../types/events';
+import {assert} from '@open-wc/testing';
 
 suite('gr-plugin-loader tests', () => {
   let plugin: PluginApi;
@@ -84,11 +78,11 @@
     );
     pluginsLoadedStub.reset();
     (window.Gerrit as any)._loadPlugins([]);
-    await flush();
+    await waitEventLoop();
     assert.isTrue(pluginsLoadedStub.called);
   });
 
-  test('arePluginsLoaded', () => {
+  test('arePluginsLoaded', async () => {
     assert.isFalse(pluginLoader.arePluginsLoaded());
     const plugins = [
       'http://test.com/plugins/foo/static/test.js',
@@ -100,7 +94,7 @@
     // Timeout on loading plugins
     clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
 
-    flush();
+    await waitEventLoop();
     assert.isTrue(pluginLoader.arePluginsLoaded());
   });
 
@@ -119,12 +113,12 @@
     ];
     pluginLoader.loadPlugins(plugins);
 
-    await flush();
+    await waitEventLoop();
     assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
     assert.isTrue(pluginLoader.arePluginsLoaded());
   });
 
-  test('isPluginEnabled and isPluginLoaded', () => {
+  test('isPluginEnabled and isPluginLoaded', async () => {
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
       window.Gerrit.install(() => void 0, undefined, url);
     });
@@ -139,7 +133,7 @@
       plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
     );
 
-    flush();
+    await waitEventLoop();
     assert.isTrue(pluginLoader.arePluginsLoaded());
     assert.isTrue(plugins.every(plugin => pluginLoader.isPluginLoaded(plugin)));
   });
@@ -151,7 +145,7 @@
     ];
 
     const alertStub = sinon.stub();
-    addListenerForTest(document, 'show-alert', alertStub);
+    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
       window.Gerrit.install(
@@ -172,7 +166,7 @@
 
     pluginLoader.loadPlugins(plugins);
 
-    await flush();
+    await waitEventLoop();
     assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
     assert.isTrue(pluginLoader.arePluginsLoaded());
     assert.isTrue(alertStub.calledOnce);
@@ -185,7 +179,7 @@
     ];
 
     const alertStub = sinon.stub();
-    addListenerForTest(document, 'show-alert', alertStub);
+    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
       window.Gerrit.install(
@@ -209,7 +203,7 @@
       plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
     );
 
-    await flush();
+    await waitEventLoop();
     assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
     assert.isTrue(pluginLoader.arePluginsLoaded());
     assert.isTrue(alertStub.calledOnce);
@@ -224,7 +218,7 @@
     ];
 
     const alertStub = sinon.stub();
-    addListenerForTest(document, 'show-alert', alertStub);
+    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
       window.Gerrit.install(
@@ -243,7 +237,7 @@
 
     pluginLoader.loadPlugins(plugins);
 
-    await flush();
+    await waitEventLoop();
     assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
     assert.isTrue(pluginLoader.arePluginsLoaded());
     assert.isTrue(alertStub.calledTwice);
@@ -256,7 +250,7 @@
     ];
 
     const alertStub = sinon.stub();
-    addListenerForTest(document, 'show-alert', alertStub);
+    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
       window.Gerrit.install(() => {}, url === plugins[0] ? '' : 'alpha', url);
@@ -269,7 +263,7 @@
 
     pluginLoader.loadPlugins(plugins);
 
-    await flush();
+    await waitEventLoop();
     assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
     assert.isTrue(pluginLoader.arePluginsLoaded());
     assert.isTrue(alertStub.calledOnce);
@@ -291,7 +285,7 @@
     ];
     pluginLoader.loadPlugins(plugins);
 
-    await flush();
+    await waitEventLoop();
     assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
     assert.isTrue(pluginLoader.arePluginsLoaded());
   });
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 3c2842d..b4f7324 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
@@ -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 {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
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
deleted file mode 100644
index 730f163..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
+++ /dev/null
@@ -1,131 +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-js-api-interface.js';
-import {GrPluginRestApi} from './gr-plugin-rest-api.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-suite('gr-plugin-rest-api tests', () => {
-  let instance;
-  let getResponseObjectStub;
-  let sendStub;
-
-  setup(() => {
-    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
-    getResponseObjectStub = stubRestApi('getResponseObject').returns(
-        Promise.resolve());
-    sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
-    window.Gerrit.install(p => {}, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    instance = new GrPluginRestApi();
-  });
-
-  test('fetch', () => {
-    const payload = {foo: 'foo'};
-    return instance.fetch('HTTP_METHOD', '/url', payload).then(r => {
-      assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
-      assert.equal(r.status, 200);
-      assert.isFalse(getResponseObjectStub.called);
-    });
-  });
-
-  test('send', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return instance.send('HTTP_METHOD', '/url', payload).then(r => {
-      assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('get', () => {
-    const response = {foo: 'foo'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return instance.get('/url').then(r => {
-      assert.isTrue(sendStub.calledWith('GET', '/url'));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('post', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return instance.post('/url', payload).then(r => {
-      assert.isTrue(sendStub.calledWith('POST', '/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('put', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return instance.put('/url', payload).then(r => {
-      assert.isTrue(sendStub.calledWith('PUT', '/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('delete works', () => {
-    const response = {status: 204};
-    sendStub.returns(Promise.resolve(response));
-    return instance.delete('/url').then(r => {
-      assert.isTrue(sendStub.calledWith('DELETE', '/url'));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('delete fails', () => {
-    sendStub.returns(Promise.resolve(
-        {status: 400, text() { return Promise.resolve('text'); }}));
-    return instance.delete('/url').then(r => {
-      throw new Error('Should not resolve');
-    })
-        .catch(err => {
-          assert.isTrue(sendStub.calledWith('DELETE', '/url'));
-          assert.equal('text', err.message);
-        });
-  });
-
-  test('getLoggedIn', () => {
-    const stub = stubRestApi('getLoggedIn').returns(Promise.resolve(true));
-    return instance.getLoggedIn().then(result => {
-      assert.isTrue(stub.calledOnce);
-      assert.isTrue(result);
-    });
-  });
-
-  test('getVersion', () => {
-    const stub = stubRestApi('getVersion').returns(Promise.resolve('foo bar'));
-    return instance.getVersion().then(result => {
-      assert.isTrue(stub.calledOnce);
-      assert.equal(result, 'foo bar');
-    });
-  });
-
-  test('getConfig', () => {
-    const stub = stubRestApi('getConfig').returns(Promise.resolve('foo bar'));
-    return instance.getConfig().then(result => {
-      assert.isTrue(stub.calledOnce);
-      assert.equal(result, 'foo bar');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
new file mode 100644
index 0000000..d6d7fc2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
@@ -0,0 +1,122 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {GrPluginRestApi} from './gr-plugin-rest-api';
+import {assertFails, stubRestApi} from '../../../test/test-utils';
+import {assert} from '@open-wc/testing';
+import {PluginApi} from '../../../api/plugin';
+import {
+  createAccountDetailWithId,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {HttpMethod} from '../../../api/rest-api';
+
+suite('gr-plugin-rest-api tests', () => {
+  let instance: GrPluginRestApi;
+  let getResponseObjectStub: sinon.SinonStub;
+  let sendStub: sinon.SinonStub;
+
+  setup(() => {
+    stubRestApi('getAccount').resolves(createAccountDetailWithId());
+    getResponseObjectStub = stubRestApi('getResponseObject').resolves();
+    sendStub = stubRestApi('send').resolves({...new Response(), status: 200});
+    let pluginApi: PluginApi;
+    window.Gerrit.install(
+      p => {
+        pluginApi = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    instance = new GrPluginRestApi(pluginApi!);
+  });
+
+  test('fetch', async () => {
+    const payload = {foo: 'foo'};
+    const r = await instance.fetch(HttpMethod.POST, '/url', payload);
+    assert.isTrue(sendStub.calledWith(HttpMethod.POST, '/url', payload));
+    assert.equal(r.status, 200);
+    assert.isFalse(getResponseObjectStub.called);
+  });
+
+  test('send', async () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.resolves(response);
+    const r = await instance.send(HttpMethod.POST, '/url', payload);
+    assert.isTrue(sendStub.calledWith(HttpMethod.POST, '/url', payload));
+    assert.strictEqual(r, response);
+  });
+
+  test('get', async () => {
+    const response = {foo: 'foo'};
+    getResponseObjectStub.resolves(response);
+    const r = await instance.get('/url');
+    assert.isTrue(sendStub.calledWith('GET', '/url'));
+    assert.strictEqual(r, response);
+  });
+
+  test('post', async () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.resolves(response);
+    const r = await instance.post('/url', payload);
+    assert.isTrue(sendStub.calledWith('POST', '/url', payload));
+    assert.strictEqual(r, response);
+  });
+
+  test('put', async () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.resolves(response);
+    const r = await instance.put('/url', payload);
+    assert.isTrue(sendStub.calledWith('PUT', '/url', payload));
+    assert.strictEqual(r, response);
+  });
+
+  test('delete works', async () => {
+    const response = {status: 204};
+    sendStub.resolves(response);
+    const r = await instance.delete('/url');
+    assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+    assert.strictEqual(r, response);
+  });
+
+  test('delete fails', async () => {
+    sendStub.resolves({
+      status: 400,
+      text() {
+        return Promise.resolve('text');
+      },
+    });
+    const error = await assertFails(instance.delete('/url'));
+    assert.equal('text', (error as Error).message);
+    assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+  });
+
+  test('getLoggedIn', async () => {
+    const stub = stubRestApi('getLoggedIn').resolves(true);
+    const loggedIn = await instance.getLoggedIn();
+    assert.isTrue(stub.calledOnce);
+    assert.isTrue(loggedIn);
+  });
+
+  test('getVersion', async () => {
+    const stub = stubRestApi('getVersion').resolves('foo bar');
+    const version = await instance.getVersion();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(version, 'foo bar');
+  });
+
+  test('getConfig', async () => {
+    const info = createServerInfo();
+    const stub = stubRestApi('getConfig').resolves(info);
+    const config = await instance.getConfig();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(config, info);
+  });
+});
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 2ece82c..21ab10a 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
@@ -1,18 +1,7 @@
 /**
  * @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 {getBaseUrl} from '../../../utils/url-util';
 import {GrAttributeHelper} from '../../plugins/gr-attribute-helper/gr-attribute-helper';
@@ -82,6 +71,7 @@
 
     if (!url) {
       this.report.error(
+        'Plugin constructor',
         new Error(
           'Plugin not being loaded from /plugins base path. Unable to determine name.'
         )
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 d600101..fab0e6c 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
@@ -1,20 +1,8 @@
 /**
  * @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 {getAppContext} from '../../../services/app-context';
 import {PluginApi} from '../../../api/plugin';
 import {EventDetails, ReportingPluginApi} from '../../../api/reporting';
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
index c96a075..8e4edd6 100644
--- 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
@@ -1,27 +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
  */
-
-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';
+import '../../../test/common-test-setup';
+import '../../change/gr-reply-dialog/gr-reply-dialog';
+import {getAppContext} from '../../../services/app-context';
+import {stubRestApi} from '../../../test/test-utils';
+import {PluginApi} from '../../../api/plugin';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {ReportingPluginApi} from '../../../api/reporting';
+import {assert} from '@open-wc/testing';
 
 suite('gr-reporting-js-api tests', () => {
   let plugin: PluginApi;
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 13375b3..47d722d 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
@@ -1,48 +1,31 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/gr-font-styles';
 import '../../../styles/gr-voting-styles';
 import '../../../styles/shared-styles';
+import '../gr-icon/gr-icon';
 import '../gr-vote-chip/gr-vote-chip';
-import '../gr-account-label/gr-account-label';
 import '../gr-account-chip/gr-account-chip';
 import '../gr-button/gr-button';
-import '../gr-icons/gr-icons';
-import '../gr-label/gr-label';
 import '../gr-tooltip-content/gr-tooltip-content';
 import {
   AccountInfo,
   LabelInfo,
   ApprovalInfo,
   AccountId,
-  isQuickLabelInfo,
   isDetailedLabelInfo,
-  LabelNameToInfoMap,
 } from '../../../types/common';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {GrButton} from '../gr-button/gr-button';
 import {
   canVote,
   getApprovalInfo,
-  getVotingRangeOrDefault,
   hasNeutralStatus,
   hasVoted,
-  showNewSubmitRequirements,
   valueString,
 } from '../../../utils/label-util';
 import {getAppContext} from '../../../services/app-context';
@@ -50,7 +33,7 @@
 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 {ifDefined} from 'lit/directives/if-defined.js';
 import {fireReload} from '../../../utils/event-util';
 import {sortReviewers} from '../../../utils/attention-set-util';
 
@@ -60,19 +43,6 @@
   }
 }
 
-enum LabelClassName {
-  NEGATIVE = 'negative',
-  POSITIVE = 'positive',
-  MIN = 'min',
-  MAX = 'max',
-}
-
-interface FormattedLabel {
-  className?: LabelClassName;
-  account: ApprovalInfo | AccountInfo;
-  value: string;
-}
-
 @customElement('gr-label-info')
 export class GrLabelInfo extends LitElement {
   @property({type: Object})
@@ -106,8 +76,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
 
@@ -117,9 +85,6 @@
       fontStyles,
       votingStyles,
       css`
-        .placeholder {
-          color: var(--deemphasized-text-color);
-        }
         .hidden {
           display: none;
         }
@@ -131,33 +96,6 @@
           margin-right: var(--spacing-s);
           padding: 1px;
         }
-        .max {
-          background-color: var(--vote-color-approved);
-        }
-        .min {
-          background-color: var(--vote-color-rejected);
-        }
-        .positive {
-          background-color: var(--vote-color-recommended);
-          border-radius: 12px;
-          border: 1px solid var(--vote-outline-recommended);
-          color: var(--chip-color);
-        }
-        .negative {
-          background-color: var(--vote-color-disliked);
-          border-radius: 12px;
-          border: 1px solid var(--vote-outline-disliked);
-          color: var(--chip-color);
-        }
-        .hidden {
-          display: none;
-        }
-        td {
-          vertical-align: top;
-        }
-        tr {
-          min-height: var(--line-height-normal);
-        }
         gr-tooltip-content {
           display: block;
         }
@@ -169,19 +107,11 @@
           width: var(--line-height-normal);
           padding: 0;
         }
-        gr-button[disabled] iron-icon {
+        gr-button[disabled] gr-icon {
           color: var(--border-color);
         }
-        gr-account-label {
-          --account-max-length: 100px;
-          margin-right: var(--spacing-xs);
-        }
-        iron-icon {
-          height: calc(var(--line-height-normal) - 2px);
-          width: calc(var(--line-height-normal) - 2px);
-        }
-        .labelValueContainer:not(:first-of-type) td {
-          padding-top: var(--spacing-s);
+        gr-icon {
+          font-size: calc(var(--line-height-normal) - 2px);
         }
         .reviewer-row {
           padding-top: var(--spacing-s);
@@ -207,14 +137,6 @@
   }
 
   override render() {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return this.renderNewSubmitRequirements();
-    } else {
-      return this.renderOldSubmitRequirements();
-    }
-  }
-
-  private renderNewSubmitRequirements() {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
     const reviewers = (this.change?.reviewers['REVIEWER'] ?? [])
@@ -237,23 +159,6 @@
     </div>`;
   }
 
-  private renderOldSubmitRequirements() {
-    const labelInfo = this.labelInfo;
-    return html` <p
-        class="placeholder ${this.computeShowPlaceholder(
-          labelInfo,
-          this.change?.labels
-        )}"
-      >
-        No votes
-      </p>
-      <table>
-        ${this.mapLabelInfo(labelInfo, this.account, this.change?.labels).map(
-          mappedLabel => this.renderLabel(mappedLabel)
-        )}
-      </table>`;
-  }
-
   renderReviewerVote(reviewer: AccountInfo) {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
@@ -284,35 +189,11 @@
     </div>`;
   }
 
-  renderLabel(mappedLabel: FormattedLabel) {
-    const {labelInfo, change} = this;
-    return html` <tr class="labelValueContainer">
-      <td>
-        <gr-tooltip-content
-          has-tooltip
-          title=${this._computeValueTooltip(labelInfo, mappedLabel.value)}
-        >
-          <gr-label class="${mappedLabel.className} voteChip font-small">
-            ${mappedLabel.value}
-          </gr-label>
-        </gr-tooltip-content>
-      </td>
-      <td>
-        <gr-account-label
-          clickable
-          .account=${mappedLabel.account}
-          .change=${change}
-        ></gr-account-label>
-      </td>
-      <td>${this.renderRemoveVote(mappedLabel.account)}</td>
-    </tr>`;
-  }
-
   private renderVoteAbility(reviewer: AccountInfo) {
     if (this.labelInfo && isDetailedLabelInfo(this.labelInfo)) {
       const approvalInfo = getApprovalInfo(this.labelInfo, reviewer);
       if (approvalInfo?.permitted_voting_range) {
-        const {min, max} = approvalInfo?.permitted_voting_range;
+        const {min, max} = approvalInfo.permitted_voting_range;
         return html`<span class="no-votes"
           >Can vote ${valueString(min)}/${valueString(max)}</span
         >`;
@@ -334,89 +215,12 @@
           this.change
         )}"
       >
-        <iron-icon icon="gr-icons:delete"></iron-icon>
+        <gr-icon icon="delete" filled></gr-icon>
       </gr-button>
     </gr-tooltip-content>`;
   }
 
   /**
-   * This method also listens on change.labels.*,
-   * to trigger computation when a label is removed from the change.
-   *
-   * The third parameter is just for *triggering* computation.
-   */
-  private mapLabelInfo(
-    labelInfo?: LabelInfo,
-    account?: AccountInfo,
-    _?: LabelNameToInfoMap
-  ): FormattedLabel[] {
-    const result: FormattedLabel[] = [];
-    if (!labelInfo) {
-      return result;
-    }
-    if (!isDetailedLabelInfo(labelInfo)) {
-      if (
-        isQuickLabelInfo(labelInfo) &&
-        (labelInfo.rejected || labelInfo.approved)
-      ) {
-        const ok = labelInfo.approved || !labelInfo.rejected;
-        return [
-          {
-            value: ok ? '👍️' : '👎️',
-            className: ok ? LabelClassName.POSITIVE : LabelClassName.NEGATIVE,
-            // executed only if approved or rejected is not undefined
-            account: ok ? labelInfo.approved! : labelInfo.rejected!,
-          },
-        ];
-      }
-      return result;
-    }
-
-    // Sort votes by positivity.
-    // TODO(TS): maybe mark value as required if always present
-    const votes = (labelInfo.all || []).sort(
-      (a, b) => (a.value || 0) - (b.value || 0)
-    );
-    const votingRange = getVotingRangeOrDefault(labelInfo);
-    for (const label of votes) {
-      if (
-        label.value &&
-        (!isQuickLabelInfo(labelInfo) ||
-          label.value !== labelInfo.default_value)
-      ) {
-        let labelClassName;
-        let labelValPrefix = '';
-        if (label.value > 0) {
-          labelValPrefix = '+';
-          if (label.value === votingRange.max) {
-            labelClassName = LabelClassName.MAX;
-          } else {
-            labelClassName = LabelClassName.POSITIVE;
-          }
-        } else if (label.value < 0) {
-          if (label.value === votingRange.min) {
-            labelClassName = LabelClassName.MIN;
-          } else {
-            labelClassName = LabelClassName.NEGATIVE;
-          }
-        }
-        const formattedLabel: FormattedLabel = {
-          value: `${labelValPrefix}${label.value}`,
-          className: labelClassName,
-          account: label,
-        };
-        if (label._account_id === account?._account_id) {
-          // Put self-votes at the top.
-          result.unshift(formattedLabel);
-        } else {
-          result.push(formattedLabel);
-        }
-      }
-    }
-    return result;
-  }
-
-  /**
    * A user is able to delete a vote iff the mutable property is true and the
    * reviewer that left the vote exists in the list of removable_reviewers
    * received from the backend.
@@ -471,7 +275,7 @@
         }
       })
       .catch(err => {
-        this.reporting.error(err);
+        this.reporting.error('Delete vote', err);
         target.disabled = false;
         return;
       });
@@ -487,39 +291,4 @@
     }
     return labelInfo.values[score];
   }
-
-  /**
-   * This method also listens change.labels.* in
-   * order to trigger computation when a label is removed from the change.
-   *
-   * The second parameter is just for *triggering* computation.
-   */
-  private computeShowPlaceholder(
-    labelInfo?: LabelInfo,
-    _?: LabelNameToInfoMap
-  ) {
-    if (!labelInfo) {
-      return '';
-    }
-    if (
-      !isDetailedLabelInfo(labelInfo) &&
-      isQuickLabelInfo(labelInfo) &&
-      (labelInfo.rejected || labelInfo.approved)
-    ) {
-      return 'hidden';
-    }
-
-    if (isDetailedLabelInfo(labelInfo) && labelInfo.all) {
-      for (const label of labelInfo.all) {
-        if (
-          label.value &&
-          (!isQuickLabelInfo(labelInfo) ||
-            label.value !== labelInfo.default_value)
-        ) {
-          return 'hidden';
-        }
-      }
-    }
-    return '';
-  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
index 0ac49a7..67af61f 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
@@ -1,52 +1,78 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-label-info';
 import {
   isHidden,
   mockPromise,
-  queryAll,
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrLabelInfo} from './gr-label-info';
 import {GrButton} from '../gr-button/gr-button';
-import {GrLabel} from '../gr-label/gr-label';
 import {
   createAccountWithIdNameAndEmail,
+  createDetailedLabelInfo,
   createParsedChange,
 } from '../../../test/test-data-generators';
-import {LabelInfo} from '../../../types/common';
-import {GrAccountLabel} from '../gr-account-label/gr-account-label';
-
-const basicFixture = fixtureFromElement('gr-label-info');
+import {ApprovalInfo, LabelInfo} from '../../../types/common';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-label-info tests', () => {
   let element: GrLabelInfo;
   const account = createAccountWithIdNameAndEmail(5);
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-label-info></gr-label-info>`);
 
     // Needed to trigger computed bindings.
     element.account = {};
-    element.change = {...createParsedChange(), labels: {}};
+    element.change = {
+      ...createParsedChange(),
+      labels: {},
+      reviewers: {
+        REVIEWER: [account],
+        CC: [],
+      },
+    };
+    const approval: ApprovalInfo = {
+      value: 2,
+      _account_id: account._account_id,
+    };
+    element.labelInfo = {
+      ...createDetailedLabelInfo(),
+      all: [approval],
+    };
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div>
+        <div class="reviewer-row">
+          <gr-account-chip>
+            <gr-vote-chip circle-shape="" slot="vote-chip"> </gr-vote-chip>
+          </gr-account-chip>
+          <gr-tooltip-content has-tooltip="" title="Remove vote">
+            <gr-button
+              aria-disabled="false"
+              aria-label="Remove vote"
+              class="deleteBtn hidden"
+              data-account-id="5"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              <gr-icon icon="delete" filled></gr-icon>
+            </gr-button>
+          </gr-tooltip-content>
+        </div>
+      </div>`
+    );
   });
 
   suite('remove reviewer votes', () => {
@@ -62,6 +88,10 @@
       element.change = {
         ...createParsedChange(),
         labels: {'Code-Review': label},
+        reviewers: {
+          REVIEWER: [account],
+          CC: [],
+        },
       };
       element.labelInfo = label;
       element.label = 'Code-Review';
@@ -92,7 +122,7 @@
       element.mutable = true;
       const removeButton = queryAndAssert<GrButton>(element, 'gr-button');
 
-      MockInteractions.tap(removeButton);
+      removeButton.click();
       assert.isTrue(removeButton.disabled);
       mock.resolve();
       await deleteResponse;
@@ -108,101 +138,6 @@
     });
   });
 
-  suite('label color and order', () => {
-    test('valueless label rejected', async () => {
-      element.labelInfo = {rejected: {name: 'someone'}};
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('negative'));
-    });
-
-    test('valueless label approved', async () => {
-      element.labelInfo = {approved: {name: 'someone'}};
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('positive'));
-    });
-
-    test('-2 to +2', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 2, name: 'user 2'},
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 3'},
-          {value: -2, name: 'user 4'},
-        ],
-        values: {
-          '-2': 'Awful',
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-          '+2': 'Ready to submit',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-      assert.isTrue(labels[2].classList.contains('negative'));
-      assert.isTrue(labels[3].classList.contains('min'));
-    });
-
-    test('-1 to +1', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 2'},
-        ],
-        values: {
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('min'));
-    });
-
-    test('0 to +2', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 2'},
-          {value: 2, name: 'user '},
-        ],
-        values: {
-          ' 0': "Don't submit as-is",
-          '+1': 'No score',
-          '+2': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-    });
-
-    test('self votes at top', async () => {
-      const otherAccount = createAccountWithIdNameAndEmail(8);
-      element.account = account;
-      element.labelInfo = {
-        all: [
-          {...otherAccount, value: 1},
-          {...account, value: -1},
-        ],
-        values: {
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const chips = queryAll<GrAccountLabel>(element, 'gr-account-label');
-      assert.equal(chips[0].account!._account_id, element.account._account_id);
-    });
-  });
-
   test('_computeValueTooltip', () => {
     // Existing label.
     let labelInfo: LabelInfo = {values: {0: 'Baz'}};
@@ -218,49 +153,4 @@
     score = '0';
     assert.equal(element._computeValueTooltip(labelInfo, score), '');
   });
-
-  test('placeholder', async () => {
-    const values = {
-      '0': 'No score',
-      '+1': 'good',
-      '+2': 'excellent',
-      '-1': 'bad',
-      '-2': 'terrible',
-    };
-    element.labelInfo = {};
-    await element.updateComplete;
-    assert.isFalse(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {all: [], values};
-    await element.updateComplete;
-    assert.isFalse(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {rejected: account};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {rejected: account, all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {approved: account};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {approved: account, all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
deleted file mode 100644
index 842b35e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * @fileoverview Consider removing this element as
- * its functionality seems to be duplicated with gr-tooltip and only
- * used in gr-label-info.
- */
-
-import {html, LitElement} from 'lit';
-import {customElement} from 'lit/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-label': GrLabel;
-  }
-}
-
-@customElement('gr-label')
-export class GrLabel extends LitElement {
-  static override get styles() {
-    return [];
-  }
-
-  override render() {
-    return html` <slot></slot> `;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
deleted file mode 100644
index 94196df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
index df37497..33dc920 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
@@ -1,23 +1,12 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-autocomplete/gr-autocomplete';
 import '../../../styles/shared-styles';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
 import {
   GrAutocomplete,
   AutocompleteQuery,
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 ee3ddcb..a8f6ff2 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
@@ -1,33 +1,21 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-labeled-autocomplete';
 import {GrLabeledAutocomplete} from './gr-labeled-autocomplete';
 import {assertIsDefined} from '../../../utils/common-util';
-
-const basicFixture = fixtureFromElement('gr-labeled-autocomplete');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-labeled-autocomplete tests', () => {
   let element: GrLabeledAutocomplete;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-labeled-autocomplete></gr-labeled-autocomplete>`
+    );
   });
 
   test('tapping trigger focuses autocomplete', () => {
@@ -51,18 +39,21 @@
     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>
+    assert.shadowDom.equal(
+      element,
+      /* 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>
-      </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 fe69a32..583cf80 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
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 export interface LibraryConfig {
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts
index fe5b1b8..7e353f6 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts
@@ -1,21 +1,11 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
+import {waitEventLoop} from '../../../test/test-utils';
 import './gr-lib-loader';
 import {GrLibLoader} from './gr-lib-loader';
 
@@ -47,12 +37,12 @@
     grLibLoader.getLibrary(libraryConfig).then(loaded2);
 
     resolveLoad();
-    await flush();
+    await waitEventLoop();
 
     const lateLoaded = sinon.stub();
     grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
 
-    await flush();
+    await waitEventLoop();
 
     assert.isTrue(loaded1.calledOnce);
     assert.isTrue(loaded2.calledOnce);
@@ -69,12 +59,12 @@
     grLibLoader.getLibrary(libraryConfig).catch(failed2);
 
     rejectLoad();
-    await flush();
+    await waitEventLoop();
 
     const lateFailed = sinon.stub();
     grLibLoader.getLibrary(libraryConfig).catch(lateFailed);
 
-    await flush();
+    await waitEventLoop();
 
     assert.isTrue(failed1.calledOnce);
     assert.isTrue(failed2.calledOnce);
@@ -95,12 +85,12 @@
     grLibLoader.getLibrary(libraryConfig).then(loaded2);
 
     resolveLoad();
-    await flush();
+    await waitEventLoop();
 
     const lateLoaded = sinon.stub();
     grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
 
-    await flush();
+    await waitEventLoop();
 
     assert.isTrue(configureCallback.calledOnce);
   });
@@ -121,7 +111,7 @@
 
     (window as any).library = library;
     resolveLoad();
-    await flush();
+    await waitEventLoop();
 
     assert.isTrue(loaded1.calledWith(library));
     assert.isTrue(loaded2.calledWith(library));
@@ -129,7 +119,7 @@
     const lateLoaded = sinon.stub();
     grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
 
-    await flush();
+    await waitEventLoop();
 
     assert.isTrue(lateLoaded.calledWith(library));
   });
@@ -158,12 +148,12 @@
       grLibLoader.getLibrary(libraryConfig).then(loaded2);
 
       resolveLoad();
-      await flush();
+      await waitEventLoop();
 
       const lateLoaded = sinon.stub();
       grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
 
-      await flush();
+      await waitEventLoop();
 
       assert.isFalse(loadStub.called);
       assert.isTrue(loaded1.called);
@@ -181,7 +171,7 @@
       grLibLoader.getLibrary(libraryConfig);
 
       resolveLoad();
-      await flush();
+      await waitEventLoop();
 
       assert.isTrue((window as any).library.initialize.calledOnce);
     });
@@ -196,7 +186,7 @@
       grLibLoader.getLibrary(libraryConfig);
 
       resolveLoad();
-      await flush();
+      await waitEventLoop();
 
       assert.isTrue(loadStub.called);
     });
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 5fd75da..02079cb 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {LibraryConfig} from './gr-lib-loader';
 
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
index 9bb112e..96d4b92 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
@@ -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 {customElement, property} from 'lit/decorators';
-import {html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {css, html, LitElement} from 'lit';
 import '../gr-tooltip-content/gr-tooltip-content';
 
 declare global {
@@ -38,13 +27,19 @@
 
   /** The maximum length for the text to display before truncating. */
   @property({type: Number})
-  limit = 0;
+  limit = 25;
 
   @property({type: String})
   tooltip?: string;
 
   static override get styles() {
-    return [];
+    return [
+      css`
+        :host {
+          white-space: nowrap;
+        }
+      `,
+    ];
   }
 
   override render() {
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts
index 9b26894..c646525 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts
@@ -1,26 +1,14 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-limited-text tests', () => {
   let element: GrLimitedText;
@@ -31,6 +19,22 @@
     );
   });
 
+  test('render', async () => {
+    element.text = 'abc 123';
+    element.limit = 5;
+    element.tooltip = 'tip';
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-tooltip-content has-tooltip="" title="abc 123 (tip)">
+          abc …
+        </gr-tooltip-content>
+      `
+    );
+  });
+
   test('tooltip without title input', async () => {
     element.text = 'abc 123';
     await element.updateComplete;
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 ad99406..e48dcb3 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
@@ -1,27 +1,15 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../gr-button/gr-button';
-import '../gr-icons/gr-icons';
+import '../gr-icon/gr-icon';
 import '../gr-limited-text/gr-limited-text';
 import {fireEvent} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -43,9 +31,6 @@
   @property({type: String})
   text = '';
 
-  @property({type: Boolean})
-  transparentBackground = false;
-
   /**  If provided, sets the maximum length of the content. */
   @property({type: Number})
   limit?: number;
@@ -65,10 +50,6 @@
           display: inline-flex;
           padding: 0 var(--spacing-m);
         }
-        .transparentBackground,
-        gr-button.transparentBackground {
-          background-color: transparent;
-        }
         :host([disabled]) {
           opacity: 0.6;
           pointer-events: none;
@@ -76,20 +57,9 @@
         a {
           color: var(--linked-chip-text-color);
         }
-        iron-icon {
-          height: 1.2rem;
-          width: 1.2rem;
+        gr-icon {
+          font-size: 1.2rem;
         }
-      `,
-    ];
-  }
-
-  override render() {
-    // To pass CSS mixins for @apply to Polymer components, they need to appear
-    // in <style> inside the template.
-    /* eslint-disable lit/prefer-static-styles */
-    const customStyle = html`
-      <style>
         gr-button::part(paper-button),
         gr-button.remove:hover::part(paper-button),
         gr-button.remove:focus::part(paper-button) {
@@ -105,37 +75,31 @@
           padding: 0;
           text-decoration: none;
         }
-      </style>
-    `;
-    return html`${customStyle}
-      <div
-        class="container ${this._getBackgroundClass(
-          this.transparentBackground
-        )}"
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<div class="container">
+      <a href=${this.href}>
+        <gr-limited-text
+          .limit=${this.limit}
+          .text=${this.text}
+        ></gr-limited-text>
+      </a>
+      <gr-button
+        id="remove"
+        link=""
+        ?hidden=${!this.removable}
+        class="remove"
+        @click=${this.handleRemoveTap}
       >
-        <a href=${this.href}>
-          <gr-limited-text
-            .limit=${this.limit}
-            .text=${this.text}
-          ></gr-limited-text>
-        </a>
-        <gr-button
-          id="remove"
-          link=""
-          ?hidden=${!this.removable}
-          class="remove ${this._getBackgroundClass(this.transparentBackground)}"
-          @click=${this._handleRemoveTap}
-        >
-          <iron-icon icon="gr-icons:close"></iron-icon>
-        </gr-button>
-      </div>`;
+        <gr-icon icon="close"></gr-icon>
+      </gr-button>
+    </div>`;
   }
 
-  _getBackgroundClass(transparent: boolean) {
-    return transparent ? 'transparentBackground' : '';
-  }
-
-  _handleRemoveTap(e: Event) {
+  private handleRemoveTap(e: Event) {
     e.preventDefault();
     fireEvent(this, 'remove');
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
index 1d98a0b..08572b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
@@ -1,41 +1,47 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-linked-chip';
 import {GrLinkedChip} from './gr-linked-chip';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-linked-chip');
+import {queryAndAssert, waitEventLoop} from '../../../test/test-utils';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
 
 suite('gr-linked-chip tests', () => {
   let element: GrLinkedChip;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(html`<gr-linked-chip></gr-linked-chip>`);
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div class="container">
+        <a href=""> <gr-limited-text> </gr-limited-text> </a>
+        <gr-button
+          aria-disabled="false"
+          class="remove"
+          hidden=""
+          id="remove"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          <gr-icon icon="close"></gr-icon>
+        </gr-button>
+      </div>`
+    );
   });
 
   test('remove fired', async () => {
     const spy = sinon.spy();
     element.addEventListener('remove', spy);
-    await flush();
-    MockInteractions.tap(queryAndAssert(element, '#remove'));
+    await waitEventLoop();
+    queryAndAssert<GrButton>(element, '#remove').click();
     assert.isTrue(spy.called);
   });
 });
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
deleted file mode 100644
index c283876..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
+++ /dev/null
@@ -1,191 +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 '../../../styles/shared-styles';
-import {GrLinkTextParser, LinkTextParserConfig} from './link-text-parser';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {assertIsDefined} from '../../../utils/common-util';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-linked-text': GrLinkedText;
-  }
-}
-
-@customElement('gr-linked-text')
-export class GrLinkedText extends LitElement {
-  private outputElement?: HTMLSpanElement;
-
-  @property({type: Boolean})
-  removeZeroWidthSpace?: boolean;
-
-  @property({type: String})
-  content = '';
-
-  @property({type: Boolean, attribute: true})
-  pre = false;
-
-  @property({type: Boolean, attribute: true})
-  disabled = false;
-
-  @property({type: Boolean, attribute: true})
-  inline = false;
-
-  @property({type: Object})
-  config?: LinkTextParserConfig;
-
-  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();
-    }
-  }
-
-  /**
-   * 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
-   */
-  _contentOrConfigChanged() {
-    if (!this.config) {
-      assertIsDefined(this.outputElement);
-      this.outputElement.textContent = this.content;
-      return;
-    }
-
-    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.removeZeroWidthSpace
-    );
-    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
-    this.outputElement.querySelectorAll('a').forEach(anchor => {
-      if (anchor.hostname === window.location.hostname) {
-        anchor.removeAttribute('target');
-      } else {
-        anchor.setAttribute('target', '_blank');
-      }
-      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)';
-    }
-  }
-
-  /**
-   * This method is called when the GrLikTextParser emits a partial result
-   * (used as the "callback" parameter). It will be called in either of two
-   * ways:
-   * - To create a link: when called with `text` and `href` arguments, a link
-   *   element should be created and attached to the resulting DOM.
-   * - 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(
-    text: string | null,
-    href: string | null,
-    fragment?: DocumentFragment
-  ) {
-    assertIsDefined(this.outputElement);
-    const output = this.outputElement;
-    if (href) {
-      const a = document.createElement('a');
-      a.setAttribute('href', href);
-      // GrLinkTextParser either pass text and href together or
-      // only DocumentFragment - see LinkTextParserCallback
-      a.textContent = text!;
-      a.target = '_blank';
-      a.setAttribute('rel', 'noopener');
-      output.appendChild(a);
-    } else if (fragment) {
-      output.appendChild(fragment);
-    }
-  }
-}
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
deleted file mode 100644
index 7275ef1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
+++ /dev/null
@@ -1,464 +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-linked-text';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-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';
-
-suite('gr-linked-text tests', () => {
-  let element: GrLinkedText;
-
-  let originalCanonicalPath: string | undefined;
-
-  setup(async () => {
-    originalCanonicalPath = window.CANONICAL_PATH;
-    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',
-      },
-      prefixsameinlinkandpattern: {
-        match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
-        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
-      },
-      changeid: {
-        match: '(I[0-9a-f]{8,40})',
-        link: '#/q/$1',
-      },
-      changeid2: {
-        match: 'Change-Id: +(I[0-9a-f]{8,40})',
-        link: '#/q/$1',
-      },
-      googlesearch: {
-        match: 'google:(.+)',
-        link: 'https://bing.com/search?q=$1', // html should supercede link.
-        html: '<a href="https://google.com/search?q=$1">$1</a>',
-      },
-      hashedhtml: {
-        match: 'hash:(.+)',
-        html: '<a href="#/awesomesauce">$1</a>',
-      },
-      baseurl: {
-        match: 'test (.+)',
-        html: '<a href="/r/awesomesauce">$1</a>',
-      },
-      anotatstartwithbaseurl: {
-        match: 'a test (.+)',
-        html: '[Lookup: <a href="/r/awesomesauce">$1</a>]',
-      },
-      disabledconfig: {
-        match: 'foo:(.+)',
-        link: 'https://google.com/search?q=$1',
-        enabled: false,
-      },
-    };
-  });
-
-  teardown(() => {
-    window.CANONICAL_PATH = originalCanonicalPath;
-  });
-
-  test('URL pattern was parsed and linked.', async () => {
-    // Regular inline link.
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-    element.content = url;
-    await element.updateComplete;
-
-    const 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, url);
-  });
-
-  test('Bug pattern was parsed and linked', async () => {
-    // "Issue/Bug" pattern.
-    element.content = 'Issue 3650';
-    await element.updateComplete;
-
-    let linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[0] as HTMLAnchorElement;
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'Issue 3650');
-
-    element.content = 'Bug 3650';
-    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');
-  });
-
-  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';
-    await element.updateComplete;
-
-    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';
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'httpexample 3650');
-  });
-
-  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, 'span#output').childNodes[0];
-    const linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[1] as HTMLAnchorElement;
-    assert.equal(textNode.textContent, prefix);
-    const url = '/q/' + changeID;
-    assert.isFalse(linkEl.hasAttribute('target'));
-    // Since url is a path, the host is added automatically.
-    assert.isTrue(linkEl.href.endsWith(url));
-    assert.equal(linkEl.textContent, changeID);
-  });
-
-  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, 'span#output').childNodes[0];
-    const linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[1] as HTMLAnchorElement;
-    assert.equal(textNode.textContent, prefix);
-    const url = '/r/q/' + changeID;
-    assert.isFalse(linkEl.hasAttribute('target'));
-    // Since url is a path, the host is added automatically.
-    assert.isTrue(linkEl.href.endsWith(url));
-    assert.equal(linkEl.textContent, changeID);
-  });
-
-  test('Multiple matches', async () => {
-    element.content = 'Issue 3650\nIssue 3450';
-    await element.updateComplete;
-
-    const linkEl1 = queryAndAssert(element, 'span#output')
-      .childNodes[0] as HTMLAnchorElement;
-    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'
-    );
-    assert.equal(linkEl1.textContent, 'Issue 3650');
-
-    assert.equal(linkEl2.target, '_blank');
-    assert.equal(
-      linkEl2.href,
-      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450'
-    );
-    assert.equal(linkEl2.textContent, 'Issue 3450');
-  });
-
-  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 changeUrl = '/q/' + changeID;
-    const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-
-    element.content = prefix + changeID + bug;
-    await element.updateComplete;
-
-    const textNode = queryAndAssert(element, 'span#output').childNodes[0];
-    const changeLinkEl = queryAndAssert(element, 'span#output')
-      .childNodes[1] as HTMLAnchorElement;
-    const bugLinkEl = queryAndAssert(element, 'span#output')
-      .childNodes[2] as HTMLAnchorElement;
-
-    assert.equal(textNode.textContent, prefix);
-
-    assert.isFalse(changeLinkEl.hasAttribute('target'));
-    assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
-    assert.equal(changeLinkEl.textContent, changeID);
-
-    assert.equal(bugLinkEl.target, '_blank');
-    assert.equal(bugLinkEl.href, bugUrl);
-    assert.equal(bugLinkEl.textContent, 'Issue 3650');
-  });
-
-  test('html field in link config', async () => {
-    element.content = 'google:do a barrel roll';
-    await element.updateComplete;
-
-    const linkEl = queryAndAssert(element, 'span#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.equal(
-      linkEl.getAttribute('href'),
-      'https://google.com/search?q=do a barrel roll'
-    );
-    assert.equal(linkEl.textContent, 'do a barrel roll');
-  });
-
-  test('removing hash from links', async () => {
-    element.content = 'hash:foo';
-    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', async () => {
-    window.CANONICAL_PATH = '/r';
-
-    element.content = 'test foo';
-    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', async () => {
-    window.CANONICAL_PATH = '/r';
-
-    element.content = 'a test foo';
-    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', async () => {
-    window.CANONICAL_PATH = '/r';
-
-    element.content = 'hash:foo';
-    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', async () => {
-    element.content = 'foo:baz';
-    await element.updateComplete;
-
-    assert.equal(queryAndAssert(element, 'span#output').innerHTML, 'foo:baz');
-  });
-
-  test('R=email labels link correctly', async () => {
-    element.removeZeroWidthSpace = true;
-    element.content = 'R=\u200Btest@google.com';
-    await element.updateComplete;
-
-    assert.equal(
-      queryAndAssert(element, 'span#output').textContent,
-      'R=test@google.com'
-    );
-    assert.equal(
-      queryAndAssert(element, 'span#output').innerHTML.match(/(R=<a)/g)!.length,
-      1
-    );
-  });
-
-  test('CC=email labels link correctly', async () => {
-    element.removeZeroWidthSpace = true;
-    element.content = 'CC=\u200Btest@google.com';
-    await element.updateComplete;
-
-    assert.equal(
-      queryAndAssert(element, 'span#output').textContent,
-      'CC=test@google.com'
-    );
-    assert.equal(
-      queryAndAssert(element, 'span#output').innerHTML.match(/(CC=<a)/g)!
-        .length,
-      1
-    );
-  });
-
-  test('only {http,https,mailto} protocols are linkified', async () => {
-    element.content = 'xx mailto:test@google.com yy';
-    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';
-    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';
-    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';
-    await element.updateComplete;
-
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 0);
-
-    element.content = 'xx ftp://google.com yy';
-    await element.updateComplete;
-
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 0);
-  });
-
-  test('links without leading whitespace are linkified', async () => {
-    element.content = 'xx abcmailto:test@google.com yy';
-    await element.updateComplete;
-
-    assert.equal(
-      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
-      'xx abc'
-    );
-    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, 'span#output').innerHTML.substr(0, 6),
-      'xx def'
-    );
-    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, 'span#output').innerHTML.substr(0, 6),
-      'xx qwe'
-    );
-    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, 'span#output').innerHTML.substr(0, 6),
-      'xx абв'
-    );
-    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';
-    await element.updateComplete;
-
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 0);
-
-    element.content = 'xx ftp://google.com yy';
-    await element.updateComplete;
-
-    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
-    assert.equal(links.length, 0);
-  });
-
-  test('overlapping links', async () => {
-    element.config = {
-      b1: {
-        match: '(B:\\s*)(\\d+)',
-        html: '$1<a href="ftp://foo/$2">$2</a>',
-      },
-      b2: {
-        match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
-        html: '$1<a href="ftp://foo/$2">$2</a>',
-      },
-    };
-    element.content = '- B: 123, 45';
-    await element.updateComplete;
-
-    const links = element.querySelectorAll('a');
-
-    assert.equal(links.length, 2);
-    assert.equal(
-      queryAndAssert<HTMLSpanElement>(element, 'span').textContent,
-      '- B: 123, 45'
-    );
-
-    assert.equal(links[0].href, 'ftp://foo/123');
-    assert.equal(links[0].textContent, '123');
-
-    assert.equal(links[1].href, 'ftp://foo/45');
-    assert.equal(links[1].textContent, '45');
-  });
-
-  test('_contentOrConfigChanged called with config', async () => {
-    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
-    element.content = 'some text';
-    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
deleted file mode 100644
index 2bd6b6c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
+++ /dev/null
@@ -1,427 +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 'ba-linkify/ba-linkify';
-import {getBaseUrl} from '../../../utils/url-util';
-import {CommentLinkInfo} from '../../../types/common';
-
-/**
- * Pattern describing URLs with supported protocols.
- */
-const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
-
-export type LinkTextParserCallback = ((text: string, href: string) => void) &
-  ((text: null, href: null, fragment: DocumentFragment) => void);
-
-export interface CommentLinkItem {
-  position: number;
-  length: number;
-  html: HTMLAnchorElement | DocumentFragment;
-}
-
-export type LinkTextParserConfig = {[name: string]: CommentLinkInfo};
-
-export class GrLinkTextParser {
-  private readonly baseUrl = getBaseUrl();
-
-  /**
-   * Construct a parser for linkifying text. Will linkify plain URLs that appear
-   * in the text as well as custom links if any are specified in the linkConfig
-   * parameter.
-   *
-   * @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
-   *     is emitted. The callback is passed text and href strings if a link is to
-   *     be created, or a document fragment otherwise.
-   * @param removeZeroWidthSpace If true, zero-width spaces will be removed from
-   *     R=<email> and CC=<email> expressions.
-   */
-  constructor(
-    private readonly linkConfig: LinkTextParserConfig,
-    private readonly callback: LinkTextParserCallback,
-    private readonly removeZeroWidthSpace?: boolean
-  ) {
-    Object.preventExtensions(this);
-  }
-
-  /**
-   * Emit a callback to create a link element.
-   *
-   * @param text The text of the link.
-   * @param href The URL to use as the href of the link.
-   */
-  addText(text: string, href: string) {
-    if (!text) {
-      return;
-    }
-    this.callback(text, href);
-  }
-
-  /**
-   * Given the source text and a list of CommentLinkItem objects that were
-   * generated by the commentlinks config, emit parsing callbacks.
-   *
-   * @param text The chuml of source text over which the outputArray items range.
-   * @param outputArray The list of items to add resulting from commentlink
-   *     matches.
-   */
-  processLinks(text: string, outputArray: CommentLinkItem[]) {
-    this.sortArrayReverse(outputArray);
-    const fragment = document.createDocumentFragment();
-    let cursor = text.length;
-
-    // Start inserting linkified URLs from the end of the String. That way, the
-    // string positions of the items don't change as we iterate through.
-    outputArray.forEach(item => {
-      // Add any text between the current linkified item and the item added
-      // before if it exists.
-      if (item.position + item.length !== cursor) {
-        fragment.insertBefore(
-          document.createTextNode(
-            text.slice(item.position + item.length, cursor)
-          ),
-          fragment.firstChild
-        );
-      }
-      fragment.insertBefore(item.html, fragment.firstChild);
-      cursor = item.position;
-    });
-
-    // Add the beginning portion at the end.
-    if (cursor !== 0) {
-      fragment.insertBefore(
-        document.createTextNode(text.slice(0, cursor)),
-        fragment.firstChild
-      );
-    }
-
-    this.callback(null, null, fragment);
-  }
-
-  /**
-   * Sort the given array of CommentLinkItems such that the positions are in
-   * reverse order.
-   */
-  sortArrayReverse(outputArray: CommentLinkItem[]) {
-    outputArray.sort((a, b) => b.position - a.position);
-  }
-
-  addItem(
-    text: string,
-    href: string,
-    html: null,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ): void;
-
-  addItem(
-    text: null,
-    href: null,
-    html: string,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ): void;
-
-  /**
-   * Create a CommentLinkItem and append it to the given output array. This
-   * method can be called in either of two ways:
-   * - With `text` and `href` parameters provided, and the `html` parameter
-   *   passed as `null`. In this case, the new CommentLinkItem will be a link
-   *   element with the given text and href value.
-   * - With the `html` paremeter provided, and the `text` and `href` parameters
-   *   passed as `null`. In this case, the string of HTML will be parsed and the
-   *   first resulting node will be used as the resulting content.
-   *
-   * @param text The text to use if creating a link.
-   * @param href The href to use as the URL if creating a link.
-   * @param html The html to parse and use as the result.
-   * @param  position The position inside the source text where the item
-   *     starts.
-   * @param length The number of characters in the source text
-   *     represented by the item.
-   * @param outputArray The array to which the
-   *     new item is to be appended.
-   */
-  addItem(
-    text: string | null,
-    href: string | null,
-    html: string | null,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ): void {
-    if (href) {
-      const a = document.createElement('a');
-      a.setAttribute('href', href);
-      a.textContent = text;
-      a.target = '_blank';
-      a.rel = 'noopener';
-      outputArray.push({
-        html: a,
-        position,
-        length,
-      });
-    } else if (html) {
-      // addItem has 2 overloads. If href is null, then html
-      // can't be null.
-      // TODO(TS): remove if(html) and keep else block without condition
-      const fragment = document.createDocumentFragment();
-      // Create temporary div to hold the nodes in.
-      const div = document.createElement('div');
-      div.innerHTML = html;
-      while (div.firstChild) {
-        fragment.appendChild(div.firstChild);
-      }
-      outputArray.push({
-        html: fragment,
-        position,
-        length,
-      });
-    }
-  }
-
-  /**
-   * Create a CommentLinkItem for a link and append it to the given output
-   * array.
-   *
-   * @param text The text for the link.
-   * @param href The href to use as the URL of the link.
-   * @param position The position inside the source text where the link
-   *     starts.
-   * @param length The number of characters in the source text
-   *     represented by the link.
-   * @param outputArray The array to which the
-   *     new item is to be appended.
-   */
-  addLink(
-    text: string,
-    href: string,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ) {
-    // TODO(TS): remove !test condition
-    if (!text || this.hasOverlap(position, length, outputArray)) {
-      return;
-    }
-    if (
-      !!this.baseUrl &&
-      href.startsWith('/') &&
-      !href.startsWith(this.baseUrl)
-    ) {
-      href = this.baseUrl + href;
-    }
-    this.addItem(text, href, null, position, length, outputArray);
-  }
-
-  /**
-   * Create a CommentLinkItem specified by an HTMl string and append it to the
-   * given output array.
-   *
-   * @param html The html to parse and use as the result.
-   * @param position The position inside the source text where the item
-   *     starts.
-   * @param length The number of characters in the source text
-   *     represented by the item.
-   * @param outputArray The array to which the
-   *     new item is to be appended.
-   */
-  addHTML(
-    html: string,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ) {
-    if (this.hasOverlap(position, length, outputArray)) {
-      return;
-    }
-    if (
-      !!this.baseUrl &&
-      html.match(/<a href="\//g) &&
-      !new RegExp(`<a href="${this.baseUrl}`, 'g').test(html)
-    ) {
-      html = html.replace(/<a href="\//g, `<a href="${this.baseUrl}/`);
-    }
-    this.addItem(null, null, html, position, length, outputArray);
-  }
-
-  /**
-   * Does the given range overlap with anything already in the item list.
-   */
-  hasOverlap(position: number, length: number, outputArray: CommentLinkItem[]) {
-    const endPosition = position + length;
-    for (let i = 0; i < outputArray.length; i++) {
-      const arrayItemStart = outputArray[i].position;
-      const arrayItemEnd = outputArray[i].position + outputArray[i].length;
-      if (
-        (position >= arrayItemStart && position < arrayItemEnd) ||
-        (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
-        (position === arrayItemStart && position === arrayItemEnd)
-      ) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  /**
-   * Parse the given source text and emit callbacks for the items that are
-   * parsed.
-   */
-  parse(text?: string | null) {
-    if (text) {
-      window.linkify(text, {
-        callback: (text: string, href?: string) => this.parseChunk(text, href),
-      });
-    }
-  }
-
-  /**
-   * Callback that is pased into the linkify function. ba-linkify will call this
-   * method in either of two ways:
-   * - With both a `text` and `href` parameter provided: this indicates that
-   *   ba-linkify has found a plain URL and wants it linkified.
-   * - With only a `text` parameter provided: this represents the non-link
-   *   content that lies between the links the library has found.
-   *
-   */
-  parseChunk(text: string, href?: string) {
-    // TODO(wyatta) switch linkify sequence, see issue 5526.
-    if (this.removeZeroWidthSpace) {
-      // Remove the zero-width space added in gr-change-view.
-      text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
-    }
-
-    // If the href is provided then ba-linkify has recognized it as a URL. If
-    // the source text does not include a protocol, the protocol will be added
-    // by ba-linkify. Create the link if the href is provided and its protocol
-    // matches the expected pattern.
-    if (href) {
-      const result = URL_PROTOCOL_PATTERN.exec(href);
-      if (result) {
-        const prefixText = result[1];
-        if (prefixText.length > 0) {
-          // Fix for simple cases from
-          // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
-          // 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
-          // before link and add this word as is, without processing
-          // any patterns in it.
-          this.parseLinks(prefixText, {});
-          text = text.substring(prefixText.length);
-          href = href.substring(prefixText.length);
-        }
-        this.addText(text, href);
-        return;
-      }
-    }
-    // For the sections of text that lie between the links found by
-    // ba-linkify, we search for the project-config-specified link patterns.
-    this.parseLinks(text, this.linkConfig);
-  }
-
-  /**
-   * Walk over the given source text to find matches for comemntlink patterns
-   * and emit parse result callbacks.
-   *
-   * @param text The raw source text.
-   * @param config A comment links specification object.
-   */
-  parseLinks(text: string, config: LinkTextParserConfig) {
-    // The outputArray is used to store all of the matches found for all
-    // patterns.
-    const outputArray: CommentLinkItem[] = [];
-    for (const [configName, linkInfo] of Object.entries(config)) {
-      // TODO(TS): it seems, the following line can be rewritten as:
-      // if(enabled === false || enabled === 0 || enabled === '')
-      // Should be double-checked before update
-      // eslint-disable-next-line eqeqeq
-      if (linkInfo.enabled != null && linkInfo.enabled == false) {
-        continue;
-      }
-      // PolyGerrit doesn't use hash-based navigation like the GWT UI.
-      // Account for this.
-      const html = linkInfo.html;
-      const link = linkInfo.link;
-      if (html) {
-        linkInfo.html = html.replace(/<a href="#\//g, '<a href="/');
-      } else if (link) {
-        if (link[0] === '#') {
-          linkInfo.link = link.substr(1);
-        }
-      }
-
-      const pattern = new RegExp(linkInfo.match, 'g');
-
-      let match;
-      let textToCheck = text;
-      let susbtrIndex = 0;
-
-      while ((match = pattern.exec(textToCheck))) {
-        textToCheck = textToCheck.substr(match.index + match[0].length);
-        let result = match[0].replace(
-          pattern,
-          // Either html or link has a value. Otherwise an exception is thrown
-          // in the code below.
-          (linkInfo.html || linkInfo.link)!
-        );
-
-        if (linkInfo.html) {
-          let i;
-          // Skip portion of replacement string that is equal to original to
-          // allow overlapping patterns.
-          for (i = 0; i < result.length; i++) {
-            if (result[i] !== match[0][i]) {
-              break;
-            }
-          }
-          result = result.slice(i);
-
-          this.addHTML(
-            result,
-            susbtrIndex + match.index + i,
-            match[0].length - i,
-            outputArray
-          );
-        } else if (linkInfo.link) {
-          this.addLink(
-            match[0],
-            result,
-            susbtrIndex + match.index,
-            match[0].length,
-            outputArray
-          );
-        } else {
-          throw Error(
-            'linkconfig entry ' +
-              configName +
-              ' doesn’t contain a link or html attribute.'
-          );
-        }
-
-        // Update the substring location so we know where we are in relation to
-        // the initial full text string.
-        susbtrIndex = susbtrIndex + match.index + match[0].length;
-      }
-    }
-    this.processLinks(text, outputArray);
-  }
-}
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 9544891..fd43869 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
@@ -1,29 +1,18 @@
 /**
  * @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 '@polymer/iron-input/iron-input';
-import '@polymer/iron-icon/iron-icon';
 import '../gr-button/gr-button';
+import '../gr-icon/gr-icon';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 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 {customElement, property} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 
 const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
@@ -97,15 +86,11 @@
           height: 3rem;
           justify-content: flex-end;
           margin-right: 20px;
-        }
-        nav,
-        iron-icon {
           color: var(--deemphasized-text-color);
         }
-        iron-icon {
-          height: 1.85rem;
+        gr-icon {
+          font-size: 1.85rem;
           margin-left: 16px;
-          width: 1.85rem;
         }
       `,
     ];
@@ -148,7 +133,7 @@
           )}
           ?hidden=${this.loading || this.offset === 0}
         >
-          <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+          <gr-icon icon="chevron_left"></gr-icon>
         </a>
         <a
           id="nextArrow"
@@ -161,7 +146,7 @@
           )}
           ?hidden=${this.hideNextArrow(this.loading, this.items)}
         >
-          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+          <gr-icon icon="chevron_right"></gr-icon>
         </a>
       </nav>
     `;
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
index 29cbb1a..bbbef72 100644
--- 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
@@ -1,36 +1,59 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-list-view tests', () => {
   let element: GrListView;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-list-view></gr-list-view>`);
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="topContainer">
+          <div class="filterContainer">
+            <label> Filter: </label>
+            <iron-input>
+              <input id="filter" type="text" />
+            </iron-input>
+          </div>
+          <div id="createNewContainer">
+            <gr-button
+              aria-disabled="false"
+              id="createNew"
+              link=""
+              primary=""
+              role="button"
+              tabindex="0"
+            >
+              Create New
+            </gr-button>
+          </div>
+        </div>
+        <slot> </slot>
+        <nav>
+          Page 1
+          <a hidden="" href="" id="prevArrow">
+            <gr-icon icon="chevron_left"></gr-icon>
+          </a>
+          <a hidden="" href=",25" id="nextArrow">
+            <gr-icon icon="chevron_right"></gr-icon>
+          </a>
+        </nav>
+      `
+    );
   });
 
   test('computeNavLink', () => {
@@ -156,7 +179,7 @@
     element.addEventListener('create-clicked', clickHandler);
     element.createNew = true;
     await element.updateComplete;
-    MockInteractions.tap(queryAndAssert<GrButton>(element, '#createNew'));
+    queryAndAssert<GrButton>(element, '#createNew').click();
     assert.isTrue(clickHandler.called);
   });
 
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 4895674..c18a31d 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -23,6 +12,7 @@
 import {findActiveElement} from '../../../utils/dom-util';
 import {fireEvent} from '../../../utils/event-util';
 import {getHovercardContainer} from '../../../mixins/hovercard-mixin/hovercard-mixin';
+import {getFocusableElements} from '../../../utils/focusable';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -45,6 +35,7 @@
  * @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
+ * @attr {String} scroll-action - inherited from IronOverlay
  */
 @customElement('gr-overlay')
 export class GrOverlay extends base {
@@ -78,18 +69,7 @@
     if (this.focusableNodes) {
       return this.focusableNodes;
     }
-    // TODO(TS): to avoid ts error for:
-    // Only public and protected methods of the base class are accessible
-    // via the 'super' keyword.
-    // we call IronFocsablesHelper directly here
-    // Currently IronFocsablesHelper is not exported from iron-focusables-helper
-    // as it should so we use Polymer.IronFocsablesHelper here instead
-    // (can not use the IronFocsablesHelperClass
-    // in case different behavior due to singleton)
-    // once the type contains the exported member,
-    // should replace with:
-    // import {IronFocusablesHelper} from '@polymer/iron-overlay-behavior/iron-focusables-helper';
-    return window.Polymer.IronFocusablesHelper.getTabbableNodes(this);
+    return Array.from(getFocusableElements(this));
   }
 
   constructor() {
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
index 730eeac..f6818a5 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {html} from '@polymer/polymer/lib/utils/html-tag';
 
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
index 1a82a8e..dc98745 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
@@ -1,36 +1,23 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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>
-`);
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-overlay tests', () => {
   let element: GrOverlay;
 
-  setup(() => {
-    element = basicFixture.instantiate() as GrOverlay;
+  setup(async () => {
+    element = await fixture(html`<gr-overlay><div>content</div></gr-overlay>`);
+  });
+
+  test('render', async () => {
+    await element.open();
+    assert.shadowDom.equal(element, /* HTML */ ' <slot></slot> ');
   });
 
   test('popstate listener is attached on open and removed on close', () => {
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 211f6dc..08ae5a3 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
@@ -1,23 +1,11 @@
 /**
  * @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 {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, query, state} from 'lit/decorators';
+import {customElement, query, state} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 
 declare global {
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
index f62dae2..1cdb5c7 100644
--- 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
@@ -1,24 +1,12 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-page-nav';
 import {GrPageNav} from './gr-page-nav';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {queryAndAssert} from '../../../test/test-utils';
 
 suite('gr-page-nav tests', () => {
@@ -32,7 +20,17 @@
         </ul>
       </gr-page-nav>
     `);
-    await element.updateComplete;
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <nav aria-label="Sidebar">
+          <slot> </slot>
+        </nav>
+      `
+    );
   });
 
   test('header is not pinned just below top', () => {
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 90ce8bb..8fa351b 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
@@ -1,22 +1,10 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-icon/iron-icon';
-import '../gr-icons/gr-icons';
 import '../gr-labeled-autocomplete/gr-labeled-autocomplete';
+import '../gr-icon/gr-icon';
 import {singleDecodeURL} from '../../../utils/url-util';
 import {AutocompleteQuery} from '../gr-autocomplete/gr-autocomplete';
 import {
@@ -29,7 +17,7 @@
 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 {customElement, property, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {fire} from '../../../utils/event-util';
 import {BindValueChangeEvent} from '../../../types/events';
@@ -79,11 +67,10 @@
         :host {
           display: block;
         }
-        gr-labeled-autocomplete,
-        iron-icon {
+        gr-labeled-autocomplete {
           display: inline-block;
         }
-        iron-icon {
+        gr-icon {
           margin-bottom: var(--spacing-l);
         }
       `,
@@ -103,7 +90,7 @@
           }}
         >
         </gr-labeled-autocomplete>
-        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+        <gr-icon icon="chevron_right"></gr-icon>
         <gr-labeled-autocomplete
           id="branchInput"
           label="Branch"
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 4c53a03..6839431 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
@@ -1,34 +1,46 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo-branch-picker';
 import {GrRepoBranchPicker} from './gr-repo-branch-picker';
 import {stubRestApi} from '../../../test/test-utils';
 import {GitRef, ProjectInfoWithName, RepoName} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-repo-branch-picker');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-repo-branch-picker tests', () => {
   let element: GrRepoBranchPicker;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-repo-branch-picker></gr-repo-branch-picker>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div>
+          <gr-labeled-autocomplete
+            id="repoInput"
+            label="Repository"
+            placeholder="Select repo"
+          >
+          </gr-labeled-autocomplete>
+          <gr-icon icon="chevron_right"></gr-icon>
+          <gr-labeled-autocomplete
+            disabled=""
+            id="branchInput"
+            label="Branch"
+            placeholder="Select branch"
+          >
+          </gr-labeled-autocomplete>
+        </div>
+      `
+    );
   });
 
   suite('getRepoSuggestions', () => {
@@ -121,9 +133,8 @@
       const repo = 'gerrit' as RepoName;
       const branchInput = 'refs/heads/stable-2.1';
       element.repo = repo;
-      return element.getRepoBranchesSuggestions(branchInput).then(() => {
-        assert.isTrue(getRepoBranchesStub.calledWith('stable-2.1', repo, 15));
-      });
+      await element.getRepoBranchesSuggestions(branchInput);
+      assert.isTrue(getRepoBranchesStub.calledWith('stable-2.1', repo, 15));
     });
 
     test('does not query when repo is unset', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts
index 2c1ba70..684863e 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.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
  */
 
 // Limit cache size because /change/detail responses may be large.
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
index ce2c72f..4f22f25 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.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 '../../../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import {GrEtagDecorator} from './gr-etag-decorator';
 
 suite('gr-etag-decorator', () => {
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 95f3eb1..9c54349 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
@@ -1,18 +1,7 @@
 /**
  * @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 {getBaseUrl} from '../../../../utils/url-util';
 import {CancelConditionCallback} from '../../../../services/gr-rest-api/gr-rest-api';
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
index ef90764..712ece4 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -1,33 +1,22 @@
 /**
  * @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 '../../../../test/common-test-setup';
 import {
   SiteBasedCache,
   FetchPromisesCache,
   GrRestApiHelper,
 } from './gr-rest-api-helper';
 import {getAppContext} from '../../../../services/app-context';
-import {stubAuth} from '../../../../test/test-utils';
+import {stubAuth, waitEventLoop} 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';
+import {assert} from '@open-wc/testing';
 
 function makeParsedJSON<T>(val: T): ParsedJSON {
   return val as unknown as ParsedJSON;
@@ -82,13 +71,13 @@
   async function assertReadRequest() {
     assert.equal(readScheduler.scheduled.length, 1);
     await readScheduler.resolve();
-    await flush();
+    await waitEventLoop();
   }
 
   async function assertWriteRequest() {
     assert.equal(writeScheduler.scheduled.length, 1);
     await writeScheduler.resolve();
-    await flush();
+    await waitEventLoop();
   }
 
   suite('send()', () => {
@@ -303,7 +292,7 @@
       );
       // Flush the retry scheduler
       clock.tick(50);
-      await flush();
+      await waitEventLoop();
       // We expect a retry.
       await assertReadRequest();
       const res: Response = await promise;
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 da5d331..b51c4a2 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
@@ -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 {parseDate} from '../../../utils/date-util';
 import {MessageTag, ReviewerState} from '../../../constants/constants';
 import {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
index 34fb709..9c87910 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
@@ -1,23 +1,12 @@
 /**
  * @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 '../../../test/common-test-setup-karma.js';
-import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
-import {parseDate} from '../../../utils/date-util.js';
+import '../../../test/common-test-setup';
+import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser';
+import {parseDate} from '../../../utils/date-util';
+import {assert} from '@open-wc/testing';
 
 suite('gr-reviewer-updates-parser tests', () => {
   let instance;
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 47295ab..083ec0b 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
@@ -1,51 +1,38 @@
 /**
  * @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 {html} from '@polymer/polymer/lib/utils/html-tag';
-import {customElement, property, observe} from '@polymer/decorators';
+import {html, LitElement, PropertyValues} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-select': GrSelect;
   }
+  interface HTMLElementEventMap {
+    'bind-value-changed': BindValueChangeEvent;
+  }
 }
 
 /**
  * GrSelect `gr-select` component.
+ * TODO: Figure out if this class still has merit over native <select>
  */
 @customElement('gr-select')
-export class GrSelect extends PolymerElement {
-  static get template() {
-    return html` <slot></slot> `;
+export class GrSelect extends LitElement {
+  private _bindValue?: string | number | boolean;
+
+  get bindValue() {
+    return this._bindValue;
   }
 
-  @property({type: String, notify: true})
-  bindValue?: string | number | boolean;
-
-  get nativeSelect() {
-    // gr-select is not a shadow component
-    // TODO(taoalpha): maybe we should convert
-    // it into a shadow dom component instead
-    // TODO(TS): should warn if no `select` detected.
-    return this.querySelector('select')!;
-  }
-
-  @observe('bindValue')
-  _updateValue() {
+  set bindValue(bindValue: string | number | boolean | undefined) {
+    if (this._bindValue === bindValue) return;
+    this._bindValue = bindValue;
+    this._updateValue();
     // It's possible to have a value of 0.
     if (this.bindValue !== undefined) {
       // Set for chrome/safari so it happens instantly
@@ -57,27 +44,55 @@
         this.nativeSelect.value = String(this.bindValue);
       }, 1);
     }
+    // TODO: bind-value-changed is polymer-specific.  Move to a new event
+    // name and rely on ValueChangedEvent instead of BindValueChangeEvent.
+    fire(this, 'bind-value-changed', {value: this.convert(this._bindValue)});
   }
 
-  _valueChanged() {
-    this.bindValue = this.nativeSelect.value;
-  }
-
-  override focus() {
-    this.nativeSelect.focus();
+  get nativeSelect() {
+    return this.querySelector('select')!;
   }
 
   constructor() {
     super();
-    this.addEventListener('change', () => this._valueChanged());
-    this.addEventListener('dom-change', () => this._updateValue());
+    this.addEventListener('change', () => {
+      this.bindValue = this.nativeSelect.value;
+    });
   }
 
-  override ready() {
-    super.ready();
+  override updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
     // If not set via the property, set bind-value to the element value.
     if (this.bindValue === undefined && this.nativeSelect.options.length > 0) {
       this.bindValue = this.nativeSelect.value;
     }
   }
+
+  override render() {
+    return html`<slot></slot>`;
+  }
+
+  _updateValue() {
+    // It's possible to have a value of 0.
+    if (this.bindValue !== undefined) {
+      // Set for chrome/safari so it happens instantly
+      this.nativeSelect.value = this.convert(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
+      setTimeout(() => {
+        this.nativeSelect.value = this.convert(this.bindValue) ?? '';
+      }, 1);
+    }
+  }
+
+  private convert(value: string | boolean | number | undefined) {
+    if (value === undefined) return undefined;
+    if (typeof value === 'string') return value;
+    return String(value);
+  }
+
+  override focus() {
+    this.nativeSelect.focus();
+  }
 }
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 80b9f65..4bb63ea 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
@@ -1,23 +1,11 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-select';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrSelect} from './gr-select';
 
 suite('gr-select tests', () => {
@@ -35,6 +23,10 @@
     `);
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(element, /* HTML */ '<slot></slot>');
+  });
+
   test('bindValue must be set to the first option value', () => {
     assert.equal(element.bindValue, '1');
     assert.equal(element.nativeSelect.value, '1');
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 d352583..ef0f531 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
@@ -1,25 +1,14 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-copy-clipboard/gr-copy-clipboard';
 import {GrCopyClipboard} from '../gr-copy-clipboard/gr-copy-clipboard';
 import {queryAndAssert} from '../../../utils/common-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
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 1b1687b..f489664 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
@@ -1,36 +1,38 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-shell-command';
 import {GrShellCommand} from './gr-shell-command';
 import {GrCopyClipboard} from '../gr-copy-clipboard/gr-copy-clipboard';
 import {queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-shell-command');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-shell-command tests', () => {
   let element: GrShellCommand;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-shell-command></gr-shell-command>`);
     element.command = `git fetch http://gerrit@localhost:8080/a/test-project
         refs/changes/05/5/1 && git checkout FETCH_HEAD`;
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('render', async () => {
+    element.label = 'label1';
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <label> label1 </label>
+        <div class="commandContainer">
+          <gr-copy-clipboard buttontitle="" hastooltip=""> </gr-copy-clipboard>
+        </div>
+      `
+    );
   });
 
   test('focusOnCopy', async () => {
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 e0c49c9..9a114e6 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.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 '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
@@ -26,14 +15,23 @@
   Item,
   ItemSelectedEvent,
 } from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {addShortcut, Key} from '../../../utils/dom-util';
-import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {Key} from '../../../utils/dom-util';
+import {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 {LitElement, css, html, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {PropertyValues} from 'lit';
-import {classMap} from 'lit/directives/class-map';
+import {classMap} from 'lit/directives/class-map.js';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {NumericChangeId, ServerInfo} from '../../../api/rest-api';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {assert} from '../../../utils/common-util';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {getAccountDisplayName} from '../../../utils/display-name-util';
+import {configModelToken} from '../../../models/config/config-model';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -61,10 +59,14 @@
   {value: '😜', match: 'winking tongue ;)'},
 ];
 
-interface EmojiSuggestion extends Item {
+export interface EmojiSuggestion extends Item {
   match: string;
 }
 
+function isEmojiSuggestion(x: EmojiSuggestion | Item): x is EmojiSuggestion {
+  return !!x && !!(x as EmojiSuggestion).match;
+}
+
 declare global {
   interface HTMLElementEventMap {
     'item-selected': CustomEvent<ItemSelectedEvent>;
@@ -80,6 +82,8 @@
 
   @query('#emojiSuggestions') emojiSuggestions?: GrAutocompleteDropdown;
 
+  @query('#mentionsSuggestions') mentionsSuggestions?: GrAutocompleteDropdown;
+
   @query('#caratSpan', true) caratSpan?: HTMLSpanElement;
 
   @query('#hiddenText') hiddenText?: HTMLDivElement;
@@ -105,28 +109,64 @@
     standard monospace font. */
   @property({type: Boolean}) code = false;
 
-  @state() colonIndex: number | null = null;
-
-  @state() currentSearchString?: string;
-
-  @state() hideEmojiAutocomplete = true;
-
-  @state() private index: number | null = null;
-
-  @state() suggestions: EmojiSuggestion[] = [];
+  @state() suggestions: (Item | EmojiSuggestion)[] = [];
 
   // Accessed in tests.
   readonly reporting = getAppContext().reportingService;
 
-  disableEnterKeyForSelectingEmoji = false;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  private readonly flagsService = getAppContext().flagsService;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private serverConfig?: ServerInfo;
+
+  private changeNum?: NumericChangeId;
+
+  // private but used in tests
+  specialCharIndex = -1;
+
+  // private but used in tests
+  currentSearchString?: string;
+
+  private readonly shortcuts = new ShortcutController(this);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+    this.shortcuts.addLocal({key: Key.UP}, e => this.handleUpKey(e), {
+      preventDefault: false,
+    });
+    this.shortcuts.addLocal({key: Key.DOWN}, e => this.handleDownKey(e), {
+      preventDefault: false,
+    });
+    this.shortcuts.addLocal({key: Key.TAB}, e => this.handleTabKey(e), {
+      preventDefault: false,
+    });
+    this.shortcuts.addLocal({key: Key.ENTER}, e => this.handleEnterByKey(e), {
+      preventDefault: false,
+    });
+    this.shortcuts.addLocal({key: Key.ESC}, e => this.handleEscKey(e), {
+      preventDefault: false,
+    });
+  }
 
   override disconnectedCallback() {
     super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
   }
 
   override connectedCallback() {
@@ -137,31 +177,6 @@
     if (this.code) {
       this.classList.add('code');
     }
-    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 = [
@@ -187,9 +202,6 @@
       #emojiSuggestions {
         font-family: var(--font-family);
       }
-      gr-autocomplete {
-        display: inline-block;
-      }
       #textarea {
         background-color: var(--view-background-color);
         width: 100%;
@@ -222,14 +234,8 @@
       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}
-      >
+      ${this.renderEmojiDropdown()}
+      ${this.renderMentionsDropdown()}
       </gr-autocomplete-dropdown>
       <iron-autogrow-textarea
         id="textarea"
@@ -243,22 +249,52 @@
         @value-changed=${(e: ValueChangedEvent) => {
           this.text = e.detail.value;
         }}
-        @bind-value-changed=${this.onValueChanged}
       ></iron-autogrow-textarea>
     `;
   }
 
-  override willUpdate(changedProperties: PropertyValues) {
+  private renderEmojiDropdown() {
+    return html`
+      <gr-autocomplete-dropdown
+        id="emojiSuggestions"
+        .suggestions=${this.suggestions}
+        .horizontalOffset=${20}
+        .verticalOffset=${20}
+        @dropdown-closed=${this.resetDropdown}
+        @item-selected=${this.handleDropdownItemSelect}
+      >
+      </gr-autocomplete-dropdown>
+    `;
+  }
+
+  private renderMentionsDropdown() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
+      return nothing;
+    return html` <gr-autocomplete-dropdown
+      id="mentionsSuggestions"
+      .suggestions=${this.suggestions}
+      @dropdown-closed=${this.resetDropdown}
+      @item-selected=${this.handleDropdownItemSelect}
+      .horizontalOffset=${20}
+      .verticalOffset=${20}
+      role="listbox"
+    ></gr-autocomplete-dropdown>`;
+  }
+
+  override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('text')) {
-      this.handleTextChanged(this.text);
-    }
-    if (changedProperties.has('currentSearchString')) {
-      this.determineSuggestions(this.currentSearchString!);
+      this.fireChangedEvents();
+      // Add to updated because we want this.textarea.selectionStart and
+      // this.textarea is null in the willUpdate lifecycle
+      this.computeSpecialCharIndex();
+      this.computeCurrentSearchString();
+      this.handleTextChanged();
     }
   }
 
   // private but used in test
   closeDropdown() {
+    this.mentionsSuggestions?.close();
     this.emojiSuggestions?.close();
   }
 
@@ -276,85 +312,109 @@
     });
   }
 
+  private getVisibleDropdown() {
+    if (this.emojiSuggestions && !this.emojiSuggestions.isHidden)
+      return this.emojiSuggestions;
+    if (this.mentionsSuggestions && !this.mentionsSuggestions.isHidden)
+      return this.mentionsSuggestions;
+    throw new Error('no dropdown visible');
+  }
+
+  private isDropdownVisible() {
+    return (
+      (this.emojiSuggestions && !this.emojiSuggestions.isHidden) ||
+      (this.mentionsSuggestions && !this.mentionsSuggestions.isHidden)
+    );
+  }
+
   private handleEscKey(e: KeyboardEvent) {
-    if (this.hideEmojiAutocomplete) {
+    if (!this.isDropdownVisible()) {
       return;
     }
     e.preventDefault();
     e.stopPropagation();
-    this.resetEmojiDropdown();
+    this.resetDropdown();
   }
 
   private handleUpKey(e: KeyboardEvent) {
-    if (this.hideEmojiAutocomplete) {
+    if (!this.isDropdownVisible()) {
       return;
     }
     e.preventDefault();
     e.stopPropagation();
-    this.emojiSuggestions!.cursorUp();
+    this.getVisibleDropdown().cursorUp();
     this.textarea!.textarea.focus();
-    this.disableEnterKeyForSelectingEmoji = false;
   }
 
   private handleDownKey(e: KeyboardEvent) {
-    if (this.hideEmojiAutocomplete) {
+    if (!this.isDropdownVisible()) {
       return;
     }
     e.preventDefault();
     e.stopPropagation();
-    this.emojiSuggestions!.cursorDown();
+    this.getVisibleDropdown().cursorDown();
     this.textarea!.textarea.focus();
-    this.disableEnterKeyForSelectingEmoji = false;
   }
 
   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.isDropdownVisible() ||
+      (this.isEmojiDropdownActive() && this.currentSearchString === '')
+    ) {
       return;
     }
     e.preventDefault();
     e.stopPropagation();
-    this.setEmoji(this.emojiSuggestions!.getCurrentText());
+    this.setValue(this.getVisibleDropdown().getCurrentText());
   }
 
   // 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.isDropdownVisible() ||
+      (this.isEmojiDropdownActive() && this.currentSearchString === '')
+    ) {
       this.indent(e);
       return;
     }
 
     e.preventDefault();
     e.stopPropagation();
-    this.setEmoji(this.emojiSuggestions!.getCurrentText());
+    this.setValue(this.getVisibleDropdown().getCurrentText());
   }
 
   // private but used in test
-  handleEmojiSelect(e: CustomEvent<ItemSelectedEvent>) {
+  handleDropdownItemSelect(e: CustomEvent<ItemSelectedEvent>) {
     if (e.detail.selected?.dataset['value']) {
-      this.setEmoji(e.detail.selected?.dataset['value']);
+      this.setValue(e.detail.selected?.dataset['value']);
     }
   }
 
-  private setEmoji(text: string) {
-    if (this.colonIndex === null) {
+  private setValue(text: string) {
+    if (this.specialCharIndex === -1) {
       return;
     }
-    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();
+    if (this.isEmojiDropdownActive()) {
+      this.text = this.addValueToText(text);
+      this.reporting.reportInteraction('select-emoji', {type: text});
+    } else {
+      this.text = this.addValueToText('@' + text);
+      this.reporting.reportInteraction('select-mention', {type: text});
+    }
+
+    this.textarea!.selectionStart = this.specialCharIndex + 1;
+    this.textarea!.selectionEnd = this.specialCharIndex + 1;
+    this.resetDropdown();
   }
 
-  private getText(value: string) {
+  private addValueToText(value: string) {
     if (!this.text) return '';
     return (
-      this.text.substr(0, this.colonIndex || 0) +
+      this.text.substr(0, this.specialCharIndex ?? 0) +
       value +
       this.text.substr(this.textarea!.selectionStart)
     );
@@ -368,7 +428,6 @@
    * private but used in test
    */
   updateCaratPosition() {
-    this.hideEmojiAutocomplete = false;
     if (typeof this.textarea!.value === 'string') {
       this.hiddenText!.textContent = this.textarea!.value.substr(
         0,
@@ -378,74 +437,137 @@
 
     const caratSpan = this.caratSpan!;
     this.hiddenText!.appendChild(caratSpan);
-    this.emojiSuggestions!.positionTarget = caratSpan;
-    this.openEmojiDropdown();
+    return caratSpan;
   }
 
-  /**
-   * handleKeydown used for key handling in the this.textarea! AND all child
-   * autocomplete options.
-   * private but used in test
-   */
-  onValueChanged(e: BindValueChangeEvent) {
-    // Relay the event.
-    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 (
-      e.currentTarget === null ||
-      !(e.currentTarget as IronAutogrowTextareaElement).focused
-    ) {
-      return;
-    }
-
-    const charAtCursor =
-      e.detail && e.detail.value
-        ? e.detail.value[this.textarea!.selectionStart - 1]
-        : '';
-    if (charAtCursor !== ':' && this.colonIndex === null) {
-      return;
-    }
-
-    // When a colon is detected, set a colon index. We are interested only on
-    // colons after space or in beginning of textarea
-    if (charAtCursor === ':') {
-      if (
-        this.textarea!.selectionStart < 2 ||
-        e.detail.value[this.textarea!.selectionStart - 2] === ' '
-      ) {
-        this.colonIndex = this.textarea!.selectionStart - 1;
-      }
-    }
-    if (this.colonIndex === null) {
-      return;
-    }
-
-    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:
+  private shouldResetDropdown(
+    text: string,
+    charIndex: number,
+    suggestions?: Item[],
+    char?: string
+  ) {
+    // Under any of 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 (
+    return (
       this.textarea!.selectionStart !==
-        this.currentSearchString.length + this.colonIndex + 1 ||
+        (this.currentSearchString ?? '').length + charIndex + 1 ||
       this.currentSearchString === ' ' ||
       this.currentSearchString === '\n' ||
-      !(e.detail.value[this.colonIndex] === ':') ||
-      !this.suggestions ||
-      !this.suggestions.length
+      !(text[charIndex] === char) ||
+      !suggestions ||
+      !suggestions.length
+    );
+  }
+
+  // When special char is detected, set index. We are interested only on
+  // special char after space or in beginning of textarea
+  // In case of mentions we are interested if previous char is '\n' as well
+  private getSpecialCharIndex(text: string) {
+    const charAtCursor = text[this.textarea!.selectionStart - 1];
+    if (
+      this.textarea!.selectionStart < 2 ||
+      text[this.textarea!.selectionStart - 2] === ' '
     ) {
-      this.resetEmojiDropdown();
+      return this.textarea!.selectionStart - 1;
+    }
+    if (
+      charAtCursor === '@' &&
+      text[this.textarea!.selectionStart - 2] === '\n'
+    ) {
+      return this.textarea!.selectionStart - 1;
+    }
+    return -1;
+  }
+
+  private async computeSuggestions() {
+    if (this.currentSearchString === undefined) {
+      this.suggestions = [];
+      return;
+    }
+    if (this.isEmojiDropdownActive()) {
+      this.computeEmojiSuggestions(this.currentSearchString);
+    } else if (this.isMentionsDropdownActive()) {
+      await this.computeReviewerSuggestions();
+    }
+  }
+
+  private openOrResetDropdown() {
+    let activeDropdown: GrAutocompleteDropdown;
+    let activate: () => void;
+    if (this.isEmojiDropdownActive()) {
+      activeDropdown = this.emojiSuggestions!;
+      activate = () => this.openEmojiDropdown();
+    } else if (this.isMentionsDropdownActive()) {
+      activeDropdown = this.mentionsSuggestions!;
+      activate = () => this.openMentionsDropdown();
+    } else {
+      this.resetDropdown();
+      return;
+    }
+
+    if (
+      this.shouldResetDropdown(
+        this.text,
+        this.specialCharIndex,
+        this.suggestions,
+        this.text[this.specialCharIndex]
+      )
+    ) {
+      this.resetDropdown();
+    } else if (activeDropdown!.isHidden && this.textarea!.focused) {
       // Otherwise open the dropdown and set the position to be just below the
       // cursor.
-    } else if (this.emojiSuggestions!.isHidden) {
-      this.updateCaratPosition();
+      // Do not open dropdown if textarea is not focused
+      activeDropdown.setPositionTarget(this.updateCaratPosition());
+      activate();
     }
+  }
+
+  private isMentionsDropdownActive() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
+      return false;
+    return (
+      this.specialCharIndex !== -1 && this.text[this.specialCharIndex] === '@'
+    );
+  }
+
+  private isEmojiDropdownActive() {
+    return (
+      this.specialCharIndex !== -1 && this.text[this.specialCharIndex] === ':'
+    );
+  }
+
+  private computeSpecialCharIndex() {
+    const charAtCursor = this.text[this.textarea!.selectionStart - 1];
+
+    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+      if (charAtCursor === '@' && this.specialCharIndex === -1) {
+        this.specialCharIndex = this.getSpecialCharIndex(this.text);
+      }
+    }
+    if (charAtCursor === ':' && this.specialCharIndex === -1) {
+      this.specialCharIndex = this.getSpecialCharIndex(this.text);
+    }
+  }
+
+  private computeCurrentSearchString() {
+    if (this.specialCharIndex === -1) {
+      this.currentSearchString = undefined;
+      return;
+    }
+    this.currentSearchString = this.text.substr(
+      this.specialCharIndex + 1,
+      this.textarea!.selectionStart - this.specialCharIndex - 1
+    );
+  }
+
+  // Private but used in tests.
+  async handleTextChanged() {
+    await this.computeSuggestions();
+    this.openOrResetDropdown();
     this.textarea!.textarea.focus();
   }
 
@@ -454,10 +576,16 @@
     this.reporting.reportInteraction('open-emoji-dropdown');
   }
 
+  private openMentionsDropdown() {
+    this.mentionsSuggestions!.open();
+    this.reporting.reportInteraction('open-mentions-dropdown');
+  }
+
   // private but used in test
   formatSuggestions(matchedSuggestions: EmojiSuggestion[]) {
     const suggestions = [];
     for (const suggestion of matchedSuggestions) {
+      assert(isEmojiSuggestion(suggestion), 'malformed suggestion');
       suggestion.dataValue = suggestion.value;
       suggestion.text = `${suggestion.value} ${suggestion.match}`;
       suggestions.push(suggestion);
@@ -466,40 +594,58 @@
   }
 
   // private but used in test
-  determineSuggestions(emojiText: string) {
-    if (!emojiText.length) {
+  computeEmojiSuggestions(suggestionsText?: string) {
+    if (suggestionsText === undefined) {
+      this.suggestions = [];
+      return;
+    }
+    if (!suggestionsText.length) {
       this.formatSuggestions(ALL_SUGGESTIONS);
-      this.disableEnterKeyForSelectingEmoji = true;
     } else {
       const matches = ALL_SUGGESTIONS.filter(suggestion =>
-        suggestion.match.includes(emojiText)
+        suggestion.match.includes(suggestionsText)
       ).slice(0, MAX_ITEMS_DROPDOWN);
       this.formatSuggestions(matches);
-      this.disableEnterKeyForSelectingEmoji = false;
     }
   }
 
+  // TODO(dhruvsri): merge with getAccountSuggestions in account-util
+  async computeReviewerSuggestions() {
+    this.suggestions = (
+      (await this.restApiService.getSuggestedAccounts(
+        this.currentSearchString ?? '',
+        /* number= */ 15,
+        this.changeNum,
+        /* filterActive= */ true
+      )) ?? []
+    )
+      .filter(account => account.email)
+      .map(account => {
+        return {
+          text: `${getAccountDisplayName(this.serverConfig, account)}`,
+          dataValue: account.email,
+        };
+      });
+  }
+
   // private but used in test
-  resetEmojiDropdown() {
+  resetDropdown() {
     // hide and reset the autocomplete dropdown.
     this.requestUpdate();
     this.currentSearchString = '';
-    this.hideEmojiAutocomplete = true;
     this.closeDropdown();
-    this.colonIndex = null;
-    this.textarea!.textarea.focus();
+    this.specialCharIndex = -1;
+    this.textarea?.textarea.focus();
   }
 
-  private handleTextChanged(text: string) {
+  private fireChangedEvents() {
     // 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}})
-    );
+    fire(this, 'value-changed', {value: this.text});
+    fire(this, 'text-changed', {value: this.text});
+    // Relay the event.
+    fire(this, 'bind-value-changed', {value: this.text});
   }
 
   private indent(e: KeyboardEvent): void {
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 a3b5bf4..78c8aa3 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
@@ -1,27 +1,21 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-textarea';
 import {GrTextarea} from './gr-textarea';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {waitUntil} from '../../../test/test-utils';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {
+  pressKey,
+  stubFlags,
+  stubRestApi,
+  waitUntil,
+} from '../../../test/test-utils';
+import {fixture, html, assert} from '@open-wc/testing';
+import {createAccountWithEmail} from '../../../test/test-data-generators';
+import {Key} from '../../../utils/dom-util';
 
 suite('gr-textarea tests', () => {
   let element: GrTextarea;
@@ -33,16 +27,299 @@
   });
 
   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> `);
+    assert.shadowDom.equal(
+      element,
+      /* 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" focused="" id="textarea">
+        </iron-autogrow-textarea> `,
+      {
+        // gr-autocomplete-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [
+          {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+        ],
+      }
+    );
+  });
+
+  suite('mention users', () => {
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('renders', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div id="hiddenText"></div>
+          <span id="caratSpan"> </span>
+          <gr-autocomplete-dropdown
+            id="emojiSuggestions"
+            is-hidden=""
+            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
+          >
+          </gr-autocomplete-dropdown>
+          <gr-autocomplete-dropdown
+            id="mentionsSuggestions"
+            is-hidden=""
+            role="listbox"
+            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
+          >
+          </gr-autocomplete-dropdown>
+          <iron-autogrow-textarea
+            focused=""
+            aria-disabled="false"
+            id="textarea"
+          >
+          </iron-autogrow-textarea>
+        `,
+        {
+          // gr-autocomplete-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [
+            {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+          ],
+        }
+      );
+    });
+
+    test('mentions selector is open when @ is typed & the textarea has focus', async () => {
+      // Needed for Safari tests. selectionStart is not updated when text is
+      // updated.
+      const listenerStub = sinon.stub();
+      element.addEventListener('bind-value-changed', listenerStub);
+      stubRestApi('getSuggestedAccounts').returns(
+        Promise.resolve([
+          createAccountWithEmail('abc@google.com'),
+          createAccountWithEmail('abcdef@google.com'),
+        ])
+      );
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '@';
+
+      await waitUntil(() => element.suggestions.length > 0);
+      await element.updateComplete;
+
+      assert.equal(listenerStub.lastCall.args[0].detail.value, '@');
+      assert.isTrue(element.textarea!.focused);
+
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+
+      assert.equal(element.specialCharIndex, 0);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+      assert.equal(element.currentSearchString, '');
+
+      element.text = '@abc@google.com';
+      await element.updateComplete;
+
+      assert.equal(element.currentSearchString, 'abc@google.com');
+      assert.equal(element.specialCharIndex, 0);
+    });
+
+    test('mention selector opens when previous char is \n', async () => {
+      stubRestApi('getSuggestedAccounts').returns(
+        Promise.resolve([
+          {
+            ...createAccountWithEmail('abc@google.com'),
+            name: 'A',
+            display_name: 'display A',
+          },
+          {...createAccountWithEmail('abcdef@google.com'), name: 'B'},
+        ])
+      );
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '\n@';
+
+      await waitUntil(() => element.suggestions.length > 0);
+      await element.updateComplete;
+
+      assert.deepEqual(element.suggestions, [
+        {
+          dataValue: 'abc@google.com',
+          text: 'display A <abc@google.com>',
+        },
+        {
+          dataValue: 'abcdef@google.com',
+          text: 'B <abcdef@google.com>',
+        },
+      ]);
+
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+    });
+
+    test('emoji selector does not open when previous char is \n', async () => {
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '\n:';
+
+      await element.updateComplete;
+
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isTrue(element.mentionsSuggestions!.isHidden);
+    });
+
+    test('selecting mentions from dropdown', async () => {
+      stubRestApi('getSuggestedAccounts').returns(
+        Promise.resolve([
+          createAccountWithEmail('abc@google.com'),
+          createAccountWithEmail('abcdef@google.com'),
+        ])
+      );
+
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '@';
+
+      await waitUntil(() => element.suggestions.length > 0);
+      await element.updateComplete;
+
+      pressKey(element, 'ArrowDown');
+      await element.updateComplete;
+
+      pressKey(element, 'ArrowDown');
+      await element.updateComplete;
+
+      pressKey(element, Key.ENTER);
+      await element.updateComplete;
+
+      assert.equal(element.text, '@abcdef@google.com');
+    });
+
+    test('emoji dropdown does not open if mention dropdown is open', async () => {
+      const listenerStub = sinon.stub();
+      element.addEventListener('bind-value-changed', listenerStub);
+      const resetSpy = sinon.spy(element, 'resetDropdown');
+      stubRestApi('getSuggestedAccounts').returns(
+        Promise.resolve([
+          createAccountWithEmail('abc@google.com'),
+          createAccountWithEmail('abcdef@google.com'),
+        ])
+      );
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '@';
+      element.suggestions = [
+        {
+          name: 'a',
+          value: 'a',
+        },
+      ];
+      await waitUntil(() => element.suggestions.length > 0);
+      await element.updateComplete;
+
+      assert.isFalse(resetSpy.called);
+
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+
+      element.text = '@h';
+      await element.updateComplete;
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+
+      element.text = '@h ';
+      await element.updateComplete;
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+
+      element.text = '@h :';
+      await element.updateComplete;
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+
+      element.text = '@h :D';
+      await element.updateComplete;
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+    });
+
+    test('mention dropdown does not open if emoji dropdown is open', async () => {
+      const listenerStub = sinon.stub();
+      element.addEventListener('bind-value-changed', listenerStub);
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = ':';
+      element.suggestions = [
+        {
+          name: 'a',
+          value: 'a',
+        },
+      ];
+
+      await element.updateComplete;
+      assert.isFalse(element.emojiSuggestions!.isHidden);
+      assert.isTrue(element.mentionsSuggestions!.isHidden);
+
+      element.text = ':D';
+      await element.updateComplete;
+      assert.isFalse(element.emojiSuggestions!.isHidden);
+      assert.isTrue(element.mentionsSuggestions!.isHidden);
+
+      element.text = ':D@';
+      await element.updateComplete;
+      // emoji dropdown hidden since we have no more suggestions
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isTrue(element.mentionsSuggestions!.isHidden);
+
+      element.text = ':D@b';
+      await element.updateComplete;
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isTrue(element.mentionsSuggestions!.isHidden);
+    });
+
+    test('mention dropdown is cleared if @ is deleted', async () => {
+      stubRestApi('getSuggestedAccounts').returns(
+        Promise.resolve([
+          createAccountWithEmail('abc@google.com'),
+          createAccountWithEmail('abcdef@google.com'),
+        ])
+      );
+
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '@';
+
+      await waitUntil(() => element.suggestions.length > 0);
+      await element.updateComplete;
+
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+
+      element.text = '';
+      await element.updateComplete;
+      assert.isTrue(element.mentionsSuggestions!.isHidden);
+    });
   });
 
   test('monospace is set properly', () => {
@@ -53,22 +330,25 @@
     assert.isFalse(element.textarea!.classList.contains('noBorder'));
   });
 
-  test('emoji selector is not open with the textarea lacks focus', async () => {
+  test('emoji selector is not open when the textarea lacks focus', async () => {
+    // by default textarea has focus when rendered
+    // explicitly remove focus from the element for the test
+    element.blur();
     element.textarea!.selectionStart = 1;
     element.textarea!.selectionEnd = 1;
     element.text = ':';
     await element.updateComplete;
-    assert.isFalse(!element.emojiSuggestions!.isHidden);
+    assert.isTrue(element.emojiSuggestions!.isHidden);
   });
 
   test('emoji selector is not open when a general text is entered', async () => {
-    MockInteractions.focus(element.textarea!);
+    element.textarea!.focus();
     await waitUntil(() => element.textarea!.focused === true);
     element.textarea!.selectionStart = 9;
     element.textarea!.selectionEnd = 9;
     element.text = 'some text';
     await element.updateComplete;
-    assert.isFalse(!element.emojiSuggestions!.isHidden);
+    assert.isTrue(element.emojiSuggestions!.isHidden);
   });
 
   test('emoji selector is open when a colon is typed & the textarea has focus', async () => {
@@ -76,7 +356,7 @@
     // updated.
     const listenerStub = sinon.stub();
     element.addEventListener('bind-value-changed', listenerStub);
-    MockInteractions.focus(element.textarea!);
+    element.textarea!.focus();
     await waitUntil(() => element.textarea!.focused === true);
     element.textarea!.selectionStart = 1;
     element.textarea!.selectionEnd = 1;
@@ -85,13 +365,13 @@
     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.specialCharIndex, 0);
+    assert.isTrue(!element.emojiSuggestions!.isHidden);
     assert.equal(element.currentSearchString, '');
   });
 
   test('emoji selector opens when a colon is typed after space', async () => {
-    MockInteractions.focus(element.textarea!);
+    element.textarea!.focus();
     await waitUntil(() => element.textarea!.focused === true);
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
@@ -100,13 +380,13 @@
     element.text = ' :';
     await element.updateComplete;
     assert.isFalse(element.emojiSuggestions!.isHidden);
-    assert.equal(element.colonIndex, 1);
-    assert.isFalse(element.hideEmojiAutocomplete);
+    assert.equal(element.specialCharIndex, 1);
+    assert.isTrue(!element.emojiSuggestions!.isHidden);
     assert.equal(element.currentSearchString, '');
   });
 
   test('emoji selector doesn`t open when a colon is typed after character', async () => {
-    MockInteractions.focus(element.textarea!);
+    element.textarea!.focus();
     await waitUntil(() => element.textarea!.focused === true);
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
@@ -115,11 +395,11 @@
     element.text = 'test:';
     await element.updateComplete;
     assert.isTrue(element.emojiSuggestions!.isHidden);
-    assert.isTrue(element.hideEmojiAutocomplete);
+    assert.isTrue(element.emojiSuggestions!.isHidden);
   });
 
   test('emoji selector opens when a colon is typed and some substring', async () => {
-    MockInteractions.focus(element.textarea!);
+    element.textarea!.focus();
     await waitUntil(() => element.textarea!.focused === true);
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
@@ -132,13 +412,13 @@
     element.text = ':t';
     await element.updateComplete;
     assert.isFalse(element.emojiSuggestions!.isHidden);
-    assert.equal(element.colonIndex, 0);
-    assert.isFalse(element.hideEmojiAutocomplete);
+    assert.equal(element.specialCharIndex, 0);
+    assert.isTrue(!element.emojiSuggestions!.isHidden);
     assert.equal(element.currentSearchString, 't');
   });
 
   test('emoji selector opens when a colon is typed in middle of text', async () => {
-    MockInteractions.focus(element.textarea!);
+    element.textarea!.focus();
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
     element.textarea!.selectionStart = 1;
@@ -149,6 +429,7 @@
     sinon.stub(element, 'textarea').value({
       selectionStart: 1,
       value: text,
+      focused: true,
       textarea: {
         focus: () => {},
       },
@@ -156,14 +437,13 @@
     element.text = text;
     await element.updateComplete;
     assert.isFalse(element.emojiSuggestions!.isHidden);
-    assert.equal(element.colonIndex, 0);
-    assert.isFalse(element.hideEmojiAutocomplete);
+    assert.equal(element.specialCharIndex, 0);
+    assert.isTrue(!element.emojiSuggestions!.isHidden);
     assert.equal(element.currentSearchString, '');
   });
 
   test('emoji selector closes when text changes before the colon', async () => {
-    const resetStub = sinon.stub(element, 'resetEmojiDropdown');
-    MockInteractions.focus(element.textarea!);
+    element.textarea!.focus();
     await waitUntil(() => element.textarea!.focused === true);
     await element.updateComplete;
     element.textarea!.selectionStart = 10;
@@ -172,37 +452,44 @@
     await element.updateComplete;
     element.textarea!.selectionStart = 12;
     element.textarea!.selectionEnd = 12;
+
     element.text = 'test test :';
     await element.updateComplete;
+
+    // typing : opens the selector
+    assert.isFalse(element.emojiSuggestions!.isHidden);
+
     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);
+    assert.isFalse(element.emojiSuggestions!.isHidden);
+
     element.text = 'test test test :smi';
     await element.updateComplete;
-    assert.isTrue(resetStub.called);
+
+    assert.isTrue(element.emojiSuggestions!.isHidden);
   });
 
-  test('resetEmojiDropdown', async () => {
+  test('resetDropdown', async () => {
     const closeSpy = sinon.spy(element, 'closeDropdown');
-    element.resetEmojiDropdown();
+    element.resetDropdown();
     assert.equal(element.currentSearchString, '');
-    assert.isTrue(element.hideEmojiAutocomplete);
-    assert.equal(element.colonIndex, null);
+    assert.isTrue(element.emojiSuggestions!.isHidden);
+    assert.equal(element.specialCharIndex, -1);
 
     element.emojiSuggestions!.open();
     await element.updateComplete;
-    element.resetEmojiDropdown();
+    element.resetDropdown();
     assert.isTrue(closeSpy.called);
   });
 
-  test('determineSuggestions', () => {
+  test('determineEmojiSuggestions', () => {
     const emojiText = 'tear';
     const formatSpy = sinon.spy(element, 'formatSuggestions');
-    element.determineSuggestions(emojiText);
+    element.computeEmojiSuggestions(emojiText);
     assert.isTrue(formatSpy.called);
     assert.isTrue(
       formatSpy.lastCall.calledWithExactly([
@@ -232,17 +519,17 @@
     );
   });
 
-  test('handleEmojiSelect', async () => {
+  test('handleDropdownItemSelect', async () => {
     element.textarea!.selectionStart = 16;
     element.textarea!.selectionEnd = 16;
     element.text = 'test test :tears';
-    element.colonIndex = 10;
+    element.specialCharIndex = 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.handleDropdownItemSelect(event);
     assert.equal(element.text, 'test test 😂');
   });
 
@@ -261,9 +548,7 @@
   test('newline receives matching indentation', async () => {
     const indentCommand = sinon.stub(document, 'execCommand');
     element.textarea!.value = '    a';
-    element.handleEnterByKey(
-      new KeyboardEvent('keydown', {key: 'Enter', keyCode: 13})
-    );
+    element.handleEnterByKey(new KeyboardEvent('keydown', {key: 'Enter'}));
     await element.updateComplete;
     assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
   });
@@ -280,19 +565,9 @@
     assert.isTrue(resetSpy.called);
   });
 
-  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);
-    assert.isTrue(listenerStub.called);
-  });
-
   suite('keyboard shortcuts', async () => {
     async function setupDropdown() {
-      MockInteractions.focus(element.textarea!);
+      element.textarea!.focus();
       element.textarea!.selectionStart = 1;
       element.textarea!.selectionEnd = 1;
       element.text = ':';
@@ -300,83 +575,44 @@
       element.textarea!.selectionStart = 1;
       element.textarea!.selectionEnd = 2;
       element.text = ':1';
+      await element.emojiSuggestions!.updateComplete;
       await element.updateComplete;
     }
 
     test('escape key', async () => {
-      const resetSpy = sinon.spy(element, 'resetEmojiDropdown');
-      MockInteractions.pressAndReleaseKeyOn(
-        element.textarea!,
-        27,
-        null,
-        'Escape'
-      );
+      const resetSpy = sinon.spy(element, 'resetDropdown');
+      pressKey(element.textarea! as HTMLElement, Key.ESC);
       assert.isFalse(resetSpy.called);
       await setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(
-        element.textarea!,
-        27,
-        null,
-        'Escape'
-      );
+      pressKey(element.textarea! as HTMLElement, Key.ESC);
       assert.isTrue(resetSpy.called);
-      assert.isFalse(!element.emojiSuggestions!.isHidden);
+      assert.isTrue(element.emojiSuggestions!.isHidden);
     });
 
     test('up key', async () => {
       const upSpy = sinon.spy(element.emojiSuggestions!, 'cursorUp');
-      MockInteractions.pressAndReleaseKeyOn(
-        element.textarea!,
-        38,
-        null,
-        'ArrowUp'
-      );
+      pressKey(element.textarea! as HTMLElement, 'ArrowUp');
       assert.isFalse(upSpy.called);
       await setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(
-        element.textarea!,
-        38,
-        null,
-        'ArrowUp'
-      );
+      pressKey(element.textarea! as HTMLElement, 'ArrowUp');
       assert.isTrue(upSpy.called);
     });
 
     test('down key', async () => {
       const downSpy = sinon.spy(element.emojiSuggestions!, 'cursorDown');
-      MockInteractions.pressAndReleaseKeyOn(
-        element.textarea!,
-        40,
-        null,
-        'ArrowDown'
-      );
+      pressKey(element.textarea! as HTMLElement, 'ArrowDown');
       assert.isFalse(downSpy.called);
       await setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(
-        element.textarea!,
-        40,
-        null,
-        'ArrowDown'
-      );
+      pressKey(element.textarea! as HTMLElement, 'ArrowDown');
       assert.isTrue(downSpy.called);
     });
 
     test('enter key', async () => {
       const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
-      MockInteractions.pressAndReleaseKeyOn(
-        element.textarea!,
-        13,
-        null,
-        'Enter'
-      );
+      pressKey(element.textarea! as HTMLElement, Key.ENTER);
       assert.isFalse(enterSpy.called);
       await setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(
-        element.textarea!,
-        13,
-        null,
-        'Enter'
-      );
+      pressKey(element.textarea! as HTMLElement, Key.ENTER);
       assert.isTrue(enterSpy.called);
       await element.updateComplete;
       assert.equal(element.text, '💯');
@@ -384,14 +620,14 @@
 
     test('enter key - ignored on just colon without more information', async () => {
       const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
-      MockInteractions.pressAndReleaseKeyOn(element.textarea!, 13);
+      pressKey(element.textarea! as HTMLElement, Key.ENTER);
       assert.isFalse(enterSpy.called);
-      MockInteractions.focus(element.textarea!);
+      element.textarea!.focus();
       element.textarea!.selectionStart = 1;
       element.textarea!.selectionEnd = 1;
       element.text = ':';
       await element.updateComplete;
-      MockInteractions.pressAndReleaseKeyOn(element.textarea!, 13);
+      pressKey(element.textarea! as HTMLElement, Key.ENTER);
       assert.isFalse(enterSpy.called);
     });
   });
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 aaf255a..0e6b19e 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
@@ -1,25 +1,14 @@
 /**
  * @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-icons/gr-icons';
+import '../gr-icon/gr-icon';
 import '../gr-tooltip/gr-tooltip';
 import {getRootElement} from '../../../scripts/rootElement';
 import {GrTooltip} from '../gr-tooltip/gr-tooltip';
 import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 
 const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
 
@@ -84,10 +73,8 @@
   static override get styles() {
     return [
       css`
-        iron-icon {
-          width: var(--line-height-normal);
-          height: var(--line-height-normal);
-          vertical-align: top;
+        gr-icon {
+          font-size: var(--line-height-normal);
         }
       `,
     ];
@@ -102,7 +89,7 @@
 
   renderIcon() {
     if (!this.showIcon) return;
-    return html`<iron-icon icon="gr-icons:info"></iron-icon>`;
+    return html`<gr-icon icon="info" filled></gr-icon>`;
   }
 
   override updated(changedProperties: PropertyValues) {
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
index de35c2d..f4dbc3e5 100644
--- 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
@@ -1,24 +1,12 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-tooltip-content';
 import {GrTooltipContent} from './gr-tooltip-content';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrTooltip} from '../gr-tooltip/gr-tooltip';
 import {query} from '../../../test/test-utils';
 
@@ -48,14 +36,27 @@
     await element.updateComplete;
   });
 
+  test('render', async () => {
+    element.showIcon = true;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <slot> </slot>
+        <gr-icon icon="info" filled></gr-icon>
+      `
+    );
+  });
+
   test('icon is not visible by default', () => {
-    assert.isNotOk(query(element, 'iron-icon'));
+    assert.isNotOk(query(element, 'gr-icon'));
   });
 
   test('icon is visible with showIcon property', async () => {
     element.showIcon = true;
     await element.updateComplete;
-    assert.isOk(query(element, 'iron-icon'));
+    assert.isOk(query(element, 'gr-icon'));
   });
 
   test('position-below attribute is reflected', async () => {
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 bd4efcd..681378d 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -1,24 +1,12 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {styleMap} from 'lit/directives/style-map';
+import {customElement, property} from 'lit/decorators.js';
+import {styleMap} from 'lit/directives/style-map.js';
 
 declare global {
   interface HTMLElementTagNameMap {
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 db25c52..63ed1ff 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
@@ -1,35 +1,38 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-tooltip';
 import {GrTooltip} from './gr-tooltip';
 import {queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-tooltip');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-tooltip tests', () => {
   let element: GrTooltip;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-tooltip></gr-tooltip>`);
     await element.updateComplete;
   });
 
+  test('render', async () => {
+    element.text = 'tooltipText';
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="tooltip">
+          <i class="arrow arrowPositionBelow" style="margin-left:0;"> </i>
+          tooltipText
+          <i class="arrow arrowPositionAbove" style="margin-left:0;"> </i>
+        </div>
+      `
+    );
+  });
+
   test('max-width is respected if set', async () => {
     element.text =
       'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
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 c4aa413..02bde24 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
@@ -1,30 +1,17 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-tooltip-content/gr-tooltip-content';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {
   ApprovalInfo,
   isDetailedLabelInfo,
   isQuickLabelInfo,
   LabelInfo,
 } from '../../../api/rest-api';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   classForLabelStatus,
   getLabelStatus,
@@ -61,8 +48,6 @@
   @property({type: Boolean, attribute: 'tooltip-with-who-voted'})
   tooltipWithWhoVoted = false;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       css`
@@ -137,9 +122,6 @@
   }
 
   override render() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI))
-      return;
-
     const renderValue = this.renderValue();
     if (!renderValue) return;
 
@@ -169,9 +151,9 @@
       }
     } else if (isQuickLabelInfo(this.label)) {
       if (this.label.approved) {
-        return '👍';
+        return html`&#x2713;`; // check mark
       } else if (this.label.rejected) {
-        return '👎';
+        return html`&#x2717;`; // x mark
       } else if (this.label.disliked || this.label.recommended) {
         return valueString(this.label.value);
       }
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
index 72ff8d7..581b577 100644
--- 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
@@ -1,22 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {fixture} from '@open-wc/testing-helpers';
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import {getAppContext} from '../../../services/app-context';
 import './gr-vote-chip';
@@ -35,26 +23,44 @@
   });
 
   suite('with QuickLabelInfo', () => {
-    let element: GrVoteChip;
-
-    setup(async () => {
+    test('renders positive', async () => {
       const labelInfo = {
         ...createQuickLabelInfo(),
         approved: createAccountWithIdNameAndEmail(),
       };
-      element = await fixture<GrVoteChip>(
+      const element = await fixture<GrVoteChip>(
         html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`
       );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ ` <gr-tooltip-content
+          class="container"
+          has-tooltip=""
+          title=""
+        >
+          <div class="max vote-chip">&#x2713;</div>
+        </gr-tooltip-content>`
+      );
     });
 
-    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>`);
+    test('renders negative', async () => {
+      const labelInfo = {
+        ...createQuickLabelInfo(),
+        rejected: createAccountWithIdNameAndEmail(),
+      };
+      const element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ ` <gr-tooltip-content
+          class="container"
+          has-tooltip=""
+          title=""
+        >
+          <div class="min vote-chip">&#x2717;</div>
+        </gr-tooltip-content>`
+      );
     });
   });
 
@@ -73,13 +79,16 @@
     });
 
     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>`);
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ ` <gr-tooltip-content
+          class="container"
+          has-tooltip=""
+          title=""
+        >
+          <div class="positive vote-chip">+2</div>
+        </gr-tooltip-content>`
+      );
     });
 
     test('renders negative vote', async () => {
@@ -90,13 +99,16 @@
       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>`);
+      assert.shadowDom.equal(
+        element,
+        /* 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 () => {
@@ -107,14 +119,62 @@
           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>`);
+      assert.shadowDom.equal(
+        element,
+        /* 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>`
+      );
+    });
+
+    test('renders with tooltip who voted', async () => {
+      vote.name = 'Tester';
+      const labelInfo = {
+        all: [{value: 2}, {value: 1}],
+        values: {'+2': 'Great'},
+      };
+      element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip
+          .label=${labelInfo}
+          .vote=${vote}
+          tooltip-with-who-voted
+        ></gr-vote-chip>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ ` <gr-tooltip-content
+          class="container"
+          has-tooltip=""
+          title="Tester: Great"
+        >
+          <div class="max vote-chip">+2</div>
+        </gr-tooltip-content>`
+      );
+    });
+
+    test('renders with display value instead of latest vote', async () => {
+      element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip
+          .displayValue=${-1}
+          .label=${labelInfo}
+          .vote=${vote}
+        ></gr-vote-chip>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ ` <gr-tooltip-content
+          class="container"
+          has-tooltip=""
+          title=""
+        >
+          <div class="min vote-chip">-1</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 b360dc0..91b9910 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -1,25 +1,15 @@
 /**
  * @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 {ChangeInfo, PatchSetNum} from '../../../types/common';
+import {
+  ChangeInfo,
+  PatchSetNum,
+  RevisionPatchSetNum,
+} from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
 
-type RevNumberToParentCountMap = {[revNumber: number]: number};
-
 export class RevisionInfo {
   /**
    * @param change A change object resulting from a change detail
@@ -47,22 +37,25 @@
    * Get an object that maps revision numbers to the number of parents of the
    * commit of that revision.
    */
-  getParentCountMap() {
-    const result: RevNumberToParentCountMap = {};
+  getParentCountMap(): Map<RevisionPatchSetNum, number> {
+    const result: Map<RevisionPatchSetNum, number> = new Map();
     if (!this.change || !this.change.revisions) {
-      return {};
+      return result;
     }
     Object.values(this.change.revisions).forEach(rev => {
-      if (rev.commit) result[rev._number as number] = rev.commit.parents.length;
+      if (rev.commit) result.set(rev._number, rev.commit.parents.length);
     });
     return result;
   }
 
-  getParentCount(patchNum: PatchSetNum) {
-    return this.getParentCountMap()[patchNum as number];
+  getParentCount(patchNum: RevisionPatchSetNum): number {
+    // The caller should make sure to pass a known `patchNum`, but `1` seems to
+    // be a reasonable default. Normally a revision has one parent.
+    return this.getParentCountMap().get(patchNum) ?? 1;
   }
 
-  isMergeCommit(patchNum: PatchSetNum) {
+  isMergeCommit(patchNum?: RevisionPatchSetNum) {
+    if (patchNum === undefined) return false;
     return this.getParentCount(patchNum) > 1;
   }
 
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
index f862d72..055626f 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.ts
@@ -1,27 +1,16 @@
 /**
  * @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 '../../../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import {
   createChange,
   createCommit,
   createRevision,
 } from '../../../test/test-data-generators';
-import {ChangeInfo, CommitId, PatchSetNum} from '../../../types/common';
+import {ChangeInfo, CommitId, PatchSetNumber} from '../../../types/common';
 import './revision-info';
 import {RevisionInfo} from './revision-info';
 
@@ -33,8 +22,7 @@
       ...createChange(),
       revisions: {
         r1: {
-          ...createRevision(),
-          _number: 1 as PatchSetNum,
+          ...createRevision(1),
           commit: {
             ...createCommit(),
             parents: [
@@ -45,8 +33,7 @@
           },
         },
         r2: {
-          ...createRevision(),
-          _number: 2 as PatchSetNum,
+          ...createRevision(2),
           commit: {
             ...createCommit(),
             parents: [
@@ -56,16 +43,14 @@
           },
         },
         r3: {
-          ...createRevision(),
-          _number: 3 as PatchSetNum,
+          ...createRevision(3),
           commit: {
             ...createCommit(),
             parents: [{commit: 'p5' as CommitId, subject: ''}],
           },
         },
         r4: {
-          ...createRevision(),
-          _number: 4 as PatchSetNum,
+          ...createRevision(4),
           commit: {
             ...createCommit(),
             parents: [
@@ -75,8 +60,7 @@
           },
         },
         r5: {
-          ...createRevision(),
-          _number: 5 as PatchSetNum,
+          ...createRevision(5),
           commit: {
             ...createCommit(),
             parents: [
@@ -97,25 +81,34 @@
 
   test('getParentCountMap', () => {
     const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentCountMap(), {1: 3, 2: 2, 3: 1, 4: 2, 5: 3});
+    assert.deepEqual(
+      ri.getParentCountMap(),
+      new Map([
+        [1 as PatchSetNumber, 3],
+        [2 as PatchSetNumber, 2],
+        [3 as PatchSetNumber, 1],
+        [4 as PatchSetNumber, 2],
+        [5 as PatchSetNumber, 3],
+      ])
+    );
   });
 
   test('getParentCount', () => {
     const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentCount(1 as PatchSetNum), 3);
-    assert.deepEqual(ri.getParentCount(3 as PatchSetNum), 1);
+    assert.deepEqual(ri.getParentCount(1 as PatchSetNumber), 3);
+    assert.deepEqual(ri.getParentCount(3 as PatchSetNumber), 1);
   });
 
   test('getParentCount', () => {
     const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentCount(1 as PatchSetNum), 3);
-    assert.deepEqual(ri.getParentCount(3 as PatchSetNum), 1);
+    assert.deepEqual(ri.getParentCount(1 as PatchSetNumber), 3);
+    assert.deepEqual(ri.getParentCount(3 as PatchSetNumber), 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);
+    assert.deepEqual(ri.getParentId(1 as PatchSetNumber, 2), 'p3' as CommitId);
+    assert.deepEqual(ri.getParentId(2 as PatchSetNumber, 1), 'p4' as CommitId);
+    assert.deepEqual(ri.getParentId(3 as PatchSetNumber, 0), 'p5' as CommitId);
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index 77a5dfb..a0d06f6 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/paper-button/paper-button';
 import '@polymer/paper-card/paper-card';
@@ -32,7 +21,7 @@
 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 {customElement, property} from 'lit/decorators.js';
 import {subscribe} from '../../../elements/lit/subscription-controller';
 
 import {
@@ -58,7 +47,7 @@
  * It finds the closest block that contains the whole line and
  * returns the whole path from the syntax layer (blocks) sent as parameter
  * to the most nested block - the complete path from the top to bottom layer of
- * a syntax tree. Example: [myNamepace, MyClass, myMethod1, aLocalFunctionInsideMethod1]
+ * a syntax tree. Example: [myNamespace, MyClass, myMethod1, aLocalFunctionInsideMethod1]
  *
  * @param lineNum line number for the targeted line.
  * @param blocks Blocks for a specific syntax level in the file (to allow recursive calls)
@@ -200,8 +189,8 @@
     }
   `;
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     this.setupButtonHoverHandler();
   }
 
@@ -220,16 +209,17 @@
   private setupButtonHoverHandler() {
     subscribe(
       this,
-      this.expandButtonsHover.pipe(
-        switchMap(e => {
-          if (e.eventType === 'leave') {
-            // cancel any previous delay
-            // for mouse enter
-            return EMPTY;
-          }
-          return of(e).pipe(delay(500));
-        })
-      ),
+      () =>
+        this.expandButtonsHover.pipe(
+          switchMap(e => {
+            if (e.eventType === 'leave') {
+              // cancel any previous delay
+              // for mouse enter
+              return EMPTY;
+            }
+            return of(e).pipe(delay(500));
+          })
+        ),
       ({buttonType, linesToExpand}) => {
         fire(this, 'diff-context-button-hovered', {
           buttonType,
@@ -248,9 +238,7 @@
   }
 
   private createExpandAllButtonContainer() {
-    return html` <div
-      class="style-scope gr-diff aboveBelowButtons fullExpansion"
-    >
+    return html` <div class="gr-diff aboveBelowButtons fullExpansion">
       ${this.createContextButton(ContextButtonType.ALL, this.numLines())}
     </div>`;
   }
@@ -444,7 +432,7 @@
     linesToExpand: number
   ) {
     // Create breadcrumb string:
-    // myNamepace > MyClass > myMethod1 > aLocalFunctionInsideMethod1 > (anonymous)
+    // myNamespace > MyClass > myMethod1 > aLocalFunctionInsideMethod1 > (anonymous)
     const tooltipText = syntaxPath.length
       ? syntaxPath.map(b => b.name || '(anonymous)').join(' > ')
       : `${linesToExpand} common lines`;
@@ -461,7 +449,7 @@
     numLines: number,
     referenceLine: number
   ) {
-    assertIsDefined(this.diff, 'diff');
+    if (!this.diff?.meta_b) return;
     const syntaxTree = this.diff.meta_b.syntax_tree;
     const outlineSyntaxPath = findBlockTreePathForLine(
       referenceLine,
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
index d8d4e25..8e2f432 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import '../gr-diff/gr-diff-group';
 import './gr-context-controls';
 import {GrContextControls} from './gr-context-controls';
@@ -23,8 +11,8 @@
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {DiffFileMetaInfo, DiffInfo, SyntaxBlock} from '../../../api/diff';
-
-const blankFixture = fixtureFromElement('div');
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitEventLoop} from '../../../test/test-utils';
 
 suite('gr-context-control tests', () => {
   let element: GrContextControls;
@@ -33,8 +21,9 @@
     element = document.createElement('gr-context-controls');
     element.diff = {content: []} as any as DiffInfo;
     element.renderPreferences = {};
-    blankFixture.instantiate().appendChild(element);
-    await flush();
+    const div = await fixture(html`<div></div>`);
+    div.appendChild(element);
+    await waitEventLoop();
   });
 
   function createContextGroup(options: {offset?: number; count?: number}) {
@@ -57,7 +46,7 @@
   test('no +10 buttons for 10 or less lines', async () => {
     element.group = createContextGroup({count: 10});
 
-    await flush();
+    await waitEventLoop();
 
     const buttons = element.shadowRoot!.querySelectorAll(
       'paper-button.showContext'
@@ -70,7 +59,7 @@
     element.group = createContextGroup({offset: 0, count: 20});
     element.showConfig = 'below';
 
-    await flush();
+    await waitEventLoop();
 
     const buttons = element.shadowRoot!.querySelectorAll(
       'paper-button.showContext'
@@ -88,7 +77,7 @@
     element.group = createContextGroup({offset: 10, count: 20});
     element.showConfig = 'both';
 
-    await flush();
+    await waitEventLoop();
 
     const buttons = element.shadowRoot!.querySelectorAll(
       'paper-button.showContext'
@@ -108,7 +97,7 @@
     element.group = createContextGroup({offset: 30, count: 20});
     element.showConfig = 'above';
 
-    await flush();
+    await waitEventLoop();
 
     const buttons = element.shadowRoot!.querySelectorAll(
       'paper-button.showContext'
@@ -134,7 +123,7 @@
     element.group = createContextGroup({offset: 0, count: 20});
     element.showConfig = 'below';
 
-    await flush();
+    await waitEventLoop();
 
     const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
       '.fullExpansion paper-button'
@@ -163,7 +152,7 @@
     element.group = createContextGroup({offset: 10, count: 20});
     element.showConfig = 'both';
 
-    await flush();
+    await waitEventLoop();
 
     const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
       '.fullExpansion paper-button'
@@ -200,7 +189,7 @@
     element.group = createContextGroup({offset: 30, count: 20});
     element.showConfig = 'above';
 
-    await flush();
+    await waitEventLoop();
 
     const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
       '.fullExpansion paper-button'
@@ -240,7 +229,7 @@
     element.group = createContextGroup({offset: 10, count: 20});
     element.showConfig = 'both';
 
-    await flush();
+    await waitEventLoop();
 
     const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
       '.blockExpansion paper-button'
@@ -292,7 +281,7 @@
     element.group = createContextGroup({offset: 10, count: 20});
     element.showConfig = 'both';
 
-    await flush();
+    await waitEventLoop();
 
     const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
       '.blockExpansion paper-button'
@@ -337,7 +326,7 @@
     ]);
     element.group = createContextGroup({offset: 10, count: 20});
     element.showConfig = 'both';
-    await flush();
+    await waitEventLoop();
 
     const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
       '.blockExpansion paper-button'
@@ -355,7 +344,7 @@
 
     element.group = createContextGroup({offset: 10, count: 20});
     element.showConfig = 'both';
-    await flush();
+    await waitEventLoop();
 
     const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
       '.blockExpansion paper-button'
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
index 5687b10..c1a123e 100644
--- 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
@@ -3,9 +3,10 @@
  * Copyright 2019 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {CoverageRange, CoverageType, Side} from '../../../api/diff';
 import {GrCoverageLayer} from './gr-coverage-layer';
+import {assert} from '@open-wc/testing';
 
 suite('gr-coverage-layer', () => {
   let layer: GrCoverageLayer;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
index 6699b57..c095ffb 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -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 {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index e426e66..cf76b8c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -1,32 +1,18 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-diff-processor/gr-diff-processor';
 import '../../../elements/shared/gr-hovercard/gr-hovercard';
 import './gr-diff-builder-side-by-side';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-builder-element_html';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {DiffBuilder, DiffContextExpandedEventDetail} from './gr-diff-builder';
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
 import {GrDiffBuilderImage} from './gr-diff-builder-image';
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-import {CancelablePromise, util} from '../../../scripts/util';
-import {customElement, property, observe} from '@polymer/decorators';
+import {CancelablePromise, makeCancelable} from '../../../scripts/util';
 import {BlameInfo, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {CoverageRange, DiffLayer} from '../../../types/types';
@@ -50,14 +36,27 @@
 } 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';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
-
-// https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 const COMMIT_MSG_LINE_LENGTH = 72;
 
+declare global {
+  interface HTMLElementEventMap {
+    /**
+     * Fired when the diff begins rendering - both for full renders and for
+     * partial rerenders.
+     */
+    'render-start': CustomEvent<{}>;
+    /**
+     * Fired when the diff finishes rendering text content - both for full
+     * renders and for partial rerenders.
+     */
+    'render-content': CustomEvent<{}>;
+  }
+}
+
 export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
   return prefs.font_size * 4;
 }
@@ -76,236 +75,188 @@
     // Skip forward by the length of the content
     pos += split[i].length;
 
-    GrAnnotation.annotateElement(
-      contentEl,
-      pos,
-      1,
-      `style-scope gr-diff ${className}`
-    );
+    GrAnnotation.annotateElement(contentEl, pos, 1, `gr-diff ${className}`);
 
     pos++;
   }
 }
 
-@customElement('gr-diff-builder')
-export class GrDiffBuilderElement
-  extends PolymerElement
-  implements GroupConsumer
-{
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the diff begins rendering.
-   *
-   * @event render-start
-   */
-
-  /**
-   * 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
-   */
-
-  @property({type: Object})
+// TODO: Rename the class and the file and remove "element". This is not an
+// element anymore.
+export class GrDiffBuilderElement implements GroupConsumer {
   diff?: DiffInfo;
 
-  @property({type: String})
+  diffElement?: HTMLTableElement;
+
   viewMode?: string;
 
-  @property({type: Boolean})
   isImageDiff?: boolean;
 
-  @property({type: Object})
   baseImage: ImageInfo | null = null;
 
-  @property({type: Object})
   revisionImage: ImageInfo | null = null;
 
-  @property({type: Number})
-  parentIndex?: number;
-
-  @property({type: String})
   path?: string;
 
-  @property({type: Object})
   prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
 
-  @property({type: Object})
   renderPrefs?: RenderPreferences;
 
-  @property({type: Object})
-  _builder?: DiffBuilder;
-
-  /**
-   * The gr-diff-processor adds (and only adds!) to this array. It does so by
-   * using `this.push()` and Polymer's two-way data binding.
-   * Below (@observe('_groups.splices')) we are observing the groups that the
-   * processor adds, and pass them on to the builder for rendering. Henceforth
-   * the builder groups are the source of truth, because when
-   * expanding/collapsing groups only the builder is updated. This field and the
-   * corresponsing one in the processor are not updated.
-   */
-  @property({type: Array})
-  _groups: GrDiffGroup[] = [];
+  useNewImageDiffUi = false;
 
   /**
    * Layers passed in from the outside.
+   *
+   * See `layersInternal` for where these layers will end up together with the
+   * internal layers.
    */
-  @property({type: Array})
   layers: DiffLayer[] = [];
 
+  // visible for testing
+  builder?: DiffBuilder;
+
   /**
-   * All layers, both from the outside and the default ones.
+   * All layers, both from the outside and the default ones. See `layers` for
+   * the property that can be set from the outside.
    */
-  @property({type: Array})
-  _layers: DiffLayer[] = [];
+  // visible for testing
+  layersInternal: DiffLayer[] = [];
 
-  @property({type: Boolean})
-  _showTabs?: boolean;
+  // visible for testing
+  showTabs?: boolean;
 
-  @property({type: Boolean})
-  _showTrailingWhitespace?: boolean;
-
-  @property({type: Array})
-  commentRanges: CommentRangeLayer[] = [];
-
-  @property({type: Array, observer: 'coverageObserver'})
-  coverageRanges: CoverageRange[] = [];
-
-  @property({type: Boolean})
-  useNewImageDiffUi = false;
+  // visible for testing
+  showTrailingWhitespace?: boolean;
 
   /**
    * The promise last returned from `render()` while the asynchronous
    * rendering is running - `null` otherwise. Provides a `cancel()`
    * method that rejects it with `{isCancelled: true}`.
    */
-  @property({type: Object})
-  _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
+  private cancelableRenderPromise: CancelablePromise<unknown> | null = null;
 
   private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
 
   private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
 
-  private rangeLayer = new GrRangedCommentLayer();
+  private rangeLayer?: GrRangedCommentLayer;
 
-  private processor = new GrDiffProcessor();
+  // visible for testing
+  processor = new GrDiffProcessor();
+
+  /**
+   * Groups are mostly just passed on to the diff builder (this.builder). But
+   * we also keep track of them here for being able to fire a `render-content`
+   * event when .element of each group has rendered.
+   *
+   * TODO: Refactor DiffBuilderElement and DiffBuilders with a cleaner
+   * separation of responsibilities.
+   */
+  private groups: GrDiffGroup[] = [];
 
   constructor() {
-    super();
-    afterNextRender(this, () => {
-      this.addEventListener(
-        'diff-context-expanded',
-        (e: CustomEvent<DiffContextExpandedEventDetail>) => {
-          // Don't stop propagation. The host may listen for reporting or
-          // resizing.
-          this.replaceGroup(e.detail.contextGroup, e.detail.groups);
-        }
-      );
-    });
     this.processor.consumer = this;
   }
 
-  override disconnectedCallback() {
-    this.processor.cancel();
-    if (this._builder) {
-      this._builder.clear();
-    }
-    super.disconnectedCallback();
+  updateCommentRanges(ranges: CommentRangeLayer[]) {
+    this.rangeLayer?.updateRanges(ranges);
   }
 
-  get diffElement(): HTMLTableElement {
-    // Not searching in shadowRoot, because the diff table is slotted!
-    return this.querySelector('#diffTable') as HTMLTableElement;
+  updateCoverageRanges(rs: CoverageRange[]) {
+    this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
+    this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
   }
 
-  @observe('commentRanges.*')
-  rangeObserver() {
-    this.rangeLayer.updateRanges(this.commentRanges);
-  }
-
-  coverageObserver(coverageRanges: CoverageRange[]) {
-    const leftRanges = coverageRanges.filter(
-      range => range && range.side === Side.LEFT
-    );
-    this.coverageLayerLeft.setRanges(leftRanges);
-
-    const rightRanges = coverageRanges.filter(
-      range => range && range.side === Side.RIGHT
-    );
-    this.coverageLayerRight.setRanges(rightRanges);
-  }
-
-  render(keyLocations: KeyLocations) {
+  render(keyLocations: KeyLocations): Promise<void> {
     // Setting up annotation layers must happen after plugins are
     // installed, and |render| satisfies the requirement, however,
     // |attached| doesn't because in the diff view page, the element is
     // attached before plugins are installed.
-    this._setupAnnotationLayers();
+    this.setupAnnotationLayers();
 
-    this._showTabs = this.prefs.show_tabs;
-    this._showTrailingWhitespace = this.prefs.show_whitespace_errors;
+    this.showTabs = this.prefs.show_tabs;
+    this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
 
     // Stop the processor if it's running.
     this.cancel();
 
-    if (this._builder) {
-      this._builder.clear();
-    }
-    if (!this.diff) {
-      throw Error('Cannot render a diff without DiffInfo.');
-    }
-    this._builder = this._getDiffBuilder();
+    this.builder?.clear();
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
+    this.builder = this.getDiffBuilder();
 
     this.processor.context = this.prefs.context;
     this.processor.keyLocations = keyLocations;
 
-    this._clearDiffContent();
-    this._builder.addColumns(
+    this.diffElement.addEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
+
+    this.clearDiffContent();
+    this.builder.addColumns(
       this.diffElement,
       getLineNumberCellWidth(this.prefs)
     );
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-    fireEvent(this, 'render-start');
-    this._cancelableRenderPromise = util.makeCancelable(
-      this.processor.process(this.diff.content, isBinary).then(() => {
-        if (this.isImageDiff) {
-          (this._builder as GrDiffBuilderImage).renderDiff();
-        }
-        fireEvent(this, 'render-content');
-      })
+    this.fireDiffEvent('render-start');
+    // TODO: processor.process() returns a cancelable promise already.
+    // Why wrap another one around it?
+    this.cancelableRenderPromise = makeCancelable(
+      this.processor.process(this.diff.content, isBinary)
     );
+    // All then/catch/finally clauses must be outside of makeCancelable().
     return (
-      this._cancelableRenderPromise
-        .finally(() => {
-          this._cancelableRenderPromise = null;
+      this.cancelableRenderPromise
+        .then(async () => {
+          if (this.isImageDiff) {
+            (this.builder as GrDiffBuilderImage).renderDiff();
+          }
+          await this.untilGroupsRendered();
+          this.fireDiffEvent('render-content');
         })
-        // Mocca testing does not like uncaught rejections, so we catch
+        // Mocha testing does not like uncaught rejections, so we catch
         // the cancels which are expected and should not throw errors in
         // tests.
         .catch(e => {
           if (!e.isCanceled) return Promise.reject(e);
           return;
         })
+        .finally(() => {
+          this.cancelableRenderPromise = null;
+        })
     );
   }
 
-  _setupAnnotationLayers() {
+  // visible for testing
+  async untilGroupsRendered(groups: readonly GrDiffGroup[] = this.groups) {
+    return Promise.all(groups.map(g => g.waitUntilRendered()));
+  }
+
+  private onDiffContextExpanded = (
+    e: CustomEvent<DiffContextExpandedEventDetail>
+  ) => {
+    // Don't stop propagation. The host may listen for reporting or
+    // resizing.
+    this.replaceGroup(e.detail.contextGroup, e.detail.groups);
+  };
+
+  private fireDiffEvent<K extends keyof HTMLElementEventMap>(type: K) {
+    assertIsDefined(this.diffElement, 'diff table');
+    fireEvent(this.diffElement, type);
+  }
+
+  // visible for testing
+  setupAnnotationLayers() {
+    this.rangeLayer = new GrRangedCommentLayer();
+
     const layers: DiffLayer[] = [
-      this._createTrailingWhitespaceLayer(),
-      this._createIntralineLayer(),
-      this._createTabIndicatorLayer(),
-      this._createSpecialCharacterIndicatorLayer(),
+      this.createTrailingWhitespaceLayer(),
+      this.createIntralineLayer(),
+      this.createTabIndicatorLayer(),
+      this.createSpecialCharacterIndicatorLayer(),
       this.rangeLayer,
       this.coverageLayerLeft,
       this.coverageLayerRight,
@@ -314,15 +265,15 @@
     if (this.layers) {
       layers.push(...this.layers);
     }
-    this._layers = layers;
+    this.layersInternal = layers;
   }
 
   getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
-    if (!this._builder) return null;
-    return this._builder.getContentTdByLine(lineNumber, side, root);
+    if (!this.builder) return null;
+    return this.builder.getContentTdByLine(lineNumber, side, root);
   }
 
-  _getDiffRowByChild(child: Element) {
+  private getDiffRowByChild(child: Element) {
     while (!child.classList.contains('diff-row') && child.parentElement) {
       child = child.parentElement;
     }
@@ -336,23 +287,23 @@
     const side = getSideByLineEl(lineEl);
     // Performance optimization because we already have an element in the
     // correct row
-    const row = this._getDiffRowByChild(lineEl);
+    const row = this.getDiffRowByChild(lineEl);
     return this.getContentTdByLine(line, side, row);
   }
 
   getLineElByNumber(lineNumber: LineNumber, side?: Side) {
-    if (!this._builder) return null;
-    return this._builder.getLineElByNumber(lineNumber, side);
+    if (!this.builder) return null;
+    return this.builder.getLineElByNumber(lineNumber, side);
   }
 
   getLineNumberRows() {
-    if (!this._builder) return [];
-    return this._builder.getLineNumberRows();
+    if (!this.builder) return [];
+    return this.builder.getLineNumberRows();
   }
 
   getLineNumEls(side: Side) {
-    if (!this._builder) return [];
-    return this._builder.getLineNumEls(side);
+    if (!this.builder) return [];
+    return this.builder.getLineNumEls(side);
   }
 
   /**
@@ -364,8 +315,8 @@
    * @param side The side the line number refer to.
    */
   unhideLine(lineNum: number, side: Side) {
-    if (!this._builder) return;
-    const group = this._builder.findGroup(side, lineNum);
+    if (!this.builder) return;
+    const group = this.builder.findGroup(side, lineNum);
     // Cannot unhide a line that is not part of the diff.
     if (!group) return;
     // If it's already visible, great!
@@ -393,8 +344,7 @@
         lineRange.end_line - lineRange.start_line + 1
       )
     );
-    this._builder.replaceGroup(group, newGroups);
-    setTimeout(() => fireEvent(this, 'render-content'), 1);
+    this.replaceGroup(group, newGroups);
   }
 
   /**
@@ -409,37 +359,47 @@
     contextGroup: GrDiffGroup,
     newGroups: readonly GrDiffGroup[]
   ) {
-    if (!this._builder) return;
-    this._builder.replaceGroup(contextGroup, newGroups);
-    setTimeout(() => fireEvent(this, 'render-content'), 1);
+    if (!this.builder) return;
+    this.fireDiffEvent('render-start');
+    this.builder.replaceGroup(contextGroup, newGroups);
+    this.groups = this.groups.filter(g => g !== contextGroup);
+    this.groups.push(...newGroups);
+    this.untilGroupsRendered(newGroups).then(() => {
+      this.fireDiffEvent('render-content');
+    });
   }
 
   cancel() {
     this.processor.cancel();
-    if (this._cancelableRenderPromise) {
-      this._cancelableRenderPromise.cancel();
-      this._cancelableRenderPromise = null;
-    }
+    this.builder?.clear();
+    this.cancelableRenderPromise?.cancel();
+    this.cancelableRenderPromise = null;
+    this.diffElement?.removeEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
   }
 
-  _handlePreferenceError(pref: string): never {
+  // visible for testing
+  handlePreferenceError(pref: string): never {
     const message =
       `The value of the '${pref}' user preference is ` +
       'invalid. Fix in diff preferences';
-    fireAlert(this, message);
+    assertIsDefined(this.diffElement, 'diff table');
+    fireAlert(this.diffElement, message);
     throw Error(`Invalid preference value: ${pref}`);
   }
 
-  _getDiffBuilder(): DiffBuilder {
-    if (!this.diff) {
-      throw Error('Cannot render a diff without DiffInfo.');
-    }
+  // visible for testing
+  getDiffBuilder(): DiffBuilder {
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
     if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
-      this._handlePreferenceError('tab size');
+      this.handlePreferenceError('tab size');
     }
 
     if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
-      this._handlePreferenceError('diff width');
+      this.handlePreferenceError('diff width');
     }
 
     const localPrefs = {...this.prefs};
@@ -468,7 +428,7 @@
         this.diff,
         localPrefs,
         this.diffElement,
-        this._layers,
+        this.layersInternal,
         this.renderPrefs
       );
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
@@ -476,7 +436,7 @@
         this.diff,
         localPrefs,
         this.diffElement,
-        this._layers,
+        this.layersInternal,
         this.renderPrefs
       );
     }
@@ -486,7 +446,8 @@
     return builder;
   }
 
-  _clearDiffContent() {
+  private clearDiffContent() {
+    assertIsDefined(this.diffElement, 'diff table');
     this.diffElement.innerHTML = '';
   }
 
@@ -495,26 +456,28 @@
    * server into chunks.
    */
   clearGroups() {
-    if (!this._builder) return;
-    this._builder.clearGroups();
+    if (!this.builder) return;
+    this.groups = [];
+    this.builder.clearGroups();
   }
 
   /**
    * Called when the processor is done converting a chunk of the diff.
    */
   addGroup(group: GrDiffGroup) {
-    if (!this._builder) return;
-    this._builder.addGroups([group]);
-    fireEvent(this, 'render-progress');
+    if (!this.builder) return;
+    this.builder.addGroups([group]);
+    this.groups.push(group);
   }
 
-  _createIntralineLayer(): DiffLayer {
+  // visible for testing
+  createIntralineLayer(): DiffLayer {
     return {
       // Take a DIV.contentText element and a line object with intraline
       // differences to highlight and apply them to the element as
       // annotations.
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-        const HL_CLASS = 'style-scope gr-diff intraline';
+        const HL_CLASS = 'gr-diff intraline';
         for (const highlight of line.highlights) {
           // The start and end indices could be the same if a highlight is
           // meant to start at the end of a line and continue onto the
@@ -540,8 +503,9 @@
     };
   }
 
-  _createTabIndicatorLayer(): DiffLayer {
-    const show = () => this._showTabs;
+  // visible for testing
+  createTabIndicatorLayer(): DiffLayer {
+    const show = () => this.showTabs;
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
         // If visible tabs are disabled, do nothing.
@@ -555,7 +519,7 @@
     };
   }
 
-  _createSpecialCharacterIndicatorLayer(): DiffLayer {
+  private createSpecialCharacterIndicatorLayer(): DiffLayer {
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
         // Find and annotate the locations of soft hyphen (\u00AD)
@@ -571,8 +535,9 @@
     };
   }
 
-  _createTrailingWhitespaceLayer(): DiffLayer {
-    const show = () => this._showTrailingWhitespace;
+  // visible for testing
+  createTrailingWhitespaceLayer(): DiffLayer {
+    const show = () => this.showTrailingWhitespace;
 
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
@@ -592,7 +557,7 @@
             contentEl,
             index,
             length,
-            'style-scope gr-diff trailing-whitespace'
+            'gr-diff trailing-whitespace'
           );
         }
       },
@@ -600,18 +565,12 @@
   }
 
   setBlame(blame: BlameInfo[] | null) {
-    if (!this._builder) return;
-    this._builder.setBlame(blame ?? []);
+    if (!this.builder) return;
+    this.builder.setBlame(blame ?? []);
   }
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
-    this._builder?.updateRenderPrefs(renderPrefs);
+    this.builder?.updateRenderPrefs(renderPrefs);
     this.processor.updateRenderPrefs(renderPrefs);
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-builder': GrDiffBuilderElement;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
deleted file mode 100644
index bd0e034..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
deleted file mode 100644
index 0ad21b0..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ /dev/null
@@ -1,1084 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../test/common-test-setup-karma.js';
-import {createDiff} from '../../../test/test-data-generators.js';
-import './gr-diff-builder-element.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {DiffViewMode, Side} from '../../../api/diff.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy.js';
-
-const basicFixture = fixtureFromTemplate(html`
-    <gr-diff-builder>
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-`);
-
-const divWithTextFixture = fixtureFromTemplate(html`
-<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-`);
-
-const mockDiffFixture = fixtureFromTemplate(html`
-<gr-diff-builder view-mode="SIDE_BY_SIDE">
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-`);
-
-// GrDiffBuilderElement forces these prefs to be set - tests that do not care
-// about these values can just set these defaults.
-const DEFAULT_PREFS = {
-  line_length: 10,
-  show_tabs: true,
-  tab_size: 4,
-};
-
-suite('gr-diff-builder tests', () => {
-  let prefs;
-  let element;
-  let builder;
-
-  const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
-  const WBR_HTML = '<wbr class="style-scope gr-diff">';
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-    stubBaseUrl('/r');
-    prefs = {...DEFAULT_PREFS};
-    builder = new GrDiffBuilderLegacy({content: []}, prefs);
-  });
-
-  test('line_length applied with <wbr> if line_wrapping is true', () => {
-    builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
-    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  test('line_length applied with line break if line_wrapping is false', () => {
-    builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
-    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE]
-      .forEach(mode => {
-        test(`line_length used for regular files under ${mode}`, () => {
-          element.path = '/a.txt';
-          element.viewMode = mode;
-          element.diff = {};
-          element.prefs = {tab_size: 4, line_length: 50};
-          builder = element._getDiffBuilder();
-          assert.equal(builder._prefs.line_length, 50);
-        });
-
-        test(`line_length ignored for commit msg under ${mode}`, () => {
-          element.path = '/COMMIT_MSG';
-          element.viewMode = mode;
-          element.diff = {};
-          element.prefs = {tab_size: 4, line_length: 50};
-          builder = element._getDiffBuilder();
-          assert.equal(builder._prefs.line_length, 72);
-        });
-      });
-
-  test('createTextEl linewrap with tabs', () => {
-    const text = '\t'.repeat(7) + '!';
-    const line = {text, highlights: []};
-    const el = builder.createTextEl(undefined, line);
-    assert.equal(el.innerText, text);
-    // With line length 10 and tab size 2, there should be a line break
-    // after every two tabs.
-    const newlineEl = el.querySelector('.contentText > .br');
-    assert.isOk(newlineEl);
-    assert.equal(
-        el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
-        newlineEl);
-  });
-
-  test('_handlePreferenceError throws with invalid preference', () => {
-    element.prefs = {tab_size: 0};
-    assert.throws(() => element._getDiffBuilder());
-  });
-
-  test('_handlePreferenceError triggers alert and javascript error', () => {
-    const errorStub = sinon.stub();
-    element.addEventListener('show-alert', errorStub);
-    assert.throws(() => element._handlePreferenceError('tab size'));
-    assert.equal(errorStub.lastCall.args[0].detail.message,
-        `The value of the 'tab size' user preference is invalid. ` +
-      `Fix in diff preferences`);
-  });
-
-  suite('intraline differences', () => {
-    let el;
-    let str;
-    let annotateElementSpy;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    function slice(str, start, end) {
-      return Array.from(str).slice(start, end)
-          .join('');
-    }
-
-    setup(() => {
-      el = divWithTextFixture.instantiate();
-      str = el.textContent;
-      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
-      layer = document.createElement('gr-diff-builder')
-          ._createIntralineLayer();
-    });
-
-    test('annotate no highlights', () => {
-      const line = {
-        text: str,
-        highlights: [],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      // The content is unchanged.
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(str, el.childNodes[0].textContent);
-    });
-
-    test('annotate with highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-          {startIndex: 18, endIndex: 22},
-        ],
-      };
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12, 18);
-      const str3 = slice(str, 18, 22);
-      const str4 = slice(str, 22);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 5);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-
-      assert.notInstanceOf(el.childNodes[3], Text);
-      assert.equal(el.childNodes[3].textContent, str3);
-
-      assert.instanceOf(el.childNodes[4], Text);
-      assert.equal(el.childNodes[4].textContent, str4);
-    });
-
-    test('annotate without endIndex', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28},
-        ],
-      };
-
-      const str0 = slice(str, 0, 28);
-      const str1 = slice(str, 28);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-
-    test('annotate ignores empty highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28, endIndex: 28},
-        ],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-    });
-
-    test('annotate handles unicode', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 3);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-    });
-
-    test('annotate handles unicode w/o endIndex', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-  });
-
-  suite('tab indicators', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element._showTabs = true;
-      layer = element._createTabIndicatorLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no tabs', () => {
-      const str = 'lorem ipsum no tabs';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates tab at beginning', () => {
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTabs = false;
-
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates multiple in beginning', () => {
-      const str = '\t\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 2);
-
-      let args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-
-      args = annotateElementStub.getCalls()[1].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 1, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('annotates intermediate tabs', () => {
-      const str = 'lorem\tupsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 5, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-  });
-
-  suite('layers', () => {
-    let element;
-    let initialLayersCount;
-    let withLayerCount;
-    setup(() => {
-      const layers = [];
-      element = basicFixture.instantiate();
-      element.layers = layers;
-      element._showTrailingWhitespace = true;
-      element._setupAnnotationLayers();
-      initialLayersCount = element._layers.length;
-    });
-
-    test('no layers', () => {
-      element._setupAnnotationLayers();
-      assert.equal(element._layers.length, initialLayersCount);
-    });
-
-    suite('with layers', () => {
-      const layers = [{}, {}];
-      setup(() => {
-        element = basicFixture.instantiate();
-        element.layers = layers;
-        element._showTrailingWhitespace = true;
-        element._setupAnnotationLayers();
-        withLayerCount = element._layers.length;
-      });
-      test('with layers', () => {
-        element._setupAnnotationLayers();
-        assert.equal(element._layers.length, withLayerCount);
-        assert.equal(initialLayersCount + layers.length,
-            withLayerCount);
-      });
-    });
-  });
-
-  suite('trailing whitespace', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element._showTrailingWhitespace = true;
-      layer = element._createTrailingWhitespaceLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no trailing whitespace', () => {
-      const str = 'lorem ipsum blah blah';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates trailing spaces', () => {
-      const str = 'lorem ipsum   ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates trailing tabs', () => {
-      const str = 'lorem ipsum\t\t\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates mixed trailing whitespace', () => {
-      const str = 'lorem ipsum\t \t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('unicode preceding trailing whitespace', () => {
-      const str = '💢\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 1);
-      assert.equal(annotateElementStub.lastCall.args[2], 1);
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTrailingWhitespace = false;
-      const str = 'lorem upsum\t \t ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-  });
-
-  suite('rendering text, images and binary files', () => {
-    let processStub;
-    let keyLocations;
-    let content;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.viewMode = 'SIDE_BY_SIDE';
-      processStub = sinon.stub(element.processor, 'process')
-          .returns(Promise.resolve());
-      keyLocations = {left: {}, right: {}};
-      element.prefs = {
-        ...DEFAULT_PREFS,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-    });
-
-    test('text', () => {
-      element.diff = {content};
-      return element.render(keyLocations).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isFalse(processStub.lastCall.args[1]);
-      });
-    });
-
-    test('image', () => {
-      element.diff = {content, binary: true};
-      element.isImageDiff = true;
-      return element.render(keyLocations).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isTrue(processStub.lastCall.args[1]);
-      });
-    });
-
-    test('binary', () => {
-      element.diff = {content, binary: true};
-      return element.render(keyLocations).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isTrue(processStub.lastCall.args[1]);
-      });
-    });
-  });
-
-  suite('rendering', () => {
-    let content;
-    let outputEl;
-    let keyLocations;
-
-    setup(async () => {
-      const prefs = {...DEFAULT_PREFS};
-      content = [
-        {
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        },
-        {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        },
-      ];
-      element = basicFixture.instantiate();
-      sinon.stub(element, 'dispatchEvent');
-      outputEl = element.querySelector('#diffTable');
-      keyLocations = {left: {}, right: {}};
-      sinon.stub(element, '_getDiffBuilder').callsFake(() => {
-        const builder = new GrDiffBuilderSideBySide({content}, prefs, outputEl);
-        sinon.stub(builder, 'addColumns');
-        builder.buildSectionElement = function(group) {
-          const section = document.createElement('stub');
-          section.textContent = group.lines
-              .reduce((acc, line) => acc + line.text, '');
-          return section;
-        };
-        return builder;
-      });
-      element.diff = {content};
-      element.prefs = prefs;
-      await element.render(keyLocations);
-    });
-
-    test('addColumns is called', () => {
-      assert.isTrue(element._builder.addColumns.called);
-    });
-
-    test('getGroupsByLineRange one line', () => {
-      const section = outputEl.querySelector('stub:nth-of-type(3)');
-      const groups = element._builder.getGroupsByLineRange(1, 1, 'left');
-      assert.equal(groups.length, 1);
-      assert.strictEqual(groups[0].element, section);
-    });
-
-    test('getGroupsByLineRange over diff', () => {
-      const section = [
-        outputEl.querySelector('stub:nth-of-type(3)'),
-        outputEl.querySelector('stub:nth-of-type(4)'),
-      ];
-      const groups = element._builder.getGroupsByLineRange(1, 2, 'left');
-      assert.equal(groups.length, 2);
-      assert.strictEqual(groups[0].element, section[0]);
-      assert.strictEqual(groups[1].element, section[1]);
-    });
-
-    test('render-start and render-content are fired', async () => {
-      const firedEventTypes = element.dispatchEvent.getCalls()
-          .map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-start');
-      assert.include(firedEventTypes, 'render-content');
-    });
-
-    test('cancel cancels the processor', () => {
-      const processorCancelStub = sinon.stub(element.processor, 'cancel');
-      element.cancel();
-      assert.isTrue(processorCancelStub.called);
-    });
-  });
-
-  suite('context hiding and expanding', () => {
-    setup(async () => {
-      element = basicFixture.instantiate();
-      sinon.stub(element, 'dispatchEvent');
-      const afterNextRenderPromise = new Promise((resolve, reject) => {
-        afterNextRender(element, resolve);
-      });
-      element.diff = {
-        content: [
-          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
-          {a: ['before'], b: ['after']},
-          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
-        ],
-      };
-      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
-
-      const keyLocations = {left: {}, right: {}};
-      element.prefs = {
-        ...DEFAULT_PREFS,
-        context: 1,
-      };
-      await element.render(keyLocations);
-      // Make sure all listeners are installed.
-      await afterNextRenderPromise;
-    });
-
-    test('hides lines behind two context controls', () => {
-      const contextControls = element.querySelectorAll('gr-context-controls');
-      assert.equal(contextControls.length, 2);
-
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 10');
-      assert.include(diffRows[3].textContent, 'before');
-      assert.include(diffRows[3].textContent, 'after');
-      assert.include(diffRows[4].textContent, 'unchanged 11');
-    });
-
-    test('clicking +x common lines expands those lines', () => {
-      const contextControls = element.querySelectorAll('gr-context-controls');
-      const topExpandCommonButton = contextControls[0].shadowRoot
-          .querySelectorAll('.showContext')[0];
-      assert.include(topExpandCommonButton.textContent, '+9 common lines');
-      topExpandCommonButton.click();
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      assert.equal(diffRows.length, 2 + 10 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 1');
-      assert.include(diffRows[3].textContent, 'unchanged 2');
-      assert.include(diffRows[4].textContent, 'unchanged 3');
-      assert.include(diffRows[5].textContent, 'unchanged 4');
-      assert.include(diffRows[6].textContent, 'unchanged 5');
-      assert.include(diffRows[7].textContent, 'unchanged 6');
-      assert.include(diffRows[8].textContent, 'unchanged 7');
-      assert.include(diffRows[9].textContent, 'unchanged 8');
-      assert.include(diffRows[10].textContent, 'unchanged 9');
-      assert.include(diffRows[11].textContent, 'unchanged 10');
-      assert.include(diffRows[12].textContent, 'before');
-      assert.include(diffRows[12].textContent, 'after');
-      assert.include(diffRows[13].textContent, 'unchanged 11');
-    });
-
-    test('unhideLine shows the line with context', async () => {
-      const clock = sinon.useFakeTimers();
-      element.dispatchEvent.reset();
-      element.unhideLine(4, Side.LEFT);
-
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
-      // Because context expanders do not hide <3 lines, lines 1-2 will also
-      // be shown.
-      // Lines 6-9 continue to be hidden
-      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 1');
-      assert.include(diffRows[3].textContent, 'unchanged 2');
-      assert.include(diffRows[4].textContent, 'unchanged 3');
-      assert.include(diffRows[5].textContent, 'unchanged 4');
-      assert.include(diffRows[6].textContent, 'unchanged 5');
-      assert.include(diffRows[7].textContent, 'unchanged 10');
-      assert.include(diffRows[8].textContent, 'before');
-      assert.include(diffRows[8].textContent, 'after');
-      assert.include(diffRows[9].textContent, 'unchanged 11');
-
-      clock.tick(1);
-      await flush();
-      const firedEventTypes = element.dispatchEvent.getCalls()
-          .map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-content');
-    });
-  });
-
-  suite('mock-diff', () => {
-    let element;
-    let builder;
-    let diff;
-    let keyLocations;
-
-    setup(async () => {
-      element = mockDiffFixture.instantiate();
-      diff = createDiff();
-      element.diff = diff;
-
-      keyLocations = {left: {}, right: {}};
-
-      element.prefs = {
-        line_length: 80,
-        show_tabs: true,
-        tab_size: 4,
-      };
-      await element.render(keyLocations);
-      builder = element._builder;
-    });
-
-    test('aria-labels on added line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.right')[5];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
-    });
-
-    test('aria-labels on removed line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.left')[10];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(
-          deltaLineNumberButton.getAttribute('aria-label'), '10 removed');
-    });
-
-    test('getContentByLine', () => {
-      let actual;
-
-      actual = builder.getContentByLine(2, 'left');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(2, 'right');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(5, 'left');
-      assert.equal(actual.textContent, diff.content[2].ab[0]);
-
-      actual = builder.getContentByLine(5, 'right');
-      assert.equal(actual.textContent, diff.content[1].b[0]);
-    });
-
-    test('getContentTdByLineEl works both with button and td', () => {
-      const diffRow = element.diffElement.querySelectorAll('tr.diff-row')[2];
-
-      const lineNumTdLeft = diffRow.querySelector('td.lineNum.left');
-      const lineNumButtonLeft = lineNumTdLeft.querySelector('button');
-      const contentTdLeft = diffRow.querySelectorAll('.content')[0];
-
-      const lineNumTdRight = diffRow.querySelector('td.lineNum.right');
-      const lineNumButtonRight = lineNumTdRight.querySelector('button');
-      const contentTdRight = diffRow.querySelectorAll('.content')[1];
-
-      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumButtonLeft), contentTdLeft);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumTdRight), contentTdRight);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumButtonRight), contentTdRight);
-    });
-
-    test('findLinesByRange', () => {
-      const lines = [];
-      const elems = [];
-      const start = 6;
-      const end = 10;
-      const count = end - start + 1;
-
-      builder.findLinesByRange(start, end, 'right', lines, elems);
-
-      assert.equal(lines.length, count);
-      assert.equal(elems.length, count);
-
-      for (let i = 0; i < 5; i++) {
-        assert.instanceOf(lines[i], GrDiffLine);
-        assert.equal(lines[i].afterNumber, start + i);
-        assert.instanceOf(elems[i], HTMLElement);
-        assert.equal(lines[i].text, elems[i].textContent);
-      }
-    });
-
-    test('renderContentByRange', () => {
-      const spy = sinon.spy(builder, 'createTextEl');
-      const start = 9;
-      const end = 14;
-      const count = end - start + 1;
-
-      builder.renderContentByRange(start, end, 'left');
-
-      assert.equal(spy.callCount, count);
-      spy.getCalls().forEach((call, i) => {
-        assert.equal(call.args[1].beforeNumber, start + i);
-      });
-    });
-
-    test('renderContentByRange non-existent elements', () => {
-      const spy = sinon.spy(builder, 'createTextEl');
-
-      sinon.stub(builder, 'getLineNumberEl').returns(
-          document.createElement('div')
-      );
-      sinon.stub(builder, 'findLinesByRange').callsFake(
-          (s, e, d, lines, elements) => {
-            // Add a line and a corresponding element.
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-            const tr = document.createElement('tr');
-            const td = document.createElement('td');
-            const el = document.createElement('div');
-            tr.appendChild(td);
-            td.appendChild(el);
-            elements.push(el);
-
-            // Add 2 lines without corresponding elements.
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-          });
-
-      builder.renderContentByRange(1, 10, 'left');
-      // Should be called only once because only one line had a corresponding
-      // element.
-      assert.equal(spy.callCount, 1);
-    });
-
-    test('getLineNumberEl side-by-side left', () => {
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('getLineNumberEl side-by-side right', () => {
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('getLineNumberEl unified left', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('getLineNumberEl unified right', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('getNextContentOnSide side-by-side left', () => {
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide side-by-side right', () => {
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide unified left', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide unified right', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-  });
-
-  suite('blame', () => {
-    let mockBlame;
-
-    setup(() => {
-      mockBlame = [
-        {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
-        {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
-      ];
-    });
-
-    test('setBlame attempts to render each blamed line', () => {
-      const getBlameStub = sinon.stub(builder, 'getBlameTdByLine')
-          .returns(null);
-      builder.setBlame(mockBlame);
-      assert.equal(getBlameStub.callCount, 32);
-    });
-
-    test('getBlameCommitForBaseLine', () => {
-      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
-      builder.setBlame(mockBlame);
-      assert.isOk(builder.getBlameCommitForBaseLine(1));
-      assert.equal(builder.getBlameCommitForBaseLine(1).id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(11));
-      assert.equal(builder.getBlameCommitForBaseLine(11).id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(32));
-      assert.equal(builder.getBlameCommitForBaseLine(32).id, 'commit 2');
-
-      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
-    });
-
-    test('getBlameCommitForBaseLine w/o blame returns null', () => {
-      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
-    });
-
-    test('createBlameCell', () => {
-      const mockBlameInfo = {
-        time: 1576155200,
-        id: 1234567890,
-        author: 'Clark Kent',
-        commit_msg: 'Testing Commit',
-        ranges: [1],
-      };
-      const getBlameStub = sinon.stub(builder, 'getBlameCommitForBaseLine')
-          .returns(mockBlameInfo);
-      const line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      const result = builder.createBlameCell(line.beforeNumber);
-
-      assert.isTrue(getBlameStub.calledWithExactly(3));
-      assert.equal(result.getAttribute('data-line-number'), '3');
-      expect(result).dom.to.equal(/* HTML */`
-        <span class="gr-diff style-scope">
-          <a class="blameDate gr-diff style-scope" href="/r/q/1234567890">
-            12/12/2019
-          </a>
-          <span class="blameAuthor gr-diff style-scope">Clark</span>
-          <gr-hovercard class="gr-diff style-scope">
-            <span class="blameHoverCard gr-diff style-scope">
-              Commit 1234567890<br>
-              Author: Clark Kent<br>
-              Date: 12/12/2019<br>
-              <br>
-              Testing Commit
-            </span>
-          </gr-hovercard>
-        </span>
-      `);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
new file mode 100644
index 0000000..2cfb895
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -0,0 +1,1170 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {
+  createConfig,
+  createDiff,
+  createEmptyDiff,
+} from '../../../test/test-data-generators';
+import './gr-diff-builder-element';
+import {queryAndAssert, stubBaseUrl, waitUntil} from '../../../test/test-utils';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffLayer,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  Side,
+} from '../../../api/diff';
+import {stubRestApi} from '../../../test/test-utils';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiffBuilderElement} from './gr-diff-builder-element';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {BlameInfo} from '../../../types/common';
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
+
+const DEFAULT_PREFS = createDefaultDiffPrefs();
+
+suite('gr-diff-builder tests', () => {
+  let element: GrDiffBuilderElement;
+  let builder: GrDiffBuilderLegacy;
+  let diffTable: HTMLTableElement;
+
+  const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
+  const WBR_HTML = '<wbr class="gr-diff">';
+
+  const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
+    builder = new GrDiffBuilderSideBySide(
+      createEmptyDiff(),
+      {...createDefaultDiffPrefs(), ...prefs},
+      diffTable
+    );
+  };
+
+  const line = (text: string) => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.text = text;
+    return line;
+  };
+
+  setup(async () => {
+    diffTable = await fixture(html`<table id="diffTable"></table>`);
+    element = new GrDiffBuilderElement();
+    element.diffElement = diffTable;
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+    stubBaseUrl('/r');
+    setBuilderPrefs({});
+  });
+
+  test('line_length applied with <wbr> if line_wrapping is true', () => {
+    setBuilderPrefs({line_wrapping: true, tab_size: 4, line_length: 50});
+    const text = 'a'.repeat(51);
+    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
+    const result = builder.createTextEl(null, line(text)).firstElementChild
+      ?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  test('line_length applied with line break if line_wrapping is false', () => {
+    setBuilderPrefs({line_wrapping: false, tab_size: 4, line_length: 50});
+    const text = 'a'.repeat(51);
+    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
+    const result = builder.createTextEl(null, line(text)).firstElementChild
+      ?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
+    test(`line_length used for regular files under ${mode}`, () => {
+      element.path = '/a.txt';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
+      assert.equal(builder._prefs.line_length, 50);
+    });
+
+    test(`line_length ignored for commit msg under ${mode}`, () => {
+      element.path = '/COMMIT_MSG';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
+      assert.equal(builder._prefs.line_length, 72);
+    });
+  });
+
+  test('createTextEl linewrap with tabs', () => {
+    setBuilderPrefs({tab_size: 4, line_length: 10});
+    const text = '\t'.repeat(7) + '!';
+    const el = builder.createTextEl(null, line(text));
+    assert.equal(el.innerText, text);
+    // With line length 10 and tab size 4, there should be a line break
+    // after every two tabs.
+    const newlineEl = el.querySelector('.contentText > .br');
+    assert.isOk(newlineEl);
+    assert.equal(
+      el.querySelector('.contentText .tab:nth-child(2)')?.nextSibling,
+      newlineEl
+    );
+  });
+
+  test('_handlePreferenceError throws with invalid preference', () => {
+    element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
+    assert.throws(() => element.getDiffBuilder());
+  });
+
+  test('_handlePreferenceError triggers alert and javascript error', () => {
+    const errorStub = sinon.stub();
+    diffTable.addEventListener(EventType.SHOW_ALERT, errorStub);
+    assert.throws(() => element.handlePreferenceError('tab size'));
+    assert.equal(
+      errorStub.lastCall.args[0].detail.message,
+      "The value of the 'tab size' user preference is invalid. " +
+        'Fix in diff preferences'
+    );
+  });
+
+  suite('intraline differences', () => {
+    let el: HTMLElement;
+    let str: string;
+    let annotateElementSpy: sinon.SinonSpy;
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    function slice(str: string, start: number, end?: number) {
+      return Array.from(str).slice(start, end).join('');
+    }
+
+    setup(async () => {
+      el = await fixture(html`
+        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+      `);
+      str = el.textContent ?? '';
+      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+      layer = element.createIntralineLayer();
+    });
+
+    test('annotate no highlights', () => {
+      layer.annotate(el, lineNumberEl, line(str), Side.LEFT);
+
+      // The content is unchanged.
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(str, el.childNodes[0].textContent);
+    });
+
+    test('annotate with highlights', () => {
+      const l = line(str);
+      l.highlights = [
+        {contentIndex: 0, startIndex: 6, endIndex: 12},
+        {contentIndex: 0, startIndex: 18, endIndex: 22},
+      ];
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12, 18);
+      const str3 = slice(str, 18, 22);
+      const str4 = slice(str, 22);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 5);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+
+      assert.notInstanceOf(el.childNodes[3], Text);
+      assert.equal(el.childNodes[3].textContent, str3);
+
+      assert.instanceOf(el.childNodes[4], Text);
+      assert.equal(el.childNodes[4].textContent, str4);
+    });
+
+    test('annotate without endIndex', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28}];
+
+      const str0 = slice(str, 0, 28);
+      const str1 = slice(str, 28);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+
+    test('annotate ignores empty highlights', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28, endIndex: 28}];
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+    });
+
+    test('annotate handles unicode', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6, endIndex: 12}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 3);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+    });
+
+    test('annotate handles unicode w/o endIndex', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+  });
+
+  suite('tab indicators', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTabs = true;
+      layer = element.createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no tabs', () => {
+      const str = 'lorem ipsum no tabs';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates tab at beginning', () => {
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTabs = false;
+
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates multiple in beginning', () => {
+      const str = '\t\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 2);
+
+      let args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+
+      args = annotateElementStub.getCalls()[1].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 1, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('annotates intermediate tabs', () => {
+      const str = 'lorem\tupsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 5, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+  });
+
+  suite('layers', () => {
+    let initialLayersCount = 0;
+    let withLayerCount = 0;
+    setup(() => {
+      const layers: DiffLayer[] = [];
+      element.layers = layers;
+      element.showTrailingWhitespace = true;
+      element.setupAnnotationLayers();
+      initialLayersCount = element.layersInternal.length;
+    });
+
+    test('no layers', () => {
+      element.setupAnnotationLayers();
+      assert.equal(element.layersInternal.length, initialLayersCount);
+    });
+
+    suite('with layers', () => {
+      const layers: DiffLayer[] = [{annotate: () => {}}, {annotate: () => {}}];
+      setup(() => {
+        element.layers = layers;
+        element.showTrailingWhitespace = true;
+        element.setupAnnotationLayers();
+        withLayerCount = element.layersInternal.length;
+      });
+      test('with layers', () => {
+        element.setupAnnotationLayers();
+        assert.equal(element.layersInternal.length, withLayerCount);
+        assert.equal(initialLayersCount + layers.length, withLayerCount);
+      });
+    });
+  });
+
+  suite('trailing whitespace', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTrailingWhitespace = true;
+      layer = element.createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no trailing whitespace', () => {
+      const str = 'lorem ipsum blah blah';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates trailing spaces', () => {
+      const str = 'lorem ipsum   ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates trailing tabs', () => {
+      const str = 'lorem ipsum\t\t\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates mixed trailing whitespace', () => {
+      const str = 'lorem ipsum\t \t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('unicode preceding trailing whitespace', () => {
+      const str = '💢\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 1);
+      assert.equal(annotateElementStub.lastCall.args[2], 1);
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTrailingWhitespace = false;
+      const str = 'lorem upsum\t \t ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+  });
+
+  suite('rendering text, images and binary files', () => {
+    let processStub: sinon.SinonStub;
+    let keyLocations: KeyLocations;
+    let content: DiffContent[] = [];
+
+    setup(() => {
+      element.viewMode = 'SIDE_BY_SIDE';
+      processStub = sinon
+        .stub(element.processor, 'process')
+        .returns(Promise.resolve());
+      keyLocations = {left: {}, right: {}};
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+    });
+
+    test('text', async () => {
+      element.diff = {...createEmptyDiff(), content};
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isFalse(processStub.lastCall.args[1]);
+    });
+
+    test('image', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.isImageDiff = true;
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isTrue(processStub.lastCall.args[1]);
+    });
+
+    test('binary', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isTrue(processStub.lastCall.args[1]);
+    });
+  });
+
+  suite('rendering', () => {
+    let content: DiffContent[];
+    let outputEl: HTMLTableElement;
+    let keyLocations: KeyLocations;
+    let addColumnsStub: sinon.SinonStub;
+    let dispatchStub: sinon.SinonStub;
+    let builder: GrDiffBuilderSideBySide;
+
+    setup(() => {
+      const prefs = {...DEFAULT_PREFS};
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+      outputEl = element.diffElement!;
+      keyLocations = {left: {}, right: {}};
+      sinon.stub(element, 'getDiffBuilder').callsFake(() => {
+        builder = new GrDiffBuilderSideBySide(
+          {...createEmptyDiff(), content},
+          prefs,
+          outputEl
+        );
+        addColumnsStub = sinon.stub(builder, 'addColumns');
+        builder.buildSectionElement = function (group) {
+          const section = document.createElement('stub');
+          section.style.display = 'block';
+          section.textContent = group.lines.reduce(
+            (acc, line) => acc + line.text,
+            ''
+          );
+          return section;
+        };
+        return builder;
+      });
+      element.diff = {...createEmptyDiff(), content};
+      element.prefs = prefs;
+      element.render(keyLocations);
+    });
+
+    test('addColumns is called', () => {
+      assert.isTrue(addColumnsStub.called);
+    });
+
+    test('getGroupsByLineRange one line', () => {
+      const section = outputEl.querySelector<HTMLElement>(
+        'stub:nth-of-type(3)'
+      );
+      const groups = builder.getGroupsByLineRange(1, 1, Side.LEFT);
+      assert.equal(groups.length, 1);
+      assert.strictEqual(groups[0].element, section);
+    });
+
+    test('getGroupsByLineRange over diff', () => {
+      const section = [
+        outputEl.querySelector<HTMLElement>('stub:nth-of-type(3)'),
+        outputEl.querySelector<HTMLElement>('stub:nth-of-type(4)'),
+      ];
+      const groups = builder.getGroupsByLineRange(1, 2, Side.LEFT);
+      assert.equal(groups.length, 2);
+      assert.strictEqual(groups[0].element, section[0]);
+      assert.strictEqual(groups[1].element, section[1]);
+    });
+
+    test('render-start and render-content are fired', async () => {
+      await waitUntil(() => dispatchStub.callCount >= 1);
+      let firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-start');
+
+      await waitUntil(() => dispatchStub.callCount >= 2);
+      firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+
+    test('cancel cancels the processor', () => {
+      const processorCancelStub = sinon.stub(element.processor, 'cancel');
+      element.cancel();
+      assert.isTrue(processorCancelStub.called);
+    });
+  });
+
+  suite('context hiding and expanding', () => {
+    let dispatchStub: sinon.SinonStub;
+
+    setup(async () => {
+      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+      element.diff = {
+        ...createEmptyDiff(),
+        content: [
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
+          {a: ['before'], b: ['after']},
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
+        ],
+      };
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+      const keyLocations: KeyLocations = {left: {}, right: {}};
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: 1,
+      };
+      element.render(keyLocations);
+      // Make sure all listeners are installed.
+      await element.untilGroupsRendered();
+    });
+
+    test('hides lines behind two context controls', () => {
+      const contextControls = diffTable.querySelectorAll('gr-context-controls');
+      assert.equal(contextControls.length, 2);
+
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 10');
+      assert.include(diffRows[3].textContent, 'before');
+      assert.include(diffRows[3].textContent, 'after');
+      assert.include(diffRows[4].textContent, 'unchanged 11');
+    });
+
+    test('clicking +x common lines expands those lines', () => {
+      const contextControls = diffTable.querySelectorAll('gr-context-controls');
+      const topExpandCommonButton =
+        contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
+          '.showContext'
+        )[0];
+      assert.isOk(topExpandCommonButton);
+      assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+      topExpandCommonButton!.click();
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 10 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 6');
+      assert.include(diffRows[8].textContent, 'unchanged 7');
+      assert.include(diffRows[9].textContent, 'unchanged 8');
+      assert.include(diffRows[10].textContent, 'unchanged 9');
+      assert.include(diffRows[11].textContent, 'unchanged 10');
+      assert.include(diffRows[12].textContent, 'before');
+      assert.include(diffRows[12].textContent, 'after');
+      assert.include(diffRows[13].textContent, 'unchanged 11');
+    });
+
+    test('unhideLine shows the line with context', async () => {
+      dispatchStub.reset();
+      element.unhideLine(4, Side.LEFT);
+
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
+      // Because context expanders do not hide <3 lines, lines 1-2 will also
+      // be shown.
+      // Lines 6-9 continue to be hidden
+      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 10');
+      assert.include(diffRows[8].textContent, 'before');
+      assert.include(diffRows[8].textContent, 'after');
+      assert.include(diffRows[9].textContent, 'unchanged 11');
+
+      await element.untilGroupsRendered();
+      const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
+    suite(`mock-diff mode:${mode}`, () => {
+      let builder: GrDiffBuilderSideBySide;
+      let diff: DiffInfo;
+      let keyLocations: KeyLocations;
+
+      setup(() => {
+        element.viewMode = mode;
+        diff = createDiff();
+        element.diff = diff;
+
+        keyLocations = {left: {}, right: {}};
+
+        element.prefs = {
+          ...createDefaultDiffPrefs(),
+          line_length: 80,
+          show_tabs: true,
+          tab_size: 4,
+        };
+        element.render(keyLocations);
+        builder = element.builder as GrDiffBuilderSideBySide;
+      });
+
+      test('aria-labels on added line numbers', () => {
+        const deltaLineNumberButton = diffTable.querySelectorAll(
+          '.lineNumButton.right'
+        )[5];
+
+        assert.isOk(deltaLineNumberButton);
+        assert.equal(
+          deltaLineNumberButton.getAttribute('aria-label'),
+          '5 added'
+        );
+      });
+
+      test('aria-labels on removed line numbers', () => {
+        const deltaLineNumberButton = diffTable.querySelectorAll(
+          '.lineNumButton.left'
+        )[10];
+
+        assert.isOk(deltaLineNumberButton);
+        assert.equal(
+          deltaLineNumberButton.getAttribute('aria-label'),
+          '10 removed'
+        );
+      });
+
+      test('getContentByLine', () => {
+        let actual: HTMLElement | null;
+
+        actual = builder.getContentByLine(2, Side.LEFT);
+        assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
+
+        actual = builder.getContentByLine(2, Side.RIGHT);
+        assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
+
+        actual = builder.getContentByLine(5, Side.LEFT);
+        assert.equal(actual?.textContent, diff.content[2].ab?.[0]);
+
+        actual = builder.getContentByLine(5, Side.RIGHT);
+        assert.equal(actual?.textContent, diff.content[1].b?.[0]);
+      });
+
+      test('getContentTdByLineEl works both with button and td', () => {
+        const diffRow = diffTable.querySelectorAll('tr.diff-row')[2];
+
+        const lineNumTdLeft = queryAndAssert(diffRow, 'td.lineNum.left');
+        const lineNumButtonLeft = queryAndAssert(lineNumTdLeft, 'button');
+        const contentTdLeft = diffRow.querySelectorAll('.content')[0];
+
+        const lineNumTdRight = queryAndAssert(diffRow, 'td.lineNum.right');
+        const lineNumButtonRight = queryAndAssert(lineNumTdRight, 'button');
+        const contentTdRight =
+          mode === DiffViewMode.SIDE_BY_SIDE
+            ? diffRow.querySelectorAll('.content')[1]
+            : contentTdLeft;
+
+        assert.equal(
+          element.getContentTdByLineEl(lineNumTdLeft),
+          contentTdLeft
+        );
+        assert.equal(
+          element.getContentTdByLineEl(lineNumButtonLeft),
+          contentTdLeft
+        );
+        assert.equal(
+          element.getContentTdByLineEl(lineNumTdRight),
+          contentTdRight
+        );
+        assert.equal(
+          element.getContentTdByLineEl(lineNumButtonRight),
+          contentTdRight
+        );
+      });
+
+      test('findLinesByRange LEFT', () => {
+        const lines: GrDiffLine[] = [];
+        const elems: HTMLElement[] = [];
+        const start = 1;
+        const end = 44;
+
+        // lines 26-29 are collapsed, so minus 4
+        let count = end - start + 1 - 4;
+        // Lines 14+15 are part of a 'common' chunk. And we have a bug in
+        // unified diff that results in not rendering these lines for the LEFT
+        // side. TODO: Fix that bug!
+        if (mode === DiffViewMode.UNIFIED) count -= 2;
+
+        builder.findLinesByRange(start, end, Side.LEFT, lines, elems);
+
+        assert.equal(lines.length, count);
+        assert.equal(elems.length, count);
+
+        for (let i = 0; i < count; i++) {
+          assert.instanceOf(lines[i], GrDiffLine);
+          assert.instanceOf(elems[i], HTMLElement);
+          assert.equal(lines[i].text, elems[i].textContent);
+        }
+      });
+
+      test('findLinesByRange RIGHT', () => {
+        const lines: GrDiffLine[] = [];
+        const elems: HTMLElement[] = [];
+        const start = 1;
+        const end = 48;
+
+        // lines 26-29 are collapsed, so minus 4
+        const count = end - start + 1 - 4;
+
+        builder.findLinesByRange(start, end, Side.RIGHT, lines, elems);
+
+        assert.equal(lines.length, count);
+        assert.equal(elems.length, count);
+
+        for (let i = 0; i < count; i++) {
+          assert.instanceOf(lines[i], GrDiffLine);
+          assert.instanceOf(elems[i], HTMLElement);
+          assert.equal(lines[i].text, elems[i].textContent);
+        }
+      });
+
+      test('renderContentByRange', () => {
+        const spy = sinon.spy(builder, 'createTextEl');
+        const start = 9;
+        const end = 14;
+        let count = end - start + 1;
+        // Lines 14+15 are part of a 'common' chunk. And we have a bug in
+        // unified diff that results in not rendering these lines for the LEFT
+        // side. TODO: Fix that bug!
+        if (mode === DiffViewMode.UNIFIED) count -= 1;
+
+        builder.renderContentByRange(start, end, Side.LEFT);
+
+        assert.equal(spy.callCount, count);
+        spy.getCalls().forEach((call, i: number) => {
+          assert.equal(call.args[1].beforeNumber, start + i);
+        });
+      });
+
+      test('renderContentByRange non-existent elements', () => {
+        const spy = sinon.spy(builder, 'createTextEl');
+
+        sinon
+          .stub(builder, 'getLineNumberEl')
+          .returns(document.createElement('div'));
+        sinon
+          .stub(builder, 'findLinesByRange')
+          .callsFake((_1, _2, _3, lines, elements) => {
+            // Add a line and a corresponding element.
+            lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+            const tr = document.createElement('tr');
+            const td = document.createElement('td');
+            const el = document.createElement('div');
+            tr.appendChild(td);
+            td.appendChild(el);
+            elements?.push(el);
+
+            // Add 2 lines without corresponding elements.
+            lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+            lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+          });
+
+        builder.renderContentByRange(1, 10, Side.LEFT);
+        // Should be called only once because only one line had a corresponding
+        // element.
+        assert.equal(spy.callCount, 1);
+      });
+
+      test('getLineNumberEl side-by-side left', () => {
+        const contentEl = builder.getContentByLine(
+          5,
+          Side.LEFT,
+          element.diffElement as HTMLTableElement
+        );
+        assert.isOk(contentEl);
+        const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
+        assert.isOk(lineNumberEl);
+        assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
+      });
+
+      test('getLineNumberEl side-by-side right', () => {
+        const contentEl = builder.getContentByLine(
+          5,
+          Side.RIGHT,
+          element.diffElement as HTMLTableElement
+        );
+        assert.isOk(contentEl);
+        const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
+        assert.isOk(lineNumberEl);
+        assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
+      });
+
+      test('getLineNumberEl unified left', async () => {
+        // Re-render as unified:
+        element.viewMode = 'UNIFIED_DIFF';
+        element.render(keyLocations);
+        builder = element.builder as GrDiffBuilderSideBySide;
+
+        const contentEl = builder.getContentByLine(
+          5,
+          Side.LEFT,
+          element.diffElement as HTMLTableElement
+        );
+        assert.isOk(contentEl);
+        const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
+        assert.isOk(lineNumberEl);
+        assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
+      });
+
+      test('getLineNumberEl unified right', async () => {
+        // Re-render as unified:
+        element.viewMode = 'UNIFIED_DIFF';
+        element.render(keyLocations);
+        builder = element.builder as GrDiffBuilderSideBySide;
+
+        const contentEl = builder.getContentByLine(
+          5,
+          Side.RIGHT,
+          element.diffElement as HTMLTableElement
+        );
+        assert.isOk(contentEl);
+        const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
+        assert.isOk(lineNumberEl);
+        assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
+      });
+
+      test('getNextContentOnSide side-by-side left', () => {
+        const startElem = builder.getContentByLine(
+          5,
+          Side.LEFT,
+          element.diffElement as HTMLTableElement
+        );
+        assert.isOk(startElem);
+        const expectedStartString = diff.content[2].ab?.[0];
+        const expectedNextString = diff.content[2].ab?.[1];
+        assert.equal(startElem!.textContent, expectedStartString);
+
+        const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
+        assert.isOk(nextElem);
+        assert.equal(nextElem!.textContent, expectedNextString);
+      });
+
+      test('getNextContentOnSide side-by-side right', () => {
+        const startElem = builder.getContentByLine(
+          5,
+          Side.RIGHT,
+          element.diffElement as HTMLTableElement
+        );
+        const expectedStartString = diff.content[1].b?.[0];
+        const expectedNextString = diff.content[1].b?.[1];
+        assert.isOk(startElem);
+        assert.equal(startElem!.textContent, expectedStartString);
+
+        const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
+        assert.isOk(nextElem);
+        assert.equal(nextElem!.textContent, expectedNextString);
+      });
+
+      test('getNextContentOnSide unified left', async () => {
+        // Re-render as unified:
+        element.viewMode = 'UNIFIED_DIFF';
+        element.render(keyLocations);
+        builder = element.builder as GrDiffBuilderSideBySide;
+
+        const startElem = builder.getContentByLine(
+          5,
+          Side.LEFT,
+          element.diffElement as HTMLTableElement
+        );
+        const expectedStartString = diff.content[2].ab?.[0];
+        const expectedNextString = diff.content[2].ab?.[1];
+        assert.isOk(startElem);
+        assert.equal(startElem!.textContent, expectedStartString);
+
+        const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
+        assert.isOk(nextElem);
+        assert.equal(nextElem!.textContent, expectedNextString);
+      });
+
+      test('getNextContentOnSide unified right', async () => {
+        // Re-render as unified:
+        element.viewMode = 'UNIFIED_DIFF';
+        element.render(keyLocations);
+        builder = element.builder as GrDiffBuilderSideBySide;
+
+        const startElem = builder.getContentByLine(
+          5,
+          Side.RIGHT,
+          element.diffElement as HTMLTableElement
+        );
+        const expectedStartString = diff.content[1].b?.[0];
+        const expectedNextString = diff.content[1].b?.[1];
+        assert.isOk(startElem);
+        assert.equal(startElem!.textContent, expectedStartString);
+
+        const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
+        assert.isOk(nextElem);
+        assert.equal(nextElem!.textContent, expectedNextString);
+      });
+    });
+  });
+
+  suite('blame', () => {
+    let mockBlame: BlameInfo[];
+
+    setup(() => {
+      mockBlame = [
+        {
+          author: 'test-author',
+          time: 314,
+          commit_msg: 'test-commit-message',
+          id: 'commit 1',
+          ranges: [
+            {start: 1, end: 2},
+            {start: 10, end: 16},
+          ],
+        },
+        {
+          author: 'test-author',
+          time: 314,
+          commit_msg: 'test-commit-message',
+          id: 'commit 2',
+          ranges: [
+            {start: 4, end: 10},
+            {start: 17, end: 32},
+          ],
+        },
+      ];
+    });
+
+    test('setBlame attempts to render each blamed line', () => {
+      const getBlameStub = sinon
+        .stub(builder, 'getBlameTdByLine')
+        .returns(undefined);
+      builder.setBlame(mockBlame);
+      assert.equal(getBlameStub.callCount, 32);
+    });
+
+    test('getBlameCommitForBaseLine', () => {
+      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
+      builder.setBlame(mockBlame);
+      assert.isOk(builder.getBlameCommitForBaseLine(1));
+      assert.equal(builder.getBlameCommitForBaseLine(1)?.id, 'commit 1');
+
+      assert.isOk(builder.getBlameCommitForBaseLine(11));
+      assert.equal(builder.getBlameCommitForBaseLine(11)?.id, 'commit 1');
+
+      assert.isOk(builder.getBlameCommitForBaseLine(32));
+      assert.equal(builder.getBlameCommitForBaseLine(32)?.id, 'commit 2');
+
+      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
+    });
+
+    test('getBlameCommitForBaseLine w/o blame returns null', () => {
+      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
+    });
+
+    test('createBlameCell', () => {
+      const mockBlameInfo = {
+        time: 1576155200,
+        id: '1234567890',
+        author: 'Clark Kent',
+        commit_msg: 'Testing Commit',
+        ranges: [{start: 4, end: 10}],
+      };
+      const getBlameStub = sinon
+        .stub(builder, 'getBlameCommitForBaseLine')
+        .returns(mockBlameInfo);
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      const result = builder.createBlameCell(line.beforeNumber);
+
+      assert.isTrue(getBlameStub.calledWithExactly(3));
+      assert.equal(result.getAttribute('data-line-number'), '3');
+      assert.dom.equal(
+        result,
+        /* HTML */ `
+          <span class="gr-diff">
+            <a class="blameDate gr-diff" href="/r/q/1234567890"> 12/12/2019 </a>
+            <span class="blameAuthor gr-diff">Clark</span>
+            <gr-hovercard class="gr-diff">
+              <span class="blameHoverCard gr-diff">
+                Commit 1234567890<br />
+                Author: Clark Kent<br />
+                Date: 12/12/2019<br />
+                <br />
+                Testing Commit
+              </span>
+            </gr-hovercard>
+          </span>
+        `
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
index 3bcec06..3cdd1f9 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
index ceadc94..8176e14 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {
   MovedLinkClickedEventDetail,
@@ -131,10 +120,12 @@
         continue;
       }
       const lineNumberEl = this.getLineNumberEl(el, side);
-      el.parentElement.replaceChild(
-        this.createTextEl(lineNumberEl, line, side).firstChild!,
-        el
-      );
+      const newContent = this.createTextEl(lineNumberEl, line, side)
+        .firstChild as HTMLElement;
+      // Note that ${el.id} ${newContent.id} might actually mismatch: In unified
+      // diff we are rendering the same content twice for all the diff chunk
+      // that are unchanged from left to right. TODO: Be smarter about this.
+      el.parentElement.replaceChild(newContent, el);
     }
   }
 
@@ -162,10 +153,8 @@
    *
    * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
    */
-  private getLineNumberEl(
-    content: HTMLElement,
-    side: Side
-  ): HTMLElement | null {
+  // visible for testing
+  getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
     let row: HTMLElement | null = content;
     while (row && !row.classList.contains('diff-row')) row = row.parentElement;
     return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
@@ -315,6 +304,8 @@
       button.classList.add('lineNumButton');
       button.classList.add(side);
       button.dataset['value'] = number.toString();
+      button.id =
+        side === Side.LEFT ? `left-button-${number}` : `right-button-${number}`;
       button.textContent = number === 'FILE' ? 'File' : number.toString();
       if (number === 'FILE') {
         button.setAttribute('aria-label', 'Add file comment');
@@ -329,6 +320,8 @@
           button.setAttribute('aria-label', `${number} removed`);
         } else if (line.type === GrDiffLineType.ADD) {
           button.setAttribute('aria-label', `${number} added`);
+        } else {
+          button.setAttribute('aria-label', `${number} unmodified`);
         }
       }
       this.addLineNumberMouseEvents(td, number, side);
@@ -349,7 +342,8 @@
     });
   }
 
-  protected createTextEl(
+  // visible for testing
+  createTextEl(
     lineNumberEl: HTMLElement | null,
     line: GrDiffLine,
     side?: Side
@@ -376,7 +370,10 @@
         line.text,
         responsiveMode,
         this._prefs.tab_size,
-        this._prefs.line_length
+        this._prefs.line_length,
+        side === Side.LEFT
+          ? `left-content-${beforeNumber}`
+          : `right-content-${afterNumber}`
       );
 
       if (side) {
@@ -477,7 +474,7 @@
       cells[signCols.right].classList.add('sign', 'right');
     }
     const moveRangeHeader = createElementDiff('gr-range-header');
-    moveRangeHeader.setAttribute('icon', 'gr-icons:move-item');
+    moveRangeHeader.setAttribute('icon', 'move_item');
     moveRangeHeader.appendChild(descriptionTextDiv);
     cells[descriptionIndex].classList.add('moveHeader');
     cells[descriptionIndex].appendChild(moveRangeHeader);
@@ -491,7 +488,8 @@
    * Create a blame cell for the given base line. Blame information will be
    * included in the cell if available.
    */
-  protected createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
+  // visible for testing
+  createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
     const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement;
     blameTd.setAttribute('data-line-number', lineNumber.toString());
     if (!lineNumber) return blameTd;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index a711215..f7d1552 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
@@ -44,7 +33,8 @@
     };
   }
 
-  protected override buildSectionElement(group: GrDiffGroup) {
+  // visible for testing
+  override buildSectionElement(group: GrDiffGroup) {
     const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (group.isTotal()) {
@@ -109,6 +99,30 @@
     row.setAttribute('right-type', rightLine.type);
     // TabIndex makes screen reader read a row when navigating with j/k
     row.tabIndex = -1;
+    // Before Chrome 102, Chrome was able to compute a11y label from children
+    // content. Now Chrome 102 and Firefox are not computing a11y label because
+    // tr is not expected to have aria label. Adding aria role button is
+    // pushing browser to compute aria even for tr. This can be removed, once
+    // browsers will again compute a11y label even for tr when it is focused.
+    // TODO: Remove when Chrome 102 is out of date for 1 year.
+    if (
+      leftLine.beforeNumber !== 'FILE' &&
+      leftLine.beforeNumber !== 'LOST' &&
+      rightLine.beforeNumber !== 'FILE' &&
+      rightLine.beforeNumber !== 'LOST'
+    ) {
+      row.setAttribute(
+        'aria-labelledby',
+        [
+          leftLine.beforeNumber ? `left-button-${leftLine.beforeNumber}` : '',
+          leftLine.beforeNumber ? `left-content-${leftLine.beforeNumber}` : '',
+          rightLine.afterNumber ? `right-button-${rightLine.afterNumber}` : '',
+          rightLine.afterNumber ? `right-content-${rightLine.afterNumber}` : '',
+        ]
+          .join(' ')
+          .trim()
+      );
+    }
 
     row.appendChild(this.createBlameCell(leftLine.beforeNumber));
 
@@ -147,7 +161,8 @@
     return td;
   }
 
-  protected override getNextContentOnSide(
+  // visible for testing
+  override getNextContentOnSide(
     content: HTMLElement,
     side: Side
   ): HTMLElement | null {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 4145485..a06701b 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
@@ -43,7 +32,8 @@
     };
   }
 
-  protected override buildSectionElement(group: GrDiffGroup): HTMLElement {
+  // visible for testing
+  override buildSectionElement(group: GrDiffGroup): HTMLElement {
     const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (group.isTotal()) {
@@ -126,6 +116,30 @@
     if (line.type === GrDiffLineType.REMOVE) {
       side = Side.LEFT;
     }
+
+    // Before Chrome 102, Chrome was able to compute a11y label from children
+    // content. Now Chrome 102 and Firefox are not computing a11y label because
+    // tr is not expected to have aria label. Adding aria role button is
+    // pushing browser to compute aria even for tr. This can be removed, once
+    // browsers will again compute a11y label even for tr when it is focused.
+    // TODO: Remove when Chrome 102 is out of date for 1 year.
+    if (line.beforeNumber !== 'FILE' && line.beforeNumber !== 'LOST') {
+      row.setAttribute(
+        'aria-labelledby',
+        [
+          line.beforeNumber ? `left-button-${line.beforeNumber}` : '',
+          line.afterNumber ? `right-button-${line.afterNumber}` : '',
+          side === Side.LEFT && line.beforeNumber
+            ? `left-content-${line.beforeNumber}`
+            : '',
+          side === Side.RIGHT && line.afterNumber
+            ? `right-content-${line.afterNumber}`
+            : '',
+        ]
+          .join(' ')
+          .trim()
+      );
+    }
     row.appendChild(this.createTextEl(lineNumberEl, line, side));
     return row;
   }
@@ -133,6 +147,11 @@
   getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
     let tr: HTMLElement = content.parentElement!.parentElement!;
     while ((tr = tr.nextSibling as HTMLElement)) {
+      // Note that this does not work when there is a "common" chunk in the
+      // diff (different content only because of whitespace). Such chunks are
+      // rendered with class "add", so these rows will be skipped for the
+      // 'left' side.
+      // TODO: Fix this when writing a Lit component for unified diff.
       if (
         tr.classList.contains('both') ||
         (side === 'left' && tr.classList.contains('remove')) ||
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js
deleted file mode 100644
index 5f3fb72..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js
+++ /dev/null
@@ -1,242 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-group.js';
-import './gr-diff-builder.js';
-import './gr-diff-builder-unified.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
-
-suite('GrDiffBuilderUnified tests', () => {
-  let prefs;
-  let outputEl;
-  let diffBuilder;
-
-  setup(()=> {
-    prefs = {
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
-    outputEl = document.createElement('div');
-    diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
-  });
-
-  suite('buildSectionElement for BOTH group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
-        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
-        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World";';
-      lines[2].text = '  return True';
-
-      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('both'));
-    });
-
-    test('creates each unchanged row once', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 3);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[0].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[1].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.left').textContent,
-          lines[2].beforeNumber);
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-    });
-  });
-
-  suite('buildSectionElement for moved chunks', () => {
-    test('creates a moved out group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 15),
-        new GrDiffLine(GrDiffLineType.REMOVE, 16),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      group.moveDetails = {changed: false};
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved out');
-    });
-
-    test('creates a moved in group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.ADD, 37),
-        new GrDiffLine(GrDiffLineType.ADD, 38),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      group.moveDetails = {changed: false};
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved in');
-    });
-  });
-
-  suite('buildSectionElement for DELTA group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 1),
-        new GrDiffLine(GrDiffLineType.REMOVE, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 3),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      lines[2].text = 'def hello_universe()';
-      lines[3].text = '  print "Hello Universe"';
-
-      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('delta'));
-    });
-
-    test('creates the section with class if ignoredWhitespaceOnly', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
-    });
-
-    test('creates the section with class if dueToRebase', () => {
-      group.dueToRebase = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
-    });
-
-    test('creates first the removed and then the added rows', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 4);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[3].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[3].querySelector('.content').textContent, lines[3].text);
-    });
-
-    test('creates only the added rows if only ignored whitespace', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 2);
-
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[3].text);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
new file mode 100644
index 0000000..8c44727
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
@@ -0,0 +1,283 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../gr-diff/gr-diff-group';
+import './gr-diff-builder';
+import './gr-diff-builder-unified';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {DiffPreferencesInfo} from '../../../api/diff';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {createDiff} from '../../../test/test-data-generators';
+import {queryAndAssert} from '../../../utils/common-util';
+import {assert} from '@open-wc/testing';
+
+suite('GrDiffBuilderUnified tests', () => {
+  let prefs: DiffPreferencesInfo;
+  let outputEl: HTMLElement;
+  let diffBuilder: GrDiffBuilderUnified;
+
+  setup(() => {
+    prefs = {
+      ...createDefaultDiffPrefs(),
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    outputEl = document.createElement('div');
+    diffBuilder = new GrDiffBuilderUnified(createDiff(), prefs, outputEl, []);
+  });
+
+  suite('buildSectionElement for BOTH group', () => {
+    let lines: GrDiffLine[];
+    let group: GrDiffGroup;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
+        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
+        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World";';
+      lines[2].text = '  return True';
+
+      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
+    });
+
+    test('creates the section', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('both'));
+    });
+
+    test('creates each unchanged row once', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 3);
+
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
+        lines[0].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
+        lines[0].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[0].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
+        lines[1].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
+        lines[1].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[1].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.left').textContent,
+        lines[2].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.content').textContent,
+        lines[2].text
+      );
+    });
+  });
+
+  suite('buildSectionElement for moved chunks', () => {
+    test('creates a moved out group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 15),
+        new GrDiffLine(GrDiffLineType.REMOVE, 16),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        moveDetails: {changed: false},
+      });
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
+      assert.equal(cells[2].textContent, 'Moved out');
+    });
+
+    test('creates a moved in group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.ADD, 37),
+        new GrDiffLine(GrDiffLineType.ADD, 38),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        moveDetails: {changed: false},
+      });
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
+      assert.equal(cells[2].textContent, 'Moved in');
+    });
+  });
+
+  suite('buildSectionElement for DELTA group', () => {
+    let lines: GrDiffLine[];
+    let group: GrDiffGroup;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 1),
+        new GrDiffLine(GrDiffLineType.REMOVE, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 3),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      lines[2].text = 'def hello_universe()';
+      lines[3].text = '  print "Hello Universe"';
+    });
+
+    test('creates the section', () => {
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('delta'));
+    });
+
+    test('creates the section with class if ignoredWhitespaceOnly', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        ignoredWhitespaceOnly: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
+    });
+
+    test('creates the section with class if dueToRebase', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        dueToRebase: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
+    });
+
+    test('creates first the removed and then the added rows', () => {
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 4);
+
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
+        lines[0].beforeNumber.toString()
+      );
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[0].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
+        lines[1].beforeNumber.toString()
+      );
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[1].text
+      );
+
+      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.content').textContent,
+        lines[2].text
+      );
+
+      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[3], '.lineNum.right').textContent,
+        lines[3].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[3], '.content').textContent,
+        lines[3].text
+      );
+    });
+
+    test('creates only the added rows if only ignored whitespace', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        ignoredWhitespaceOnly: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 2);
+
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[2].text
+      );
+
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
+        lines[3].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[3].text
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index add7ffa..0006f26 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {
   ContentLoadNeededEventDetail,
@@ -21,7 +10,7 @@
 } from '../../../api/diff';
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
-
+import {assert} from '../../../utils/common-util';
 import '../gr-context-controls/gr-context-controls';
 import {BlameInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
@@ -89,7 +78,8 @@
 
   protected readonly numLinesLeft: number;
 
-  protected readonly _prefs: DiffPreferencesInfo;
+  // visible for testing
+  readonly _prefs: DiffPreferencesInfo;
 
   protected readonly renderPrefs?: RenderPreferences;
 
@@ -194,7 +184,8 @@
     group.element = element;
   }
 
-  private getGroupsByLineRange(
+  // visible for testing
+  getGroupsByLineRange(
     startLine: LineNumber,
     endLine: LineNumber,
     side: Side
@@ -249,20 +240,18 @@
    * @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.
+   * @param out_lines The output list of line objects.
+   *        TODO: Change to camelCase.
+   * @param out_elements The output list of line elements.
+   *        TODO: Change to camelCase.
    */
-  protected findLinesByRange(
+  // visible for testing
+  findLinesByRange(
     start: LineNumber,
     end: LineNumber,
     side: Side,
-    out_lines: GrDiffLine[] | null,
-    out_elements: HTMLElement[] | null
+    out_lines: GrDiffLine[],
+    out_elements: HTMLElement[]
   ) {
     const groups = this.getGroupsByLineRange(start, end, side);
     for (const group of groups) {
@@ -280,21 +269,23 @@
           continue;
         }
 
-        if (out_lines) {
-          out_lines.push(line);
+        if (content) {
+          content = this.getNextContentOnSide(content, side);
+        } else {
+          content = this.getContentByLine(lineNumber, side, group.element);
         }
-        if (out_elements) {
-          if (content) {
-            content = this.getNextContentOnSide(content, side);
-          } else {
-            content = this.getContentByLine(lineNumber, side, group.element);
-          }
-          if (content) {
-            out_elements.push(content);
-          }
+        if (content) {
+          // out_lines and out_elements must match. So if we don't have an
+          // element to push, then also don't push a line.
+          out_lines.push(line);
+          out_elements.push(content);
         }
       }
     }
+    assert(
+      out_lines.length === out_elements.length,
+      'findLinesByRange: lines and elements arrays must have same length'
+    );
   }
 
   protected abstract renderContentByRange(
@@ -352,9 +343,8 @@
    *
    * @return The commit information.
    */
-  protected getBlameCommitForBaseLine(
-    lineNum: LineNumber
-  ): BlameInfo | undefined {
+  // visible for testing
+  getBlameCommitForBaseLine(lineNum: LineNumber): BlameInfo | undefined {
     for (const blameCommit of this.blameInfo) {
       for (const range of blameCommit.ranges) {
         if (range.start <= lineNum && range.end >= lineNum) {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
index 0a60bdb..19e0e22 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
@@ -1,20 +1,9 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {DiffLayer, DiffLayerListener} from '../../../types/types';
+import {DiffLayer} from '../../../types/types';
 import {GrDiffLine, Side, TokenHighlightListener} from '../../../api/diff';
 import {assertIsDefined} from '../../../utils/common-util';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
@@ -55,12 +44,6 @@
 const TOKEN_OCCURRENCES_LIMIT = 1000;
 
 /**
- * Token highlighting is only useful for code on-screen, so we only highlight
- * the nearest set of tokens up to this limit.
- */
-const TOKEN_HIGHLIGHT_LIMIT = 100;
-
-/**
  * When a user hovers over a token in the diff, then this layer makes sure that
  * all occurrences of this token are annotated with the 'token-highlight' css
  * class. And removes that class when the user moves the mouse away from the
@@ -72,9 +55,6 @@
  * And when that re-rendering happens the appropriate css class is added.
  */
 export class TokenHighlightLayer implements DiffLayer {
-  /** The only listener is typically the renderer of gr-diff. */
-  private listeners: DiffLayerListener[] = [];
-
   /** The currently highlighted token. */
   private currentHighlight?: string;
 
@@ -103,16 +83,14 @@
    * Keeps track of where tokens occur in a file during rendering, so that it is
    * easy to look up when processing mouse events.
    */
-  private tokenToLinesLeft = new Map<string, Set<number>>();
-
-  private tokenToLinesRight = new Map<string, Set<number>>();
+  private tokenToElements = new Map<string, Set<HTMLElement>>();
 
   private hoveredElement?: Element;
 
   private updateTokenTask?: DelayedTask;
 
   constructor(
-    container: HTMLElement = document.documentElement,
+    container: HTMLElement,
     tokenHighlightListener?: TokenHighlightListener
   ) {
     this.tokenHighlightListener = tokenHighlightListener;
@@ -121,12 +99,7 @@
     });
   }
 
-  annotate(
-    el: HTMLElement,
-    _: HTMLElement,
-    line: GrDiffLine,
-    side: Side
-  ): void {
+  annotate(el: HTMLElement, _1: HTMLElement, _2: GrDiffLine, _3: Side): void {
     const text = el.textContent;
     if (!text) return;
     // Binary files encoded as text for example can have super long lines
@@ -143,7 +116,7 @@
       if (length > TOKEN_LENGTH_LIMIT) continue;
       atLeastOneTokenMatched = true;
       const highlightTypeClass =
-        token === this.currentHighlight ? CSS_HIGHLIGHT : CSS_TOKEN;
+        token === this.currentHighlight ? CSS_HIGHLIGHT : '';
       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
@@ -153,12 +126,12 @@
         el,
         index,
         length,
-        `${textClass} ${indexClass} ${highlightTypeClass}`
+        `${textClass} ${indexClass} ${CSS_TOKEN} ${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.
-      this.storeLineForToken(token, line, side);
+      this.storeElementsForToken(token, el, textClass);
     }
     if (atLeastOneTokenMatched) {
       // These listeners do not have to be cleaned, because listeners are
@@ -173,21 +146,23 @@
     }
   }
 
-  private storeLineForToken(token: string, line: GrDiffLine, side: Side) {
-    const tokenToLines =
-      side === Side.LEFT ? this.tokenToLinesLeft : this.tokenToLinesRight;
-    // Just to make sure that we don't break down on large files.
-    if (tokenToLines.size > TOKEN_COUNT_LIMIT) return;
-    let numbers = tokenToLines.get(token);
-    if (!numbers) {
-      numbers = new Set<number>();
-      tokenToLines.set(token, numbers);
+  private storeElementsForToken(
+    token: string,
+    lineEl: HTMLElement,
+    cssClass: string
+  ) {
+    for (const el of lineEl.querySelectorAll(`.${cssClass}`)) {
+      let tokenEls = this.tokenToElements.get(token);
+      if (!tokenEls) {
+        // Just to make sure that we don't break down on large files.
+        if (this.tokenToElements.size > TOKEN_COUNT_LIMIT) return;
+        tokenEls = new Set<HTMLElement>();
+        this.tokenToElements.set(token, tokenEls);
+      }
+      // Just to make sure that we don't break down on large files.
+      if (tokenEls.size > TOKEN_OCCURRENCES_LIMIT) return;
+      tokenEls.add(el as HTMLElement);
     }
-    // Just to make sure that we don't break down on large files.
-    if (numbers.size > TOKEN_OCCURRENCES_LIMIT) return;
-    const lineNumber =
-      side === Side.LEFT ? line.beforeNumber : line.afterNumber;
-    numbers.add(Number(lineNumber));
   }
 
   private handleTokenMouseOut(e: MouseEvent) {
@@ -245,10 +220,7 @@
   } {
     if (!(el instanceof Element))
       return {line: 0, token: undefined, element: undefined};
-    if (
-      el.classList.contains(CSS_TOKEN) ||
-      el.classList.contains(CSS_HIGHLIGHT)
-    ) {
+    if (el.classList.contains(CSS_TOKEN)) {
       const tkTextClass = [...el.classList].find(c =>
         c.startsWith(TOKEN_TEXT_PREFIX)
       );
@@ -276,8 +248,8 @@
       this.currentHighlightLineNumber === newLineNumber
     )
       return;
+
     const oldHighlight = this.currentHighlight;
-    const oldLineNumber = this.currentHighlightLineNumber;
     this.currentHighlight = newHighlight;
     this.currentHighlightLineNumber = newLineNumber;
     this.triggerTokenHighlightEvent(
@@ -285,8 +257,21 @@
       newLineNumber,
       newHoveredElement
     );
-    this.notifyForToken(oldHighlight, oldLineNumber);
-    this.notifyForToken(newHighlight, newLineNumber);
+    this.toggleTokenHighlight(oldHighlight, CSS_HIGHLIGHT);
+    this.toggleTokenHighlight(newHighlight, CSS_HIGHLIGHT);
+  }
+
+  private toggleTokenHighlight(token: string | undefined, cssClass: string) {
+    if (!token) {
+      return;
+    }
+    const tokenEls = this.tokenToElements.get(token);
+    if (!tokenEls) {
+      return;
+    }
+    for (const el of tokenEls) {
+      el.classList.toggle(cssClass);
+    }
   }
 
   triggerTokenHighlightEvent(
@@ -317,58 +302,4 @@
     };
     this.tokenHighlightListener({token, element, side, range});
   }
-
-  getSortedLinesForSide(
-    lineMapping: Map<string, Set<number>>,
-    token: string | undefined,
-    lineNumber: number
-  ): Array<number> {
-    if (!token) return [];
-    const lineSet = lineMapping.get(token);
-    if (!lineSet) return [];
-    const lines = [...lineSet];
-    lines.sort((a, b) => {
-      const da = Math.abs(a - lineNumber);
-      const db = Math.abs(b - lineNumber);
-      // For equal distance, prefer lines later in the file over earlier in the
-      // file. This ensures total ordering.
-      if (da === db) return b - a;
-      // Compare the distance to lineNumber.
-      return da - db;
-    });
-    return lines.slice(0, TOKEN_HIGHLIGHT_LIMIT);
-  }
-
-  notifyForToken(token: string | undefined, lineNumber: number) {
-    const leftLines = this.getSortedLinesForSide(
-      this.tokenToLinesLeft,
-      token,
-      lineNumber
-    );
-    for (const line of leftLines) {
-      this.notifyListeners(line, Side.LEFT);
-    }
-    const rightLines = this.getSortedLinesForSide(
-      this.tokenToLinesRight,
-      token,
-      lineNumber
-    );
-    for (const line of rightLines) {
-      this.notifyListeners(line, Side.RIGHT);
-    }
-  }
-
-  addListener(listener: DiffLayerListener) {
-    this.listeners.push(listener);
-  }
-
-  removeListener(listener: DiffLayerListener) {
-    this.listeners = this.listeners.filter(f => f !== listener);
-  }
-
-  notifyListeners(line: number, side: Side) {
-    for (const listener of this.listeners) {
-      listener(line, line, side);
-    }
-  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
index 384f173..0e2def0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -1,42 +1,25 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import '../../../test/common-test-setup';
 import {Side, TokenHighlightEventDetails} from '../../../api/diff';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {HOVER_DELAY_MS, TokenHighlightLayer} from './token-highlight-layer';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {html, render} from 'lit';
 import {_testOnly_allTasks} from '../../../utils/async-util';
 import {queryAndAssert} from '../../../test/test-utils';
+import {assert} from '@open-wc/testing';
 
-// MockInteractions.makeMouseEvent always sets buttons to 1.
-function dispatchMouseEvent(
-  type: string,
-  xy: {x: number; y: number},
-  node: Element
-) {
+function dispatchMouseEvent(type: string, node: Element) {
   const props = {
     bubbles: true,
     cancellable: true,
     composed: true,
-    clientX: xy.x,
-    clientY: xy.y,
+    clientX: 100,
+    clientY: 100,
     buttons: 0,
   };
   node.dispatchEvent(new MouseEvent(type, props));
@@ -72,6 +55,7 @@
     highlightDetails?: TokenHighlightEventDetails
   ) {
     tokenHighlightingCalls.push({details: highlightDetails});
+    listener.notify({details: highlightDetails});
   }
 
   setup(async () => {
@@ -80,7 +64,6 @@
     container = document.createElement('div');
     document.body.appendChild(container);
     highlighter = new TokenHighlightLayer(container, tokenHighlightListener);
-    highlighter.addListener((...args) => listener.notify(...args));
   });
 
   teardown(() => {
@@ -143,21 +126,21 @@
         el,
         0,
         5,
-        'tk-text-these tk-index-0 token'
+        'tk-text-these tk-index-0 token '
       );
       assertAnnotation(
         annotateElementStub.args[1],
         el,
         6,
         3,
-        'tk-text-are tk-index-6 token'
+        'tk-text-are tk-index-6 token '
       );
       assertAnnotation(
         annotateElementStub.args[2],
         el,
         10,
         5,
-        'tk-text-words tk-index-10 token'
+        'tk-text-words tk-index-10 token '
       );
     });
 
@@ -201,11 +184,7 @@
       annotate(line2, Side.RIGHT, 2);
       const words1 = queryAndAssert(line1, '.tk-text-words');
       assert.isTrue(words1.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
+      dispatchMouseEvent('mouseover', words1);
 
       assert.equal(listener.pending, 0);
       assert.equal(_testOnly_allTasks.size, 1);
@@ -217,10 +196,8 @@
 
       // After a total of HOVER_DELAY_MS ms the hover behavior should trigger.
       clock.tick(HOVER_DELAY_MS - 100);
-      assert.equal(listener.pending, 2);
+      assert.equal(listener.pending, 1);
       assert.equal(_testOnly_allTasks.size, 0);
-      assert.deepEqual(listener.shift(), [1, 1, Side.LEFT]);
-      assert.deepEqual(listener.shift(), [2, 2, Side.RIGHT]);
     });
 
     test('highlighting spans many lines', async () => {
@@ -231,20 +208,20 @@
       annotate(line2, Side.RIGHT, 1000);
       const words1 = queryAndAssert(line1, '.tk-text-words');
       assert.isTrue(words1.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
+      dispatchMouseEvent('mouseover', words1);
 
       assert.equal(listener.pending, 0);
 
       // After a total of HOVER_DELAY_MS ms the hover behavior should trigger.
       clock.tick(HOVER_DELAY_MS);
-      assert.equal(listener.pending, 2);
+      assert.equal(listener.pending, 1);
       assert.equal(_testOnly_allTasks.size, 0);
-      assert.deepEqual(listener.shift(), [1, 1, Side.LEFT]);
-      assert.deepEqual(listener.shift(), [1000, 1000, Side.RIGHT]);
+      assert.deepEqual(tokenHighlightingCalls[0].details, {
+        token: 'words',
+        side: Side.RIGHT,
+        element: words1,
+        range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
+      });
     });
 
     test('highlighting mouse out before delay', async () => {
@@ -255,19 +232,11 @@
       annotate(line2, Side.RIGHT, 2);
       const words1 = queryAndAssert(line1, '.tk-text-words');
       assert.isTrue(words1.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
+      dispatchMouseEvent('mouseover', words1);
       assert.equal(listener.pending, 0);
       clock.tick(100);
       // Mouse out after 100ms but before hover delay.
-      dispatchMouseEvent(
-        'mouseout',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
+      dispatchMouseEvent('mouseout', words1);
       assert.equal(listener.pending, 0);
       clock.tick(HOVER_DELAY_MS - 100);
       assert.equal(listener.pending, 0);
@@ -282,11 +251,7 @@
       annotate(line2, Side.RIGHT, 2);
       const words1 = queryAndAssert(line1, '.tk-text-words');
       assert.isTrue(words1.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
+      dispatchMouseEvent('mouseover', words1);
       assert.equal(tokenHighlightingCalls.length, 0);
       clock.tick(HOVER_DELAY_MS);
       assert.equal(tokenHighlightingCalls.length, 1);
@@ -296,10 +261,12 @@
         element: words1,
         range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
       });
+      assert.isTrue(words1.classList.contains('token-highlight'));
 
-      MockInteractions.click(container);
+      container.click();
       assert.equal(tokenHighlightingCalls.length, 2);
       assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
+      assert.isFalse(words1.classList.contains('token-highlight'));
     });
 
     test('triggers listener on token with single occurrence', async () => {
@@ -313,11 +280,7 @@
         '.tk-text-tokenWithSingleOccurence'
       );
       assert.isTrue(tokenNode.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(tokenNode),
-        tokenNode
-      );
+      dispatchMouseEvent('mouseover', tokenNode);
       assert.equal(tokenHighlightingCalls.length, 0);
       clock.tick(HOVER_DELAY_MS);
       assert.equal(tokenHighlightingCalls.length, 1);
@@ -328,7 +291,7 @@
         range: {start_line: 1, start_column: 3, end_line: 1, end_column: 26},
       });
 
-      MockInteractions.click(container);
+      container.click();
       assert.equal(tokenHighlightingCalls.length, 2);
       assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
     });
@@ -341,20 +304,16 @@
       annotate(line2, Side.RIGHT, 2);
       const words1 = queryAndAssert(line1, '.tk-text-words');
       assert.isTrue(words1.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
+      dispatchMouseEvent('mouseover', words1);
       assert.equal(listener.pending, 0);
       clock.tick(HOVER_DELAY_MS);
-      assert.equal(listener.pending, 2);
+      assert.equal(listener.pending, 1);
       listener.flush();
       assert.equal(listener.pending, 0);
-      MockInteractions.click(container);
-      assert.equal(listener.pending, 2);
-      assert.deepEqual(listener.shift(), [1, 1, Side.LEFT]);
-      assert.deepEqual(listener.shift(), [2, 2, Side.RIGHT]);
+      assert.isTrue(words1.classList.contains('token-highlight'));
+      container.click();
+      assert.equal(listener.pending, 1);
+      assert.isFalse(words1.classList.contains('token-highlight'));
     });
 
     test('clicking on word does not clear highlight', async () => {
@@ -363,20 +322,18 @@
       annotate(line1);
       const line2 = createLine('three words', 2);
       annotate(line2, Side.RIGHT, 2);
-      const words1 = queryAndAssert(line1, '.tk-text-words');
+      const words1 = queryAndAssert<HTMLDivElement>(line1, '.tk-text-words');
       assert.isTrue(words1.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
+      dispatchMouseEvent('mouseover', words1);
       assert.equal(listener.pending, 0);
       clock.tick(HOVER_DELAY_MS);
-      assert.equal(listener.pending, 2);
+      assert.equal(listener.pending, 1);
       listener.flush();
       assert.equal(listener.pending, 0);
-      MockInteractions.click(words1);
+      assert.isTrue(words1.classList.contains('token-highlight'));
+      words1.click();
       assert.equal(listener.pending, 0);
+      assert.isTrue(words1.classList.contains('token-highlight'));
     });
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index 5e2dec0..35439d6 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -1,21 +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 {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {Subscription} from 'rxjs';
 import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
 import {
@@ -24,7 +11,6 @@
   LineNumberEventDetail,
 } from '../../../api/diff';
 import {ScrollMode, Side} from '../../../constants/constants';
-import {PolymerDomWrapper} from '../../../types/types';
 import {toggleClass} from '../../../utils/dom-util';
 import {
   GrCursorManager,
@@ -39,6 +25,11 @@
 const LEFT_SIDE_CLASS = 'target-side-left';
 const RIGHT_SIDE_CLASS = 'target-side-right';
 
+interface Address {
+  leftSide: boolean;
+  number: number;
+}
+
 /** A subset of the GrDiff API that the cursor is using. */
 export interface GrDiffCursorable extends HTMLElement {
   isRangeSelected(): boolean;
@@ -109,7 +100,8 @@
    */
   initialLineNumber: number | null = null;
 
-  private cursorManager = new GrCursorManager();
+  // visible for testing
+  cursorManager = new GrCursorManager();
 
   private targetSubscription?: Subscription;
 
@@ -255,7 +247,7 @@
   getTargetDiffElement(): GrDiff | null {
     if (!this.diffRow) return null;
 
-    const hostOwner = (dom(this.diffRow) as PolymerDomWrapper).getOwnerRoot();
+    const hostOwner = this.diffRow.getRootNode() as ShadowRoot;
     if (hostOwner?.host?.tagName === 'GR-DIFF') {
       return hostOwner.host as GrDiff;
     }
@@ -287,7 +279,7 @@
    * This may grab the focus from the app.
    *
    * If you do not want to move the cursor or grab focus, and just want to
-   * reset the scroll behavior, use reInit() instead.
+   * reset the scroll behavior, use reInitAndUpdateStops() instead.
    */
   reInitCursor() {
     this._updateStops();
@@ -323,10 +315,6 @@
     this._updateStops();
   }
 
-  handleDiffUpdate() {
-    this.reInitCursor();
-  }
-
   private boundHandleDiffLoadingChanged = () => {
     this._updateStops();
   };
@@ -335,10 +323,6 @@
     this.preventAutoScrollOnManualScroll = true;
   };
 
-  private _boundHandleDiffRenderProgress = () => {
-    this._updateStops();
-  };
-
   private _boundHandleDiffRenderContent = () => {
     this._updateStops();
     // When done rendering, turn focus on move and automatic scrolling back on
@@ -376,7 +360,7 @@
    * {leftSide: true, number: 321} for line 321 of the base patch.
    * Returns null if an address is not available.
    */
-  getAddress() {
+  getAddress(): Address | null {
     if (!this.diffRow) {
       return null;
     }
@@ -385,7 +369,7 @@
     return this.getAddressFor(this.diffRow, this.side);
   }
 
-  private getAddressFor(diffRow: HTMLElement, side: Side) {
+  private getAddressFor(diffRow: HTMLElement, side: Side): Address | null {
     let cell;
     if (this._getViewMode() === DiffViewMode.UNIFIED) {
       cell = diffRow.querySelector('.lineNum.right');
@@ -550,10 +534,6 @@
     );
     diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
     diff.removeEventListener(
-      'render-progress',
-      this._boundHandleDiffRenderProgress
-    );
-    diff.removeEventListener(
       'render-content',
       this._boundHandleDiffRenderContent
     );
@@ -569,10 +549,6 @@
       this.boundHandleDiffLoadingChanged
     );
     diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
-    diff.addEventListener(
-      'render-progress',
-      this._boundHandleDiffRenderProgress
-    );
     diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
     diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
deleted file mode 100644
index c64f484..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ /dev/null
@@ -1,696 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff.js';
-import './gr-diff-cursor.js';
-import {fixture, html} from '@open-wc/testing-helpers';
-import {listenOnce, mockPromise} from '../../../test/test-utils.js';
-import {createDiff} from '../../../test/test-data-generators.js';
-import {createDefaultDiffPrefs} from '../../../constants/constants.js';
-import {GrDiffCursor} from './gr-diff-cursor.js';
-
-suite('gr-diff-cursor tests', () => {
-  let cursor;
-  let diffElement;
-  let diff;
-
-  setup(async () => {
-    diffElement = await fixture(html`<gr-diff></gr-diff>`);
-    cursor = new GrDiffCursor();
-
-    // Register the diff with the cursor.
-    cursor.replaceDiffs([diffElement]);
-
-    diffElement.loggedIn = false;
-    diffElement.comments = {
-      left: [],
-      right: [],
-      meta: {},
-    };
-    diffElement.path = 'some/path.ts';
-    const promise = mockPromise();
-    const setupDone = () => {
-      cursor._updateStops();
-      cursor.moveToFirstChunk();
-      diffElement.removeEventListener('render', setupDone);
-      promise.resolve();
-    };
-    diffElement.addEventListener('render', setupDone);
-
-    diff = createDiff();
-    diffElement.prefs = createDefaultDiffPrefs();
-    diffElement.diff = diff;
-    await promise;
-  });
-
-  test('diff cursor functionality (side-by-side)', () => {
-    // The cursor has been initialized to the first delta.
-    assert.isOk(cursor.diffRow);
-
-    const firstDeltaRow = diffElement.shadowRoot
-        .querySelector('.section.delta .diff-row');
-    assert.equal(cursor.diffRow, firstDeltaRow);
-
-    cursor.moveDown();
-
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
-
-    cursor.moveUp();
-
-    assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
-    assert.equal(cursor.diffRow, firstDeltaRow);
-  });
-
-  test('moveToFirstChunk', async () => {
-    const diff = {
-      meta_a: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      meta_b: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-        'index b2adcf4..554ae49 100644',
-        '--- a/lorem-ipsum.txt',
-        '+++ b/lorem-ipsum.txt',
-      ],
-      content: [
-        {b: ['new line 1']},
-        {ab: ['unchanged line']},
-        {a: ['old line 2']},
-        {ab: ['more unchanged lines']},
-      ],
-    };
-
-    diffElement.diff = diff;
-    // The file comment button, if present, is a cursor stop. Ensure
-    // moveToFirstChunk() works correctly even if the button is not shown.
-    diffElement.prefs.show_file_comment_button = false;
-    await flush();
-    cursor._updateStops();
-
-    const chunks = Array.from(diffElement.root.querySelectorAll(
-        '.section.delta'));
-    assert.equal(chunks.length, 2);
-
-    // Verify it works on fresh diff.
-    cursor.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'right');
-
-    // Verify it works from other cursor positions.
-    cursor.moveToNextChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'left');
-    cursor.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'right');
-  });
-
-  test('moveToLastChunk', async () => {
-    const diff = {
-      meta_a: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      meta_b: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-        'index b2adcf4..554ae49 100644',
-        '--- a/lorem-ipsum.txt',
-        '+++ b/lorem-ipsum.txt',
-      ],
-      content: [
-        {ab: ['unchanged line']},
-        {a: ['old line 2']},
-        {ab: ['more unchanged lines']},
-        {b: ['new line 3']},
-      ],
-    };
-
-    diffElement.diff = diff;
-    await flush();
-    cursor._updateStops();
-
-    const chunks = Array.from(diffElement.root.querySelectorAll(
-        '.section.delta'));
-    assert.equal(chunks.length, 2);
-
-    // Verify it works on fresh diff.
-    cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'right');
-
-    // Verify it works from other cursor positions.
-    cursor.moveToPreviousChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'left');
-    cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'right');
-  });
-
-  test('cursor scroll behavior', () => {
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-
-    diffElement.dispatchEvent(new Event('render-start'));
-    assert.isTrue(cursor.cursorManager.focusOnMove);
-
-    window.dispatchEvent(new Event('scroll'));
-    assert.equal(cursor.cursorManager.scrollMode, 'never');
-    assert.isFalse(cursor.cursorManager.focusOnMove);
-
-    diffElement.dispatchEvent(new Event('render-content'));
-    assert.isTrue(cursor.cursorManager.focusOnMove);
-
-    cursor.reInitCursor();
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-  });
-
-  test('moves to selected line', () => {
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
-
-    diffElement.dispatchEvent(
-        new CustomEvent('line-selected', {
-          detail: {number: '123', side: 'right', path: 'some/file'},
-        }));
-
-    assert.isTrue(moveToNumStub.called);
-    assert.equal(moveToNumStub.lastCall.args[0], '123');
-    assert.equal(moveToNumStub.lastCall.args[1], 'right');
-    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
-  });
-
-  suite('unified diff', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      // We must allow the diff to re-render after setting the viewMode.
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.viewMode = 'UNIFIED_DIFF';
-      await promise;
-    });
-
-    test('diff cursor functionality (unified)', () => {
-      // The cursor has been initialized to the first delta.
-      assert.isOk(cursor.diffRow);
-
-      let firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursor.diffRow, firstDeltaRow);
-
-      firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursor.diffRow, firstDeltaRow);
-
-      cursor.moveDown();
-
-      assert.notEqual(cursor.diffRow, firstDeltaRow);
-      assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
-
-      cursor.moveUp();
-
-      assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
-      assert.equal(cursor.diffRow, firstDeltaRow);
-    });
-  });
-
-  test('cursor side functionality', () => {
-    // The side only applies to side-by-side mode, which should be the default
-    // mode.
-    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
-
-    const firstDeltaSection = diffElement.shadowRoot
-        .querySelector('.section.delta');
-    const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
-
-    // Because the first delta in this diff is on the right, it should be set
-    // to the right side.
-    assert.equal(cursor.side, 'right');
-    assert.equal(cursor.diffRow, firstDeltaRow);
-    const firstIndex = cursor.cursorManager.index;
-
-    // Move the side to the left. Because this delta only has a right side, we
-    // should be moved up to the previous line where there is content on the
-    // right. The previous row is part of the previous section.
-    cursor.moveLeft();
-
-    assert.equal(cursor.side, 'left');
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(cursor.cursorManager.index, firstIndex - 1);
-    assert.equal(cursor.diffRow.parentElement,
-        firstDeltaSection.previousSibling);
-
-    // If we move down, we should skip everything in the first delta because
-    // we are on the left side and the first delta has no content on the left.
-    cursor.moveDown();
-
-    assert.equal(cursor.side, 'left');
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.isTrue(cursor.cursorManager.index > firstIndex);
-    assert.equal(cursor.diffRow.parentElement,
-        firstDeltaSection.nextSibling);
-  });
-
-  test('chunk skip functionality', () => {
-    const chunks = diffElement.root.querySelectorAll(
-        '.section.delta');
-    const indexOfChunk = function(chunk) {
-      return Array.prototype.indexOf.call(chunks, chunk);
-    };
-
-    // We should be initialized to the first chunk. Since this chunk only has
-    // content on the right side, our side should be right.
-    let currentIndex = indexOfChunk(cursor.diffRow.parentElement);
-    assert.equal(currentIndex, 0);
-    assert.equal(cursor.side, 'right');
-
-    // Move to the next chunk.
-    cursor.moveToNextChunk();
-
-    // Since this chunk only has content on the left side. we should have been
-    // automatically moved over.
-    const previousIndex = currentIndex;
-    currentIndex = indexOfChunk(cursor.diffRow.parentElement);
-    assert.equal(currentIndex, previousIndex + 1);
-    assert.equal(cursor.side, 'left');
-  });
-
-  suite('moved chunks without line range)', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.diff = {...diff, content: [
-        {
-          ab: [
-            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
-          ],
-        },
-        {
-          b: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false},
-        },
-        {
-          ab: [
-            'Sem nascetur, erat ut, non in.',
-          ],
-        },
-        {
-          a: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false},
-        },
-        {
-          ab: [
-            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          ],
-        },
-      ]};
-      await promise;
-    });
-
-    test('renders moveControls with simple descriptions', () => {
-      const [movedIn, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      assert.equal(movedIn.textContent, 'Moved in');
-      assert.equal(movedOut.textContent, 'Moved out');
-    });
-  });
-
-  suite('moved chunks (moveDetails)', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.diff = {...diff, content: [
-        {
-          ab: [
-            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
-          ],
-        },
-        {
-          b: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false, range: {start: 4, end: 6}},
-        },
-        {
-          ab: [
-            'Sem nascetur, erat ut, non in.',
-          ],
-        },
-        {
-          a: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false, range: {start: 2, end: 4}},
-        },
-        {
-          ab: [
-            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          ],
-        },
-      ]};
-      await promise;
-    });
-
-    test('renders moveControls with simple descriptions', () => {
-      const [movedIn, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
-      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
-    });
-
-    test('startLineAnchor of movedIn chunk fires events', async () => {
-      const [movedIn] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      const [startLineAnchor] = movedIn.querySelectorAll('a');
-
-      const promise = mockPromise();
-      const onMovedLinkClicked = e => {
-        assert.deepEqual(e.detail, {lineNum: 4, side: 'left'});
-        promise.resolve();
-      };
-      assert.equal(startLineAnchor.textContent, '4');
-      startLineAnchor
-          .addEventListener('moved-link-clicked', onMovedLinkClicked);
-      MockInteractions.click(startLineAnchor);
-      await promise;
-    });
-
-    test('endLineAnchor of movedOut fires events', async () => {
-      const [, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      const [, endLineAnchor] = movedOut.querySelectorAll('a');
-
-      const promise = mockPromise();
-      const onMovedLinkClicked = e => {
-        assert.deepEqual(e.detail, {lineNum: 4, side: 'right'});
-        promise.resolve();
-      };
-      assert.equal(endLineAnchor.textContent, '4');
-      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
-      MockInteractions.click(endLineAnchor);
-      await promise;
-    });
-  });
-
-  test('initialLineNumber not provided', async () => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
-    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk')
-        .callsFake(() => {
-          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
-        });
-
-    const promise = mockPromise();
-    function renderHandler() {
-      diffElement.removeEventListener('render', renderHandler);
-      cursor.reInitCursor();
-      assert.isFalse(moveToNumStub.called);
-      assert.isTrue(moveToChunkStub.called);
-      assert.equal(scrollBehaviorDuringMove, 'never');
-      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-      promise.resolve();
-    }
-    diffElement.addEventListener('render', renderHandler);
-    diffElement._diffChanged(createDiff());
-    await promise;
-  });
-
-  test('initialLineNumber provided', async () => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber')
-        .callsFake(() => {
-          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
-        });
-    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
-    const promise = mockPromise();
-    function renderHandler() {
-      diffElement.removeEventListener('render', renderHandler);
-      cursor.reInitCursor();
-      assert.isFalse(moveToChunkStub.called);
-      assert.isTrue(moveToNumStub.called);
-      assert.equal(moveToNumStub.lastCall.args[0], 10);
-      assert.equal(moveToNumStub.lastCall.args[1], 'right');
-      assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-      promise.resolve();
-    }
-    diffElement.addEventListener('render', renderHandler);
-    cursor.initialLineNumber = 10;
-    cursor.side = 'right';
-
-    diffElement._diffChanged(createDiff());
-    await promise;
-  });
-
-  test('getTargetDiffElement', () => {
-    cursor.initialLineNumber = 1;
-    assert.isTrue(!!cursor.diffRow);
-    assert.equal(
-        cursor.getTargetDiffElement(),
-        diffElement
-    );
-  });
-
-  suite('createCommentInPlace', () => {
-    setup(() => {
-      diffElement.loggedIn = true;
-    });
-
-    test('adds new draft for selected line on the left', async () => {
-      cursor.moveToLineNumber(2, 'left');
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 2);
-        assert.equal(range, undefined);
-        assert.equal(side, 'left');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('adds draft for selected line on the right', async () => {
-      cursor.moveToLineNumber(4, 'right');
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 4);
-        assert.equal(range, undefined);
-        assert.equal(side, 'right');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('creates comment for range if selected', async () => {
-      const someRange = {
-        start_line: 2,
-        start_character: 3,
-        end_line: 6,
-        end_character: 1,
-      };
-      diffElement.highlights.selectedRange = {
-        side: 'right',
-        range: someRange,
-      };
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 6);
-        assert.equal(range, someRange);
-        assert.equal(side, 'right');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('ignores call if nothing is selected', () => {
-      const createRangeCommentStub = sinon.stub(diffElement,
-          'createRangeComment');
-      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
-      cursor.diffRow = undefined;
-      cursor.createCommentInPlace();
-      assert.isFalse(createRangeCommentStub.called);
-      assert.isFalse(addDraftAtLineStub.called);
-    });
-  });
-
-  test('getAddress', () => {
-    // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 5});
-
-    // Revision line 4 is up.
-    cursor.moveUp();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 4});
-
-    // Base line 4 is left.
-    cursor.moveLeft();
-    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
-
-    // Moving to the next chunk takes it back to the start.
-    cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 5});
-
-    // The following chunk is a removal starting on line 10 of the base.
-    cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: true, number: 10});
-
-    // Should be null if there is no selection.
-    cursor.cursorManager.unsetCursor();
-    assert.isNotOk(cursor.getAddress());
-  });
-
-  test('_findRowByNumberAndFile', () => {
-    // Get the first ab row after the first chunk.
-    const row = diffElement.root.querySelectorAll('tr')[9];
-
-    // It should be line 8 on the right, but line 5 on the left.
-    assert.equal(cursor._findRowByNumberAndFile(8, 'right'), row);
-    assert.equal(cursor._findRowByNumberAndFile(5, 'left'), row);
-  });
-
-  test('expand context updates stops', async () => {
-    sinon.spy(cursor, '_updateStops');
-    MockInteractions.tap(diffElement.shadowRoot
-        .querySelector('gr-context-controls').shadowRoot
-        .querySelector('.showContext'));
-    await flush();
-    assert.isTrue(cursor._updateStops.called);
-  });
-
-  test('updates stops when loading changes', () => {
-    sinon.spy(cursor, '_updateStops');
-    diffElement.dispatchEvent(new Event('loading-changed'));
-    assert.isTrue(cursor._updateStops.called);
-  });
-
-  suite('multi diff', () => {
-    let diffElements;
-
-    setup(async () => {
-      diffElements = [
-        await fixture(html`<gr-diff></gr-diff>`),
-        await fixture(html`<gr-diff></gr-diff>`),
-        await fixture(html`<gr-diff></gr-diff>`),
-      ];
-      cursor = new GrDiffCursor();
-
-      // Register the diff with the cursor.
-      cursor.replaceDiffs(diffElements);
-
-      for (const el of diffElements) {
-        el.prefs = createDefaultDiffPrefs();
-      }
-    });
-
-    function getTargetDiffIndex() {
-      // Mocha has a bug where when `assert.equals` fails, it will try to
-      // JSON.stringify the operands, which fails when they are cyclic structures
-      // like GrDiffElement. The failure is difficult to attribute to a specific
-      // assertion because of the async nature assertion errors are handled and
-      // can cause the test simply timing out, causing a lot of debugging headache.
-      // Working with indices circumvents the problem.
-      return diffElements.indexOf(cursor.getTargetDiffElement());
-    }
-
-    test('do not skip loading diffs', async () => {
-      const diffRenderedPromises =
-          diffElements.map(diffEl => listenOnce(diffEl, 'render'));
-
-      diffElements[0].diff = createDiff();
-      diffElements[2].diff = createDiff();
-      await Promise.all([diffRenderedPromises[0], diffRenderedPromises[2]]);
-
-      const lastLine = diffElements[0].diff.meta_b.lines;
-
-      // Goto second last line of the first diff
-      cursor.moveToLineNumber(lastLine - 1, 'right');
-      assert.equal(
-          cursor.getTargetLineElement().textContent, lastLine - 1);
-
-      // Can move down until we reach the loading file
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
-
-      // Cannot move down while still loading the diff we would switch to
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
-
-      // Diff 1 finishing to load
-      diffElements[1].diff = createDiff();
-      await diffRenderedPromises[1];
-
-      // Now we can go down
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 1);
-      assert.equal(cursor.getTargetLineElement().textContent, 'File');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
new file mode 100644
index 0000000..1e554b7
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -0,0 +1,694 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../gr-diff/gr-diff';
+import './gr-diff-cursor';
+import {fixture, html, assert} from '@open-wc/testing';
+import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {createDiff} from '../../../test/test-data-generators';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {GrDiffCursor} from './gr-diff-cursor';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {DiffInfo, DiffViewMode, Side} from '../../../api/diff';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {assertIsDefined} from '../../../utils/common-util';
+
+suite('gr-diff-cursor tests', () => {
+  let cursor: GrDiffCursor;
+  let diffElement: GrDiff;
+  let diff: DiffInfo;
+
+  setup(async () => {
+    diffElement = await fixture(html`<gr-diff></gr-diff>`);
+    cursor = new GrDiffCursor();
+
+    // Register the diff with the cursor.
+    cursor.replaceDiffs([diffElement]);
+
+    diffElement.loggedIn = false;
+    diffElement.path = 'some/path.ts';
+    const promise = mockPromise();
+    const setupDone = () => {
+      cursor._updateStops();
+      cursor.moveToFirstChunk();
+      diffElement.removeEventListener('render', setupDone);
+      promise.resolve();
+    };
+    diffElement.addEventListener('render', setupDone);
+
+    diff = createDiff();
+    diffElement.prefs = createDefaultDiffPrefs();
+    diffElement.diff = diff;
+    await promise;
+  });
+
+  test('diff cursor functionality (side-by-side)', () => {
+    // The cursor has been initialized to the first delta.
+    assert.isOk(cursor.diffRow);
+
+    const firstDeltaRow = queryAndAssert<HTMLElement>(
+      diffElement,
+      '.section.delta .diff-row'
+    );
+    assert.equal(cursor.diffRow, firstDeltaRow);
+
+    cursor.moveDown();
+
+    assert.isOk(firstDeltaRow.nextElementSibling);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(
+      cursor.diffRow,
+      firstDeltaRow.nextElementSibling as HTMLElement
+    );
+
+    cursor.moveUp();
+
+    assert.isOk(firstDeltaRow.nextElementSibling);
+    assert.notEqual(
+      cursor.diffRow,
+      firstDeltaRow.nextElementSibling as HTMLElement
+    );
+    assert.equal(cursor.diffRow, firstDeltaRow);
+  });
+
+  test('moveToFirstChunk', async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {b: ['new line 1']},
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    // The file comment button, if present, is a cursor stop. Ensure
+    // moveToFirstChunk() works correctly even if the button is not shown.
+    diffElement.prefs!.show_file_comment_button = false;
+    await waitForEventOnce(diffElement, 'render');
+
+    cursor._updateStops();
+
+    const chunks = [
+      ...queryAll(diffElement, '.section.delta'),
+    ] as HTMLElement[];
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToNextChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.LEFT);
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('moveToLastChunk', async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+        {b: ['new line 3']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    await waitForEventOnce(diffElement, 'render');
+    cursor._updateStops();
+
+    const chunks = [...queryAll(diffElement, '.section.delta')];
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToPreviousChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.LEFT);
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('cursor scroll behavior', () => {
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+
+    diffElement.dispatchEvent(new Event('render-start'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    window.dispatchEvent(new Event('scroll'));
+    assert.equal(cursor.cursorManager.scrollMode, 'never');
+    assert.isFalse(cursor.cursorManager.focusOnMove);
+
+    diffElement.dispatchEvent(new Event('render-content'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    cursor.reInitCursor();
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('moves to selected line', () => {
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+
+    diffElement.dispatchEvent(
+      new CustomEvent('line-selected', {
+        detail: {number: '123', side: Side.RIGHT, path: 'some/file'},
+      })
+    );
+
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 123);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
+  });
+
+  suite('unified diff', () => {
+    setup(async () => {
+      diffElement.viewMode = DiffViewMode.UNIFIED;
+      await waitForEventOnce(diffElement, 'render');
+      cursor.reInitCursor();
+    });
+
+    test('diff cursor functionality (unified)', () => {
+      // The cursor has been initialized to the first delta.
+      assert.isOk(cursor.diffRow);
+
+      const firstDeltaRow = queryAndAssert<HTMLElement>(
+        diffElement,
+        '.section.delta .diff-row'
+      );
+      assert.equal(cursor.diffRow, firstDeltaRow);
+
+      cursor.moveDown();
+
+      assert.notEqual(cursor.diffRow, firstDeltaRow);
+      assert.equal(
+        cursor.diffRow,
+        firstDeltaRow.nextElementSibling as HTMLElement
+      );
+
+      cursor.moveUp();
+
+      assert.notEqual(
+        cursor.diffRow,
+        firstDeltaRow.nextElementSibling as HTMLElement
+      );
+      assert.equal(cursor.diffRow, firstDeltaRow);
+    });
+  });
+
+  test('cursor side functionality', () => {
+    // The side only applies to side-by-side mode, which should be the default
+    // mode.
+    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+    const firstDeltaSection = queryAndAssert<HTMLElement>(
+      diffElement,
+      '.section.delta'
+    );
+    const firstDeltaRow = queryAndAssert<HTMLElement>(
+      firstDeltaSection,
+      '.diff-row'
+    );
+
+    // Because the first delta in this diff is on the right, it should be set
+    // to the right side.
+    assert.equal(cursor.side, Side.RIGHT);
+    assert.equal(cursor.diffRow, firstDeltaRow);
+    const firstIndex = cursor.cursorManager.index;
+
+    // Move the side to the left. Because this delta only has a right side, we
+    // should be moved up to the previous line where there is content on the
+    // right. The previous row is part of the previous section.
+    cursor.moveLeft();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.cursorManager.index, firstIndex - 1);
+    assert.equal(
+      cursor.diffRow!.parentElement,
+      firstDeltaSection.previousSibling
+    );
+
+    // If we move down, we should skip everything in the first delta because
+    // we are on the left side and the first delta has no content on the left.
+    cursor.moveDown();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.isTrue(cursor.cursorManager.index > firstIndex);
+    assert.equal(cursor.diffRow!.parentElement, firstDeltaSection.nextSibling);
+  });
+
+  test('chunk skip functionality', () => {
+    const chunks = [...queryAll(diffElement, '.section.delta')];
+    const indexOfChunk = function (chunk: HTMLElement) {
+      return Array.prototype.indexOf.call(chunks, chunk);
+    };
+
+    // We should be initialized to the first chunk. Since this chunk only has
+    // content on the right side, our side should be right.
+    let currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
+    assert.equal(currentIndex, 0);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Move to the next chunk.
+    cursor.moveToNextChunk();
+
+    // Since this chunk only has content on the left side. we should have been
+    // automatically moved over.
+    const previousIndex = currentIndex;
+    currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
+    assert.equal(currentIndex, previousIndex + 1);
+    assert.equal(cursor.side, Side.LEFT);
+  });
+
+  suite('moved chunks without line range)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {
+        ...diff,
+        content: [
+          {
+            ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+          },
+          {
+            b: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false},
+          },
+          {
+            ab: ['Sem nascetur, erat ut, non in.'],
+          },
+          {
+            a: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      assert.equal(movedIn.textContent, 'Moved in');
+      assert.equal(movedOut.textContent, 'Moved out');
+    });
+  });
+
+  suite('moved chunks (moveDetails)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {
+        ...diff,
+        content: [
+          {
+            ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+          },
+          {
+            b: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false, range: {start: 4, end: 6}},
+          },
+          {
+            ab: ['Sem nascetur, erat ut, non in.'],
+          },
+          {
+            a: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false, range: {start: 2, end: 4}},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
+      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
+    });
+
+    test('startLineAnchor of movedIn chunk fires events', async () => {
+      const [movedIn] = [...queryAll(diffElement, '.dueToMove .moveControls')];
+      const [startLineAnchor] = movedIn.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.LEFT});
+        promise.resolve();
+      };
+      assert.equal(startLineAnchor.textContent, '4');
+      startLineAnchor.addEventListener(
+        'moved-link-clicked',
+        onMovedLinkClicked
+      );
+      startLineAnchor.click();
+      await promise;
+    });
+
+    test('endLineAnchor of movedOut fires events', async () => {
+      const [, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      const [, endLineAnchor] = movedOut.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.RIGHT});
+        promise.resolve();
+      };
+      assert.equal(endLineAnchor.textContent, '4');
+      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
+      endLineAnchor.click();
+      await promise;
+    });
+  });
+
+  test('initialLineNumber not provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+    const moveToChunkStub = sinon
+      .stub(cursor, 'moveToFirstChunk')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+    diffElement.diff = createDiff();
+    await diffElement.updateComplete;
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToNumStub.called);
+    assert.isTrue(moveToChunkStub.called);
+    assert.equal(scrollBehaviorDuringMove, 'never');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('initialLineNumber provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon
+      .stub(cursor, 'moveToLineNumber')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
+    cursor.initialLineNumber = 10;
+    cursor.side = Side.RIGHT;
+
+    diffElement.diff = createDiff();
+    await diffElement.updateComplete;
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToChunkStub.called);
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 10);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(scrollBehaviorDuringMove, 'keep-visible');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('getTargetDiffElement', () => {
+    cursor.initialLineNumber = 1;
+    assert.isTrue(!!cursor.diffRow);
+    assert.equal(cursor.getTargetDiffElement(), diffElement);
+  });
+
+  suite('createCommentInPlace', () => {
+    setup(() => {
+      diffElement.loggedIn = true;
+    });
+
+    test('adds new draft for selected line on the left', async () => {
+      cursor.moveToLineNumber(2, Side.LEFT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 2);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.LEFT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('adds draft for selected line on the right', async () => {
+      cursor.moveToLineNumber(4, Side.RIGHT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 4);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('creates comment for range if selected', async () => {
+      const someRange = {
+        start_line: 2,
+        start_character: 3,
+        end_line: 6,
+        end_character: 1,
+      };
+      diffElement.highlights.selectedRange = {
+        side: Side.RIGHT,
+        range: someRange,
+      };
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 6);
+        assert.equal(range, someRange);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('ignores call if nothing is selected', () => {
+      const createRangeCommentStub = sinon.stub(
+        diffElement,
+        'createRangeComment'
+      );
+      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
+      cursor.diffRow = undefined;
+      cursor.createCommentInPlace();
+      assert.isFalse(createRangeCommentStub.called);
+      assert.isFalse(addDraftAtLineStub.called);
+    });
+  });
+
+  test('getAddress', () => {
+    // It should initialize to the first chunk: line 5 of the revision.
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // Revision line 4 is up.
+    cursor.moveUp();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 4});
+
+    // Base line 4 is left.
+    cursor.moveLeft();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
+
+    // Moving to the next chunk takes it back to the start.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // The following chunk is a removal starting on line 10 of the base.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 10});
+
+    // Should be null if there is no selection.
+    cursor.cursorManager.unsetCursor();
+    assert.isNotOk(cursor.getAddress());
+  });
+
+  test('_findRowByNumberAndFile', () => {
+    // Get the first ab row after the first chunk.
+    const rows = [...queryAll<HTMLTableRowElement>(diffElement, 'tr')];
+    const row = rows[9];
+    assert.ok(row);
+
+    // It should be line 8 on the right, but line 5 on the left.
+    assert.equal(cursor._findRowByNumberAndFile(8, Side.RIGHT), row);
+    assert.equal(cursor._findRowByNumberAndFile(5, Side.LEFT), row);
+  });
+
+  test('expand context updates stops', async () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    const controls = queryAndAssert(diffElement, 'gr-context-controls');
+    const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
+    showContext.click();
+    await waitForEventOnce(diffElement, 'render');
+    assert.isTrue(spy.called);
+  });
+
+  test('updates stops when loading changes', () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    diffElement.dispatchEvent(new Event('loading-changed'));
+    assert.isTrue(spy.called);
+  });
+
+  suite('multi diff', () => {
+    let diffElements: GrDiff[];
+
+    setup(async () => {
+      diffElements = [
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+      ];
+      cursor = new GrDiffCursor();
+
+      // Register the diff with the cursor.
+      cursor.replaceDiffs(diffElements);
+
+      for (const el of diffElements) {
+        el.prefs = createDefaultDiffPrefs();
+      }
+    });
+
+    function getTargetDiffIndex() {
+      // Mocha has a bug where when `assert.equals` fails, it will try to
+      // JSON.stringify the operands, which fails when they are cyclic structures
+      // like GrDiffElement. The failure is difficult to attribute to a specific
+      // assertion because of the async nature assertion errors are handled and
+      // can cause the test simply timing out, causing a lot of debugging headache.
+      // Working with indices circumvents the problem.
+      const target = cursor.getTargetDiffElement();
+      assertIsDefined(target);
+      return diffElements.indexOf(target);
+    }
+
+    test('do not skip loading diffs', async () => {
+      diffElements[0].diff = createDiff();
+      diffElements[2].diff = createDiff();
+      await waitForEventOnce(diffElements[0], 'render');
+      await waitForEventOnce(diffElements[2], 'render');
+
+      const lastLine = diffElements[0].diff.meta_b?.lines;
+      assertIsDefined(lastLine);
+
+      // Goto second last line of the first diff
+      cursor.moveToLineNumber(lastLine - 1, Side.RIGHT);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        `${lastLine - 1}`
+      );
+
+      // Can move down until we reach the loading file
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        lastLine.toString()
+      );
+
+      // Cannot move down while still loading the diff we would switch to
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        lastLine.toString()
+      );
+
+      // Diff 1 finishing to load
+      diffElements[1].diff = createDiff();
+      await waitForEventOnce(diffElements[1], 'render');
+
+      // Now we can go down
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 1);
+      assert.equal(cursor.getTargetLineElement()!.textContent, 'File');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
index 79e7dbc..cc7cd49 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {getSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings';
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.js b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.js
index d8295a5..c5cab0c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.js
@@ -1,36 +1,29 @@
 /**
  * @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
  */
 
-const basicFixture = fixtureFromTemplate(html`
-<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-`);
-
-import '../../../test/common-test-setup-karma.js';
-import {GrAnnotation} from './gr-annotation.js';
-import {sanitizeDOMValue, setSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import '../../../test/common-test-setup';
+import {GrAnnotation} from './gr-annotation';
+import {
+  sanitizeDOMValue,
+  setSanitizeDOMValue,
+} from '@polymer/polymer/lib/utils/settings';
+// eslint-disable-next-line import/named
+import {assert, fixture, html} from '@open-wc/testing';
 
 suite('annotation', () => {
   let str;
   let parent;
   let textNode;
 
-  setup(() => {
-    parent = basicFixture.instantiate();
+  setup(async () => {
+    parent = await fixture(
+        html`
+        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+      `
+    );
     textNode = parent.childNodes[0];
     str = textNode.textContent;
   });
@@ -107,17 +100,16 @@
   });
 
   test('_annotateElement design doc example', () => {
-    const layers = [
-      'amet, ',
-      'inceptos ',
-      'amet, ',
-      'et, suspendisse ince',
-    ];
+    const layers = ['amet, ', 'inceptos ', 'amet, ', 'et, suspendisse ince'];
 
     // Apply the layers successively.
     layers.forEach((layer, i) => {
       GrAnnotation.annotateElement(
-          parent, str.indexOf(layer), layer.length, `layer-${i + 1}`);
+          parent,
+          str.indexOf(layer),
+          layer.length,
+          `layer-${i + 1}`
+      );
     });
 
     assert.equal(parent.textContent, str);
@@ -153,10 +145,10 @@
     assert.equal(layer4[2].textContent, 'ince');
     assert.equal(layer4[2].parentElement, layer2[0]);
 
-    assert.equal(layer4[0].textContent +
-        layer4[1].textContent +
-        layer4[2].textContent,
-    layers[3]);
+    assert.equal(
+        layer4[0].textContent + layer4[1].textContent + layer4[2].textContent,
+        layers[3]
+    );
   });
 
   test('splitTextNode', () => {
@@ -186,6 +178,7 @@
     let originalSanitizeDOMValue;
 
     setup(() => {
+      setSanitizeDOMValue((p0, p1, p2, node) => p0);
       originalSanitizeDOMValue = sanitizeDOMValue;
       assert.isDefined(originalSanitizeDOMValue);
       mockSanitize = sinon.spy(originalSanitizeDOMValue);
@@ -200,12 +193,14 @@
       const length = 10;
       const container = document.createElement('div');
       container.textContent = fullText;
-      GrAnnotation.annotateWithElement(
-          container, 1, length, {tagName: 'test-wrapper'});
+      GrAnnotation.annotateWithElement(container, 1, length, {
+        tagName: 'test-wrapper',
+      });
 
       assert.equal(
           container.innerHTML,
-          '0<test-wrapper>1234567890</test-wrapper>123456789');
+          '0<test-wrapper>1234567890</test-wrapper>123456789'
+      );
     });
 
     test('annotates when spanning multiple nodes', () => {
@@ -213,8 +208,9 @@
       const container = document.createElement('div');
       container.textContent = fullText;
       GrAnnotation.annotateElement(container, 5, length, 'testclass');
-      GrAnnotation.annotateWithElement(
-          container, 1, length, {tagName: 'test-wrapper'});
+      GrAnnotation.annotateWithElement(container, 1, length, {
+        tagName: 'test-wrapper',
+      });
 
       assert.equal(
           container.innerHTML,
@@ -224,19 +220,22 @@
           '<hl class="testclass">567890</hl>' +
           '</test-wrapper>' +
           '<hl class="testclass">1234</hl>' +
-          '56789');
+          '56789'
+      );
     });
 
     test('annotates text node', () => {
       const length = 10;
       const container = document.createElement('div');
       container.textContent = fullText;
-      GrAnnotation.annotateWithElement(
-          container.childNodes[0], 1, length, {tagName: 'test-wrapper'});
+      GrAnnotation.annotateWithElement(container.childNodes[0], 1, length, {
+        tagName: 'test-wrapper',
+      });
 
       assert.equal(
           container.innerHTML,
-          '0<test-wrapper>1234567890</test-wrapper>123456789');
+          '0<test-wrapper>1234567890</test-wrapper>123456789'
+      );
     });
 
     test('handles zero-length nodes', () => {
@@ -244,12 +243,14 @@
       container.appendChild(document.createTextNode('0123456789'));
       container.appendChild(document.createElement('span'));
       container.appendChild(document.createTextNode('0123456789'));
-      GrAnnotation.annotateWithElement(
-          container, 1, 10, {tagName: 'test-wrapper'});
+      GrAnnotation.annotateWithElement(container, 1, 10, {
+        tagName: 'test-wrapper',
+      });
 
       assert.equal(
           container.innerHTML,
-          '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789');
+          '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789'
+      );
     });
 
     test('handles comment nodes', () => {
@@ -259,15 +260,17 @@
       container.appendChild(document.createComment('comment2'));
       container.appendChild(document.createElement('span'));
       container.appendChild(document.createTextNode('0123456789'));
-      GrAnnotation.annotateWithElement(
-          container, 1, 10, {tagName: 'test-wrapper'});
+      GrAnnotation.annotateWithElement(container, 1, 10, {
+        tagName: 'test-wrapper',
+      });
 
       assert.equal(
           container.innerHTML,
           '<!--comment1-->' +
           '0<test-wrapper>123456789' +
           '<!--comment2-->' +
-          '<span></span>0</test-wrapper>123456789');
+          '<span></span>0</test-wrapper>123456789'
+      );
     });
 
     test('sets sanitized attributes', () => {
@@ -278,17 +281,34 @@
         'data-foo': 'bar',
         'class': 'hello world',
       };
-      GrAnnotation.annotateWithElement(
-          container, 1, length, {tagName: 'test-wrapper', attributes});
-      assert(mockSanitize.calledWith(
-          'foo', 'href', 'attribute', sinon.match.instanceOf(Element)));
-      assert(mockSanitize.calledWith(
-          'bar', 'data-foo', 'attribute', sinon.match.instanceOf(Element)));
-      assert(mockSanitize.calledWith(
-          'hello world',
-          'class',
-          'attribute',
-          sinon.match.instanceOf(Element)));
+      GrAnnotation.annotateWithElement(container, 1, length, {
+        tagName: 'test-wrapper',
+        attributes,
+      });
+      assert(
+          mockSanitize.calledWith(
+              'foo',
+              'href',
+              'attribute',
+              sinon.match.instanceOf(Element)
+          )
+      );
+      assert(
+          mockSanitize.calledWith(
+              'bar',
+              'data-foo',
+              'attribute',
+              sinon.match.instanceOf(Element)
+          )
+      );
+      assert(
+          mockSanitize.calledWith(
+              'hello world',
+              'class',
+              'attribute',
+              sinon.match.instanceOf(Element)
+          )
+      );
       const el = container.querySelector('test-wrapper');
       assert.equal(el.getAttribute('href'), 'foo');
       assert.equal(el.getAttribute('data-foo'), 'bar');
@@ -296,4 +316,3 @@
     });
   });
 });
-
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
index b819754..af921e4 100644
--- 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
@@ -3,10 +3,10 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-diff-highlight';
 import {_getTextOffset} from './gr-range-normalizer';
-import {fixture, fixtureCleanup, html} from '@open-wc/testing-helpers';
+import {fixture, fixtureCleanup, html, assert} from '@open-wc/testing';
 import {
   GrDiffHighlight,
   DiffBuilderInterface,
@@ -16,7 +16,11 @@
 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 {
+  stubElement,
+  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:
@@ -222,8 +226,8 @@
       element = new GrDiffHighlight();
       element.init(diff, builder);
       contentStubs = [];
-      stub('gr-selection-action-box', 'placeAbove');
-      stub('gr-selection-action-box', 'placeBelow');
+      stubElement('gr-selection-action-box', 'placeAbove');
+      stubElement('gr-selection-action-box', 'placeBelow');
     });
 
     teardown(() => {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
index 469c24a..9f23162 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 1068a8d..8a92bcc 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/paper-button/paper-button';
 import '@polymer/paper-card/paper-card';
@@ -24,15 +13,16 @@
 import '@polymer/paper-listbox/paper-listbox';
 import './gr-overview-image';
 import './gr-zoomed-image';
+import '../../../elements/shared/gr-icons/gr-icons';
 
 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';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
 
 import {
   createEvent,
@@ -586,18 +576,20 @@
     /* eslint-disable lit/prefer-static-styles */
     const customStyle = html`
       <style>
+        /* prettier formatter removes semi-colons after css mixins. */
+        /* prettier-ignore */
         paper-item {
           --paper-item-min-height: 48;
           --paper-item: {
             min-height: 48px;
             padding: 0 var(--spacing-xl);
-          }
+          };
           --paper-item-focused-before: {
             background-color: var(--selection-background-color);
-          }
+          };
           --paper-item-focused: {
             background-color: var(--selection-background-color);
-          }
+          };
         }
       </style>
     `;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
index 49a0eb5..1bc1447 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
-import {StyleInfo, styleMap} from 'lit/directives/style-map';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
 import {ImageDiffAction} from '../../../api/diff';
 
 import {createEvent, Dimensions, fitToFrame, Point, Rect} from './util';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
index 6fdec67..7b46c51 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
-import {StyleInfo, styleMap} from 'lit/directives/style-map';
+import {customElement, property, state} from 'lit/decorators.js';
+import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
 import {Rect} from './util';
 
 /**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
index 7036ce4..38a07b7 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {ImageDiffAction} from '../../../api/diff';
 
 export interface Point {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.js b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.js
deleted file mode 100644
index 80cfa36..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.js
+++ /dev/null
@@ -1,171 +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.js';
-import {FrameConstrainer} from './util.js';
-
-suite('FrameConstrainer tests', () => {
-  let constrainer;
-
-  setup(() => {
-    constrainer = new FrameConstrainer();
-    constrainer.setBounds({width: 100, height: 100});
-    constrainer.setFrameSize({width: 50, height: 50});
-    constrainer.requestCenter({x: 50, y: 50});
-  });
-
-  suite('changing center', () => {
-    test('moves frame to requested position', () => {
-      constrainer.requestCenter({x: 30, y: 30});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 5, y: 5}, dimensions: {width: 50, height: 50}});
-    });
-
-    test('keeps frame in bounds for top left corner', () => {
-      constrainer.requestCenter({x: 5, y: 5});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 0, y: 0}, dimensions: {width: 50, height: 50}});
-    });
-
-    test('keeps frame in bounds for bottom right corner', () => {
-      constrainer.requestCenter({x: 95, y: 95});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
-    });
-
-    test('handles out-of-bounds center left', () => {
-      constrainer.requestCenter({x: -5, y: 50});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 0, y: 25}, dimensions: {width: 50, height: 50}});
-    });
-
-    test('handles out-of-bounds center right', () => {
-      constrainer.requestCenter({x: 105, y: 50});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 50, y: 25}, dimensions: {width: 50, height: 50}});
-    });
-
-    test('handles out-of-bounds center top', () => {
-      constrainer.requestCenter({x: 50, y: -5});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 25, y: 0}, dimensions: {width: 50, height: 50}});
-    });
-
-    test('handles out-of-bounds center bottom', () => {
-      constrainer.requestCenter({x: 50, y: 105});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 25, y: 50}, dimensions: {width: 50, height: 50}});
-    });
-  });
-
-  suite('changing frame size', () => {
-    test('maintains center when decreased', () => {
-      constrainer.setFrameSize({width: 10, height: 10});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 45, y: 45}, dimensions: {width: 10, height: 10}});
-    });
-
-    test('maintains center when increased', () => {
-      constrainer.setFrameSize({width: 80, height: 80});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 10, y: 10}, dimensions: {width: 80, height: 80}});
-    });
-
-    test('updates center to remain in bounds when increased', () => {
-      constrainer.setFrameSize({width: 10, height: 10});
-      constrainer.requestCenter({x: 95, y: 95});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
-
-      constrainer.setFrameSize({width: 20, height: 20});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 80, y: 80}, dimensions: {width: 20, height: 20}});
-    });
-  });
-
-  suite('changing scale', () => {
-    suite('for unscaled frame', () => {
-      test('adjusts origin to maintain center when zooming in', () => {
-        constrainer.setScale(2);
-        assert.deepEqual(
-            constrainer.getUnscaledFrame(),
-            {origin: {x: 75, y: 75}, dimensions: {width: 50, height: 50}});
-      });
-
-      test('adjusts origin to maintain center when zooming out', () => {
-        constrainer.setFrameSize({width: 20, height: 20});
-        constrainer.setScale(0.5);
-        assert.deepEqual(
-            constrainer.getUnscaledFrame(),
-            {origin: {x: 15, y: 15}, dimensions: {width: 20, height: 20}});
-      });
-
-      test('keeps frame in bounds when zooming out', () => {
-        constrainer.setScale(5);
-        constrainer.requestCenter({x: 100, y: 100});
-        assert.deepEqual(
-            constrainer.getUnscaledFrame(),
-            {origin: {x: 450, y: 450}, dimensions: {width: 50, height: 50}});
-
-        constrainer.setScale(1);
-        assert.deepEqual(
-            constrainer.getUnscaledFrame(),
-            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
-      });
-    });
-
-    suite('for scaled frame', () => {
-      test('decreases frame size and maintains center when zooming in', () => {
-        constrainer.setScale(2);
-        assert.deepEqual(
-            constrainer.getScaledFrame(),
-            {origin: {x: 37.5, y: 37.5}, dimensions: {width: 25, height: 25}});
-      });
-
-      test('increases frame size and maintains center when zooming out', () => {
-        constrainer.setFrameSize({width: 20, height: 20});
-        constrainer.setScale(0.5);
-        assert.deepEqual(
-            constrainer.getScaledFrame(),
-            {origin: {x: 30, y: 30}, dimensions: {width: 40, height: 40}});
-      });
-
-      test('keeps frame in bounds when zooming out', () => {
-        constrainer.setScale(5);
-        constrainer.requestCenter({x: 100, y: 100});
-        assert.deepEqual(
-            constrainer.getScaledFrame(),
-            {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
-
-        constrainer.setScale(1);
-        assert.deepEqual(
-            constrainer.getScaledFrame(),
-            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
-      });
-    });
-  });
-});
\ No newline at end of file
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.ts
new file mode 100644
index 0000000..521b2e1
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.ts
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
+import {FrameConstrainer} from './util';
+
+suite('FrameConstrainer tests', () => {
+  let constrainer: FrameConstrainer;
+
+  setup(() => {
+    constrainer = new FrameConstrainer();
+    constrainer.setBounds({width: 100, height: 100});
+    constrainer.setFrameSize({width: 50, height: 50});
+    constrainer.requestCenter({x: 50, y: 50});
+  });
+
+  suite('changing center', () => {
+    test('moves frame to requested position', () => {
+      constrainer.requestCenter({x: 30, y: 30});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 5, y: 5},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+
+    test('keeps frame in bounds for top left corner', () => {
+      constrainer.requestCenter({x: 5, y: 5});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 0, y: 0},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+
+    test('keeps frame in bounds for bottom right corner', () => {
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 50, y: 50},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+
+    test('handles out-of-bounds center left', () => {
+      constrainer.requestCenter({x: -5, y: 50});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 0, y: 25},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+
+    test('handles out-of-bounds center right', () => {
+      constrainer.requestCenter({x: 105, y: 50});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 50, y: 25},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+
+    test('handles out-of-bounds center top', () => {
+      constrainer.requestCenter({x: 50, y: -5});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 25, y: 0},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+
+    test('handles out-of-bounds center bottom', () => {
+      constrainer.requestCenter({x: 50, y: 105});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 25, y: 50},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+  });
+
+  suite('changing frame size', () => {
+    test('maintains center when decreased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 45, y: 45},
+        dimensions: {width: 10, height: 10},
+      });
+    });
+
+    test('maintains center when increased', () => {
+      constrainer.setFrameSize({width: 80, height: 80});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 10, y: 10},
+        dimensions: {width: 80, height: 80},
+      });
+    });
+
+    test('updates center to remain in bounds when increased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 90, y: 90},
+        dimensions: {width: 10, height: 10},
+      });
+
+      constrainer.setFrameSize({width: 20, height: 20});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 80, y: 80},
+        dimensions: {width: 20, height: 20},
+      });
+    });
+  });
+
+  suite('changing scale', () => {
+    suite('for unscaled frame', () => {
+      test('adjusts origin to maintain center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(constrainer.getUnscaledFrame(), {
+          origin: {x: 75, y: 75},
+          dimensions: {width: 50, height: 50},
+        });
+      });
+
+      test('adjusts origin to maintain center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(constrainer.getUnscaledFrame(), {
+          origin: {x: 15, y: 15},
+          dimensions: {width: 20, height: 20},
+        });
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(constrainer.getUnscaledFrame(), {
+          origin: {x: 450, y: 450},
+          dimensions: {width: 50, height: 50},
+        });
+
+        constrainer.setScale(1);
+        assert.deepEqual(constrainer.getUnscaledFrame(), {
+          origin: {x: 50, y: 50},
+          dimensions: {width: 50, height: 50},
+        });
+      });
+    });
+
+    suite('for scaled frame', () => {
+      test('decreases frame size and maintains center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(constrainer.getScaledFrame(), {
+          origin: {x: 37.5, y: 37.5},
+          dimensions: {width: 25, height: 25},
+        });
+      });
+
+      test('increases frame size and maintains center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(constrainer.getScaledFrame(), {
+          origin: {x: 30, y: 30},
+          dimensions: {width: 40, height: 40},
+        });
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(constrainer.getScaledFrame(), {
+          origin: {x: 90, y: 90},
+          dimensions: {width: 10, height: 10},
+        });
+
+        constrainer.setScale(1);
+        assert.deepEqual(constrainer.getScaledFrame(), {
+          origin: {x: 50, y: 50},
+          dimensions: {width: 50, height: 50},
+        });
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index 1b53d05..5caffe6 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -1,25 +1,14 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 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 '../../../elements/shared/gr-icon/gr-icon';
 import {DiffViewMode} from '../../../constants/constants';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {FixIronA11yAnnouncer} from '../../../types/types';
 import {getAppContext} from '../../../services/app-context';
@@ -37,9 +26,11 @@
    */
   @property({type: Boolean}) saveOnChange = false;
 
-  @property({type: Boolean}) showTooltipBelow = false;
+  @property({type: Boolean, attribute: 'show-tooltip-below'})
+  showTooltipBelow = false;
 
-  @state() private mode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
+  // visible for testing
+  @state() mode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
 
   private readonly getBrowserModel = resolve(this, browserModelToken);
 
@@ -78,12 +69,11 @@
         /* Used to remove horizontal whitespace between the icons. */
         display: flex;
       }
-      gr-button.selected iron-icon {
+      gr-button.selected gr-icon {
         color: var(--link-color);
       }
-      iron-icon {
-        height: 1.3rem;
-        width: 1.3rem;
+      gr-icon {
+        font-size: 1.3rem;
       }
     `,
   ];
@@ -102,7 +92,7 @@
           aria-pressed=${this.isSideBySideSelected()}
           @click=${this.handleSideBySideTap}
         >
-          <iron-icon icon="gr-icons:side-by-side"></iron-icon>
+          <gr-icon icon="view_column_2" filled></gr-icon>
         </gr-button>
       </gr-tooltip-content>
       <gr-tooltip-content
@@ -117,7 +107,7 @@
           aria-pressed=${this.isUnifiedSelected()}
           @click=${this.handleUnifiedTap}
         >
-          <iron-icon icon="gr-icons:unified"></iron-icon>
+          <gr-icon icon="calendar_view_day" filled></gr-icon>
         </gr-button>
       </gr-tooltip-content>
     `;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
index 6ba5533..34af01e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-diff-mode-selector';
 import {GrDiffModeSelector} from './gr-diff-mode-selector';
 import {DiffViewMode} from '../../../constants/constants';
@@ -24,7 +12,7 @@
   stubUsers,
   waitUntilObserved,
 } from '../../../test/test-utils';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {wrapInProvider} from '../../../models/di-provider-element';
 import {
   BrowserModel,
@@ -64,33 +52,36 @@
       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>
-    `);
+    assert.shadowDom.equal(
+      element,
+      /* 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"
+          >
+            <gr-icon icon="view_column_2" filled></gr-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"
+          >
+            <gr-icon filled icon="calendar_view_day"></gr-icon>
+          </gr-button>
+        </gr-tooltip-content>
+      `
+    );
   });
 
   test('renders unified selected', async () => {
@@ -103,34 +94,37 @@
       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>
-    `);
+    assert.shadowDom.equal(
+      element,
+      /* 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"
+          >
+            <gr-icon icon="view_column_2" filled></gr-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"
+          >
+            <gr-icon icon="calendar_view_day" filled></gr-icon>
+          </gr-button>
+        </gr-tooltip-content>
+      `
+    );
   });
 
   test('set mode', async () => {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 4a1e5be..062347f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -15,7 +15,7 @@
   GrDiffGroupType,
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
-import {CancelablePromise, util} from '../../../scripts/util';
+import {CancelablePromise, makeCancelable} from '../../../scripts/util';
 import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {debounce, DelayedTask} from '../../../utils/async-util';
@@ -138,7 +138,13 @@
       return Promise.resolve();
     }
 
-    this.processPromise = util.makeCancelable(
+    // TODO: Canceling this promise does not help much. `nextStep` will continue
+    // to be scheduled anyway. So either just remove the cancelable promise, so
+    // future programmers are not fooled about this promise can do. Or fix the
+    // scheduling of `nextStep` such that cancellation is taken into account.
+    // The easiest approach is likely to just not re-use the processor for
+    // multiple processing passes. There is no benefit from doing so.
+    this.processPromise = makeCancelable(
       new Promise(resolve => {
         const state = {
           lineNums: {left: 0, right: 0},
@@ -353,7 +359,7 @@
       ignoredWhitespaceOnly: !!chunk.common,
       keyLocation: !!chunk.keyLocation,
     };
-    if (chunk.skip) {
+    if (chunk.skip !== undefined) {
       return new GrDiffGroup({
         type,
         skip: chunk.skip,
@@ -614,6 +620,7 @@
     const result = [];
     let lastChunkEndOffset = 0;
     for (const {offset, keyLocation} of chunkEnds) {
+      if (lastChunkEndOffset === offset) continue;
       result.push({
         lines: lines.slice(lastChunkEndOffset, offset),
         keyLocation,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index 60a1cba..6caeb62 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -3,12 +3,13 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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';
+import {assert} from '@open-wc/testing';
 
 suite('gr-diff-processor tests', () => {
   const WHOLE_FILE = -1;
@@ -488,6 +489,26 @@
       // group[4] is the displayed part of the second ab
     });
 
+    test('works with skip === 0', async () => {
+      element.context = 3;
+      const content = [
+        {
+          skip: 0,
+        },
+        {
+          b: [
+            '/**',
+            ' * @license',
+            ' * Copyright 2015 Google LLC',
+            ' * SPDX-License-Identifier: Apache-2.0',
+            ' */',
+            "import '../../../test/common-test-setup';",
+          ],
+        },
+      ];
+      await element.process(content, false);
+    });
+
     test('break up common diff chunks', () => {
       element.keyLocations = {
         left: {1: true},
@@ -497,55 +518,38 @@
       const content = [
         {
           ab: [
-            'Copyright (C) 2015 The Android Open Source Project',
+            'copy',
             '',
-            'Licensed under the Apache License, Version 2.0 (the "License");',
-            'you may not use this file except in compliance with the ' +
-              'License.',
-            'You may obtain a copy of the License at',
+            'asdf',
+            'qwer',
+            'zxcv',
             '',
-            'http://www.apache.org/licenses/LICENSE-2.0',
+            'http',
             '',
-            'Unless required by applicable law or agreed to in writing, ',
-            'software distributed under the License is distributed on an ',
-            '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
-            'either express or implied. See the License for the specific ',
-            'language governing permissions and limitations under the ' +
-              'License.',
+            'vbnm',
+            'dfgh',
+            'yuio',
+            'sdfg',
+            '1234',
           ],
         },
       ];
       const result = element.splitCommonChunksWithKeyLocations(content);
       assert.deepEqual(result, [
         {
-          ab: ['Copyright (C) 2015 The Android Open Source Project'],
+          ab: ['copy'],
           keyLocation: true,
         },
         {
-          ab: [
-            '',
-            'Licensed under the Apache License, Version 2.0 (the "License");',
-            'you may not use this file except in compliance with the ' +
-              'License.',
-            'You may obtain a copy of the License at',
-            '',
-            'http://www.apache.org/licenses/LICENSE-2.0',
-            '',
-            'Unless required by applicable law or agreed to in writing, ',
-          ],
+          ab: ['', 'asdf', 'qwer', 'zxcv', '', 'http', '', 'vbnm'],
           keyLocation: false,
         },
         {
-          ab: ['software distributed under the License is distributed on an '],
+          ab: ['dfgh'],
           keyLocation: true,
         },
         {
-          ab: [
-            '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
-            'either express or implied. See the License for the specific ',
-            'language governing permissions and limitations under the ' +
-              'License.',
-          ],
+          ab: ['yuio', 'sdfg', '1234'],
           keyLocation: false,
         },
       ]);
@@ -633,8 +637,8 @@
       // REST API.
       let content = [
         '      <section class="summary">',
-        '        <gr-linked-text content="' +
-          '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
+        '        <gr-formatted-text content="' +
+          '[[_computeCurrentRevisionMessage(change)]]"></gr-formatted-text>',
         '      </section>',
       ];
       let highlights = [
@@ -655,13 +659,9 @@
         },
         {
           contentIndex: 1,
+          endIndex: 101,
           startIndex: 75,
         },
-        {
-          contentIndex: 2,
-          startIndex: 0,
-          endIndex: 6,
-        },
       ]);
       const lines = element.linesFromRows(
         GrDiffLineType.BOTH,
@@ -675,7 +675,7 @@
       assert.isTrue(lines[1].hasIntralineInfo);
       assert.equal(lines[1].highlights.length, 2);
       assert.isTrue(lines[2].hasIntralineInfo);
-      assert.equal(lines[2].highlights.length, 1);
+      assert.equal(lines[2].highlights.length, 0);
 
       content = [
         '        this._path = value.path;',
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
index b44114a..8acaf04 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -3,13 +3,13 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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 {fixture, html, assert} from '@open-wc/testing';
 import {mouseDown} from '../../../test/test-utils';
 
 const diffTableTemplate = html`
@@ -22,7 +22,9 @@
         <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>
+              <span id="output" class="gr-formatted-text"
+                >This is a comment</span
+              >
             </div>
           </div>
         </div>
@@ -44,7 +46,7 @@
         <div data-side="right">
           <div class="comment-thread">
             <div class="gr-formatted-text message">
-              <span id="output" class="gr-linked-text"
+              <span id="output" class="gr-formatted-text"
                 >This is a comment on the right</span
               >
             </div>
@@ -60,7 +62,7 @@
         <div data-side="left">
           <div class="comment-thread">
             <div class="gr-formatted-text message">
-              <span id="output" class="gr-linked-text"
+              <span id="output" class="gr-formatted-text"
                 >This is <a>a</a> different comment 💩 unicode is fun</span
               >
             </div>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index b8262e0..5a34ae3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -1,22 +1,13 @@
 /**
  * @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 {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
 import {LineRange, Side} from '../../../api/diff';
 import {LineNumber} from './gr-diff-line';
+import {assertIsDefined, assert} from '../../../utils/common-util';
+import {untilRendered} from '../../../utils/dom-util';
 
 export enum GrDiffGroupType {
   /** Unchanged context. */
@@ -263,7 +254,7 @@
           throw new Error('Cannot set skip and lines');
         }
         this.skip = options.skip;
-        if (options.skip) {
+        if (options.skip !== undefined) {
           this.lineRange = {
             left: {
               start_line: options.offsetLeft,
@@ -275,7 +266,9 @@
             },
           };
         } else {
-          for (const line of options.lines ?? []) {
+          assertIsDefined(options.lines);
+          assert(options.lines.length > 0, 'diff group must have lines');
+          for (const line of options.lines) {
             this.addLine(line);
           }
         }
@@ -465,6 +458,22 @@
     }
   }
 
+  async waitUntilRendered() {
+    const lineNumber = this.lines[0]?.beforeNumber;
+    // The LOST or FILE lines may be hidden and thus never resolve an
+    // untilRendered() promise.
+    if (
+      this.skip ||
+      lineNumber === 'LOST' ||
+      lineNumber === 'FILE' ||
+      this.type === GrDiffGroupType.CONTEXT_CONTROL
+    ) {
+      return Promise.resolve();
+    }
+    assertIsDefined(this.element);
+    await untilRendered(this.element);
+  }
+
   /**
    * Determines whether the group is either totally an addition or totally
    * a removal.
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
similarity index 60%
rename from polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index 321086c..71e2e71d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -1,32 +1,26 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma.js';
-import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType, hideInContextControl} from './gr-diff-group.js';
+import '../../../test/common-test-setup';
+import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from './gr-diff-group';
+import {assert} from '@open-wc/testing';
 
 suite('gr-diff-group tests', () => {
   test('delta line pairs', () => {
     const l1 = new GrDiffLine(GrDiffLineType.ADD, 0, 128);
     const l2 = new GrDiffLine(GrDiffLineType.ADD, 0, 129);
     const l3 = new GrDiffLine(GrDiffLineType.REMOVE, 64, 0);
-    let group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [
-      l1, l2, l3,
-    ]});
+    let group = new GrDiffGroup({
+      type: GrDiffGroupType.DELTA,
+      lines: [l1, l2, l3],
+    });
     assert.deepEqual(group.lines, [l1, l2, l3]);
     assert.deepEqual(group.adds, [l1, l2]);
     assert.deepEqual(group.removes, [l3]);
@@ -53,13 +47,25 @@
     ]);
   });
 
+  test('group must have lines', () => {
+    try {
+      new GrDiffGroup({type: GrDiffGroupType.BOTH});
+    } catch (e) {
+      // expected
+      return;
+    }
+    assert.fail('a standard diff group cannot be empty');
+  });
+
   test('group/header line pairs', () => {
     const l1 = new GrDiffLine(GrDiffLineType.BOTH, 64, 128);
     const l2 = new GrDiffLine(GrDiffLineType.BOTH, 65, 129);
     const l3 = new GrDiffLine(GrDiffLineType.BOTH, 66, 130);
 
     const group = new GrDiffGroup({
-      type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]});
+      type: GrDiffGroupType.BOTH,
+      lines: [l1, l2, l3],
+    });
 
     assert.deepEqual(group.lines, [l1, l2, l3]);
     assert.deepEqual(group.adds, []);
@@ -83,34 +89,44 @@
     const l2 = new GrDiffLine(GrDiffLineType.REMOVE);
     const l3 = new GrDiffLine(GrDiffLineType.BOTH);
 
-    assert.throws(() =>
-      new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]}));
+    assert.throws(
+      () => new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]})
+    );
   });
 
   suite('hideInContextControl', () => {
-    let groups;
+    let groups: GrDiffGroup[];
     setup(() => {
       groups = [
-        new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-          new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
-          new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
-          new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
-        ]}),
-        new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [
-          new GrDiffLine(GrDiffLineType.REMOVE, 8),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 10),
-          new GrDiffLine(GrDiffLineType.REMOVE, 9),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 11),
-          new GrDiffLine(GrDiffLineType.REMOVE, 10),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 12),
-          new GrDiffLine(GrDiffLineType.REMOVE, 11),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 13),
-        ]}),
-        new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-          new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
-          new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
-          new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
-        ]}),
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.DELTA,
+          lines: [
+            new GrDiffLine(GrDiffLineType.REMOVE, 8),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 10),
+            new GrDiffLine(GrDiffLineType.REMOVE, 9),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 11),
+            new GrDiffLine(GrDiffLineType.REMOVE, 10),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+            new GrDiffLine(GrDiffLineType.REMOVE, 11),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 13),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
+            new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+            new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
+          ],
+        }),
       ];
     });
 
@@ -140,21 +156,25 @@
       assert.equal(collapsedGroups[2].contextGroups.length, 2);
 
       assert.equal(
-          collapsedGroups[2].contextGroups[0].type,
-          GrDiffGroupType.DELTA);
+        collapsedGroups[2].contextGroups[0].type,
+        GrDiffGroupType.DELTA
+      );
       assert.deepEqual(
-          collapsedGroups[2].contextGroups[0].adds,
-          groups[1].adds.slice(1));
+        collapsedGroups[2].contextGroups[0].adds,
+        groups[1].adds.slice(1)
+      );
       assert.deepEqual(
-          collapsedGroups[2].contextGroups[0].removes,
-          groups[1].removes.slice(1));
+        collapsedGroups[2].contextGroups[0].removes,
+        groups[1].removes.slice(1)
+      );
 
       assert.equal(
-          collapsedGroups[2].contextGroups[1].type,
-          GrDiffGroupType.BOTH);
-      assert.deepEqual(
-          collapsedGroups[2].contextGroups[1].lines,
-          [groups[2].lines[0]]);
+        collapsedGroups[2].contextGroups[1].type,
+        GrDiffGroupType.BOTH
+      );
+      assert.deepEqual(collapsedGroups[2].contextGroups[1].lines, [
+        groups[2].lines[0],
+      ]);
 
       assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
       assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
@@ -166,19 +186,26 @@
           type: GrDiffGroupType.BOTH,
           skip: 60,
           offsetLeft: 8,
-          offsetRight: 10});
+          offsetRight: 10,
+        });
         groups = [
-          new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
-            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
-            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
-          ]}),
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+              new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+              new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+            ],
+          }),
           skipGroup,
-          new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-            new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
-            new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
-            new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
-          ]}),
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+              new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+              new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+            ],
+          }),
         ];
       });
 
@@ -189,13 +216,11 @@
     });
 
     test('groups unchanged if the hidden range is empty', () => {
-      assert.deepEqual(
-          hideInContextControl(groups, 0, 0), groups);
+      assert.deepEqual(hideInContextControl(groups, 0, 0), groups);
     });
 
     test('groups unchanged if there is only 1 line to hide', () => {
-      assert.deepEqual(
-          hideInContextControl(groups, 3, 4), groups);
+      assert.deepEqual(hideInContextControl(groups, 3, 4), groups);
     });
   });
 
@@ -206,7 +231,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.ADD));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isTrue(group.isTotal(group));
+      assert.isTrue(group.isTotal());
     });
 
     test('is total for remove', () => {
@@ -215,12 +240,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.REMOVE));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isTrue(group.isTotal(group));
-    });
-
-    test('not total for empty', () => {
-      const group = new GrDiffGroup({type: GrDiffGroupType.BOTH});
-      assert.isFalse(group.isTotal(group));
+      assert.isTrue(group.isTotal());
     });
 
     test('not total for non-delta', () => {
@@ -229,8 +249,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.BOTH));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isFalse(group.isTotal(group));
+      assert.isFalse(group.isTotal());
     });
   });
 });
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
index 2927101..7ca4a03 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
@@ -1,27 +1,16 @@
 /**
  * @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 {
   GrDiffLine as GrDiffLineApi,
   GrDiffLineType,
   LineNumber,
 } from '../../../api/diff';
 
-export {GrDiffLineType, LineNumber};
+export {GrDiffLineType};
+export type {LineNumber};
 
 export const FILE = 'FILE';
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index 182d48e..2b61c8c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {BlameInfo, CommentRange} from '../../../types/common';
 import {FILE, LineNumber} from './gr-diff-line';
@@ -197,9 +186,6 @@
 /**
  * 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(
@@ -207,13 +193,8 @@
   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');
+
+  el.classList.add('gr-diff');
   if (classStr) {
     for (const className of classStr.split(' ')) {
       el.classList.add(className);
@@ -269,10 +250,11 @@
   text: string,
   responsiveMode: DiffResponsiveMode,
   tabSize: number,
-  lineLimit: number
+  lineLimit: number,
+  elementId: string
 ): HTMLElement {
   const contentText = createElementDiff('div', 'contentText');
-  contentText.ariaLabel = text;
+  contentText.id = elementId;
   let columnPos = 0;
   let textOffset = 0;
   for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
@@ -366,3 +348,18 @@
 
   return blameNode;
 }
+
+/**
+ * Get the approximate length of the diff as the sum of the maximum
+ * length of the chunks.
+ */
+export function getDiffLength(diff?: DiffInfo) {
+  if (!diff) return 0;
+  return diff.content.reduce((sum, sec) => {
+    if (sec.ab) {
+      return sum + sec.ab.length;
+    } else {
+      return sum + Math.max(sec.a?.length ?? 0, sec.b?.length ?? 0);
+    }
+  }, 0);
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
index 600913e..7e8eb4c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -3,10 +3,11 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import {createElementDiff, formatText, createTabWrapper} from './gr-diff-utils';
 
-const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
+const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
 
 suite('gr-diff-utils tests', () => {
   test('createElementDiff classStr applies all classes', () => {
@@ -19,10 +20,10 @@
   test('formatText newlines 1', () => {
     let text = 'abcdef';
 
-    assert.equal(formatText(text, 'NONE', 4, 10).innerHTML, text);
+    assert.equal(formatText(text, 'NONE', 4, 10, '').innerHTML, text);
     text = 'a'.repeat(20);
     assert.equal(
-      formatText(text, 'NONE', 4, 10).innerHTML,
+      formatText(text, 'NONE', 4, 10, '').innerHTML,
       'a'.repeat(10) + LINE_BREAK_HTML + 'a'.repeat(10)
     );
   });
@@ -30,7 +31,7 @@
   test('formatText newlines 2', () => {
     const text = '<span class="thumbsup">👍</span>';
     assert.equal(
-      formatText(text, 'NONE', 4, 10).innerHTML,
+      formatText(text, 'NONE', 4, 10, '').innerHTML,
       '&lt;span clas' +
         LINE_BREAK_HTML +
         's="thumbsu' +
@@ -44,7 +45,7 @@
   test('formatText newlines 3', () => {
     const text = '01234\t56789';
     assert.equal(
-      formatText(text, 'NONE', 4, 10).innerHTML,
+      formatText(text, 'NONE', 4, 10, '').innerHTML,
       '01234' + createTabWrapper(3).outerHTML + '56' + LINE_BREAK_HTML + '789'
     );
   });
@@ -52,7 +53,7 @@
   test('formatText newlines 4', () => {
     const text = '👍'.repeat(58);
     assert.equal(
-      formatText(text, 'NONE', 4, 20).innerHTML,
+      formatText(text, 'NONE', 4, 20, '').innerHTML,
       '👍'.repeat(20) +
         LINE_BREAK_HTML +
         '👍'.repeat(20) +
@@ -63,13 +64,13 @@
 
   test('tab wrapper style', () => {
     const pattern = new RegExp(
-      '^<span class="style-scope gr-diff tab" ' +
+      '^<span class="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.match(html, pattern);
       assert.equal(html.match(pattern)?.[2], size.toString());
     }
   });
@@ -81,7 +82,7 @@
     assert.ok(wrapper);
     assert.equal(wrapper.innerText, '\t');
     assert.equal(
-      formatText(html, 'NONE', tabSize, Infinity).innerHTML,
+      formatText(html, 'NONE', tabSize, Infinity, '').innerHTML,
       'abc' + wrapper.outerHTML + 'def'
     );
   });
@@ -94,20 +95,27 @@
       input,
       'NONE',
       1,
-      Number.POSITIVE_INFINITY
+      Number.POSITIVE_INFINITY,
+      ''
     ).innerHTML;
     assert.equal(result, expected);
 
     input = '& < > " \' / `';
     expected = '&amp; &lt; &gt; " \' / `';
-    result = formatText(input, 'NONE', 1, Number.POSITIVE_INFINITY).innerHTML;
+    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);
+      const result = formatText(text, 'NONE', tabSize, expected, '');
       assert.isNotOk(
         result.querySelector('.contentText > .br'),
         '  Expected the result of: \n' +
@@ -118,17 +126,17 @@
 
       // Increasing the line limit should produce the same markup.
       assert.equal(
-        formatText(text, 'NONE', tabSize, Infinity).innerHTML,
+        formatText(text, 'NONE', tabSize, Infinity, '').innerHTML,
         result.innerHTML
       );
       assert.equal(
-        formatText(text, 'NONE', tabSize, expected + 1).innerHTML,
+        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);
+        const tooSmall = formatText(text, 'NONE', tabSize, expected - 1, '');
         assert.isOk(
           tooSmall.querySelector('.contentText > .br'),
           '  Expected the result of: \n' +
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 34c2a33..53c2780 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -1,31 +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 '../../../styles/shared-styles';
 import '../../../elements/shared/gr-button/gr-button';
-import '../../../elements/shared/gr-icons/gr-icons';
+import '../../../elements/shared/gr-icon/gr-icon';
 import '../gr-diff-builder/gr-diff-builder-element';
 import '../gr-diff-highlight/gr-diff-highlight';
 import '../gr-diff-selection/gr-diff-selection';
 import '../gr-syntax-themes/gr-syntax-theme';
 import '../gr-ranged-comment-themes/gr-ranged-comment-theme';
 import '../gr-ranged-comment-hint/gr-ranged-comment-hint';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {htmlTemplate} from './gr-diff_html';
 import {LineNumber} from './gr-diff-line';
 import {
   getLine,
@@ -39,25 +25,17 @@
   rangesEqual,
   getResponsiveMode,
   isResponsive,
+  getDiffLength,
 } from './gr-diff-utils';
 import {getHiddenScroll} from '../../../scripts/hiddenscroll';
-import {customElement, observe, property} from '@polymer/decorators';
 import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
-import {
-  DiffInfo,
-  DiffPreferencesInfo,
-  DiffPreferencesInfoKey,
-} from '../../../types/diff';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
 import {
   GrDiffBuilderElement,
   getLineNumberCellWidth,
 } from '../gr-diff-builder/gr-diff-builder-element';
-import {
-  CoverageRange,
-  DiffLayer,
-  PolymerDomWrapper,
-} from '../../../types/types';
+import {CoverageRange, DiffLayer} from '../../../types/types';
 import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {
   createDefaultDiffPrefs,
@@ -65,10 +43,8 @@
   Side,
 } from '../../../constants/constants';
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
-import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
-import {MovedLinkClickedEvent} from '../../../types/events';
+import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
+import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
 import {getContentEditableRange} from '../../../utils/safari-selection-util';
 import {AbortStop} from '../../../api/core';
 import {
@@ -79,8 +55,20 @@
 } from '../../../api/diff';
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
-import {debounce, DelayedTask} from '../../../utils/async-util';
+import {
+  debounceP,
+  DelayedPromise,
+  DELAYED_CANCELLATION,
+} from '../../../utils/async-util';
 import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {when} from 'lit/directives/when.js';
+import {grSyntaxTheme} from '../gr-syntax-themes/gr-syntax-theme';
+import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
+import {classMap} from 'lit/directives/class-map.js';
+import {iconStyles} from '../../../styles/gr-icon-styles';
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -98,23 +86,12 @@
  */
 const COMMIT_MSG_LINE_LENGTH = 72;
 
-export interface GrDiff {
-  $: {
-    diffBuilder: GrDiffBuilderElement;
-    diffTable: HTMLTableElement;
-  };
-}
-
 export interface CreateCommentEventDetail extends CreateCommentEventDetailApi {
   path: string;
 }
 
 @customElement('gr-diff')
-export class GrDiff extends PolymerElement implements GrDiffApi {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDiff extends LitElement implements GrDiffApi {
   /**
    * Fired when the user selects a line.
    *
@@ -148,16 +125,19 @@
    * @event diff-context-expanded
    */
 
+  @query('#diffTable')
+  diffTable?: HTMLTableElement;
+
   @property({type: Boolean})
   noAutoRender = false;
 
-  @property({type: String, observer: '_pathObserver'})
+  @property({type: String})
   path?: string;
 
-  @property({type: Object, observer: '_prefsObserver'})
+  @property({type: Object})
   prefs?: DiffPreferencesInfo;
 
-  @property({type: Object, observer: '_renderPrefsChanged'})
+  @property({type: Object})
   renderPrefs?: RenderPreferences;
 
   @property({type: Boolean})
@@ -166,14 +146,15 @@
   @property({type: Boolean})
   isImageDiff?: boolean;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   override hidden = false;
 
   @property({type: Boolean})
   noRenderOnPrefsChange?: boolean;
 
-  @property({type: Array})
-  _commentRanges: CommentRangeLayer[] = [];
+  // Private but used in tests.
+  @state()
+  commentRanges: CommentRangeLayer[] = [];
 
   // explicitly highlight a range if it is not associated with any comment
   @property({type: Object})
@@ -182,39 +163,41 @@
   @property({type: Array})
   coverageRanges: CoverageRange[] = [];
 
-  @property({type: Boolean, observer: '_lineWrappingObserver'})
+  @property({type: Boolean})
   lineWrapping = false;
 
-  @property({type: String, observer: '_viewModeObserver'})
+  @property({type: String})
   viewMode = DiffViewMode.SIDE_BY_SIDE;
 
-  @property({type: Object, observer: '_lineOfInterestObserver'})
+  @property({type: Object})
   lineOfInterest?: DisplayLine;
 
   /**
    * True when diff is changed, until the content is done rendering.
-   *
-   * This is readOnly, meaning one can listen for the loading-changed event, but
-   * not write to it from the outside. Code in this class should use the
-   * "private" _setLoading method.
+   * Use getter/setter loading instead of this.
    */
-  @property({type: Boolean, notify: true, readOnly: true})
-  loading!: boolean;
+  private _loading = true;
 
-  // Polymer generated when setting readOnly above.
-  _setLoading!: (loading: boolean) => void;
+  get loading() {
+    return this._loading;
+  }
+
+  set loading(loading: boolean) {
+    if (this._loading === loading) return;
+    const oldLoading = this._loading;
+    this._loading = loading;
+    fire(this, 'loading-changed', {value: this._loading});
+    this.requestUpdate('loading', oldLoading);
+  }
 
   @property({type: Boolean})
   loggedIn = false;
 
-  @property({type: Object, observer: '_diffChanged'})
+  @property({type: Object})
   diff?: DiffInfo;
 
-  @property({type: Array, computed: '_computeDiffHeaderItems(diff.*)'})
-  _diffHeaderItems: string[] = [];
-
-  @property({type: String})
-  _diffTableClass = '';
+  @state()
+  private diffTableClass = '';
 
   @property({type: Object})
   baseImage?: ImageInfo;
@@ -235,134 +218,938 @@
    * been bypassed. If the value is null, then the safety has not been
    * bypassed. If the value is a number, then that number represents the
    * context preference to use when rendering the bypassed diff.
+   *
+   * Private but used in tests.
    */
-  @property({type: Number})
-  _safetyBypass: number | null = null;
+  @state()
+  safetyBypass: number | null = null;
 
-  @property({type: Boolean})
-  _showWarning?: boolean;
+  // Private but used in tests.
+  @state()
+  showWarning?: boolean;
 
   @property({type: String})
   errorMessage: string | null = null;
 
-  @property({type: Object, observer: '_blameChanged'})
+  @property({type: Array})
   blame: BlameInfo[] | null = null;
 
-  @property({type: Number})
-  parentIndex?: number;
-
   @property({type: Boolean})
   showNewlineWarningLeft = false;
 
   @property({type: Boolean})
   showNewlineWarningRight = false;
 
-  @property({type: String, observer: '_useNewImageDiffUiObserver'})
+  @property({type: Boolean})
   useNewImageDiffUi = false;
 
-  @property({
-    type: String,
-    computed:
-      '_computeNewlineWarning(' +
-      'showNewlineWarningLeft, showNewlineWarningRight)',
-  })
-  _newlineWarning: string | null = null;
-
-  @property({type: Number})
-  _diffLength?: number;
-
-  /**
-   * Observes comment nodes added or removed after the initial render.
-   * Can be used to unregister when the entire diff is (re-)rendered or upon
-   * detachment.
-   */
-  @property({type: Object})
-  _incrementalNodeObserver?: FlattenedNodesObserver;
+  // Private but used in tests.
+  @state()
+  diffLength?: number;
 
   /**
    * Observes comment nodes added or removed at any point.
    * Can be used to unregister upon detachment.
    */
-  @property({type: Object})
-  _nodeObserver?: FlattenedNodesObserver;
+  private nodeObserver?: MutationObserver;
 
   @property({type: Array})
   layers?: DiffLayer[];
 
-  @property({type: Boolean})
-  isAttached = false;
-
-  private renderDiffTableTask?: DelayedTask;
+  // Private but used in tests.
+  renderDiffTableTask?: DelayedPromise<void>;
 
   private diffSelection = new GrDiffSelection();
 
-  private highlights = new GrDiffHighlight();
+  // Private but used in tests.
+  highlights = new GrDiffHighlight();
+
+  // Private but used in tests.
+  diffBuilder = new GrDiffBuilderElement();
+
+  static override get styles() {
+    return [
+      iconStyles,
+      sharedStyles,
+      grSyntaxTheme,
+      grRangedCommentTheme,
+      css`
+        /**
+          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) {
+          --context-control-display: none;
+        }
+        :host(.disable-context-control-buttons) .section {
+          border-right: none;
+        }
+        :host(.hide-line-length-indicator) .full-width td.content .contentText {
+          background-image: none;
+        }
+
+        :host {
+          font-family: var(--monospace-font-family, ''), 'Roboto Mono';
+          font-size: var(--font-size, var(--font-size-code, 12px));
+          /* usually 16px = 12px + 4px */
+          line-height: calc(
+            var(--font-size, var(--font-size-code, 12px)) +
+              var(--spacing-s, 4px)
+          );
+        }
+
+        .thread-group {
+          display: block;
+          max-width: var(--content-width, 80ch);
+          white-space: normal;
+          background-color: var(--diff-blank-background-color);
+        }
+        .diffContainer {
+          max-width: var(--diff-max-width, none);
+          display: flex;
+          font-family: var(--monospace-font-family);
+        }
+        .diffContainer.hiddenscroll {
+          margin-bottom: var(--spacing-m);
+        }
+        table {
+          border-collapse: collapse;
+          table-layout: fixed;
+        }
+        td.lineNum {
+          /* Enforces background whenever lines wrap */
+          background-color: var(--diff-blank-background-color);
+        }
+
+        /**
+          Provides the option to add side borders (left and right) to the line
+          number column.
+        */
+        td.lineNum,
+        td.blankLineNum,
+        td.moveControlsLineNumCol,
+        td.contextLineNum {
+          box-shadow: var(--line-number-box-shadow, unset);
+        }
+
+        /**
+          Context controls break up the table visually, so we set the right
+          border on individual sections to leave a gap for the divider.
+
+          Also taken into account for max-width calculations in SHRINK_ONLY mode
+          (check GrDiff.updatePreferenceStyles).
+        */
+        .section {
+          border-right: 1px solid var(--border-color);
+        }
+        .section.contextControl {
+          /**
+            Divider inside this section must not have border; we set borders on
+            the padding rows below.
+          */
+          border-right-width: 0;
+        }
+        /**
+          Padding rows behind context controls. The diff is styled to be cut
+          into two halves by the negative space of the divider on which the
+          context control buttons are anchored.
+        */
+        .contextBackground {
+          border-right: 1px solid var(--border-color);
+        }
+        .contextBackground.above {
+          border-bottom: 1px solid var(--border-color);
+        }
+        .contextBackground.below {
+          border-top: 1px solid var(--border-color);
+        }
+
+        .lineNumButton {
+          display: block;
+          width: 100%;
+          height: 100%;
+          background-color: var(--diff-blank-background-color);
+          box-shadow: var(--line-number-box-shadow, unset);
+        }
+        td.lineNum {
+          vertical-align: top;
+        }
+
+        /**
+          The only way to focus this (clicking) will apply our own focus
+          styling, so this default styling is not needed and distracting.
+        */
+        .lineNumButton:focus {
+          outline: none;
+        }
+        gr-image-viewer {
+          width: 100%;
+          height: 100%;
+          max-width: var(--image-viewer-max-width, 95vw);
+          max-height: var(--image-viewer-max-height, 90vh);
+          /**
+            Defined by paper-styles default-theme and used in various
+            components. background-color-secondary is a compromise between
+            fairly light in light theme (where we ideally would want
+            background-color-primary) yet slightly offset against the app
+            background in dark mode, where drop shadows e.g. around paper-card
+            are almost invisible.
+          */
+          --primary-background-color: var(--background-color-secondary);
+        }
+        .image-diff .gr-diff {
+          text-align: center;
+        }
+        .image-diff img {
+          box-shadow: var(--elevation-level-1);
+          max-width: 50em;
+        }
+        .image-diff .right.lineNumButton {
+          border-left: 1px solid var(--border-color);
+        }
+        .image-diff label,
+        .binary-diff label {
+          font-family: var(--font-family);
+          font-style: italic;
+        }
+        .diff-row {
+          outline: none;
+          user-select: none;
+        }
+        .diff-row.target-row.target-side-left .lineNumButton.left,
+        .diff-row.target-row.target-side-right .lineNumButton.right,
+        .diff-row.target-row.unified .lineNumButton {
+          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 there is at least one comment thread, and the slotting
+          makes :empty not work as expected.
+        */
+        .content.file slot:first-child::slotted(.comment-thread) {
+          display: block;
+          margin-top: var(--spacing-xs);
+        }
+        .contentText {
+          background-color: var(--view-background-color);
+        }
+        .blank {
+          background-color: var(--diff-blank-background-color);
+        }
+        .image-diff .content {
+          background-color: var(--diff-blank-background-color);
+        }
+        .responsive {
+          width: 100%;
+        }
+        .responsive .contentText {
+          white-space: break-spaces;
+          word-break: break-all;
+        }
+        .lineNumButton,
+        .content {
+          vertical-align: top;
+          white-space: pre;
+        }
+        .contextLineNum,
+        .lineNumButton {
+          -webkit-user-select: none;
+          -moz-user-select: none;
+          -ms-user-select: none;
+          user-select: none;
+
+          color: var(--deemphasized-text-color);
+          padding: 0 var(--spacing-m);
+          text-align: right;
+        }
+        .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 CJK
+            (Chinese-Japanese-Korean) glyphs have variable width
+          */
+          min-width: var(--content-width, 80ch);
+          width: var(--content-width, 80ch);
+        }
+        .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,
+        .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,
+          .sign.remove.no-intraline-info {
+          background-color: var(--dark-remove-highlight-color);
+        }
+        .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 {
+          background-color: var(--dark-rebased-add-highlight-color);
+        }
+        .dueToRebase .content.add .contentText {
+          background-color: var(--light-rebased-add-highlight-color);
+        }
+        .dueToRebase .content.remove .contentText .intraline,
+        .delta.total.dueToRebase .content.remove .contentText {
+          background-color: var(--dark-rebased-remove-highlight-color);
+        }
+        .dueToRebase .content.remove .contentText {
+          background-color: var(--light-rebased-remove-highlight-color);
+        }
+
+        /* 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);
+        }
+
+        .delta.dueToMove .movedIn .moveHeader {
+          --gr-range-header-color: var(--diff-moved-in-label-color);
+        }
+        .delta.dueToMove .movedOut .moveHeader {
+          --gr-range-header-color: var(--diff-moved-out-label-color);
+        }
+
+        .moveHeader a {
+          color: inherit;
+        }
+
+        /* ignoredWhitespaceOnly */
+        .ignoredWhitespaceOnly .content.add .contentText .intraline,
+        .delta.total.ignoredWhitespaceOnly .content.add .contentText,
+        .ignoredWhitespaceOnly .content.add .contentText,
+        .ignoredWhitespaceOnly .content.remove .contentText .intraline,
+        .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
+        .ignoredWhitespaceOnly .content.remove .contentText {
+          background-color: var(--view-background-color);
+        }
+
+        .content .contentText:empty:after {
+          /* Newline, to ensure empty lines are one line-height tall. */
+          content: '\\A';
+        }
+
+        /* Context controls */
+        .contextControl {
+          display: var(--context-control-display, table-row-group);
+          background-color: transparent;
+          border: none;
+          --divider-height: var(--spacing-s);
+          --divider-border: 1px;
+        }
+        /* TODO: Is this still used? */
+        .contextControl gr-button gr-icon {
+          /* should match line-height of gr-button */
+          font-size: var(--line-height-mono, 18px);
+        }
+        .contextControl td:not(.lineNumButton) {
+          text-align: center;
+        }
+
+        /**
+          Padding rows behind context controls. Styled as a continuation of the
+          line gutters and code area.
+        */
+        .contextBackground > .contextLineNum {
+          background-color: var(--diff-blank-background-color);
+        }
+        .contextBackground > td:not(.contextLineNum) {
+          background-color: var(--view-background-color);
+        }
+        .contextBackground {
+          /**
+            One line of background behind the context expanders which they can
+            render on top of, plus some padding.
+          */
+          height: calc(var(--line-height-normal) + var(--spacing-s));
+        }
+
+        .dividerCell {
+          vertical-align: top;
+        }
+        .dividerRow.show-both .dividerCell {
+          height: var(--divider-height);
+        }
+        .dividerRow.show-above .dividerCell,
+        .dividerRow.show-above .dividerCell {
+          height: 0;
+        }
+
+        .br:after {
+          /* Line feed */
+          content: '\\A';
+        }
+        .tab {
+          display: inline-block;
+        }
+        .tab-indicator:before {
+          color: var(--diff-tab-indicator-color);
+          /* >> character */
+          content: '\\00BB';
+          position: absolute;
+        }
+        .special-char-indicator {
+          /* spacing so elements don't collide */
+          padding-right: var(--spacing-m);
+        }
+        .special-char-indicator:before {
+          color: var(--diff-tab-indicator-color);
+          content: '•';
+          position: absolute;
+        }
+        .special-char-warning {
+          /* spacing so elements don't collide */
+          padding-right: var(--spacing-m);
+        }
+        .special-char-warning:before {
+          color: var(--warning-foreground);
+          content: '!';
+          position: absolute;
+        }
+        /**
+          Is defined after other background-colors, such that this
+          rule wins in case of same specificity.
+        */
+        .trailing-whitespace,
+        .content .trailing-whitespace,
+        .trailing-whitespace .intraline,
+        .content .trailing-whitespace .intraline {
+          border-radius: var(--border-radius, 4px);
+          background-color: var(--diff-trailing-whitespace-indicator);
+        }
+        #diffHeader {
+          background-color: var(--table-header-background-color);
+          border-bottom: 1px solid var(--border-color);
+          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;
+        }
+        #loadingError,
+        #sizeWarning {
+          display: none;
+          margin: var(--spacing-l) auto;
+          max-width: 60em;
+          text-align: center;
+        }
+        #loadingError {
+          color: var(--error-text-color);
+        }
+        #sizeWarning gr-button {
+          margin: var(--spacing-l);
+        }
+        #loadingError.showError,
+        #sizeWarning.warn {
+          display: block;
+        }
+        .target-row td.blame {
+          background: var(--diff-selection-background-color);
+        }
+        td.lost div {
+          background-color: var(--info-background);
+          padding: var(--spacing-s) 0 0 0;
+        }
+        td.lost div:first-of-type {
+          font-family: var(--font-family, 'Roboto');
+          font-size: var(--font-size-normal, 14px);
+          line-height: var(--line-height-normal);
+        }
+        td.lost gr-icon {
+          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;
+        }
+        td.blame {
+          display: none;
+          padding: 0 var(--spacing-m);
+          white-space: pre;
+        }
+        :host(.showBlame) col.blame {
+          display: table-column;
+        }
+        :host(.showBlame) td.blame {
+          display: table-cell;
+        }
+        td.blame > span {
+          opacity: 0.6;
+        }
+        td.blame > span.startOfRange {
+          opacity: 1;
+        }
+        td.blame .blameDate {
+          font-family: var(--monospace-font-family);
+          color: var(--link-color);
+          text-decoration: none;
+        }
+        .responsive td.blame {
+          overflow: hidden;
+          width: 200px;
+        }
+        /** Support the line length indicator **/
+        .responsive td.content .contentText {
+          /**
+            Same strategy as in
+            https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
+          */
+          background-image: linear-gradient(
+            var(--line-length-indicator-color),
+            var(--line-length-indicator-color)
+          );
+          background-size: 1px 100%;
+          background-position: var(--line-limit-marker) 0;
+          background-repeat: no-repeat;
+        }
+        .newlineWarning {
+          color: var(--deemphasized-text-color);
+          text-align: center;
+        }
+        .newlineWarning.hidden {
+          display: none;
+        }
+        .lineNum.COVERED .lineNumButton {
+          color: var(
+            --coverage-covered-line-num-color,
+            var(--deemphasized-text-color)
+          );
+          background-color: var(--coverage-covered, #e0f2f1);
+        }
+        .lineNum.NOT_COVERED .lineNumButton {
+          color: var(
+            --coverage-covered-line-num-color,
+            var(--deemphasized-text-color)
+          );
+          background-color: var(--coverage-not-covered, #ffd1a4);
+        }
+        .lineNum.PARTIALLY_COVERED .lineNumButton {
+          color: var(
+            --coverage-covered-line-num-color,
+            var(--deemphasized-text-color)
+          );
+          background: linear-gradient(
+            to right bottom,
+            var(--coverage-not-covered, #ffd1a4) 0%,
+            var(--coverage-not-covered, #ffd1a4) 50%,
+            var(--coverage-covered, #e0f2f1) 50%,
+            var(--coverage-covered, #e0f2f1) 100%
+          );
+        }
+
+        // TODO: Investigate whether this CSS is still necessary.
+        /** BEGIN: Select and copy for Polymer 2 */
+        /**
+          Below was copied and modified from the original css in
+          gr-diff-selection.html
+        */
+        .content,
+        .contextControl,
+        .blame {
+          -webkit-user-select: none;
+          -moz-user-select: none;
+          -ms-user-select: none;
+          user-select: none;
+        }
+
+        .selected-left:not(.selected-comment)
+          .side-by-side
+          .left
+          + .content
+          .contentText,
+        .selected-right:not(.selected-comment)
+          .side-by-side
+          .right
+          + .content
+          .contentText,
+        .selected-left:not(.selected-comment)
+          .unified
+          .left.lineNum
+          ~ .content:not(.both)
+          .contentText,
+        .selected-right:not(.selected-comment)
+          .unified
+          .right.lineNum
+          ~ .content
+          .contentText,
+        .selected-left.selected-comment .side-by-side .left + .content .message,
+        .selected-right.selected-comment
+          .side-by-side
+          .right
+          + .content
+          .message
+          :not(.collapsedContent),
+        .selected-comment .unified .message :not(.collapsedContent),
+        .selected-blame .blame {
+          -webkit-user-select: text;
+          -moz-user-select: text;
+          -ms-user-select: text;
+          user-select: text;
+        }
+
+        /** Make comments and check results selectable when selected */
+        .selected-left.selected-comment
+          ::slotted(.comment-thread[diff-side='left']),
+        .selected-right.selected-comment
+          ::slotted(.comment-thread[diff-side='right']) {
+          -webkit-user-select: text;
+          -moz-user-select: text;
+          -ms-user-select: text;
+          user-select: text;
+        }
+        /** END: Select and copy for Polymer 2 */
+
+        .whitespace-change-only-message {
+          background-color: var(--diff-context-control-background-color);
+          border: 1px solid var(--diff-context-control-border-color);
+          text-align: center;
+        }
+
+        .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;
+        }
+      `,
+    ];
+  }
 
   constructor() {
     super();
-    this._setLoading(true);
     this.addEventListener('create-range-comment', (e: Event) =>
-      this._handleCreateRangeComment(e as CustomEvent)
+      this.handleCreateRangeComment(e as CustomEvent)
     );
-    this.addEventListener('render-content', () => this._handleRenderContent());
-    this.addEventListener('moved-link-clicked', e => this._movedLinkClicked(e));
+    this.addEventListener('render-content', () => this.handleRenderContent());
+    this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
+      this.dispatchSelectedLine(e.detail.lineNum, e.detail.side);
+    });
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._observeNodes();
-    this.isAttached = true;
+    if (this.loggedIn) {
+      this.addSelectionListeners();
+    }
   }
 
   override disconnectedCallback() {
-    this.isAttached = false;
+    this.removeSelectionListeners();
     this.renderDiffTableTask?.cancel();
-    this._unobserveIncrementalNodes();
-    this._unobserveNodes();
     this.diffSelection.cleanup();
     this.highlights.cleanup();
+    this.diffBuilder.cancel();
     super.disconnectedCallback();
   }
 
-  getLineNumEls(side: Side): HTMLElement[] {
-    return this.$.diffBuilder.getLineNumEls(side);
-  }
-
-  showNoChangeMessage(
-    loading?: boolean,
-    prefs?: DiffPreferencesInfo,
-    diffLength?: number,
-    diff?: DiffInfo
-  ) {
-    return (
-      !loading &&
-      diff &&
-      !diff.binary &&
-      prefs &&
-      prefs.ignore_whitespace !== 'IGNORE_NONE' &&
-      diffLength === 0
-    );
-  }
-
-  @observe('loggedIn', 'isAttached')
-  _enableSelectionObserver(loggedIn: boolean, isAttached: boolean) {
-    if (loggedIn && isAttached) {
-      document.addEventListener('selectionchange', this.handleSelectionChange);
-      document.addEventListener('mouseup', this.handleMouseUp);
-    } else {
-      document.removeEventListener(
-        'selectionchange',
-        this.handleSelectionChange
-      );
-      document.removeEventListener('mouseup', this.handleMouseUp);
+  protected override willUpdate(changedProperties: PropertyValues<this>): void {
+    if (
+      changedProperties.has('path') ||
+      changedProperties.has('lineWrapping') ||
+      changedProperties.has('viewMode') ||
+      changedProperties.has('useNewImageDiffUi') ||
+      changedProperties.has('prefs')
+    ) {
+      this.prefsChanged();
     }
+    if (changedProperties.has('blame')) {
+      this.blameChanged();
+    }
+    if (changedProperties.has('renderPrefs')) {
+      this.renderPrefsChanged();
+    }
+    if (changedProperties.has('loggedIn')) {
+      if (this.loggedIn && this.isConnected) {
+        this.addSelectionListeners();
+      } else {
+        this.removeSelectionListeners();
+      }
+    }
+    if (changedProperties.has('coverageRanges')) {
+      this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+      if (this.diff) {
+        this.debounceRenderDiffTable();
+      }
+    }
+    if (changedProperties.has('lineOfInterest')) {
+      this.lineOfInterestChanged();
+    }
+  }
+
+  protected override updated(changedProperties: PropertyValues<this>): void {
+    if (changedProperties.has('diff')) {
+      // diffChanged relies on diffTable ahving been rendered.
+      this.diffChanged();
+    }
+  }
+
+  override render() {
+    return html`
+      ${this.renderHeader()} ${this.renderContainer()}
+      ${this.renderNewlineWarning()} ${this.renderLoadingError()}
+      ${this.renderSizeWarning()}
+    `;
+  }
+
+  private renderHeader() {
+    const diffheaderItems = this.computeDiffHeaderItems();
+    if (diffheaderItems.length === 0) return nothing;
+    return html`
+      <div id="diffHeader">
+        ${diffheaderItems.map(item => html`<div>${item}</div>`)}
+      </div>
+    `;
+  }
+
+  private renderContainer() {
+    const cssClasses = {
+      diffContainer: true,
+      unified: this.viewMode === DiffViewMode.UNIFIED,
+      sideBySide: this.viewMode === DiffViewMode.SIDE_BY_SIDE,
+      hiddenscroll: !!getHiddenScroll(),
+      canComment: this.loggedIn,
+      displayLine: this.displayLine,
+    };
+    return html`
+      <div class=${classMap(cssClasses)} @click=${this.handleTap}>
+        <table
+          id="diffTable"
+          class=${this.diffTableClass}
+          ?contenteditable=${this.isContentEditable}
+        ></table>
+        ${when(
+          this.showNoChangeMessage(),
+          () => html`
+            <div class="whitespace-change-only-message">
+              This file only contains whitespace changes. Modify the whitespace
+              setting to see the changes.
+            </div>
+          `
+        )}
+      </div>
+    `;
+  }
+
+  private renderNewlineWarning() {
+    const newlineWarning = this.computeNewlineWarning();
+    const newlineWarningClass = this.computeNewlineWarningClass(
+      !!newlineWarning
+    );
+    return html` <div class=${newlineWarningClass}>${newlineWarning}</div> `;
+  }
+
+  private renderLoadingError() {
+    return html`
+      <div id="loadingError" class=${this.errorMessage ? 'showError' : ''}>
+        ${this.errorMessage}
+      </div>
+    `;
+  }
+
+  private renderSizeWarning() {
+    // TODO: Update comment about 'Whole file' as it's not in settings.
+    return html`
+      <div id="sizeWarning" class=${this.showWarning ? 'warn' : ''}>
+        <p>
+          Prevented render because "Whole file" is enabled and this diff is very
+          large (about ${this.diffLength} lines).
+        </p>
+        <gr-button @click=${this.collapseContext}>
+          Render with limited context
+        </gr-button>
+        <gr-button @click=${this.handleFullBypass}>
+          Render anyway (may be slow)
+        </gr-button>
+      </div>
+    `;
+  }
+
+  private addSelectionListeners() {
+    document.addEventListener('selectionchange', this.handleSelectionChange);
+    document.addEventListener('mouseup', this.handleMouseUp);
+  }
+
+  private removeSelectionListeners() {
+    document.removeEventListener('selectionchange', this.handleSelectionChange);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+  }
+
+  getLineNumEls(side: Side): HTMLElement[] {
+    return this.diffBuilder.getLineNumEls(side);
+  }
+
+  // Private but used in tests.
+  showNoChangeMessage() {
+    return (
+      !this.loading &&
+      this.diff &&
+      !this.diff.binary &&
+      this.prefs &&
+      this.prefs.ignore_whitespace !== 'IGNORE_NONE' &&
+      this.diffLength === 0
+    );
   }
 
   private readonly handleSelectionChange = () => {
     // Because of shadow DOM selections, we handle the selectionchange here,
     // and pass the shadow DOM selection into gr-diff-highlight, where the
     // corresponding range is determined and normalized.
-    const selection = this._getShadowOrDocumentSelection();
+    const selection = this.getShadowOrDocumentSelection();
     this.highlights.handleSelectionChange(selection, false);
   };
 
@@ -370,35 +1157,24 @@
     // To handle double-click outside of text creating comments, we check on
     // 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();
+    const selection = this.getShadowOrDocumentSelection();
     this.highlights.handleSelectionChange(selection, true);
   };
 
   /** Gets the current selection, preferring the shadow DOM selection. */
-  _getShadowOrDocumentSelection() {
+  private getShadowOrDocumentSelection() {
     // When using native shadow DOM, the selection returned by
     // document.getSelection() cannot reference the actual DOM elements making
     // up the diff in Safari because they are in the shadow DOM of the gr-diff
     // element. This takes the shadow DOM selection if one exists.
-    return this.root instanceof ShadowRoot && this.root.getSelection
-      ? this.root.getSelection()
+    return this.shadowRoot?.getSelection
+      ? this.shadowRoot.getSelection()
       : isSafari()
       ? getContentEditableRange()
       : document.getSelection();
   }
 
-  _observeNodes() {
-    this._nodeObserver = (dom(this) as PolymerDomWrapper).observeNodes(info => {
-      const addedThreadEls = info.addedNodes.filter(isThreadEl);
-      const removedThreadEls = info.removedNodes.filter(isThreadEl);
-      this._updateRanges(addedThreadEls, removedThreadEls);
-      addedThreadEls.forEach(threadEl =>
-        this._redispatchHoverEvents(threadEl, threadEl)
-      );
-    });
-  }
-
-  _updateRanges(
+  private updateRanges(
     addedThreadEls: GrDiffThreadElement[],
     removedThreadEls: GrDiffThreadElement[]
   ) {
@@ -421,24 +1197,26 @@
       .map(commentRangeFromThreadEl)
       .filter(range => !!range) as CommentRangeLayer[];
     for (const removedCommentRange of removedCommentRanges) {
-      const i = this._commentRanges.findIndex(
+      const i = this.commentRanges.findIndex(
         cr =>
           cr.side === removedCommentRange.side &&
           rangesEqual(cr.range, removedCommentRange.range)
       );
-      this.splice('_commentRanges', i, 1);
+      this.commentRanges.splice(i, 1);
     }
 
-    if (addedCommentRanges && addedCommentRanges.length) {
-      this.push('_commentRanges', ...addedCommentRanges);
+    if (addedCommentRanges?.length) {
+      this.commentRanges.push(...addedCommentRanges);
     }
     if (this.highlightRange) {
-      this.push('_commentRanges', {
+      this.commentRanges.push({
         side: Side.RIGHT,
         range: this.highlightRange,
         rootId: '',
       });
     }
+
+    this.diffBuilder.updateCommentRanges(this.commentRanges);
   }
 
   /**
@@ -446,15 +1224,13 @@
    * where lines should not be collapsed.
    *
    */
-  _computeKeyLocations() {
+  private computeKeyLocations() {
     const keyLocations: KeyLocations = {left: {}, right: {}};
     if (this.lineOfInterest) {
       const side = this.lineOfInterest.side;
       keyLocations[side][this.lineOfInterest.lineNum] = true;
     }
-    const threadEls = (dom(this) as PolymerDomWrapper)
-      .getEffectiveChildNodes()
-      .filter(isThreadEl);
+    const threadEls = [...this.childNodes].filter(isThreadEl);
 
     for (const threadEl of threadEls) {
       const side = getSide(threadEl);
@@ -472,7 +1248,10 @@
   }
 
   // Dispatch events that are handled by the gr-diff-highlight.
-  _redispatchHoverEvents(hoverEl: HTMLElement, threadEl: GrDiffThreadElement) {
+  private redispatchHoverEvents(
+    hoverEl: HTMLElement,
+    threadEl: GrDiffThreadElement
+  ) {
     hoverEl.addEventListener('mouseenter', () => {
       fireEvent(threadEl, 'comment-thread-mouseenter');
     });
@@ -483,7 +1262,7 @@
 
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
-    this.$.diffBuilder.cancel();
+    this.diffBuilder.cancel();
     this.renderDiffTableTask?.cancel();
   }
 
@@ -492,7 +1271,7 @@
 
     // Get rendered stops.
     const stops: Array<HTMLElement | AbortStop> =
-      this.$.diffBuilder.getLineNumberRows();
+      this.diffBuilder.getLineNumberRows();
 
     // If we are still loading this diff, abort after the rendered stops to
     // avoid skipping over to e.g. the next file.
@@ -510,32 +1289,18 @@
     toggleClass(this, 'no-left');
   }
 
-  _blameChanged(newValue?: BlameInfo[] | null) {
-    if (newValue === undefined) return;
-    this.$.diffBuilder.setBlame(newValue);
-    if (newValue) {
+  private blameChanged() {
+    this.diffBuilder.setBlame(this.blame);
+    if (this.blame) {
       this.classList.add('showBlame');
     } else {
       this.classList.remove('showBlame');
     }
   }
 
-  _computeContainerClass(
-    loggedIn: boolean,
-    viewMode: DiffViewMode,
-    displayLine: boolean
-  ) {
-    const classes = ['diffContainer'];
-    if (viewMode === DiffViewMode.UNIFIED) classes.push('unified');
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) classes.push('sideBySide');
-    if (getHiddenScroll()) classes.push('hiddenscroll');
-    if (loggedIn) classes.push('canComment');
-    if (displayLine) classes.push('displayLine');
-    return classes.join(' ');
-  }
-
-  _handleTap(e: CustomEvent) {
-    const el = (dom(e) as EventApi).localTarget as Element;
+  // Private but used in tests.
+  handleTap(e: Event) {
+    const el = e.target as Element;
 
     if (
       el.getAttribute('data-value') !== 'LOST' &&
@@ -550,18 +1315,19 @@
     ) {
       const target = getLineElByChild(el);
       if (target) {
-        this._selectLine(target);
+        this.selectLine(target);
       }
     }
   }
 
-  _selectLine(el: Element) {
+  // Private but used in tests.
+  selectLine(el: Element) {
     const lineNumber = Number(el.getAttribute('data-value'));
     const side = el.classList.contains('left') ? Side.LEFT : Side.RIGHT;
-    this._dispatchSelectedLine(lineNumber, side);
+    this.dispatchSelectedLine(lineNumber, side);
   }
 
-  _dispatchSelectedLine(number: LineNumber, side: Side) {
+  private dispatchSelectedLine(number: LineNumber, side: Side) {
     this.dispatchEvent(
       new CustomEvent('line-selected', {
         detail: {
@@ -575,12 +1341,8 @@
     );
   }
 
-  _movedLinkClicked(e: MovedLinkClickedEvent) {
-    this._dispatchSelectedLine(e.detail.lineNum, e.detail.side);
-  }
-
   addDraftAtLine(el: Element) {
-    this._selectLine(el);
+    this.selectLine(el);
 
     const lineNum = getLineNumber(el);
     if (lineNum === null) {
@@ -588,7 +1350,7 @@
       return;
     }
 
-    this._createComment(el, lineNum);
+    this.createComment(el, lineNum);
   }
 
   createRangeComment() {
@@ -598,32 +1360,33 @@
     const selectedRange = this.highlights.selectedRange;
     if (!selectedRange) throw Error('selected range not set');
     const {side, range} = selectedRange;
-    this._createCommentForSelection(side, range);
+    this.createCommentForSelection(side, range);
   }
 
-  _createCommentForSelection(side: Side, range: CommentRange) {
+  createCommentForSelection(side: Side, range: CommentRange) {
     const lineNum = range.end_line;
-    const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
+    const lineEl = this.diffBuilder.getLineElByNumber(lineNum, side);
     if (lineEl) {
-      this._createComment(lineEl, lineNum, side, range);
+      this.createComment(lineEl, lineNum, side, range);
     }
   }
 
-  _handleCreateRangeComment(e: CustomEvent) {
+  private handleCreateRangeComment(e: CustomEvent) {
     const range = e.detail.range;
     const side = e.detail.side;
-    this._createCommentForSelection(side, range);
+    this.createCommentForSelection(side, range);
   }
 
-  _createComment(
+  // Private but used in tests.
+  createComment(
     lineEl: Element,
     lineNum: LineNumber,
     side?: Side,
     range?: CommentRange
   ) {
-    const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+    const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentEl) throw new Error('content el not found for line el');
-    side = side ?? this._getCommentSideByLineAndContent(lineEl, contentEl);
+    side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
     assertIsDefined(this.path, 'path');
     this.dispatchEvent(
       new CustomEvent<CreateCommentEventDetail>('create-comment', {
@@ -642,8 +1405,9 @@
   /**
    * Gets or creates a comment thread group for a specific line and side on a
    * diff.
+   * Private but used in tests.
    */
-  _getOrCreateThreadGroup(contentEl: Element, commentSide: Side) {
+  getOrCreateThreadGroup(contentEl: Element, commentSide: Side) {
     // Check if thread group exists.
     let threadGroupEl = contentEl.querySelector('.thread-group');
     if (!threadGroupEl) {
@@ -655,98 +1419,60 @@
     return threadGroupEl;
   }
 
-  _getCommentSideByLineAndContent(lineEl: Element, contentEl: Element): Side {
+  private getCommentSideByLineAndContent(
+    lineEl: Element,
+    contentEl: Element
+  ): Side {
     return lineEl.classList.contains(Side.LEFT) ||
       contentEl.classList.contains('remove')
       ? Side.LEFT
       : Side.RIGHT;
   }
 
-  _prefsObserver(newPrefs: DiffPreferencesInfo, oldPrefs: DiffPreferencesInfo) {
-    if (!this._prefsEqual(newPrefs, oldPrefs)) {
-      this._prefsChanged(newPrefs);
-    }
-  }
-
-  _prefsEqual(prefs1: DiffPreferencesInfo, prefs2: DiffPreferencesInfo) {
-    if (prefs1 === prefs2) {
-      return true;
-    }
-    if (!prefs1 || !prefs2) {
-      return false;
-    }
-    // Scan the preference objects one level deep to see if they differ.
-    const keys1 = Object.keys(prefs1) as DiffPreferencesInfoKey[];
-    const keys2 = Object.keys(prefs2) as DiffPreferencesInfoKey[];
-    return (
-      keys1.length === keys2.length &&
-      keys1.every(key => prefs1[key] === prefs2[key]) &&
-      keys2.every(key => prefs1[key] === prefs2[key])
-    );
-  }
-
-  _pathObserver() {
-    // Call _prefsChanged(), because line-limit style value depends on path.
-    this._prefsChanged(this.prefs);
-  }
-
-  _viewModeObserver() {
-    this._prefsChanged(this.prefs);
-  }
-
-  _lineOfInterestObserver() {
+  private lineOfInterestChanged() {
     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);
+    this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
   }
 
-  _cleanup() {
+  private cleanup() {
     this.cancel();
     this.blame = null;
-    this._safetyBypass = null;
-    this._showWarning = false;
+    this.safetyBypass = null;
+    this.showWarning = false;
     this.clearDiffContent();
   }
 
-  _lineWrappingObserver() {
-    this._prefsChanged(this.prefs);
-  }
-
-  _useNewImageDiffUiObserver() {
-    this._prefsChanged(this.prefs);
-  }
-
-  _prefsChanged(prefs?: DiffPreferencesInfo) {
-    if (!prefs) return;
+  private prefsChanged() {
+    if (!this.prefs) return;
 
     this.blame = null;
-    this._updatePreferenceStyles(prefs, this.renderPrefs);
+    this.updatePreferenceStyles();
 
     if (this.diff && !this.noRenderOnPrefsChange) {
-      this._debounceRenderDiffTable();
+      this.debounceRenderDiffTable();
     }
   }
 
-  _updatePreferenceStyles(
-    prefs: DiffPreferencesInfo,
-    renderPrefs?: RenderPreferences
-  ) {
+  private updatePreferenceStyles() {
+    assertIsDefined(this.prefs, 'prefs');
     const lineLength =
       this.path === COMMIT_MSG_PATH
         ? COMMIT_MSG_LINE_LENGTH
-        : prefs.line_length;
+        : this.prefs.line_length;
     const sideBySide = this.viewMode === 'SIDE_BY_SIDE';
-    const stylesToUpdate: {[key: string]: string} = {};
 
-    const responsiveMode = getResponsiveMode(prefs, renderPrefs);
+    const responsiveMode = getResponsiveMode(this.prefs, this.renderPrefs);
     const responsive = isResponsive(responsiveMode);
-    this._diffTableClass = responsive ? 'responsive' : '';
+    this.diffTableClass = responsive ? 'responsive' : '';
     const lineLimit = `${lineLength}ch`;
-    stylesToUpdate['--line-limit-marker'] =
-      responsiveMode === 'FULL_RESPONSIVE' ? lineLimit : '-1px';
-    stylesToUpdate['--content-width'] = responsive ? 'none' : lineLimit;
+    this.style.setProperty(
+      '--line-limit-marker',
+      responsiveMode === 'FULL_RESPONSIVE' ? lineLimit : '-1px'
+    );
+    this.style.setProperty('--content-width', responsive ? 'none' : lineLimit);
     if (responsiveMode === 'SHRINK_ONLY') {
       // Calculating ideal (initial) width for the whole table including
       // width of each table column (content and line number columns) and
@@ -758,72 +1484,75 @@
       const contentWidth = `${sideBySide ? 2 : 1} * ${lineLimit}`;
 
       // We always have 2 columns for line number
-      const lineNumberWidth = `2 * ${getLineNumberCellWidth(prefs)}px`;
+      const lineNumberWidth = `2 * ${getLineNumberCellWidth(this.prefs)}px`;
 
       // 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';
+        sideBySide && this.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
-      // exclusively by the browser).This happens even in monospace fonts.
-      // Empirically adding 2px as correction to be sure wrapping won't happen in these
-      // cases so it doesn' block further experimentation with the SHRINK_MODE.
-      // This was previously set to 1px but due to to a more aggressive
-      // text wrapping (via word-break: break-all; - check .contextText)
-      // we need to be even more lenient in some cases.
-      // If we find another way to avoid this correction we will change it.
+      // 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 exclusively by
+      // the browser).This happens even in monospace fonts. Empirically adding
+      // 2px as correction to be sure wrapping won't happen in these cases so it
+      // doesn't block further experimentation with the SHRINK_MODE. This was
+      // previously set to 1px but due to to a more aggressive text wrapping
+      // (via word-break: break-all; - check .contextText) we need to be even
+      // more lenient in some cases. If we find another way to avoid this
+      // correction we will change it.
       const dontWrapCorrection = '2px';
-      stylesToUpdate[
-        '--diff-max-width'
-      ] = `calc(${contentWidth} + ${lineNumberWidth} + ${signColsWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`;
+      this.style.setProperty(
+        '--diff-max-width',
+        `calc(${contentWidth} + ${lineNumberWidth} + ${signColsWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`
+      );
     } else {
-      stylesToUpdate['--diff-max-width'] = 'none';
+      this.style.setProperty('--diff-max-width', 'none');
     }
-    if (prefs.font_size) {
-      stylesToUpdate['--font-size'] = `${prefs.font_size}px`;
+    if (this.prefs.font_size) {
+      this.style.setProperty('--font-size', `${this.prefs.font_size}px`);
     }
-
-    this.updateStyles(stylesToUpdate);
   }
 
-  _renderPrefsChanged(renderPrefs?: RenderPreferences) {
-    if (!renderPrefs) return;
-    if (renderPrefs.hide_left_side) {
+  private renderPrefsChanged() {
+    if (!this.renderPrefs) return;
+    if (this.renderPrefs.hide_left_side) {
       this.classList.add('no-left');
     }
-    if (renderPrefs.disable_context_control_buttons) {
+    if (this.renderPrefs.disable_context_control_buttons) {
       this.classList.add('disable-context-control-buttons');
     }
-    if (renderPrefs.hide_line_length_indicator) {
+    if (this.renderPrefs.hide_line_length_indicator) {
       this.classList.add('hide-line-length-indicator');
     }
-    if (renderPrefs.show_sign_col) {
+    if (this.renderPrefs.show_sign_col) {
       this.classList.add('with-sign-col');
     }
     if (this.prefs) {
-      this._updatePreferenceStyles(this.prefs, renderPrefs);
+      this.updatePreferenceStyles();
     }
-    this.$.diffBuilder.updateRenderPrefs(renderPrefs);
+    this.diffBuilder.updateRenderPrefs(this.renderPrefs);
   }
 
-  _diffChanged(newValue?: DiffInfo) {
-    this._setLoading(true);
-    this._cleanup();
-    if (newValue) {
-      this._diffLength = this.getDiffLength(newValue);
-      this._debounceRenderDiffTable();
-    }
+  private diffChanged() {
+    this.loading = true;
+    this.cleanup();
     if (this.diff) {
-      this.diffSelection.init(this.diff, this.$.diffTable);
-      this.highlights.init(this.$.diffTable, this.$.diffBuilder);
+      this.diffLength = this.getDiffLength(this.diff);
+      this.debounceRenderDiffTable();
+      assertIsDefined(this.diffTable, 'diffTable');
+      this.diffSelection.init(this.diff, this.diffTable);
+      this.highlights.init(this.diffTable, this.diffBuilder);
     }
   }
 
+  // Implemented so the test can stub it.
+  getDiffLength(diff?: DiffInfo) {
+    return getDiffLength(diff);
+  }
+
   /**
    * When called multiple times from the same task, will call
    * _renderDiffTable only once, in the next task (scheduled via `setTimeout`).
@@ -833,7 +1562,7 @@
    * multiple inputs changing in the same microtask, but we only want to
    * render once.
    */
-  _debounceRenderDiffTable() {
+  private debounceRenderDiffTable() {
     // at this point gr-diff might be considered as rendered from the outside
     // (client), although it was not actually rendered. Clients need to know
     // when it is safe to perform operations like cursor moves, for example,
@@ -842,128 +1571,172 @@
     // 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()
+    this.renderDiffTableTask = debounceP(
+      this.renderDiffTableTask,
+      async () => await this.renderDiffTable()
     );
+    this.renderDiffTableTask.catch((e: unknown) => {
+      if (e === DELAYED_CANCELLATION) return;
+      throw e;
+    });
   }
 
-  _renderDiffTable() {
+  // Private but used in tests.
+  async renderDiffTable() {
+    this.unobserveNodes();
     if (!this.prefs) {
       fireEvent(this, 'render');
       return;
     }
     if (
       this.prefs.context === -1 &&
-      this._diffLength &&
-      this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
-      this._safetyBypass === null
+      this.diffLength &&
+      this.diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
+      this.safetyBypass === null
     ) {
-      this._showWarning = true;
+      this.showWarning = true;
       fireEvent(this, 'render');
       return;
     }
 
-    this._showWarning = false;
+    this.showWarning = false;
 
-    const keyLocations = this._computeKeyLocations();
-    this.$.diffBuilder.prefs = this._getBypassPrefs(this.prefs);
-    this.$.diffBuilder.renderPrefs = this.renderPrefs;
-    this.$.diffBuilder.render(keyLocations).then(() => {
-      fireEvent(this, 'render');
-    });
+    const keyLocations = this.computeKeyLocations();
+
+    // TODO: Setting tons of public properties like this is obviously a code
+    // smell. We are planning to introduce a diff model for managing all this
+    // data. Then diff builder will only need access to that model.
+    this.diffBuilder.prefs = this.getBypassPrefs();
+    this.diffBuilder.renderPrefs = this.renderPrefs;
+    this.diffBuilder.diff = this.diff;
+    this.diffBuilder.path = this.path;
+    this.diffBuilder.viewMode = this.viewMode;
+    this.diffBuilder.layers = this.layers ?? [];
+    this.diffBuilder.isImageDiff = this.isImageDiff;
+    this.diffBuilder.baseImage = this.baseImage ?? null;
+    this.diffBuilder.revisionImage = this.revisionImage ?? null;
+    this.diffBuilder.useNewImageDiffUi = this.useNewImageDiffUi;
+    this.diffBuilder.diffElement = this.diffTable;
+    // `this.commentRanges` are probably empty here, because they will only be
+    // populated by the node observer, which starts observing *after* rendering.
+    this.diffBuilder.updateCommentRanges(this.commentRanges);
+    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+    await this.diffBuilder.render(keyLocations);
   }
 
-  _handleRenderContent() {
+  private handleRenderContent() {
     this.querySelectorAll('gr-ranged-comment-hint').forEach(element =>
       element.remove()
     );
-    this._setLoading(false);
-    this._unobserveIncrementalNodes();
-    this._incrementalNodeObserver = (
-      dom(this) as PolymerDomWrapper
-    ).observeNodes(info => {
-      const addedThreadEls = info.addedNodes.filter(isThreadEl);
-      // Removed nodes do not need to be handled because all this code does is
-      // adding a slot for the added thread elements, and the extra slots do
-      // not hurt. It's probably a bigger performance cost to remove them than
-      // to keep them around. Medium term we can even consider to add one slot
-      // for each line from the start.
-      let lastEl;
-      for (const threadEl of addedThreadEls) {
-        const lineNum = getLine(threadEl);
-        const commentSide = getSide(threadEl);
-        const range = getRange(threadEl);
-        if (!commentSide) continue;
-        const lineEl = this.$.diffBuilder.getLineElByNumber(
-          lineNum,
-          commentSide
-        );
-        // When the line the comment refers to does not exist, log an error
-        // but don't crash. This can happen e.g. if the API does not fully
-        // validate e.g. (robot) comments
-        if (!lineEl) {
-          console.error(
-            'thread attached to line ',
-            commentSide,
-            lineNum,
-            ' which does not exist.'
-          );
-          continue;
-        }
-        const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
-        if (!contentEl) continue;
-        if (lineNum === 'LOST' && !contentEl.hasChildNodes()) {
-          contentEl.appendChild(this._portedCommentsWithoutRangeMessage());
-        }
-        const threadGroupEl = this._getOrCreateThreadGroup(
-          contentEl,
-          commentSide
-        );
-
-        const slotAtt = threadEl.getAttribute('slot');
-        if (range && isLongCommentRange(range) && slotAtt) {
-          const longRangeCommentHint = document.createElement(
-            'gr-ranged-comment-hint'
-          );
-          longRangeCommentHint.range = range;
-          longRangeCommentHint.setAttribute('threadElRootId', threadEl.rootId);
-          longRangeCommentHint.setAttribute('slot', slotAtt);
-          this.insertBefore(longRangeCommentHint, threadEl);
-          this._redispatchHoverEvents(longRangeCommentHint, threadEl);
-        }
-
-        // Create a slot for the thread and attach it to the thread group.
-        // The Polyfill has some bugs and this only works if the slot is
-        // attached to the group after the group is attached to the DOM.
-        // 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');
-        if (slotAtt) slot.name = slotAtt;
-        threadGroupEl.appendChild(slot);
-        lastEl = threadEl;
-      }
-
-      // Safari is not binding newly created comment-thread
-      // with the slot somehow, replace itself will rebind it
-      // @see Issue 11182
-      if (isSafari() && lastEl && lastEl.replaceWith) {
-        lastEl.replaceWith(lastEl);
-      }
-
-      const removedThreadEls = info.removedNodes.filter(isThreadEl);
-      for (const threadEl of removedThreadEls) {
-        this.querySelector(
-          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
-        )?.remove();
-      }
-    });
+    this.loading = false;
+    this.observeNodes();
+    // We are just converting 'render-content' into 'render' here. Maybe we
+    // should retire the 'render' event in favor of 'render-content'?
+    fireEvent(this, 'render');
   }
 
-  _portedCommentsWithoutRangeMessage() {
+  private observeNodes() {
+    // First stop observing old nodes.
+    this.unobserveNodes();
+    // Then introduce a Mutation observer that watches for children being added
+    // to gr-diff. If those children are `isThreadEl`, namely then they are
+    // processed.
+    this.nodeObserver = new MutationObserver(mutations => {
+      const addedThreadEls = extractAddedNodes(mutations).filter(isThreadEl);
+      const removedThreadEls =
+        extractRemovedNodes(mutations).filter(isThreadEl);
+      this.processNodes(addedThreadEls, removedThreadEls);
+    });
+    this.nodeObserver.observe(this, {childList: true});
+    // Make sure to process existing gr-comment-threads that already exist.
+    this.processNodes([...this.childNodes].filter(isThreadEl), []);
+  }
+
+  private processNodes(
+    addedThreadEls: GrDiffThreadElement[],
+    removedThreadEls: GrDiffThreadElement[]
+  ) {
+    this.updateRanges(addedThreadEls, removedThreadEls);
+    addedThreadEls.forEach(threadEl =>
+      this.redispatchHoverEvents(threadEl, threadEl)
+    );
+    // Removed nodes do not need to be handled because all this code does is
+    // adding a slot for the added thread elements, and the extra slots do
+    // not hurt. It's probably a bigger performance cost to remove them than
+    // to keep them around. Medium term we can even consider to add one slot
+    // for each line from the start.
+    for (const threadEl of addedThreadEls) {
+      const lineNum = getLine(threadEl);
+      const commentSide = getSide(threadEl);
+      const range = getRange(threadEl);
+      if (!commentSide) continue;
+      const lineEl = this.diffBuilder.getLineElByNumber(lineNum, commentSide);
+      // When the line the comment refers to does not exist, log an error
+      // but don't crash. This can happen e.g. if the API does not fully
+      // validate e.g. (robot) comments
+      if (!lineEl) {
+        console.error(
+          'thread attached to line ',
+          commentSide,
+          lineNum,
+          ' which does not exist.'
+        );
+        continue;
+      }
+      const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+      if (!contentEl) continue;
+      if (lineNum === 'LOST' && !contentEl.hasChildNodes()) {
+        contentEl.appendChild(this.portedCommentsWithoutRangeMessage());
+      }
+      const threadGroupEl = this.getOrCreateThreadGroup(contentEl, commentSide);
+
+      const slotAtt = threadEl.getAttribute('slot');
+      if (range && isLongCommentRange(range) && slotAtt) {
+        const longRangeCommentHint = document.createElement(
+          'gr-ranged-comment-hint'
+        );
+        longRangeCommentHint.range = range;
+        longRangeCommentHint.setAttribute('threadElRootId', threadEl.rootId);
+        longRangeCommentHint.setAttribute('slot', slotAtt);
+        this.insertBefore(longRangeCommentHint, threadEl);
+        this.redispatchHoverEvents(longRangeCommentHint, threadEl);
+      }
+
+      // Create a slot for the thread and attach it to the thread group.
+      // The Polyfill has some bugs and this only works if the slot is
+      // attached to the group after the group is attached to the DOM.
+      // 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');
+      if (slotAtt) slot.name = slotAtt;
+      threadGroupEl.appendChild(slot);
+    }
+
+    for (const threadEl of removedThreadEls) {
+      this.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
+      )?.remove();
+    }
+  }
+
+  private unobserveNodes() {
+    if (this.nodeObserver) {
+      this.nodeObserver.disconnect();
+      this.nodeObserver = undefined;
+    }
+    // You only stop observing for comment thread elements when the diff is
+    // completely rendered from scratch. And then comment thread elements
+    // will be (re-)added *after* rendering is done. That is also when we
+    // re-start observing. So it is appropriate to thoroughly clean up
+    // everything that the observer is managing.
+    this.commentRanges = [];
+  }
+
+  private portedCommentsWithoutRangeMessage() {
     const div = document.createElement('div');
-    const icon = document.createElement('iron-icon');
-    icon.setAttribute('icon', 'gr-icons:info-outline');
+    const icon = document.createElement('gr-icon');
+    icon.setAttribute('icon', 'info');
     div.appendChild(icon);
     const span = document.createElement('span');
     span.innerText = 'Original comment position not found in this patchset';
@@ -971,45 +1744,31 @@
     return div;
   }
 
-  _unobserveIncrementalNodes() {
-    if (this._incrementalNodeObserver) {
-      (dom(this) as PolymerDomWrapper).unobserveNodes(
-        this._incrementalNodeObserver
-      );
-    }
-  }
-
-  _unobserveNodes() {
-    if (this._nodeObserver) {
-      (dom(this) as PolymerDomWrapper).unobserveNodes(this._nodeObserver);
-    }
-  }
-
   /**
    * Get the preferences object including the safety bypass context (if any).
    */
-  _getBypassPrefs(prefs: DiffPreferencesInfo) {
-    if (this._safetyBypass !== null) {
-      return {...prefs, context: this._safetyBypass};
+  private getBypassPrefs() {
+    assertIsDefined(this.prefs, 'prefs');
+    if (this.safetyBypass !== null) {
+      return {...this.prefs, context: this.safetyBypass};
     }
-    return prefs;
+    return this.prefs;
   }
 
   clearDiffContent() {
-    this._unobserveIncrementalNodes();
-    while (this.$.diffTable.hasChildNodes()) {
-      this.$.diffTable.removeChild(this.$.diffTable.lastChild!);
+    this.unobserveNodes();
+    if (!this.diffTable) return;
+    while (this.diffTable.hasChildNodes()) {
+      this.diffTable.removeChild(this.diffTable.lastChild!);
     }
   }
 
-  _computeDiffHeaderItems(
-    diffInfoRecord: PolymerDeepPropertyChange<DiffInfo, DiffInfo>
-  ) {
-    const diffInfo = diffInfoRecord.base;
-    if (!diffInfo || !diffInfo.diff_header) {
+  // Private but used in tests.
+  computeDiffHeaderItems() {
+    if (!this.diff || !this.diff.diff_header) {
       return [];
     }
-    return diffInfo.diff_header.filter(
+    return this.diff.diff_header.filter(
       item =>
         !(
           item.startsWith('diff --git ') ||
@@ -1021,49 +1780,37 @@
     );
   }
 
-  _computeDiffHeaderHidden(items: string[]) {
-    return items.length === 0;
+  private handleFullBypass() {
+    this.safetyBypass = FULL_CONTEXT;
+    this.debounceRenderDiffTable();
   }
 
-  _handleFullBypass() {
-    this._safetyBypass = FULL_CONTEXT;
-    this._debounceRenderDiffTable();
-  }
-
-  _collapseContext() {
+  private collapseContext() {
     // Uses the default context amount if the preference is for the entire file.
-    this._safetyBypass =
+    this.safetyBypass =
       this.prefs?.context && this.prefs.context >= 0
         ? null
         : createDefaultDiffPrefs().context;
-    this._debounceRenderDiffTable();
-  }
-
-  _computeWarningClass(showWarning?: boolean) {
-    return showWarning ? 'warn' : '';
-  }
-
-  _computeErrorClass(errorMessage?: string | null) {
-    return errorMessage ? 'showError' : '';
+    this.debounceRenderDiffTable();
   }
 
   toggleAllContext() {
     if (!this.prefs) {
       return;
     }
-    if (this._getBypassPrefs(this.prefs).context < 0) {
-      this._collapseContext();
+    if (this.getBypassPrefs().context < 0) {
+      this.collapseContext();
     } else {
-      this._handleFullBypass();
+      this.handleFullBypass();
     }
   }
 
-  _computeNewlineWarning(warnLeft: boolean, warnRight: boolean) {
+  private computeNewlineWarning() {
     const messages = [];
-    if (warnLeft) {
+    if (this.showNewlineWarningLeft) {
       messages.push(NO_NEWLINE_LEFT);
     }
-    if (warnRight) {
+    if (this.showNewlineWarningRight) {
       messages.push(NO_NEWLINE_RIGHT);
     }
     if (!messages.length) {
@@ -1072,33 +1819,28 @@
     return messages.join(' \u2014 '); // \u2014 - '—'
   }
 
-  _computeNewlineWarningClass(warning: boolean, loading: boolean) {
-    if (loading || !warning) {
+  // Private but used in tests.
+  computeNewlineWarningClass(warning: boolean) {
+    if (this.loading || !warning) {
       return 'newlineWarning hidden';
     }
     return 'newlineWarning';
   }
+}
 
-  /**
-   * Get the approximate length of the diff as the sum of the maximum
-   * length of the chunks.
-   */
-  getDiffLength(diff?: DiffInfo) {
-    if (!diff) return 0;
-    return diff.content.reduce((sum, sec) => {
-      if (sec.ab) {
-        return sum + sec.ab.length;
-      } else {
-        return (
-          sum + Math.max(sec.a ? sec.a.length : 0, sec.b ? sec.b.length : 0)
-        );
-      }
-    }, 0);
-  }
+function extractAddedNodes(mutations: MutationRecord[]) {
+  return mutations.flatMap(mutation => [...mutation.addedNodes]);
+}
+
+function extractRemovedNodes(mutations: MutationRecord[]) {
+  return mutations.flatMap(mutation => [...mutation.removedNodes]);
 }
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-diff': GrDiff;
   }
+  interface HTMLElementEventMap {
+    'loading-changed': ValueChangedEvent<boolean>;
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
deleted file mode 100644
index 6d36b89..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
+++ /dev/null
@@ -1,750 +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">
-    /**
-     * 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) {
-      --context-control-display: none;
-    }
-    :host(.disable-context-control-buttons) .section {
-      border-right: none;
-    }
-    :host(.hide-line-length-indicator) .full-width td.content .contentText {
-      background-image: none;
-    }
-
-    :host {
-      font-family: var(--monospace-font-family, ''), 'Roboto Mono';
-      font-size: var(--font-size, var(--font-size-code, 12px));
-      /* usually 16px = 12px + 4px */
-      line-height: calc(
-        var(--font-size, var(--font-size-code, 12px)) + var(--spacing-s, 4px)
-      );
-    }
-
-    .thread-group {
-      display: block;
-      max-width: var(--content-width, 80ch);
-      white-space: normal;
-      background-color: var(--diff-blank-background-color);
-    }
-    .diffContainer {
-      max-width: var(--diff-max-width, none);
-      display: flex;
-      font-family: var(--monospace-font-family);
-    }
-    .diffContainer.hiddenscroll {
-      margin-bottom: var(--spacing-m);
-    }
-    table {
-      border-collapse: collapse;
-      table-layout: fixed;
-    }
-    td.lineNum {
-      /* Enforces background whenever lines wrap */
-      background-color: var(--diff-blank-background-color);
-    }
-
-    /* Provides the option to add side borders (left and right) to the line number column. */
-    td.lineNum,
-    td.blankLineNum,
-    td.moveControlsLineNumCol,
-    td.contextLineNum {
-      box-shadow: var(--line-number-box-shadow, unset);
-    }
-
-    /*
-      Context controls break up the table visually, so we set the right border
-      on individual sections to leave a gap for the divider.
-
-      Also taken into account for max-width calculations in SHRINK_ONLY
-      mode (check GrDiff._updatePreferenceStyles).
-      */
-    .section {
-      border-right: 1px solid var(--border-color);
-    }
-    .section.contextControl {
-      /*
-       * Divider inside this section must not have border; we set borders on
-       * the padding rows below.
-       */
-      border-right-width: 0;
-    }
-    /*
-     * Padding rows behind context controls. The diff is styled to be cut into
-     * two halves by the negative space of the divider on which the context
-     * control buttons are anchored.
-     */
-    .contextBackground {
-      border-right: 1px solid var(--border-color);
-    }
-    .contextBackground.above {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .contextBackground.below {
-      border-top: 1px solid var(--border-color);
-    }
-
-    .lineNumButton {
-      display: block;
-      width: 100%;
-      height: 100%;
-      background-color: var(--diff-blank-background-color);
-      box-shadow: var(--line-number-box-shadow, unset);
-    }
-    td.lineNum {
-      vertical-align: top;
-    }
-
-    /*
-      The only way to focus this (clicking) will apply our own focus styling,
-      so this default styling is not needed and distracting.
-      */
-    .lineNumButton:focus {
-      outline: none;
-    }
-    gr-image-viewer {
-      width: 100%;
-      height: 100%;
-      max-width: var(--image-viewer-max-width, 95vw);
-      max-height: var(--image-viewer-max-height, 90vh);
-      /*
-        Defined by paper-styles default-theme and used in various components.
-        background-color-secondary is a compromise between fairly light in
-        light theme (where we ideally would want background-color-primary) yet
-        slightly offset against the app background in dark mode, where drop
-        shadows e.g. around paper-card are almost invisible.
-        */
-      --primary-background-color: var(--background-color-secondary);
-    }
-    .image-diff .gr-diff {
-      text-align: center;
-    }
-    .image-diff img {
-      box-shadow: var(--elevation-level-1);
-      max-width: 50em;
-    }
-    .image-diff .right.lineNumButton {
-      border-left: 1px solid var(--border-color);
-    }
-    .image-diff label,
-    .binary-diff label {
-      font-family: var(--font-family);
-      font-style: italic;
-    }
-    .diff-row {
-      outline: none;
-      user-select: none;
-    }
-    .diff-row.target-row.target-side-left .lineNumButton.left,
-    .diff-row.target-row.target-side-right .lineNumButton.right,
-    .diff-row.target-row.unified .lineNumButton {
-      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
-      there is at least one comment thread, and the slotting makes :empty not
-      work as expected.
-     */
-    .content.file slot:first-child::slotted(.comment-thread) {
-      display: block;
-      margin-top: var(--spacing-xs);
-    }
-    .contentText {
-      background-color: var(--view-background-color);
-    }
-    .blank {
-      background-color: var(--diff-blank-background-color);
-    }
-    .image-diff .content {
-      background-color: var(--diff-blank-background-color);
-    }
-    .responsive {
-      width: 100%;
-    }
-    .responsive .contentText {
-      white-space: break-spaces;
-      word-break: break-all;
-    }
-    .lineNumButton,
-    .content {
-      vertical-align: top;
-      white-space: pre;
-    }
-    .contextLineNum,
-    .lineNumButton {
-      -webkit-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-      user-select: none;
-
-      color: var(--deemphasized-text-color);
-      padding: 0 var(--spacing-m);
-      text-align: right;
-    }
-    .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
-           CJK (Chinese-Japanese-Korean) glyphs have variable width */
-      min-width: var(--content-width, 80ch);
-      width: var(--content-width, 80ch);
-    }
-    .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,
-    .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,
-      .sign.remove.no-intraline-info {
-      background-color: var(--dark-remove-highlight-color);
-    }
-    .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 {
-      background-color: var(--dark-rebased-add-highlight-color);
-    }
-    .dueToRebase .content.add .contentText {
-      background-color: var(--light-rebased-add-highlight-color);
-    }
-    .dueToRebase .content.remove .contentText .intraline,
-    .delta.total.dueToRebase .content.remove .contentText {
-      background-color: var(--dark-rebased-remove-highlight-color);
-    }
-    .dueToRebase .content.remove .contentText {
-      background-color: var(--light-remove-add-highlight-color);
-    }
-
-    /* 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);
-    }
-
-    .delta.dueToMove .movedIn .moveHeader {
-      --gr-range-header-color: var(--diff-moved-in-label-color);
-    }
-    .delta.dueToMove .movedOut .moveHeader {
-      --gr-range-header-color: var(--diff-moved-out-label-color);
-    }
-
-    .moveHeader a {
-      color: inherit;
-    }
-
-    /* ignoredWhitespaceOnly */
-    .ignoredWhitespaceOnly .content.add .contentText .intraline,
-    .delta.total.ignoredWhitespaceOnly .content.add .contentText,
-    .ignoredWhitespaceOnly .content.add .contentText,
-    .ignoredWhitespaceOnly .content.remove .contentText .intraline,
-    .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
-    .ignoredWhitespaceOnly .content.remove .contentText {
-      background-color: var(--view-background-color);
-    }
-
-    .content .contentText:empty:after {
-      /* Newline, to ensure empty lines are one line-height tall. */
-      content: '\\A';
-    }
-
-    /* Context controls */
-    .contextControl {
-      display: var(--context-control-display, table-row-group);
-      background-color: transparent;
-      border: none;
-      --divider-height: var(--spacing-s);
-      --divider-border: 1px;
-    }
-    .contextControl gr-button iron-icon {
-      /* should match line-height of gr-button */
-      width: var(--line-height-mono, 18px);
-      height: var(--line-height-mono, 18px);
-    }
-    .contextControl td:not(.lineNumButton) {
-      text-align: center;
-    }
-
-    /*
-     * Padding rows behind context controls. Styled as a continuation of the
-     * line gutters and code area.
-     */
-    .contextBackground > .contextLineNum {
-      background-color: var(--diff-blank-background-color);
-    }
-    .contextBackground > td:not(.contextLineNum) {
-      background-color: var(--view-background-color);
-    }
-    .contextBackground {
-      /*
-       * One line of background behind the context expanders which they can
-       * render on top of, plus some padding.
-       */
-      height: calc(var(--line-height-normal) + var(--spacing-s));
-    }
-
-    .dividerCell {
-      vertical-align: top;
-    }
-    .dividerRow.show-both .dividerCell {
-      height: var(--divider-height);
-    }
-    .dividerRow.show-above .dividerCell,
-    .dividerRow.show-above .dividerCell {
-      height: 0;
-    }
-
-    .br:after {
-      /* Line feed */
-      content: '\\A';
-    }
-    .tab {
-      display: inline-block;
-    }
-    .tab-indicator:before {
-      color: var(--diff-tab-indicator-color);
-      /* >> character */
-      content: '\\00BB';
-      position: absolute;
-    }
-    .special-char-indicator {
-      /* spacing so elements don't collide */
-      padding-right: var(--spacing-m);
-    }
-    .special-char-indicator:before {
-      color: var(--diff-tab-indicator-color);
-      content: '•';
-      position: absolute;
-    }
-    .special-char-warning {
-      /* spacing so elements don't collide */
-      padding-right: var(--spacing-m);
-    }
-    .special-char-warning:before {
-      color: var(--warning-foreground);
-      content: '!';
-      position: absolute;
-    }
-    /* Is defined after other background-colors, such that this
-         rule wins in case of same specificity. */
-    .trailing-whitespace,
-    .content .trailing-whitespace,
-    .trailing-whitespace .intraline,
-    .content .trailing-whitespace .intraline {
-      border-radius: var(--border-radius, 4px);
-      background-color: var(--diff-trailing-whitespace-indicator);
-    }
-    #diffHeader {
-      background-color: var(--table-header-background-color);
-      border-bottom: 1px solid var(--border-color);
-      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;
-    }
-    #loadingError,
-    #sizeWarning {
-      display: none;
-      margin: var(--spacing-l) auto;
-      max-width: 60em;
-      text-align: center;
-    }
-    #loadingError {
-      color: var(--error-text-color);
-    }
-    #sizeWarning gr-button {
-      margin: var(--spacing-l);
-    }
-    #loadingError.showError,
-    #sizeWarning.warn {
-      display: block;
-    }
-    .target-row td.blame {
-      background: var(--diff-selection-background-color);
-    }
-    td.lost div {
-      background-color: var(--info-background);
-      padding: var(--spacing-s) 0 0 0;
-    }
-    td.lost div:first-of-type {
-      font-family: var(--font-family, 'Roboto');
-      font-size: var(--font-size-normal, 14px);
-      line-height: var(--line-height-normal);
-    }
-    td.lost iron-icon {
-      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;
-    }
-    td.blame {
-      display: none;
-      padding: 0 var(--spacing-m);
-      white-space: pre;
-    }
-    :host(.showBlame) col.blame {
-      display: table-column;
-    }
-    :host(.showBlame) td.blame {
-      display: table-cell;
-    }
-    td.blame > span {
-      opacity: 0.6;
-    }
-    td.blame > span.startOfRange {
-      opacity: 1;
-    }
-    td.blame .blameDate {
-      font-family: var(--monospace-font-family);
-      color: var(--link-color);
-      text-decoration: none;
-    }
-    .responsive td.blame {
-      overflow: hidden;
-      width: 200px;
-    }
-    /** Support the line length indicator **/
-    .responsive td.content .contentText {
-      /*
-      Same strategy as in https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
-      */
-      background-image: linear-gradient(
-        var(--line-length-indicator-color),
-        var(--line-length-indicator-color)
-      );
-      background-size: 1px 100%;
-      background-position: var(--line-limit-marker) 0;
-      background-repeat: no-repeat;
-    }
-    .newlineWarning {
-      color: var(--deemphasized-text-color);
-      text-align: center;
-    }
-    .newlineWarning.hidden {
-      display: none;
-    }
-    .lineNum.COVERED .lineNumButton {
-      background-color: var(--coverage-covered, #e0f2f1);
-    }
-    .lineNum.NOT_COVERED .lineNumButton {
-      background-color: var(--coverage-not-covered, #ffd1a4);
-    }
-    .lineNum.PARTIALLY_COVERED .lineNumButton {
-      background: linear-gradient(
-        to right bottom,
-        var(--coverage-not-covered, #ffd1a4) 0%,
-        var(--coverage-not-covered, #ffd1a4) 50%,
-        var(--coverage-covered, #e0f2f1) 50%,
-        var(--coverage-covered, #e0f2f1) 100%
-      );
-    }
-
-    /** BEGIN: Select and copy for Polymer 2 */
-    /** Below was copied and modified from the original css in gr-diff-selection.html */
-    .content,
-    .contextControl,
-    .blame {
-      -webkit-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-      user-select: none;
-    }
-
-    .selected-left:not(.selected-comment)
-      .side-by-side
-      .left
-      + .content
-      .contentText,
-    .selected-right:not(.selected-comment)
-      .side-by-side
-      .right
-      + .content
-      .contentText,
-    .selected-left:not(.selected-comment)
-      .unified
-      .left.lineNum
-      ~ .content:not(.both)
-      .contentText,
-    .selected-right:not(.selected-comment)
-      .unified
-      .right.lineNum
-      ~ .content
-      .contentText,
-    .selected-left.selected-comment .side-by-side .left + .content .message,
-    .selected-right.selected-comment
-      .side-by-side
-      .right
-      + .content
-      .message
-      :not(.collapsedContent),
-    .selected-comment .unified .message :not(.collapsedContent),
-    .selected-blame .blame {
-      -webkit-user-select: text;
-      -moz-user-select: text;
-      -ms-user-select: text;
-      user-select: text;
-    }
-
-    /** Make comments and check results selectable when selected */
-    .selected-left.selected-comment
-      ::slotted(.comment-thread[diff-side='left']),
-    .selected-right.selected-comment
-      ::slotted(.comment-thread[diff-side='right']) {
-      -webkit-user-select: text;
-      -moz-user-select: text;
-      -ms-user-select: text;
-      user-select: text;
-    }
-    /** END: Select and copy for Polymer 2 */
-
-    .whitespace-change-only-message {
-      background-color: var(--diff-context-control-background-color);
-      border: 1px solid var(--diff-context-control-border-color);
-      text-align: center;
-    }
-
-    .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 */
-  </style>
-  <style include="gr-ranged-comment-theme">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
-    <template is="dom-repeat" items="[[_diffHeaderItems]]">
-      <div>[[item]]</div>
-    </template>
-  </div>
-  <div
-    class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
-    on-click="_handleTap"
-  >
-    <gr-diff-builder
-      id="diffBuilder"
-      comment-ranges="[[_commentRanges]]"
-      coverage-ranges="[[coverageRanges]]"
-      diff="[[diff]]"
-      path="[[path]]"
-      view-mode="[[viewMode]]"
-      is-image-diff="[[isImageDiff]]"
-      base-image="[[baseImage]]"
-      layers="[[layers]]"
-      revision-image="[[revisionImage]]"
-      use-new-image-diff-ui="[[useNewImageDiffUi]]"
-    >
-      <table
-        id="diffTable"
-        class$="[[_diffTableClass]]"
-        role="presentation"
-        contenteditable$="[[isContentEditable]]"
-      ></table>
-
-      <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]]
-  </div>
-  <div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
-    [[errorMessage]]
-  </div>
-  <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
-    <p>
-      Prevented render because "Whole file" is enabled and this diff is very
-      large (about [[_diffLength]] lines).
-    </p>
-    <gr-button on-click="_collapseContext">
-      Render with limited context
-    </gr-button>
-    <gr-button on-click="_handleFullBypass">
-      Render anyway (may be slow)
-    </gr-button>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
deleted file mode 100644
index c8d8a2f..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
+++ /dev/null
@@ -1,1239 +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 {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');
-
-suite('gr-diff a11y test', () => {
-  test('audit', async () => {
-    await runA11yAudit(basicFixture);
-  });
-});
-
-suite('gr-diff tests', () => {
-  let element;
-
-  const MINIMAL_PREFS = {tab_size: 2, line_length: 80, font_size: 12};
-
-  setup(() => {
-
-  });
-
-  suite('selectionchange event handling', () => {
-    const emulateSelection = function() {
-      document.dispatchEvent(new CustomEvent('selectionchange'));
-    };
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element.highlights, 'handleSelectionChange');
-    });
-
-    test('enabled if logged in', async () => {
-      element.loggedIn = true;
-      emulateSelection();
-      await flush();
-      assert.isTrue(element.highlights.handleSelectionChange.called);
-    });
-
-    test('ignored if logged out', async () => {
-      element.loggedIn = false;
-      emulateSelection();
-      await flush();
-      assert.isFalse(element.highlights.handleSelectionChange.called);
-    });
-  });
-
-  test('cancel', () => {
-    element = basicFixture.instantiate();
-    const cancelStub = sinon.stub(element.$.diffBuilder, 'cancel');
-    element.cancel();
-    assert.isTrue(cancelStub.calledOnce);
-  });
-
-  test('line limit with line_wrapping', () => {
-    element = basicFixture.instantiate();
-    element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
-    flush();
-    assert.equal(getComputedStyleValue('--line-limit-marker', element), '80ch');
-  });
-
-  test('line limit without line_wrapping', () => {
-    element = basicFixture.instantiate();
-    element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
-    flush();
-    assert.equal(getComputedStyleValue('--line-limit-marker', element), '-1px');
-  });
-  suite('FULL_RESPONSIVE mode', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.prefs = {...MINIMAL_PREFS};
-      element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
-    });
-
-    test('line limit is based on line_length', () => {
-      element.prefs = {...element.prefs, line_length: 100};
-      flush();
-      assert.equal(getComputedStyleValue('--line-limit-marker', element),
-          '100ch');
-    });
-
-    test('content-width should not be defined', () => {
-      flush();
-      assert.equal(getComputedStyleValue('--content-width', element), 'none');
-    });
-  });
-
-  suite('SHRINK_ONLY mode', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.prefs = {...MINIMAL_PREFS};
-      element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
-    });
-
-    test('content-width should not be defined', () => {
-      flush();
-      assert.equal(getComputedStyleValue('--content-width', element), 'none');
-    });
-
-    test('max-width considers two content columns in side-by-side', () => {
-      element.viewMode = 'SIDE_BY_SIDE';
-      flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 48px + 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 + 0ch + 1px + 2px)');
-    });
-
-    test('max-width considers font-size', () => {
-      element.prefs = {...element.prefs, font_size: 13};
-      flush();
-      // Each line number column: 4 * 13 = 52px
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)');
-    });
-
-    test('sign cols are considered if show_sign_col is true', () => {
-      element.renderPrefs = {...element.renderPrefs, show_sign_col: true};
-      flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)');
-    });
-  });
-
-  suite('not logged in', () => {
-    setup(() => {
-      const getLoggedInPromise = Promise.resolve(false);
-      stubRestApi('getLoggedIn').returns(getLoggedInPromise);
-      element = basicFixture.instantiate();
-      return getLoggedInPromise;
-    });
-
-    test('toggleLeftDiff', () => {
-      element.toggleLeftDiff();
-      assert.isTrue(element.classList.contains('no-left'));
-      element.toggleLeftDiff();
-      assert.isFalse(element.classList.contains('no-left'));
-    });
-
-    test('view does not start with displayLine classList', () => {
-      assert.isFalse(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
-    });
-
-    test('displayLine class added called when displayLine is true', () => {
-      const spy = sinon.spy(element, '_computeContainerClass');
-      element.displayLine = true;
-      assert.isTrue(spy.called);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
-    });
-
-    test('thread groups', () => {
-      const contentEl = document.createElement('div');
-
-      element.changeNum = 123;
-      element.patchRange = {basePatchNum: 1, patchNum: 2};
-      element.path = 'file.txt';
-      element.$.diffBuilder.diff = createDiff();
-      element.$.diffBuilder.prefs = {...MINIMAL_PREFS};
-      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder();
-
-      // No thread groups.
-      assert.equal(contentEl.querySelectorAll('.thread-group').length, 0);
-
-      // A thread group gets created.
-      const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
-      assert.isOk(threadGroupEl);
-
-      // The new thread group can be fetched.
-      assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
-    });
-
-    suite('image diffs', () => {
-      let mockFile1;
-      let mockFile2;
-      setup(() => {
-        mockFile1 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAAAAAA/w==',
-          type: 'image/bmp',
-        };
-        mockFile2 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAA/////w==',
-          type: 'image/bmp',
-        };
-
-        element.isImageDiff = true;
-        element.prefs = {
-          context: 10,
-          cursor_blink_rate: 0,
-          font_size: 12,
-          ignore_whitespace: 'IGNORE_NONE',
-          line_length: 100,
-          line_wrapping: false,
-          show_line_endings: true,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          tab_size: 8,
-          theme: 'DEFAULT',
-        };
-      });
-
-      test('renders image diffs with same file name', async () => {
-        const leftRendered = mockPromise();
-        const rightRendered = mockPromise();
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isNotOk(rightLabelName);
-          assert.isNotOk(leftLabelName);
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-            leftRendered.resolve();
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-
-            rightRendered.resolve();
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.revisionImage = mockFile2;
-        element.diff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        await Promise.all([leftRendered, rightRendered]);
-        element.removeEventListener('render', rendered);
-      });
-
-      test('renders image diffs with a different file name', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot2.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot2.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        const leftRendered = mockPromise();
-        const rightRendered = mockPromise();
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isOk(rightLabelName);
-          assert.isOk(leftLabelName);
-          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-            leftRendered.resolve();
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-
-            rightRendered.resolve();
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.baseImage._name = mockDiff.meta_a.name;
-        element.revisionImage = mockFile2;
-        element.revisionImage._name = mockDiff.meta_b.name;
-        element.diff = mockDiff;
-        await Promise.all([leftRendered, rightRendered]);
-        element.removeEventListener('render', rendered);
-      });
-
-      test('renders added image', async () => {
-        const mockDiff = {
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'ADDED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 0000000..f9c2f2c 100644',
-            '--- /dev/null',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-
-        const promise = mockPromise();
-        function rendered() { promise.resolve(); }
-        element.addEventListener('render', rendered);
-
-        element.revisionImage = mockFile2;
-        element.diff = mockDiff;
-        await promise;
-        element.removeEventListener('render', rendered);
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const rightImage = element.$.diffTable.querySelector('td.right img');
-
-        assert.isNotOk(leftImage);
-        assert.isOk(rightImage);
-      });
-
-      test('renders removed image', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        const promise = mockPromise();
-        function rendered() { promise.resolve(); }
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.diff = mockDiff;
-        await promise;
-        element.removeEventListener('render', rendered);
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const rightImage = element.$.diffTable.querySelector('td.right img');
-
-        assert.isOk(leftImage);
-        assert.isNotOk(rightImage);
-      });
-
-      test('does not render disallowed image type', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        mockFile1.type = 'image/jpeg-evil';
-
-        const promise = mockPromise();
-        function rendered() { promise.resolve(); }
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.diff = mockDiff;
-        await promise;
-        element.removeEventListener('render', rendered);
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        assert.isNotOk(leftImage);
-      });
-    });
-
-    test('_handleTap lineNum', async () => {
-      const addDraftStub = sinon.stub(element, 'addDraftAtLine');
-      const el = document.createElement('div');
-      el.className = 'lineNum';
-      const promise = mockPromise();
-      el.addEventListener('click', e => {
-        element._handleTap(e);
-        assert.isTrue(addDraftStub.called);
-        assert.equal(addDraftStub.lastCall.args[0], el);
-        promise.resolve();
-      });
-      el.click();
-      await promise;
-    });
-
-    test('_handleTap content', async () => {
-      const content = document.createElement('div');
-      const lineEl = document.createElement('div');
-      lineEl.className = 'lineNum';
-      const row = document.createElement('div');
-      row.appendChild(lineEl);
-      row.appendChild(content);
-
-      const selectStub = sinon.stub(element, '_selectLine');
-
-      content.className = 'content';
-      const promise = mockPromise();
-      content.addEventListener('click', e => {
-        element._handleTap(e);
-        assert.isTrue(selectStub.called);
-        assert.equal(selectStub.lastCall.args[0], lineEl);
-        promise.resolve();
-      });
-      content.click();
-      await promise;
-    });
-
-    suite('getCursorStops', () => {
-      function setupDiff() {
-        element.diff = createDiff();
-        element.prefs = {
-          context: 10,
-          tab_size: 8,
-          font_size: 12,
-          line_length: 100,
-          cursor_blink_rate: 0,
-          line_wrapping: false,
-
-          show_line_endings: true,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          theme: 'DEFAULT',
-          ignore_whitespace: 'IGNORE_NONE',
-        };
-
-        element._renderDiffTable();
-
-        flush();
-      }
-
-      test('returns [] when hidden and noAutoRender', () => {
-        element.noAutoRender = true;
-        setupDiff();
-        element._setLoading(false);
-        flush();
-        element.hidden = true;
-        assert.equal(element.getCursorStops().length, 0);
-      });
-
-      test('returns one stop per line and one for the file row', () => {
-        setupDiff();
-        element._setLoading(false);
-        flush();
-        const ROWS = 48;
-        const FILE_ROW = 1;
-        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
-      });
-
-      test('returns an additional AbortStop when still loading', () => {
-        setupDiff();
-        element._setLoading(true);
-        flush();
-        const ROWS = 48;
-        const FILE_ROW = 1;
-        const actual = element.getCursorStops();
-        assert.equal(actual.length, ROWS + FILE_ROW + 1);
-        assert.isTrue(actual[actual.length -1] instanceof AbortStop);
-      });
-    });
-
-    test('adds .hiddenscroll', () => {
-      _setHiddenScroll(true);
-      element.displayLine = true;
-      assert.include(element.shadowRoot
-          .querySelector('.diffContainer').className, 'hiddenscroll');
-    });
-  });
-
-  suite('logged in', () => {
-    let fakeLineEl;
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.loggedIn = true;
-
-      fakeLineEl = {
-        getAttribute: sinon.stub().returns(42),
-        classList: {
-          contains: sinon.stub().returns(true),
-        },
-      };
-    });
-
-    test('addDraftAtLine', () => {
-      sinon.stub(element, '_selectLine');
-      sinon.stub(element, '_createComment');
-      element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(element._createComment
-          .calledWithExactly(fakeLineEl, 42));
-    });
-
-    test('adds long range comment hint', async () => {
-      const range = {
-        start_line: 1,
-        end_line: 12,
-        start_character: 0,
-        end_character: 0,
-      };
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 1);
-      threadEl.setAttribute('range', JSON.stringify(range));
-      threadEl.setAttribute('slot', 'right-1');
-      const content = [{
-        a: [],
-        b: [],
-      }, {
-        ab: Array(13).fill('text'),
-      }];
-      setupSampleDiff({content});
-
-      element.appendChild(threadEl);
-      await flush();
-
-      assert.deepEqual(
-          element.querySelector('gr-ranged-comment-hint').range, range);
-    });
-
-    test('no duplicate range hint for same thread', async () => {
-      const range = {
-        start_line: 1,
-        end_line: 12,
-        start_character: 0,
-        end_character: 0,
-      };
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 1);
-      threadEl.setAttribute('range', JSON.stringify(range));
-      threadEl.setAttribute('slot', 'right-1');
-      const firstHint = document.createElement('gr-ranged-comment-hint');
-      firstHint.range = range;
-      firstHint.setAttribute('threadElRootId', threadEl.rootId);
-      firstHint.setAttribute('slot', 'right-1');
-      const content = [{
-        a: [],
-        b: [],
-      }, {
-        ab: Array(13).fill('text'),
-      }];
-      setupSampleDiff({content});
-
-      element.appendChild(firstHint);
-      await flush();
-      element._handleRenderContent();
-      await flush();
-      element.appendChild(threadEl);
-      await flush();
-
-      assert.equal(
-          element.querySelectorAll('gr-ranged-comment-hint').length, 1);
-    });
-
-    test('removes long range comment hint when comment is discarded',
-        async () => {
-          const range = {
-            start_line: 1,
-            end_line: 7,
-            start_character: 0,
-            end_character: 0,
-          };
-          const threadEl = document.createElement('div');
-          threadEl.className = 'comment-thread';
-          threadEl.setAttribute('diff-side', 'right');
-          threadEl.setAttribute('line-num', 1);
-          threadEl.setAttribute('range', JSON.stringify(range));
-          threadEl.setAttribute('slot', 'right-1');
-          const content = [{
-            a: [],
-            b: [],
-          }, {
-            ab: Array(8).fill('text'),
-          }];
-          setupSampleDiff({content});
-          element.appendChild(threadEl);
-          await flush();
-
-          threadEl.remove();
-          await flush();
-
-          assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
-        });
-
-    suite('change in preferences', () => {
-      setup(() => {
-        element.diff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          diff_header: [],
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          content: [{skip: 66}],
-        };
-        element.renderDiffTableTask.flush();
-      });
-
-      test('change in preferences re-renders diff', () => {
-        sinon.stub(element, '_renderDiffTable');
-        element.prefs = {
-          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
-      });
-
-      test('adding/removing property in preferences re-renders diff', () => {
-        const stub = sinon.stub(element, '_renderDiffTable');
-        const newPrefs1 = {...MINIMAL_PREFS,
-          line_wrapping: true};
-        element.prefs = newPrefs1;
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
-        stub.reset();
-
-        const newPrefs2 = {...newPrefs1};
-        delete newPrefs2.line_wrapping;
-        element.prefs = newPrefs2;
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
-      });
-
-      test('change in preferences does not re-renders diff with ' +
-          'noRenderOnPrefsChange', () => {
-        sinon.stub(element, '_renderDiffTable');
-        element.noRenderOnPrefsChange = true;
-        element.prefs = {
-          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.renderDiffTableTask.flush();
-        assert.isFalse(element._renderDiffTable.called);
-      });
-    });
-  });
-
-  suite('diff header', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.diff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        diff_header: [],
-        intraline_status: 'OK',
-        change_type: 'MODIFIED',
-        content: [{skip: 66}],
-      };
-    });
-
-    test('hidden', () => {
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', '--- a/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', '+++ b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'test');
-      assert.equal(element._diffHeaderItems.length, 1);
-      flush();
-
-      assert.equal(element.$.diffHeader.textContent.trim(), 'test');
-    });
-
-    test('binary files', () => {
-      element.diff.binary = true;
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'test');
-      assert.equal(element._diffHeaderItems.length, 1);
-      element.push('diff.diff_header', 'Binary files differ');
-      assert.equal(element._diffHeaderItems.length, 1);
-    });
-  });
-
-  suite('safety and bypass', () => {
-    let renderStub;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      renderStub = sinon.stub(element.$.diffBuilder, 'render').callsFake(
-          () => {
-            element.$.diffBuilder.dispatchEvent(
-                new CustomEvent('render', {bubbles: true, composed: true}));
-            return Promise.resolve({});
-          });
-      sinon.stub(element, 'getDiffLength').returns(10000);
-      element.diff = createDiff();
-      element.noRenderOnPrefsChange = true;
-    });
-
-    test('large render w/ context = 10', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 10};
-      const promise = mockPromise();
-      function rendered() {
-        assert.isTrue(renderStub.called);
-        assert.isFalse(element._showWarning);
-        promise.resolve();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-      await promise;
-    });
-
-    test('large render w/ whole file and bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-      element._safetyBypass = 10;
-      const promise = mockPromise();
-      function rendered() {
-        assert.isTrue(renderStub.called);
-        assert.isFalse(element._showWarning);
-        promise.resolve();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-      await promise;
-    });
-
-    test('large render w/ whole file and no bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-      const promise = mockPromise();
-      function rendered() {
-        assert.isFalse(renderStub.called);
-        assert.isTrue(element._showWarning);
-        promise.resolve();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-      await promise;
-    });
-
-    test('toggles expand context using bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 3};
-
-      element.toggleAllContext();
-      element._renderDiffTable();
-      await flush();
-
-      assert.equal(element.prefs.context, 3);
-      assert.equal(element._safetyBypass, -1);
-      assert.equal(element.$.diffBuilder.prefs.context, -1);
-    });
-
-    test('toggles collapse context from bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 3};
-      element._safetyBypass = -1;
-
-      element.toggleAllContext();
-      element._renderDiffTable();
-      await flush();
-
-      assert.equal(element.prefs.context, 3);
-      assert.isNull(element._safetyBypass);
-      assert.equal(element.$.diffBuilder.prefs.context, 3);
-    });
-
-    test('toggles collapse context from pref using default', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-
-      element.toggleAllContext();
-      element._renderDiffTable();
-      await flush();
-
-      assert.equal(element.prefs.context, -1);
-      assert.equal(element._safetyBypass, 10);
-      assert.equal(element.$.diffBuilder.prefs.context, 10);
-    });
-  });
-
-  suite('blame', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-    });
-
-    test('unsetting', () => {
-      element.blame = [];
-      const setBlameSpy = sinon.spy(element.$.diffBuilder, 'setBlame');
-      element.classList.add('showBlame');
-      element.blame = null;
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
-      assert.isFalse(element.classList.contains('showBlame'));
-    });
-
-    test('setting', () => {
-      element.blame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-      assert.isTrue(element.classList.contains('showBlame'));
-    });
-  });
-
-  suite('trailing newline warnings', () => {
-    const NO_NEWLINE_LEFT = 'No newline at end of left file.';
-    const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
-
-    const getWarning = element =>
-      element.shadowRoot.querySelector('.newlineWarning').textContent;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.showNewlineWarningLeft = false;
-      element.showNewlineWarningRight = false;
-    });
-
-    test('shows combined warning if both sides set to warn', () => {
-      element.showNewlineWarningLeft = true;
-      element.showNewlineWarningRight = true;
-      assert.include(
-          getWarning(element),
-          NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT);// \u2014 - '—'
-    });
-
-    suite('showNewlineWarningLeft', () => {
-      test('show warning if true', () => {
-        element.showNewlineWarningLeft = true;
-        assert.include(getWarning(element), NO_NEWLINE_LEFT);
-      });
-
-      test('hide warning if false', () => {
-        element.showNewlineWarningLeft = false;
-        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
-      });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningLeft = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
-      });
-    });
-
-    suite('showNewlineWarningRight', () => {
-      test('show warning if true', () => {
-        element.showNewlineWarningRight = true;
-        assert.include(getWarning(element), NO_NEWLINE_RIGHT);
-      });
-
-      test('hide warning if false', () => {
-        element.showNewlineWarningRight = false;
-        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
-      });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningRight = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
-      });
-    });
-
-    test('_computeNewlineWarningClass', () => {
-      const hidden = 'newlineWarning hidden';
-      const shown = 'newlineWarning';
-      assert.equal(element._computeNewlineWarningClass(null, true), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
-      assert.equal(element._computeNewlineWarningClass(null, false), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', false), shown);
-    });
-
-    test('_prefsEqual', () => {
-      element = basicFixture.instantiate();
-      assert.isTrue(element._prefsEqual(null, null));
-      assert.isTrue(element._prefsEqual({}, {}));
-      assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-      assert.isTrue(
-          element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-      const somePref = {abc: 'def', p: true};
-      assert.isTrue(element._prefsEqual(somePref, somePref));
-
-      assert.isFalse(element._prefsEqual({}, null));
-      assert.isFalse(element._prefsEqual(null, {}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
-    });
-  });
-
-  suite('key locations', () => {
-    let renderStub;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.prefs = {};
-      renderStub = sinon.stub(element.$.diffBuilder, 'render')
-          .returns(new Promise(() => {}));
-    });
-
-    test('lineOfInterest is a key location', () => {
-      element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {789: true},
-        right: {},
-      });
-    });
-
-    test('line comments are key locations', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      flush();
-
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {},
-        right: {3: true},
-      });
-    });
-
-    test('file comments are key locations', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'left');
-      element.appendChild(threadEl);
-      flush();
-
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {FILE: true},
-        right: {},
-      });
-    });
-  });
-  const setupSampleDiff = function(params) {
-    const {ignore_whitespace, content} = params;
-    // binary can't be undefined, use false if not set
-    const binary = params.binary || false;
-    element = basicFixture.instantiate();
-    element.prefs = {
-      ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
-      context: 10,
-      cursor_blink_rate: 0,
-      font_size: 12,
-
-      line_length: 100,
-      line_wrapping: false,
-      show_line_endings: true,
-      show_tabs: true,
-      show_whitespace_errors: true,
-      syntax_highlighting: true,
-      tab_size: 8,
-      theme: 'DEFAULT',
-    };
-    element.diff = {
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/carrot.js b/carrot.js',
-        'index 2adc47d..f9c2f2c 100644',
-        '--- a/carrot.js',
-        '+++ b/carrot.jjs',
-        'file differ',
-      ],
-      content,
-      binary,
-    };
-    element._renderDiffTable();
-    flush();
-  };
-
-  test('clear diff table content as soon as diff changes', () => {
-    const content = [{
-      a: ['all work and no play make andybons a dull boy'],
-    }, {
-      b: [
-        'Non eram nescius, Brute, cum, quae summis ingeniis ',
-      ],
-    }];
-    function assertDiffTableWithContent() {
-      assert.isTrue(element.$.diffTable.innerText.includes(content[0].a));
-    }
-    setupSampleDiff({content});
-    assertDiffTableWithContent();
-    element.diff = {...element.diff};
-    // immediately cleaned up
-    assert.equal(element.$.diffTable.innerHTML, '');
-    element._renderDiffTable();
-    flush();
-    // rendered again
-    assertDiffTableWithContent();
-  });
-
-  suite('selection test', () => {
-    test('user-select set correctly on side-by-side view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      flush();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
-      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      // click to mark it as selected
-      MockInteractions.tap(diffLine);
-      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
-    });
-
-    test('user-select set correctly on unified view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      element.viewMode = 'UNIFIED_DIFF';
-      flush();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
-      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      MockInteractions.tap(diffLine);
-      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
-    });
-  });
-
-  suite('whitespace changes only message', () => {
-    test('show the message if ignore_whitespace is criteria matches', () => {
-      setupSampleDiff({content: [{skip: 100}]});
-      assert.isTrue(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show the message for binary files', () => {
-      setupSampleDiff({content: [{skip: 100}], binary: true});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show the message if still loading', () => {
-      setupSampleDiff({content: [{skip: 100}]});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ true,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show the message if contains valid changes', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      assert.equal(element._diffLength, 3);
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show message if ignore whitespace is disabled', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-  });
-
-  test('getDiffLength', () => {
-    const diff = createDiff();
-    assert.equal(element.getDiffLength(diff), 52);
-  });
-
-  test('_prefsEqual', () => {
-    element = basicFixture.instantiate();
-    assert.isTrue(element._prefsEqual(null, null));
-    assert.isTrue(element._prefsEqual({}, {}));
-    assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-    assert.isTrue(element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-    const somePref = {abc: 'def', p: true};
-    assert.isTrue(element._prefsEqual(somePref, somePref));
-
-    assert.isFalse(element._prefsEqual({}, null));
-    assert.isFalse(element._prefsEqual(null, {}));
-    assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-    assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
-  });
-});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
new file mode 100644
index 0000000..59bfc8d
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -0,0 +1,1217 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {createDiff} from '../../../test/test-data-generators';
+import './gr-diff';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {_setHiddenScroll} from '../../../scripts/hiddenscroll';
+import '@polymer/paper-button/paper-button';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  IgnoreWhitespaceType,
+  Side,
+} from '../../../api/diff';
+import {
+  mockPromise,
+  mouseDown,
+  query,
+  queryAll,
+  queryAndAssert,
+  waitEventLoop,
+  waitQueryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
+import {AbortStop} from '../../../api/core';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiff} from './gr-diff';
+import {ImageInfo} from '../../../types/common';
+import {GrRangedCommentHint} from '../gr-ranged-comment-hint/gr-ranged-comment-hint';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-diff a11y test', () => {
+  test('audit', async () => {
+    assert.isAccessible(await fixture(html`<gr-diff></gr-diff>`));
+  });
+});
+
+suite('gr-diff tests', () => {
+  let element: GrDiff;
+
+  const MINIMAL_PREFS: DiffPreferencesInfo = {
+    tab_size: 2,
+    line_length: 80,
+    font_size: 12,
+    context: 3,
+    ignore_whitespace: 'IGNORE_NONE',
+  };
+
+  setup(async () => {
+    element = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+  });
+
+  suite('selectionchange event handling', () => {
+    let handleSelectionChangeStub: sinon.SinonSpy;
+
+    const emulateSelection = function () {
+      document.dispatchEvent(new CustomEvent('selectionchange'));
+    };
+
+    setup(async () => {
+      handleSelectionChangeStub = sinon.spy(
+        element.highlights,
+        'handleSelectionChange'
+      );
+    });
+
+    test('enabled if logged in', async () => {
+      element.loggedIn = true;
+      await element.updateComplete;
+      emulateSelection();
+      assert.isTrue(handleSelectionChangeStub.called);
+    });
+
+    test('ignored if logged out', async () => {
+      element.loggedIn = false;
+      await element.updateComplete;
+      emulateSelection();
+      assert.isFalse(handleSelectionChangeStub.called);
+    });
+  });
+
+  test('cancel', () => {
+    const cancelStub = sinon.stub(element.diffBuilder, 'cancel');
+    element.cancel();
+    assert.isTrue(cancelStub.calledOnce);
+  });
+
+  test('line limit with line_wrapping', async () => {
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
+    await element.updateComplete;
+    assert.equal(getComputedStyleValue('--line-limit-marker', element), '80ch');
+  });
+
+  test('line limit without line_wrapping', async () => {
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
+    await element.updateComplete;
+    assert.equal(getComputedStyleValue('--line-limit-marker', element), '-1px');
+  });
+
+  suite('FULL_RESPONSIVE mode', () => {
+    setup(async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
+      await element.updateComplete;
+    });
+
+    test('line limit is based on line_length', async () => {
+      element.prefs = {...element.prefs!, line_length: 100};
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyleValue('--line-limit-marker', element),
+        '100ch'
+      );
+    });
+
+    test('content-width should not be defined', () => {
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+  });
+
+  suite('SHRINK_ONLY mode', () => {
+    setup(async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
+      await element.updateComplete;
+    });
+
+    test('content-width should not be defined', () => {
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+
+    test('max-width considers two content columns in side-by-side', async () => {
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('max-width considers one content column in unified', async () => {
+      element.viewMode = DiffViewMode.UNIFIED;
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('max-width considers font-size', async () => {
+      element.prefs = {...element.prefs!, font_size: 13};
+      await element.updateComplete;
+      // Each line number column: 4 * 13 = 52px
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('sign cols are considered if show_sign_col is true', async () => {
+      element.renderPrefs = {...element.renderPrefs, show_sign_col: true};
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)'
+      );
+    });
+  });
+
+  suite('not logged in', () => {
+    setup(async () => {
+      element.loggedIn = false;
+      await element.updateComplete;
+    });
+
+    test('toggleLeftDiff', () => {
+      element.toggleLeftDiff();
+      assert.isTrue(element.classList.contains('no-left'));
+      element.toggleLeftDiff();
+      assert.isFalse(element.classList.contains('no-left'));
+    });
+
+    test('view does not start with displayLine classList', () => {
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.isFalse(container.classList.contains('displayLine'));
+    });
+
+    test('displayLine class added when displayLine is true', async () => {
+      element.displayLine = true;
+      await element.updateComplete;
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.isTrue(container.classList.contains('displayLine'));
+    });
+
+    test('thread groups', () => {
+      const contentEl = document.createElement('div');
+
+      element.path = 'file.txt';
+
+      // No thread groups.
+      assert.equal(contentEl.querySelectorAll('.thread-group').length, 0);
+
+      // A thread group gets created.
+      const threadGroupEl = element.getOrCreateThreadGroup(
+        contentEl,
+        Side.LEFT
+      );
+      assert.isOk(threadGroupEl);
+
+      // The new thread group can be fetched.
+      assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
+    });
+
+    suite('image diffs', () => {
+      let mockFile1: ImageInfo;
+      let mockFile2: ImageInfo;
+      setup(() => {
+        mockFile1 = {
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAAAAAA/w==',
+          type: 'image/bmp',
+        };
+        mockFile2 = {
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAA/////w==',
+          type: 'image/bmp',
+        };
+
+        element.isImageDiff = true;
+        element.prefs = {
+          context: 10,
+          cursor_blink_rate: 0,
+          font_size: 12,
+          ignore_whitespace: 'IGNORE_NONE',
+          line_length: 100,
+          line_wrapping: false,
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          tab_size: 8,
+        };
+      });
+
+      test('renders image diffs with same file name', async () => {
+        element.baseImage = mockFile1;
+        element.revisionImage = mockFile2;
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        await waitForEventOnce(element, 'render');
+
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+
+        // Left image rendered with the parent commit's version of the file.
+        assertIsDefined(element.diffTable);
+        const diffTable = element.diffTable;
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const leftLabel = queryAndAssert(diffTable, 'td.left label');
+        const leftLabelContent = queryAndAssert(leftLabel, '.label');
+        const leftLabelName = query(leftLabel, '.name');
+
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+        const rightLabel = queryAndAssert(diffTable, 'td.right label');
+        const rightLabelContent = queryAndAssert(rightLabel, '.label');
+        const rightLabelName = query(rightLabel, '.name');
+
+        assert.isNotOk(rightLabelName);
+        assert.isNotOk(leftLabelName);
+
+        assert.equal(
+          leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body
+        );
+        assert.isTrue(leftLabelContent.textContent?.includes('image/bmp'));
+
+        assert.equal(
+          rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body
+        );
+        assert.isTrue(rightLabelContent.textContent?.includes('image/bmp'));
+      });
+
+      test('renders image diffs with a different file name', async () => {
+        const mockDiff: DiffInfo = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot2.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot2.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        element.baseImage = mockFile1;
+        element.baseImage._name = mockDiff.meta_a!.name;
+        element.revisionImage = mockFile2;
+        element.revisionImage._name = mockDiff.meta_b!.name;
+        element.diff = mockDiff;
+        await waitForEventOnce(element, 'render');
+
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+
+        // Left image rendered with the parent commit's version of the file.
+        assertIsDefined(element.diffTable);
+        const diffTable = element.diffTable;
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const leftLabel = queryAndAssert(diffTable, 'td.left label');
+        const leftLabelContent = queryAndAssert(leftLabel, '.label');
+        const leftLabelName = queryAndAssert(leftLabel, '.name');
+
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+        const rightLabel = queryAndAssert(diffTable, 'td.right label');
+        const rightLabelContent = queryAndAssert(rightLabel, '.label');
+        const rightLabelName = queryAndAssert(rightLabel, '.name');
+
+        assert.isOk(rightLabelName);
+        assert.isOk(leftLabelName);
+        assert.equal(leftLabelName.textContent, mockDiff.meta_a?.name);
+        assert.equal(rightLabelName.textContent, mockDiff.meta_b?.name);
+
+        assert.isOk(leftImage);
+        assert.equal(
+          leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body
+        );
+        assert.isTrue(leftLabelContent.textContent?.includes('image/bmp'));
+
+        assert.isOk(rightImage);
+        assert.equal(
+          rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body
+        );
+        assert.isTrue(rightLabelContent.textContent?.includes('image/bmp'));
+      });
+
+      test('renders added image', async () => {
+        const mockDiff: DiffInfo = {
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'ADDED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 0000000..f9c2f2c 100644',
+            '--- /dev/null',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        const promise = mockPromise();
+        function rendered() {
+          promise.resolve();
+        }
+        element.addEventListener('render', rendered);
+
+        element.revisionImage = mockFile2;
+        element.diff = mockDiff;
+        await promise;
+        element.removeEventListener('render', rendered);
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+
+        assertIsDefined(element.diffTable);
+        const diffTable = element.diffTable;
+        const leftImage = query(diffTable, 'td.left img');
+        assert.isNotOk(leftImage);
+        queryAndAssert(diffTable, 'td.right img');
+      });
+
+      test('renders removed image', async () => {
+        const mockDiff: DiffInfo = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        const promise = mockPromise();
+        function rendered() {
+          promise.resolve();
+        }
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+        await promise;
+        element.removeEventListener('render', rendered);
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+
+        assertIsDefined(element.diffTable);
+        const diffTable = element.diffTable;
+        queryAndAssert(diffTable, 'td.left img');
+        const rightImage = query(diffTable, 'td.right img');
+        assert.isNotOk(rightImage);
+      });
+
+      test('does not render disallowed image type', async () => {
+        const mockDiff: DiffInfo = {
+          meta_a: {
+            name: 'carrot.jpg',
+            content_type: 'image/jpeg-evil',
+            lines: 560,
+          },
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        mockFile1.type = 'image/jpeg-evil';
+
+        const promise = mockPromise();
+        function rendered() {
+          promise.resolve();
+        }
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+        await promise;
+        element.removeEventListener('render', rendered);
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+        assertIsDefined(element.diffTable);
+        const diffTable = element.diffTable;
+        const leftImage = query(diffTable, 'td.left img');
+        assert.isNotOk(leftImage);
+      });
+    });
+
+    test('handleTap lineNum', async () => {
+      const addDraftStub = sinon.stub(element, 'addDraftAtLine');
+      const el = document.createElement('div');
+      el.className = 'lineNum';
+      const promise = mockPromise();
+      el.addEventListener('click', e => {
+        element.handleTap(e);
+        assert.isTrue(addDraftStub.called);
+        assert.equal(addDraftStub.lastCall.args[0], el);
+        promise.resolve();
+      });
+      el.click();
+      await promise;
+    });
+
+    test('handleTap content', async () => {
+      const content = document.createElement('div');
+      const lineEl = document.createElement('div');
+      lineEl.className = 'lineNum';
+      const row = document.createElement('div');
+      row.appendChild(lineEl);
+      row.appendChild(content);
+
+      const selectStub = sinon.stub(element, 'selectLine');
+
+      content.className = 'content';
+      const promise = mockPromise();
+      content.addEventListener('click', e => {
+        element.handleTap(e);
+        assert.isTrue(selectStub.called);
+        assert.equal(selectStub.lastCall.args[0], lineEl);
+        promise.resolve();
+      });
+      content.click();
+      await promise;
+    });
+
+    suite('getCursorStops', () => {
+      async function setupDiff() {
+        element.diff = createDiff();
+        element.prefs = {
+          context: 10,
+          tab_size: 8,
+          font_size: 12,
+          line_length: 100,
+          cursor_blink_rate: 0,
+          line_wrapping: false,
+
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          ignore_whitespace: 'IGNORE_NONE',
+        };
+        await element.updateComplete;
+        element.renderDiffTable();
+      }
+
+      test('returns [] when hidden and noAutoRender', async () => {
+        element.noAutoRender = true;
+        await setupDiff();
+        element.loading = false;
+        await element.updateComplete;
+        element.hidden = true;
+        await element.updateComplete;
+        assert.equal(element.getCursorStops().length, 0);
+      });
+
+      test('returns one stop per line and one for the file row', async () => {
+        await setupDiff();
+        element.loading = false;
+        await element.updateComplete;
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
+      });
+
+      test('returns an additional AbortStop when still loading', async () => {
+        await setupDiff();
+        element.loading = true;
+        await element.updateComplete;
+        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', async () => {
+      _setHiddenScroll(true);
+      element.displayLine = true;
+      await element.updateComplete;
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.include(container.className, 'hiddenscroll');
+    });
+  });
+
+  suite('logged in', async () => {
+    let fakeLineEl: HTMLElement;
+    setup(async () => {
+      element.loggedIn = true;
+
+      fakeLineEl = {
+        getAttribute: sinon.stub().returns(42),
+        classList: {
+          contains: sinon.stub().returns(true),
+        },
+      } as unknown as HTMLElement;
+      await element.updateComplete;
+    });
+
+    test('addDraftAtLine', () => {
+      sinon.stub(element, 'selectLine');
+      const createCommentStub = sinon.stub(element, 'createComment');
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(createCommentStub.calledWithExactly(fakeLineEl, 42));
+    });
+
+    test('adds long range comment hint', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 12,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          a: ['asdf'],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
+      await setupSampleDiff({content});
+
+      element.appendChild(threadEl);
+
+      const hint = await waitQueryAndAssert<GrRangedCommentHint>(
+        element,
+        'gr-ranged-comment-hint'
+      );
+      assert.deepEqual(hint.range, range);
+    });
+
+    test('no duplicate range hint for same thread', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 12,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const firstHint = document.createElement('gr-ranged-comment-hint');
+      firstHint.range = range;
+      firstHint.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          a: ['asdf'],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
+      await setupSampleDiff({content});
+
+      element.appendChild(firstHint);
+      element.appendChild(threadEl);
+
+      assert.equal(
+        element.querySelectorAll('gr-ranged-comment-hint').length,
+        1
+      );
+    });
+
+    test('removes long range comment hint when comment is discarded', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 7,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          ab: Array(8).fill('text'),
+        },
+      ];
+      await setupSampleDiff({content});
+
+      element.appendChild(threadEl);
+      await waitUntil(() => element.commentRanges.length === 1);
+
+      threadEl.remove();
+      await waitUntil(() => element.commentRanges.length === 0);
+
+      assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
+    });
+
+    suite('change in preferences', () => {
+      setup(async () => {
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          diff_header: [],
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          content: [{skip: 66}],
+        };
+        await element.updateComplete;
+        await element.renderDiffTableTask?.flush();
+      });
+
+      test('change in preferences re-renders diff', async () => {
+        const stub = sinon.stub(element, 'renderDiffTable');
+        element.prefs = {
+          ...MINIMAL_PREFS,
+        };
+        await element.updateComplete;
+        await element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+      });
+
+      test('adding/removing property in preferences re-renders diff', async () => {
+        const stub = sinon.stub(element, 'renderDiffTable');
+        const newPrefs1: DiffPreferencesInfo = {
+          ...MINIMAL_PREFS,
+          line_wrapping: true,
+        };
+        element.prefs = newPrefs1;
+        await element.updateComplete;
+        await element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+        stub.reset();
+
+        const newPrefs2 = {...newPrefs1};
+        delete newPrefs2.line_wrapping;
+        element.prefs = newPrefs2;
+        await element.updateComplete;
+        await element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+      });
+
+      test(
+        'change in preferences does not re-renders diff with ' +
+          'noRenderOnPrefsChange',
+        async () => {
+          const stub = sinon.stub(element, 'renderDiffTable');
+          element.noRenderOnPrefsChange = true;
+          element.prefs = {
+            ...MINIMAL_PREFS,
+            context: 12,
+          };
+          await element.updateComplete;
+          await element.renderDiffTableTask?.flush();
+          assert.isFalse(stub.called);
+        }
+      );
+    });
+  });
+
+  suite('diff header', () => {
+    setup(async () => {
+      element.diff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+        diff_header: [],
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        content: [{skip: 66}],
+      };
+      await element.updateComplete;
+    });
+
+    test('hidden', async () => {
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('diff --git a/test.jpg b/test.jpg');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('index 2adc47d..f9c2f2c 100644');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('--- a/test.jpg');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('+++ b/test.jpg');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('test');
+      assert.equal(element.computeDiffHeaderItems().length, 1);
+      element.requestUpdate('diff');
+      await element.updateComplete;
+
+      const header = queryAndAssert(element, '#diffHeader');
+      assert.equal(header.textContent?.trim(), 'test');
+    });
+
+    test('binary files', () => {
+      element.diff!.binary = true;
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('diff --git a/test.jpg b/test.jpg');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('test');
+      assert.equal(element.computeDiffHeaderItems().length, 1);
+      element.diff?.diff_header?.push('Binary files differ');
+      assert.equal(element.computeDiffHeaderItems().length, 1);
+    });
+  });
+
+  suite('safety and bypass', () => {
+    let renderStub: sinon.SinonStub;
+
+    setup(async () => {
+      renderStub = sinon.stub(element.diffBuilder, 'render').callsFake(() => {
+        assertIsDefined(element.diffTable);
+        const diffTable = element.diffTable;
+        diffTable.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true})
+        );
+        return Promise.resolve();
+      });
+      sinon.stub(element, 'getDiffLength').returns(10000);
+      element.diff = createDiff();
+      element.noRenderOnPrefsChange = true;
+      await element.updateComplete;
+    });
+
+    test('large render w/ context = 10', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 10};
+      const promise = mockPromise();
+      function rendered() {
+        assert.isTrue(renderStub.called);
+        assert.isFalse(element.showWarning);
+        promise.resolve();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element.renderDiffTable();
+      await promise;
+    });
+
+    test('large render w/ whole file and bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+      element.safetyBypass = 10;
+      const promise = mockPromise();
+      function rendered() {
+        assert.isTrue(renderStub.called);
+        assert.isFalse(element.showWarning);
+        promise.resolve();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element.renderDiffTable();
+      await promise;
+    });
+
+    test('large render w/ whole file and no bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+      const promise = mockPromise();
+      function rendered() {
+        assert.isFalse(renderStub.called);
+        assert.isTrue(element.showWarning);
+        promise.resolve();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element.renderDiffTable();
+      await promise;
+    });
+
+    test('toggles expand context using bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+
+      element.toggleAllContext();
+      element.renderDiffTable();
+      await element.updateComplete;
+
+      assert.equal(element.prefs.context, 3);
+      assert.equal(element.safetyBypass, -1);
+      assert.equal(element.diffBuilder.prefs.context, -1);
+    });
+
+    test('toggles collapse context from bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+      element.safetyBypass = -1;
+
+      element.toggleAllContext();
+      element.renderDiffTable();
+      await element.updateComplete;
+
+      assert.equal(element.prefs.context, 3);
+      assert.isNull(element.safetyBypass);
+      assert.equal(element.diffBuilder.prefs.context, 3);
+    });
+
+    test('toggles collapse context from pref using default', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+
+      element.toggleAllContext();
+      element.renderDiffTable();
+      await element.updateComplete;
+
+      assert.equal(element.prefs.context, -1);
+      assert.equal(element.safetyBypass, 10);
+      assert.equal(element.diffBuilder.prefs.context, 10);
+    });
+  });
+
+  suite('blame', () => {
+    test('unsetting', async () => {
+      element.blame = [];
+      const setBlameSpy = sinon.spy(element.diffBuilder, 'setBlame');
+      element.classList.add('showBlame');
+      element.blame = null;
+      await element.updateComplete;
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isFalse(element.classList.contains('showBlame'));
+    });
+
+    test('setting', async () => {
+      element.blame = [
+        {
+          author: 'test-author',
+          time: 12345,
+          commit_msg: '',
+          id: 'commit id',
+          ranges: [{start: 1, end: 2}],
+        },
+      ];
+      await element.updateComplete;
+      assert.isTrue(element.classList.contains('showBlame'));
+    });
+  });
+
+  suite('trailing newline warnings', () => {
+    const NO_NEWLINE_LEFT = 'No newline at end of left file.';
+    const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
+
+    const getWarning = (element: GrDiff) => {
+      const warningElement = queryAndAssert(element, '.newlineWarning');
+      return warningElement.textContent;
+    };
+
+    setup(async () => {
+      element.showNewlineWarningLeft = false;
+      element.showNewlineWarningRight = false;
+      await element.updateComplete;
+    });
+
+    test('shows combined warning if both sides set to warn', async () => {
+      element.showNewlineWarningLeft = true;
+      element.showNewlineWarningRight = true;
+      await element.updateComplete;
+      assert.include(
+        getWarning(element),
+        NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT
+      ); // \u2014 - '—'
+    });
+
+    suite('showNewlineWarningLeft', () => {
+      test('show warning if true', async () => {
+        element.showNewlineWarningLeft = true;
+        await element.updateComplete;
+        assert.include(getWarning(element), NO_NEWLINE_LEFT);
+      });
+
+      test('hide warning if false', async () => {
+        element.showNewlineWarningLeft = false;
+        await element.updateComplete;
+        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
+      });
+    });
+
+    suite('showNewlineWarningRight', () => {
+      test('show warning if true', async () => {
+        element.showNewlineWarningRight = true;
+        await element.updateComplete;
+        assert.include(getWarning(element), NO_NEWLINE_RIGHT);
+      });
+
+      test('hide warning if false', async () => {
+        element.showNewlineWarningRight = false;
+        await element.updateComplete;
+        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
+      });
+    });
+
+    test('computeNewlineWarningClass', () => {
+      const hidden = 'newlineWarning hidden';
+      const shown = 'newlineWarning';
+      element.loading = true;
+      assert.equal(element.computeNewlineWarningClass(false), hidden);
+      assert.equal(element.computeNewlineWarningClass(true), hidden);
+      element.loading = false;
+      assert.equal(element.computeNewlineWarningClass(false), hidden);
+      assert.equal(element.computeNewlineWarningClass(true), shown);
+    });
+  });
+
+  suite('key locations', () => {
+    let renderStub: sinon.SinonStub;
+
+    setup(async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      renderStub = sinon.stub(element.diffBuilder, 'render');
+      await element.updateComplete;
+    });
+
+    test('lineOfInterest is a key location', () => {
+      element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
+      element.renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {789: true},
+        right: {},
+      });
+    });
+
+    test('line comments are key locations', async () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '3');
+      element.appendChild(threadEl);
+      await element.updateComplete;
+
+      element.renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {},
+        right: {3: true},
+      });
+    });
+
+    test('file comments are key locations', async () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'left');
+      element.appendChild(threadEl);
+      await element.updateComplete;
+
+      element.renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {FILE: true},
+        right: {},
+      });
+    });
+  });
+  const setupSampleDiff = async function (params: {
+    content: DiffContent[];
+    ignore_whitespace?: IgnoreWhitespaceType;
+    binary?: boolean;
+  }) {
+    const {ignore_whitespace, content} = params;
+    // binary can't be undefined, use false if not set
+    const binary = params.binary || false;
+    element.prefs = {
+      ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
+      context: 10,
+      cursor_blink_rate: 0,
+      font_size: 12,
+
+      line_length: 100,
+      line_wrapping: false,
+      show_line_endings: true,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      tab_size: 8,
+    };
+    element.diff = {
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/carrot.js b/carrot.js',
+        'index 2adc47d..f9c2f2c 100644',
+        '--- a/carrot.js',
+        '+++ b/carrot.jjs',
+        'file differ',
+      ],
+      content,
+      binary,
+    };
+    await element.updateComplete;
+    await element.renderDiffTableTask;
+  };
+
+  test('clear diff table content as soon as diff changes', async () => {
+    const content = [
+      {
+        a: ['all work and no play make andybons a dull boy'],
+      },
+      {
+        b: ['Non eram nescius, Brute, cum, quae summis ingeniis '],
+      },
+    ];
+    function assertDiffTableWithContent() {
+      assertIsDefined(element.diffTable);
+      const diffTable = element.diffTable;
+      assert.isTrue(diffTable.innerText.includes(content[0].a?.[0] ?? ''));
+    }
+    await setupSampleDiff({content});
+    assertDiffTableWithContent();
+    element.diff = {...element.diff!};
+    await element.updateComplete;
+    // immediately cleaned up
+    assertIsDefined(element.diffTable);
+    const diffTable = element.diffTable;
+    assert.equal(diffTable.innerHTML, '');
+    element.renderDiffTable();
+    await element.updateComplete;
+    // rendered again
+    assertDiffTableWithContent();
+  });
+
+  suite('selection test', () => {
+    test('user-select set correctly on side-by-side view', async () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      await setupSampleDiff({content});
+      await waitEventLoop();
+
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      mouseDown(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+
+    test('user-select set correctly on unified view', async () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      await setupSampleDiff({content});
+      element.viewMode = DiffViewMode.UNIFIED;
+      await element.updateComplete;
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      mouseDown(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+  });
+
+  suite('whitespace changes only message', () => {
+    test('show the message if ignore_whitespace is criteria matches', async () => {
+      await setupSampleDiff({content: [{skip: 100}]});
+      element.loading = false;
+      assert.isTrue(element.showNoChangeMessage());
+    });
+
+    test('do not show the message for binary files', async () => {
+      await setupSampleDiff({content: [{skip: 100}], binary: true});
+      element.loading = false;
+      assert.isFalse(element.showNoChangeMessage());
+    });
+
+    test('do not show the message if still loading', async () => {
+      await setupSampleDiff({content: [{skip: 100}]});
+      element.loading = true;
+      assert.isFalse(element.showNoChangeMessage());
+    });
+
+    test('do not show the message if contains valid changes', async () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      await setupSampleDiff({content});
+      element.loading = false;
+      assert.equal(element.diffLength, 3);
+      assert.isFalse(element.showNoChangeMessage());
+    });
+
+    test('do not show message if ignore whitespace is disabled', async () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      await setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
+      element.loading = false;
+      assert.isFalse(element.showNoChangeMessage());
+    });
+  });
+
+  test('getDiffLength', () => {
+    const diff = createDiff();
+    assert.equal(element.getDiffLength(diff), 52);
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
index 8ce8ce2..5d1eaa6 100644
--- a/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
+++ b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import '../../../elements/shared/gr-icon/gr-icon';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import '@polymer/iron-icon/iron-icon';
+import {customElement, property} from 'lit/decorators.js';
 
 /**
  * Represents a header (label) for a code chunk whenever showing
@@ -29,6 +18,9 @@
   @property({type: String})
   icon?: string;
 
+  @property({type: Boolean})
+  filled?: boolean;
+
   static override get styles() {
     return [
       css`
@@ -44,8 +36,7 @@
         }
         .icon {
           color: var(--gr-range-header-color);
-          height: var(--line-height-small, 16px);
-          width: var(--line-height-small, 16px);
+          font-size: var(--line-height-small, 16px);
           margin-right: var(--spacing-s);
         }
       `,
@@ -55,7 +46,12 @@
   override render() {
     const icon = this.icon ?? '';
     return html` <div class="row">
-      <iron-icon class="icon" .icon=${icon} aria-hidden="true"></iron-icon>
+      <gr-icon
+        class="icon"
+        icon=${icon}
+        ?filled=${this.filled}
+        aria-hidden="true"
+      ></gr-icon>
       <slot></slot>
     </div>`;
   }
diff --git a/polygerrit-ui/app/embed/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
index 3f2258d..d7883a0 100644
--- a/polygerrit-ui/app/embed/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
@@ -1,24 +1,12 @@
 /**
  * @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 '../gr-range-header/gr-range-header';
 import {CommentRange} from '../../../types/common';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
 
@@ -34,6 +22,7 @@
       css`
         .row {
           display: flex;
+          --gr-range-header-color: var(--ranged-comment-hint-text-color);
         }
         gr-range-header {
           flex-grow: 1;
@@ -43,22 +32,11 @@
   }
 
   override render() {
-    // To pass CSS mixins for @apply to Polymer components, they need to appear
-    // in <style> inside the template.
-    /* eslint-disable lit/prefer-static-styles */
-    const customStyle = html`
-      <style>
-        .row {
-          --gr-range-header-color: var(--ranged-comment-hint-text-color);
-        }
-      </style>
-    `;
-    return html`${customStyle}
-      <div class="rangeHighlight row">
-        <gr-range-header icon="gr-icons:comment"
-          >${this._computeRangeLabel(this.range)}</gr-range-header
-        >
-      </div>`;
+    return html`<div class="rangeHighlight row">
+      <gr-range-header icon="mode_comment" filled
+        >${this._computeRangeLabel(this.range)}</gr-range-header
+      >
+    </div>`;
   }
 
   _computeRangeLabel(range?: CommentRange): string {
diff --git a/polygerrit-ui/app/embed/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
index 5782e4a..87ef3f8 100644
--- a/polygerrit-ui/app/embed/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
@@ -1,35 +1,24 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-ranged-comment-hint';
 import {CommentRange} from '../../../types/common';
 import {GrRangedCommentHint} from './gr-ranged-comment-hint';
-import {queryAndAssert} from '../../../test/test-utils';
+import {queryAndAssert, waitEventLoop} from '../../../test/test-utils';
 import {GrRangeHeader} from '../gr-range-header/gr-range-header';
-
-const basicFixture = fixtureFromElement('gr-ranged-comment-hint');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-ranged-comment-hint tests', () => {
   let element: GrRangedCommentHint;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(
+      html`<gr-ranged-comment-hint></gr-ranged-comment-hint>`
+    );
+    await waitEventLoop();
   });
 
   test('shows line range', async () => {
@@ -39,7 +28,7 @@
       end_line: 5,
       end_character: 3,
     } as CommentRange;
-    await flush();
+    await waitEventLoop();
     const textDiv = queryAndAssert<GrRangeHeader>(element, 'gr-range-header');
     assert.equal(textDiv?.innerText.trim(), 'Long comment range 2 - 5');
   });
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 70cec64..58f3f75 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -55,10 +55,14 @@
   [side in Side]: LinesMap;
 };
 
-const RANGE_BASE_ONLY = 'style-scope gr-diff range';
-const RANGE_HIGHLIGHT = 'style-scope gr-diff range rangeHighlight';
+const RANGE_BASE_ONLY = 'gr-diff range';
+const RANGE_HIGHLIGHT = 'gr-diff range rangeHighlight';
 // Note that there is also `rangeHoverHighlight` being set by GrDiffHighlight.
 
+/**
+ * This layer does not have a `reset` or `cleanup` method, so don't re-use it
+ * for rendering another diff. You should create a new layer then.
+ */
 export class GrRangedCommentLayer implements DiffLayer {
   private knownRanges: CommentRangeLayer[] = [];
 
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index 15d14e3..33515b25 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -3,7 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import '../gr-diff/gr-diff-line';
 import './gr-ranged-comment-layer';
 import {
@@ -14,6 +14,7 @@
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {Side} from '../../../api/diff';
 import {SinonStub} from 'sinon';
+import {assert} from '@open-wc/testing';
 
 const rangeA: CommentRangeLayer = {
   side: Side.LEFT,
@@ -149,7 +150,7 @@
       assert.equal(lastCall.args[2], expectedLength);
       assert.equal(
         lastCall.args[3],
-        'style-scope gr-diff range rangeHighlight generated_a'
+        'gr-diff range rangeHighlight generated_a'
       );
     });
 
@@ -169,7 +170,7 @@
       assert.equal(lastCall.args[2], expectedLength);
       assert.equal(
         lastCall.args[3],
-        'style-scope gr-diff range rangeHighlight generated_a'
+        'gr-diff range rangeHighlight generated_a'
       );
     });
 
@@ -200,7 +201,7 @@
       assert.equal(lastCall.args[2], expectedLength);
       assert.equal(
         lastCall.args[3],
-        'style-scope gr-diff range rangeHighlight generated_b'
+        'gr-diff range rangeHighlight generated_b'
       );
     });
 
@@ -214,7 +215,7 @@
       assert.isTrue(annotateElementStub.called);
       assert.equal(
         annotateElementStub.lastCall.args[3],
-        'style-scope gr-diff range generated_right-60-1-71-1'
+        'gr-diff range generated_right-60-1-71-1'
       );
     });
 
diff --git a/polygerrit-ui/app/embed/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
index 6db361d..38a9533 100644
--- a/polygerrit-ui/app/embed/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
@@ -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 {css} from 'lit';
 
 const $_documentContainer = document.createElement('template');
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
index bb6d1e9..cb08e55 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -7,7 +7,7 @@
 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 {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 
 declare global {
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
index a92c967..67836a4 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
@@ -3,11 +3,11 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-selection-action-box', () => {
   let container: HTMLDivElement;
@@ -31,9 +31,16 @@
   });
 
   test('renders', () => {
-    expect(element).shadowDom.to.equal(/* HTML */ `
-      <gr-tooltip invisible id="tooltip" text="Press c to comment"></gr-tooltip>
-    `);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-tooltip
+          invisible
+          id="tooltip"
+          text="Press c to comment"
+        ></gr-tooltip>
+      `
+    );
   });
 
   test('ignores regular keys', () => {
@@ -103,9 +110,12 @@
     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>
-      `);
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-tooltip id="tooltip" text="Press c to comment"></gr-tooltip>
+        `
+      );
     });
 
     test('placeAbove for Element argument', async () => {
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index 9938d34..a9f88bd 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -10,7 +10,7 @@
 import {Side} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
 import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
-import {CancelablePromise, util} from '../../../scripts/util';
+import {CancelablePromise, makeCancelable} from '../../../scripts/util';
 
 const LANGUAGE_MAP = new Map<string, string>([
   ['application/dart', 'dart'],
@@ -275,7 +275,8 @@
       this.notify();
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
     } catch (err: any) {
-      if (!err.isCanceled) this.reportingService.error(err as Error);
+      if (!err.isCanceled)
+        this.reportingService.error('Diff Syntax Layer', err as Error);
       // One source of "error" can promise cancelation.
       this.leftRanges = [];
       this.rightRanges = [];
@@ -287,7 +288,7 @@
     code?: string
   ): CancelablePromise<SyntaxLayerLine[]> {
     const hlPromise = this.highlightService.highlight(language, code);
-    return util.makeCancelable(hlPromise);
+    return makeCancelable(hlPromise);
   }
 
   notify() {
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
index ee82d5d..5c9a6cc 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
@@ -3,8 +3,9 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {assert} from '@open-wc/testing';
 import {DiffInfo, GrDiffLineType, Side} from '../../../api/diff';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {mockPromise, stubHighlightService} from '../../../test/test-utils';
 import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts b/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
index 363de13..042215f 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {css} from 'lit';
 
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 46bdec8..f865d6d 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -1,20 +1,8 @@
 /**
  * @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 {create, Registry, Finalizable} from '../services/registry';
 import {AppContext} from '../services/app-context';
 import {AuthService} from '../services/gr-auth/gr-auth';
@@ -84,6 +72,9 @@
     userModel: (_ctx: Partial<AppContext>) => {
       throw new Error('userModel is not implemented');
     },
+    accountsModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('accountsModel is not implemented');
+    },
     routerModel: (_ctx: Partial<AppContext>) => {
       throw new Error('routerModel is not implemented');
     },
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
deleted file mode 100644
index bb46484..0000000
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import {createDiffAppContext} from './gr-diff-app-context-init.js';
-
-suite('gr diff app context initializer tests', () => {
-  test('all services initialized and are singletons', () => {
-    const appContext = createDiffAppContext();
-    Object.keys(appContext).forEach(serviceName => {
-      const service = appContext[serviceName];
-      assert.isNotNull(service);
-      const service2 = appContext[serviceName];
-      assert.strictEqual(service, service2);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts
new file mode 100644
index 0000000..baa0a34
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts
@@ -0,0 +1,23 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {AppContext} from '../services/app-context';
+import '../test/common-test-setup';
+import {createDiffAppContext} from './gr-diff-app-context-init';
+
+suite('gr diff app context initializer tests', () => {
+  test('all services initialized and are singletons', () => {
+    const appContext: AppContext = createDiffAppContext();
+    for (const serviceName of Object.keys(appContext) as Array<
+      keyof AppContext
+    >) {
+      const service = appContext[serviceName];
+      assert.isNotNull(service);
+      const service2 = appContext[serviceName];
+      assert.strictEqual(service, service2);
+    }
+  });
+});
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 1b32c55..977f8a9 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 // TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.ts b/polygerrit-ui/app/gr-diff/gr-diff-root.ts
deleted file mode 100644
index 7111d80..0000000
--- a/polygerrit-ui/app/gr-diff/gr-diff-root.ts
+++ /dev/null
@@ -1,18 +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 '../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 473a3ca..2e0e315 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -1,24 +1,13 @@
 /**
  * @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 {getRootElement} from '../../scripts/rootElement';
 import {Constructor} from '../../utils/common-util';
 import {LitElement, PropertyValues} from 'lit';
-import {property, query} from 'lit/decorators';
-import {ShowAlertEventDetail} from '../../types/events';
+import {property, query} from 'lit/decorators.js';
+import {EventType, ShowAlertEventDetail} from '../../types/events';
 import {debounce, DelayedTask} from '../../utils/async-util';
 import {hovercardStyles} from '../../styles/gr-hovercard-styles';
 import {sharedStyles} from '../../styles/shared-styles';
@@ -35,6 +24,11 @@
   getFocusableElements,
   getFocusableElementsReverse,
 } from '../../utils/focusable';
+import {getAppContext} from '../../services/app-context';
+import {
+  ReportingService,
+  Timer,
+} from '../../services/gr-reporting/gr-reporting';
 
 interface ReloadEventDetail {
   clearPatchset?: boolean;
@@ -147,6 +141,10 @@
 
     openedByKeyboard = false;
 
+    reporting: ReportingService = getAppContext().reportingService;
+
+    reportingTimer?: Timer;
+
     private targetCleanups: Array<() => void> = [];
 
     /** Called in disconnectedCallback. */
@@ -188,7 +186,7 @@
             this.pressTab(e);
           },
           {
-            doNotPrevent: true,
+            preventDefault: false,
           }
         )
       );
@@ -200,7 +198,7 @@
             this.pressShiftTab(e);
           },
           {
-            doNotPrevent: true,
+            preventDefault: false,
           }
         )
       );
@@ -315,7 +313,7 @@
     dispatchEventThroughTarget(eventName: string): void;
 
     dispatchEventThroughTarget(
-      eventName: 'show-alert',
+      eventName: EventType.SHOW_ALERT,
       detail: ShowAlertEventDetail
     ): void;
 
@@ -426,6 +424,10 @@
         this.container.removeChild(this);
       }
       document.removeEventListener('click', this.documentClickListener);
+      this.reportingTimer?.end({
+        targetId: this._target?.id,
+        tagName: this.tagName,
+      });
     };
 
     /**
@@ -520,6 +522,7 @@
         this.focus();
       }
       document.addEventListener('click', this.documentClickListener);
+      this.reportingTimer = this.reporting.getTimer('Show Hovercard');
     };
 
     updatePosition() {
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 88e57a0..8d32c5b9 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
@@ -1,26 +1,20 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../test/common-test-setup-karma.js';
-import {HovercardMixin} from './hovercard-mixin.js';
-import {html, LitElement} from 'lit';
-import {customElement} from 'lit/decorators';
-import {MockPromise, mockPromise, pressKey} from '../../test/test-utils.js';
-import {findActiveElement, Key} from '../../utils/dom-util.js';
+import '../../test/common-test-setup';
+import {HovercardMixin} from './hovercard-mixin';
+import {LitElement} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import {
+  MockPromise,
+  mockPromise,
+  pressKey,
+  waitEventLoop,
+} from '../../test/test-utils';
+import {findActiveElement, Key} from '../../utils/dom-util';
+import {fixture, html, assert} from '@open-wc/testing';
 
 const base = HovercardMixin(LitElement);
 
@@ -45,22 +39,22 @@
   }
 }
 
-const basicFixture = fixtureFromElement('hovercard-mixin-test');
-
 suite('gr-hovercard tests', () => {
   let element: HovercardMixinTest;
 
   let button: HTMLElement;
   let testPromise: MockPromise<void>;
 
-  setup(() => {
+  setup(async () => {
     testPromise = mockPromise();
     button = document.createElement('button');
     button.innerHTML = 'Hello';
     button.setAttribute('id', 'foo');
     document.body.appendChild(button);
 
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<hovercard-mixin-test></hovercard-mixin-test>`
+    );
   });
 
   teardown(() => {
@@ -80,7 +74,7 @@
     assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
 
     const parentRect = document.documentElement.getBoundingClientRect();
-    const targetRect = element!._target!.getBoundingClientRect();
+    const targetRect = element._target!.getBoundingClientRect();
     const thisRect = element.getBoundingClientRect();
 
     const targetLeft = targetRect.left - parentRect.left;
@@ -143,9 +137,9 @@
     button!.dispatchEvent(new CustomEvent('mousemove'));
 
     await enterPromise;
-    await flush();
+    await waitEventLoop();
     assert.isTrue(element.isScheduledToShow);
-    element!.showTask!.flush();
+    element.showTask!.flush();
     assert.isTrue(element._isShowing);
     assert.isFalse(element.isScheduledToShow);
 
@@ -154,7 +148,7 @@
     await leavePromise;
     assert.isTrue(element.isScheduledToHide);
     assert.isTrue(element._isShowing);
-    element!.hideTask!.flush();
+    element.hideTask!.flush();
     assert.isFalse(element.isScheduledToShow);
     assert.isFalse(element._isShowing);
   });
@@ -171,7 +165,7 @@
     button!.dispatchEvent(new CustomEvent('mousemove'));
 
     await enterPromise;
-    await flush();
+    await waitEventLoop();
     assert.isTrue(element.isScheduledToShow);
     button!.click();
 
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 b41b42b..30adedf 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
@@ -1,20 +1,8 @@
 /**
  * @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 {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
 import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
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 c1aa7ef..3625228 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
@@ -1,20 +1,8 @@
 /**
  * @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 {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
 import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
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
deleted file mode 100644
index 8e27c74..0000000
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ /dev/null
@@ -1,146 +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 {property} from '@polymer/decorators';
-import {PolymerElement} from '@polymer/polymer';
-import {check, Constructor} from '../../utils/common-util';
-import {getAppContext} from '../../services/app-context';
-import {
-  Shortcut,
-  ShortcutSection,
-  SPECIAL_SHORTCUT,
-} from '../../services/shortcuts/shortcuts-config';
-import {
-  SectionView,
-  ShortcutListener,
-} from '../../services/shortcuts/shortcuts-service';
-
-export {
-  Shortcut,
-  ShortcutSection,
-  SPECIAL_SHORTCUT,
-  ShortcutListener,
-  SectionView,
-};
-
-export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T
-) => {
-  /**
-   * @polymer
-   * @mixinClass
-   */
-  class Mixin extends superClass {
-    // This enables `Shortcut` to be used in the html template.
-    Shortcut = Shortcut;
-
-    // This enables `ShortcutSection` to be used in the html template.
-    ShortcutSection = ShortcutSection;
-
-    private readonly shortcuts = getAppContext().shortcutsService;
-
-    /** Used to disable shortcuts when the element is not visible. */
-    private observer?: IntersectionObserver;
-
-    /**
-     * Enabling shortcuts only when the element is visible (see `observer`
-     * above) is a great feature, but often what you want is for the *page* to
-     * be visible, not the specific child element that registers keyboard
-     * shortcuts. An example is the FileList in the ChangeView. So we allow
-     * a broader observer target to be specified here, and fall back to
-     * `this` as the default.
-     */
-    @property({type: Object})
-    observerTarget: Element = this;
-
-    /** Are shortcuts currently enabled? True only when element is visible. */
-    private bindingsEnabled = false;
-
-    override connectedCallback() {
-      super.connectedCallback();
-      this.createVisibilityObserver();
-      this.enableBindings();
-    }
-
-    override disconnectedCallback() {
-      this.destroyVisibilityObserver();
-      this.disableBindings();
-      super.disconnectedCallback();
-    }
-
-    /**
-     * Creates an intersection observer that enables bindings when the
-     * element is visible and disables them when the element is hidden.
-     */
-    private createVisibilityObserver() {
-      if (!this.hasKeyboardShortcuts()) return;
-      if (this.observer) return;
-      this.observer = new IntersectionObserver(entries => {
-        check(entries.length === 1, 'Expected one observer entry.');
-        const isVisible = entries[0].isIntersecting;
-        if (isVisible) {
-          this.enableBindings();
-        } else {
-          this.disableBindings();
-        }
-      });
-      this.observer.observe(this.observerTarget);
-    }
-
-    private destroyVisibilityObserver() {
-      if (this.observer) this.observer.unobserve(this.observerTarget);
-    }
-
-    /**
-     * Enables all the shortcuts returned by keyboardShortcuts().
-     * This is a private method being called when the element becomes
-     * connected or visible.
-     */
-    private enableBindings() {
-      if (!this.hasKeyboardShortcuts()) return;
-      if (this.bindingsEnabled) return;
-      this.bindingsEnabled = true;
-
-      this.shortcuts.attachHost(this, this.keyboardShortcuts());
-    }
-
-    /**
-     * Disables all the shortcuts returned by keyboardShortcuts().
-     * This is a private method being called when the element becomes
-     * disconnected or invisible.
-     */
-    private disableBindings() {
-      if (!this.bindingsEnabled) return;
-      this.bindingsEnabled = false;
-      this.shortcuts.detachHost(this);
-    }
-
-    private hasKeyboardShortcuts() {
-      return this.keyboardShortcuts().length > 0;
-    }
-
-    keyboardShortcuts(): ShortcutListener[] {
-      return [];
-    }
-  }
-
-  return Mixin as T & Constructor<KeyboardShortcutMixinInterface>;
-};
-
-/** The interface corresponding to KeyboardShortcutMixin */
-export interface KeyboardShortcutMixinInterface {
-  keyboardShortcuts(): ShortcutListener[];
-}
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model.ts b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
new file mode 100644
index 0000000..3f35127
--- /dev/null
+++ b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
@@ -0,0 +1,58 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {AccountDetailInfo, AccountInfo} from '../../api/rest-api';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {Finalizable} from '../../services/registry';
+import {UserId} from '../../types/common';
+import {getUserId, isDetailedAccount} from '../../utils/account-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+
+export interface AccountsState {
+  accounts: {[id: UserId]: AccountDetailInfo};
+}
+
+export const accountsModelToken = define<AccountsModel>('accounts-model');
+
+export class AccountsModel extends Model<AccountsState> implements Finalizable {
+  constructor(readonly restApiService: RestApiService) {
+    super({
+      accounts: {},
+    });
+  }
+
+  private updateStateAccount(id: UserId, account?: AccountDetailInfo) {
+    if (!account) return;
+    const current = {...this.getState()};
+    current.accounts = {...current.accounts, [id]: account};
+    this.setState(current);
+  }
+
+  async getAccount(partialAccount: AccountInfo) {
+    const current = this.getState();
+    const id = getUserId(partialAccount);
+    if (current.accounts[id]) return current.accounts[id];
+    // It is possible to add emails to CC when they don't have a Gerrit
+    // account. In this case getAccountDetails will return a 404 error hence
+    // pass an empty error function to handle that.
+    const account = await this.restApiService.getAccountDetails(id, () => {
+      this.updateStateAccount(id, partialAccount as AccountDetailInfo);
+      return;
+    });
+    if (account) this.updateStateAccount(id, account);
+    return account;
+  }
+
+  async fillDetails(account: AccountInfo) {
+    if (!isDetailedAccount(account)) {
+      if (account.email) return await this.getAccount({email: account.email});
+      else if (account._account_id)
+        return await this.getAccount({_account_id: account._account_id});
+    }
+    return account;
+  }
+}
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts b/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
new file mode 100644
index 0000000..53c90a6
--- /dev/null
+++ b/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../test/common-test-setup';
+import {EmailAddress} from '../../api/rest-api';
+import {getAppContext} from '../../services/app-context';
+import {stubRestApi} from '../../test/test-utils';
+import {AccountsModel} from './accounts-model';
+import {assert} from '@open-wc/testing';
+
+suite('accounts-model tests', () => {
+  let model: AccountsModel;
+
+  setup(() => {
+    model = new AccountsModel(getAppContext().restApiService);
+  });
+
+  teardown(() => {
+    model.finalize();
+  });
+
+  test('invalid account makes only one request', () => {
+    const response = {...new Response(), status: 404};
+    const getAccountDetails = stubRestApi('getAccountDetails').callsFake(
+      (_, errFn) => {
+        if (errFn !== undefined) {
+          errFn(response);
+        }
+        return Promise.resolve(undefined);
+      }
+    );
+
+    model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
+    assert.equal(getAccountDetails.callCount, 1);
+
+    model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
+    assert.equal(getAccountDetails.callCount, 1);
+  });
+});
diff --git a/polygerrit-ui/app/models/browser/browser-model.ts b/polygerrit-ui/app/models/browser/browser-model.ts
index 490f868..1592cd8 100644
--- a/polygerrit-ui/app/models/browser/browser-model.ts
+++ b/polygerrit-ui/app/models/browser/browser-model.ts
@@ -1,26 +1,15 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 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';
+import {select} from '../../utils/observable-util';
 
 // This value is somewhat arbitrary and not based on research or calculations.
 const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
@@ -38,30 +27,24 @@
 export const browserModelToken = define<BrowserModel>('browser-model');
 
 export class BrowserModel extends Model<BrowserState> implements Finalizable {
-  readonly diffViewMode$: Observable<DiffViewMode>;
+  private readonly isScreenTooSmall$ = select(
+    this.state$,
+    state =>
+      !!state.screenWidth &&
+      state.screenWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX
+  );
+
+  readonly diffViewMode$: Observable<DiffViewMode> = select(
+    combineLatest([
+      this.isScreenTooSmall$,
+      this.userModel.preferenceDiffViewMode$,
+    ]),
+    ([isScreenTooSmall, preferenceDiffViewMode]) =>
+      isScreenTooSmall ? DiffViewMode.UNIFIED : preferenceDiffViewMode
+  );
 
   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 */
@@ -75,8 +58,6 @@
 
   // Private but used in tests.
   setScreenWidth(screenWidth: number) {
-    this.subject$.next({...this.subject$.getValue(), screenWidth});
+    this.updateState({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
index b0984c1..f706712 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -3,20 +3,30 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import {
   ChangeInfo,
   NumericChangeId,
   ChangeStatus,
   ReviewerState,
+  AccountId,
   AccountInfo,
+  GroupInfo,
+  Hashtag,
 } from '../../api/rest-api';
 import {Model} from '../model';
 import {Finalizable} from '../../services/registry';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {define} from '../dependency';
 import {select} from '../../utils/observable-util';
-import {ReviewInput, ReviewerInput} from '../../types/common';
+import {
+  ReviewInput,
+  ReviewerInput,
+  AttentionSetInput,
+  RelatedChangeAndCommitInfo,
+} from '../../types/common';
+import {getUserId} from '../../utils/account-util';
+import {getChangeNumber} from '../../utils/change-util';
+import {deepEqual} from '../../utils/deep-util';
 
 export const bulkActionsModelToken =
   define<BulkActionsModel>('bulk-actions-model');
@@ -28,6 +38,7 @@
 }
 export interface BulkActionsState {
   loadingState: LoadingState;
+  selectableChangeNums: NumericChangeId[];
   selectedChangeNums: NumericChangeId[];
   allChanges: Map<NumericChangeId, ChangeInfo>;
 }
@@ -35,6 +46,7 @@
 const initialState: BulkActionsState = {
   loadingState: LoadingState.NOT_SYNCED,
   selectedChangeNums: [],
+  selectableChangeNums: [],
   allChanges: new Map(),
 };
 
@@ -61,11 +73,6 @@
     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) {
@@ -75,9 +82,15 @@
     return result;
   });
 
+  toggleSelectedChangeNum(changeNum: NumericChangeId) {
+    this.getState().selectedChangeNums.includes(changeNum)
+      ? this.removeSelectedChangeNum(changeNum)
+      : this.addSelectedChangeNum(changeNum);
+  }
+
   addSelectedChangeNum(changeNum: NumericChangeId) {
     const current = this.getState();
-    if (!current.allChanges.has(changeNum)) {
+    if (!current.selectableChangeNums.includes(changeNum)) {
       throw new Error(
         `Trying to add change ${changeNum} that is not part of bulk-actions model`
       );
@@ -89,7 +102,7 @@
 
   removeSelectedChangeNum(changeNum: NumericChangeId) {
     const current = this.getState();
-    if (!current.allChanges.has(changeNum)) {
+    if (!current.selectableChangeNums.includes(changeNum)) {
       throw new Error(
         `Trying to remove change ${changeNum} that is not part of bulk-actions model`
       );
@@ -98,11 +111,18 @@
     const index = selectedChangeNums.findIndex(item => item === changeNum);
     if (index === -1) return;
     selectedChangeNums.splice(index, 1);
-    this.setState({...current, selectedChangeNums});
+    this.updateState({selectedChangeNums});
   }
 
   clearSelectedChangeNums() {
-    this.setState({...this.subject$.getValue(), selectedChangeNums: []});
+    this.updateState({selectedChangeNums: []});
+  }
+
+  selectAll() {
+    const current = this.getState();
+    this.updateState({
+      selectedChangeNums: Array.from(current.allChanges.keys()),
+    });
   }
 
   abandonChanges(
@@ -110,7 +130,7 @@
     // errorFn is needed to avoid showing an error dialog
     errFn?: (changeNum: NumericChangeId) => void
   ): Promise<Response | undefined>[] {
-    const current = this.subject$.getValue();
+    const current = this.getState();
     return current.selectedChangeNums.map(changeNum => {
       if (!current.allChanges.get(changeNum))
         throw new Error('invalid change id');
@@ -119,23 +139,23 @@
         return Promise.resolve(new Response());
       }
       return this.restApiService.executeChangeAction(
-        change._number,
+        getChangeNumber(change),
         change.actions!.abandon!.method,
         '/abandon',
         undefined,
         {message: reason ?? ''},
-        () => errFn && errFn(change._number)
+        () => errFn && errFn(getChangeNumber(change))
       );
     });
   }
 
   voteChanges(reviewInput: ReviewInput) {
-    const current = this.subject$.getValue();
+    const current = this.getState();
     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,
+        getChangeNumber(change),
         'current',
         reviewInput,
         () => {
@@ -146,14 +166,15 @@
   }
 
   addReviewers(
-    changedReviewers: Map<ReviewerState, AccountInfo[]>
+    changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>,
+    reason: string
   ): Promise<Response>[] {
-    const current = this.subject$.getValue();
+    const current = this.getState();
     const changes = current.selectedChangeNums.map(
       changeNum => current.allChanges.get(changeNum)!
     );
     return changes.map(change => {
-      const reviewersNewToChange = [
+      const reviewersNewToChange: ReviewerInput[] = [
         ReviewerState.REVIEWER,
         ReviewerState.CC,
       ].flatMap(state =>
@@ -162,28 +183,71 @@
       if (reviewersNewToChange.length === 0) {
         return Promise.resolve(new Response());
       }
+      const attentionSetUpdates: AttentionSetInput[] = reviewersNewToChange
+        .filter(reviewerInput => reviewerInput.state === ReviewerState.REVIEWER)
+        .map(reviewerInput => {
+          return {
+            // TODO: Once Groups are supported, filter them out and only add
+            // Accounts to the attention set, just like gr-reply-dialog.
+            user: reviewerInput.reviewer as AccountId,
+            reason,
+          };
+        });
       const reviewInput: ReviewInput = {
         reviewers: reviewersNewToChange,
+        ignore_automatic_attention_set_rules: true,
+        add_to_attention_set: attentionSetUpdates,
       };
       return this.restApiService.saveChangeReview(
-        change._number,
+        getChangeNumber(change),
         'current',
         reviewInput
       );
     });
   }
 
-  async sync(changes: ChangeInfo[]) {
-    const basicChanges = new Map(changes.map(c => [c._number, c]));
-    let currentState = this.subject$.getValue();
+  addHashtags(hashtags: Hashtag[]): Promise<Hashtag[]>[] {
+    const current = this.getState();
+    return current.selectedChangeNums.map(changeNum =>
+      this.restApiService
+        .setChangeHashtag(changeNum, {
+          add: hashtags,
+        })
+        .then(responseHashtags => {
+          // Once we get server confirmation that the hashtags were added to the
+          // change, we are updating the model's ChangeInfo. This way we can
+          // keep the page state (dialog status) but use the updated change info
+          // naturally.
+
+          // refetch the current state since other changes may have been updated
+          // since the promises were launched.
+          const current = this.getState();
+          const nextState = {
+            ...current,
+            allChanges: new Map(current.allChanges),
+          };
+          nextState.allChanges.set(changeNum, {
+            ...nextState.allChanges.get(changeNum)!,
+            hashtags: responseHashtags,
+          });
+          this.setState(nextState);
+          return responseHashtags;
+        })
+    );
+  }
+
+  async sync(changes: (ChangeInfo | RelatedChangeAndCommitInfo)[]) {
+    const basicChanges = new Map(changes.map(c => [getChangeNumber(c), c]));
+    let currentState = this.getState();
     const selectedChangeNums = currentState.selectedChangeNums.filter(
       changeNum => basicChanges.has(changeNum)
     );
-    this.setState({
-      ...currentState,
+    const selectableChangeNums = changes.map(c => getChangeNumber(c));
+    this.updateState({
       loadingState: LoadingState.LOADING,
       selectedChangeNums,
-      allChanges: basicChanges,
+      selectableChangeNums,
+      allChanges: new Map(),
     });
 
     if (changes.length === 0) {
@@ -191,18 +255,16 @@
     }
     const changeDetails =
       await this.restApiService.getDetailedChangesWithActions(
-        changes.map(c => c._number)
+        changes.map(c => getChangeNumber(c))
       );
-    currentState = this.subject$.getValue();
+    currentState = this.getState();
     // Return early if sync has been called again since starting the load.
-    if (basicChanges !== currentState.allChanges) return;
+    if (!deepEqual(selectableChangeNums, currentState.selectableChangeNums)) {
+      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)
-      );
+      allDetailedChanges.set(detailedChange._number, detailedChange);
     }
     this.setState({
       ...currentState,
@@ -211,40 +273,18 @@
     });
   }
 
-  /** 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[]>
+    changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>
   ): ReviewerInput[] {
     return (
       changedReviewers
         .get(state)
         ?.filter(account => !change.reviewers[state]?.includes(account))
         .map(account => {
-          return {state, reviewer: account._account_id!};
+          return {state, reviewer: getUserId(account)};
         }) ?? []
     );
   }
-
-  finalize() {}
 }
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
index b08455c..e2f75f7 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -3,10 +3,10 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import {
   createAccountWithIdNameAndEmail,
   createChange,
+  createGroupInfo,
   createRevisions,
 } from '../../test/test-data-generators';
 import {
@@ -14,19 +14,24 @@
   NumericChangeId,
   ChangeStatus,
   HttpMethod,
-  SubmitRequirementStatus,
   AccountInfo,
   ReviewerState,
-  AccountId,
+  GroupInfo,
+  Hashtag,
 } 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 '../../test/common-test-setup';
+import {
+  stubRestApi,
+  waitEventLoop,
+  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';
+import {assert} from '@open-wc/testing';
 
 suite('bulk actions model test', () => {
   let bulkActionsModel: BulkActionsModel;
@@ -42,7 +47,7 @@
     assert.isTrue(detailedActionsStub.notCalled);
   });
 
-  test('add changes before sync', () => {
+  test('add changes before sync does not add them', () => {
     const c1 = createChange();
     c1._number = 1 as NumericChangeId;
     const c2 = createChange();
@@ -72,6 +77,7 @@
     bulkActionsModel.sync([c1, c2]);
 
     assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
+    assert.deepEqual(bulkActionsModel.getState().selectableChangeNums, [1, 2]);
 
     bulkActionsModel.addSelectedChangeNum(c1._number);
     assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [
@@ -93,6 +99,41 @@
     assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
   });
 
+  test('toggle selected changes', async () => {
+    const change1 = createChange();
+    change1._number = 1 as NumericChangeId;
+    const change2 = createChange();
+    change2._number = 2 as NumericChangeId;
+    bulkActionsModel.sync([change1, change2]);
+
+    // toggle first change on
+    bulkActionsModel.toggleSelectedChangeNum(change1._number);
+
+    let selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      selectedChangeNums => selectedChangeNums.includes(change1._number)
+    );
+    assert.sameMembers(selectedChangeNums, [change1._number]);
+
+    // toggle second change on
+    bulkActionsModel.toggleSelectedChangeNum(change2._number);
+
+    selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      selectedChangeNums => selectedChangeNums.includes(change2._number)
+    );
+    assert.sameMembers(selectedChangeNums, [change1._number, change2._number]);
+
+    // toggle first change off
+    bulkActionsModel.toggleSelectedChangeNum(change1._number);
+
+    selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      selectedChangeNums => !selectedChangeNums.includes(change1._number)
+    );
+    assert.sameMembers(selectedChangeNums, [change2._number]);
+  });
+
   test('clears selected change numbers', async () => {
     const c1 = createChange();
     c1._number = 1 as NumericChangeId;
@@ -102,7 +143,7 @@
     bulkActionsModel.addSelectedChangeNum(c1._number);
     bulkActionsModel.addSelectedChangeNum(c2._number);
     let selectedChangeNums = await waitUntilObserved(
-      bulkActionsModel!.selectedChangeNums$,
+      bulkActionsModel.selectedChangeNums$,
       s => s.length === 2
     );
     let totalChangeCount = await waitUntilObserved(
@@ -114,7 +155,7 @@
 
     bulkActionsModel.clearSelectedChangeNums();
     selectedChangeNums = await waitUntilObserved(
-      bulkActionsModel!.selectedChangeNums$,
+      bulkActionsModel.selectedChangeNums$,
       s => s.length === 0
     );
     totalChangeCount = await waitUntilObserved(
@@ -126,6 +167,37 @@
     assert.equal(totalChangeCount, 2);
   });
 
+  test('selects all changes', async () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+    bulkActionsModel.sync([c1, c2]);
+    let selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      s => s.length === 0
+    );
+    let totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+    assert.isEmpty(selectedChangeNums);
+    assert.equal(totalChangeCount, 2);
+
+    bulkActionsModel.selectAll();
+    selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      s => s.length === 2
+    );
+    totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+
+    assert.sameMembers(selectedChangeNums, [c1._number, c2._number]);
+    assert.equal(totalChangeCount, 2);
+  });
+
   suite('abandon changes', () => {
     let detailedActionsStub: SinonStubbedMember<
       RestApiService['getDetailedChangesWithActions']
@@ -169,6 +241,7 @@
       createAccountWithIdNameAndEmail(0),
       createAccountWithIdNameAndEmail(1),
     ];
+    const groups: GroupInfo[] = [createGroupInfo('groupId')];
     const changes: ChangeInfo[] = [
       {
         ...createChange(),
@@ -203,21 +276,49 @@
     test('adds reviewers/cc only to changes that need it', async () => {
       bulkActionsModel.addReviewers(
         new Map([
-          [ReviewerState.REVIEWER, [accounts[0]]],
+          [ReviewerState.REVIEWER, [accounts[0], groups[0]]],
           [ReviewerState.CC, [accounts[1]]],
-        ])
+        ]),
+        '<GERRIT_ACCOUNT_12345> replied on the change'
       );
 
-      // changes[0] is not updated since it already has the reviewer & CC
-      assert.isTrue(saveChangeReviewStub.calledOnce);
+      assert.isTrue(saveChangeReviewStub.calledTwice);
+      // changes[0] only adds the group since it already has the other
+      // reviewer/CCs
       assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
+        changes[0]._number,
+        'current',
+        {
+          reviewers: [{reviewer: groups[0].id, state: ReviewerState.REVIEWER}],
+          ignore_automatic_attention_set_rules: true,
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: groups[0].id,
+            },
+          ],
+        },
+      ]);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
         changes[1]._number,
         'current',
         {
           reviewers: [
             {reviewer: accounts[0]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[1]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: accounts[0]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
     });
@@ -278,6 +379,47 @@
     });
   });
 
+  suite('add hashtags', () => {
+    const change1: ChangeInfo = {
+      ...createChange(),
+      _number: 1 as NumericChangeId,
+      hashtags: ['existingHashtag' as Hashtag],
+    };
+    const change2: ChangeInfo = {
+      ...createChange(),
+      _number: 2 as NumericChangeId,
+      hashtags: ['existingHashtag' as Hashtag],
+    };
+    const existingHashtag = 'existingHashtag' as Hashtag;
+    const newHashtag = 'newHashtag' as Hashtag;
+    let detailedActionsStub: SinonStubbedMember<
+      RestApiService['getDetailedChangesWithActions']
+    >;
+    setup(async () => {
+      detailedActionsStub = stubRestApi('getDetailedChangesWithActions');
+      detailedActionsStub.returns(Promise.resolve([change1, change2]));
+
+      await bulkActionsModel.sync([change1, change2]);
+      bulkActionsModel.addSelectedChangeNum(change1._number);
+      bulkActionsModel.addSelectedChangeNum(change2._number);
+      stubRestApi('setChangeHashtag').resolves([existingHashtag, newHashtag]);
+    });
+
+    test('server-acked hashtags are added to the model', async () => {
+      await Promise.all(bulkActionsModel.addHashtags([newHashtag]));
+
+      const updatedChanges = await waitUntilObserved(
+        bulkActionsModel.selectedChanges$,
+        changes => changes.some(change => change.hashtags?.includes(newHashtag))
+      );
+
+      assert.deepEqual(updatedChanges, [
+        {...change1, hashtags: [existingHashtag, newHashtag]},
+        {...change2, hashtags: [existingHashtag, newHashtag]},
+      ]);
+    });
+  });
+
   test('stale changes are removed from the model', async () => {
     const c1 = createChange();
     c1._number = 1 as NumericChangeId;
@@ -289,7 +431,7 @@
     bulkActionsModel.addSelectedChangeNum(c2._number);
 
     let selectedChangeNums = await waitUntilObserved(
-      bulkActionsModel!.selectedChangeNums$,
+      bulkActionsModel.selectedChangeNums$,
       s => s.length === 2
     );
     let totalChangeCount = await waitUntilObserved(
@@ -302,7 +444,7 @@
 
     bulkActionsModel.sync([c1]);
     selectedChangeNums = await waitUntilObserved(
-      bulkActionsModel!.selectedChangeNums$,
+      bulkActionsModel.selectedChangeNums$,
       s => s.length === 1
     );
     totalChangeCount = await waitUntilObserved(
@@ -347,60 +489,6 @@
     );
   });
 
-  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;
@@ -412,7 +500,7 @@
     const getChangesStub = stubRestApi(
       'getDetailedChangesWithActions'
     ).callsFake(() => promise);
-    bulkActionsModel.sync([c1, c2]);
+    bulkActionsModel.sync([c1]);
     assert.strictEqual(getChangesStub.callCount, 1);
     await waitUntilObserved(
       bulkActionsModel.loadingState$,
@@ -446,9 +534,8 @@
     // Resolve the old promise.
     responsePromise1.resolve([
       {...createChange(), _number: 1, subject: 'Subject 1-old'},
-      {...createChange(), _number: 2, subject: 'Subject 2-old'},
     ] as ChangeInfo[]);
-    await flush();
+    await waitEventLoop();
     const model2 = bulkActionsModel.getState();
 
     // No change should happen.
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index b9a768c..12d09b3 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -1,40 +1,24 @@
 /**
  * @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 {
+  BasePatchSetNum,
   EditInfo,
-  EditPatchSetNum,
+  EDIT,
+  PARENT,
   NumericChangeId,
   PatchSetNum,
+  PreferencesInfo,
+  RevisionPatchSetNum,
 } from '../../types/common';
-import {
-  combineLatest,
-  from,
-  fromEvent,
-  Observable,
-  Subscription,
-  forkJoin,
-  of,
-} from 'rxjs';
+import {DefaultBase} from '../../constants/constants';
+import {combineLatest, from, fromEvent, Observable, forkJoin, of} from 'rxjs';
 import {
   map,
   filter,
   withLatestFrom,
-  distinctUntilChanged,
   startWith,
   switchMap,
 } from 'rxjs/operators';
@@ -54,6 +38,7 @@
 import {Model} from '../model';
 import {UserModel} from '../user/user-model';
 import {define} from '../dependency';
+import {isOwner} from '../../utils/change-util';
 
 export enum LoadingStatus {
   NOT_LOADED = 'NOT_LOADED',
@@ -98,7 +83,7 @@
   assertIsDefined(edit.commit.commit, 'edit.commit.commit');
   if (!change.revisions) change.revisions = {};
   change.revisions[edit.commit.commit] = {
-    _number: EditPatchSetNum,
+    _number: EDIT,
     basePatchNum: edit.base_patch_set_number,
     commit: edit.commit,
     fetch: edit.fetch,
@@ -117,6 +102,44 @@
   return change;
 }
 
+/**
+ * Derives the base patchset number from all the data that can potentially
+ * influence it. Mostly just returns `routerBasePatchNum` or PARENT, but has
+ * some special logic when looking at merge commits.
+ *
+ * NOTE: At the moment this returns just `routerBasePatchNum ?? PARENT`, see
+ * TODO below.
+ */
+function computeBase(
+  routerBasePatchNum: BasePatchSetNum | undefined,
+  patchNum: RevisionPatchSetNum | undefined,
+  change: ParsedChangeInfo | undefined,
+  preferences: PreferencesInfo
+): BasePatchSetNum {
+  if (routerBasePatchNum && routerBasePatchNum !== PARENT) {
+    return routerBasePatchNum;
+  }
+  if (!change || !patchNum) return PARENT;
+
+  const preferFirst =
+    preferences.default_base_for_merges === DefaultBase.FIRST_PARENT;
+  if (!preferFirst) return PARENT;
+
+  // TODO: Re-enable respecting the default_base_for_merges preference.
+  // For the Polygerrit UI this was originally implemented in change 214432,
+  // but we are not sure whether this was ever 100% working correctly. A
+  // major challenge is being able to select PARENT explicitly even if your
+  // preference for the default choice is FIRST_PARENT. <gr-file-list-header>
+  // just uses `navigation.setUrl()` and the router does not have any
+  // way of forcing the basePatchSetNum to stick to PARENT without being
+  // altered back to FIRST_PARENT here.
+  // See also corresponding TODO in gr-settings-view.
+  return PARENT;
+  // const revisionInfo = new RevisionInfo(change);
+  // const isMergeCommit = revisionInfo.isMergeCommit(patchNum);
+  // return isMergeCommit ? (-1 as PatchSetNumber) : PARENT;
+}
+
 // TODO: Figure out how to best enforce immutability of all states. Use Immer?
 // Use DeepReadOnly?
 const initialState: ChangeState = {
@@ -128,7 +151,7 @@
 export class ChangeModel extends Model<ChangeState> implements Finalizable {
   private change?: ParsedChangeInfo;
 
-  private currentPatchNum?: PatchSetNum;
+  private patchNum?: PatchSetNum;
 
   public readonly change$ = select(
     this.state$,
@@ -165,38 +188,74 @@
    * 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!
+   * Note that this selector can emit without the change being available!
    */
-  public readonly currentPatchNum$: Observable<PatchSetNum | undefined> =
+  public readonly patchNum$: Observable<RevisionPatchSetNum | undefined> =
+    select(
+      combineLatest([
+        this.routerModel.state$,
+        this.state$,
+        this.latestPatchNum$,
+      ]).pipe(
+        /**
+         * 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.
+         */
+        filter(([routerState, changeState, _latestPatchN]) => {
+          const changeNum = changeState.change?._number;
+          const routerChangeNum = routerState.changeNum;
+          return changeNum === undefined || changeNum === routerChangeNum;
+        })
+      ),
+      ([routerState, _changeState, latestPatchN]) =>
+        routerState?.patchNum || latestPatchN
+    );
+
+  /**
+   * Emits the base patchset number. This is identical to the
+   * `routerBasePatchNum$`, but has some special logic for merges.
+   *
+   * Note that this selector can emit without the change being available!
+   */
+  public readonly basePatchNum$: Observable<BasePatchSetNum> =
     /**
      * 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]) => {
+    select(
+      combineLatest([
+        this.routerModel.state$,
+        this.state$,
+        this.userModel.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()
-      );
+        withLatestFrom(
+          this.routerModel.routerBasePatchNum$,
+          this.patchNum$,
+          this.change$,
+          this.userModel.preferences$
+        )
+      ),
+      ([_, routerBasePatchNum, patchNum, change, preferences]) =>
+        computeBase(routerBasePatchNum, patchNum, change, preferences)
+    );
 
-  private subscriptions: Subscription[] = [];
+  public readonly isOwner$: Observable<boolean> = select(
+    combineLatest([this.change$, this.userModel.account$]),
+    ([change, account]) => isOwner(change, account)
+  );
 
   // 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));
+  readonly reload$: Observable<unknown> = fromEvent(document, 'reload').pipe(
+    startWith(undefined)
+  );
 
   constructor(
     readonly routerModel: RouterModel,
@@ -230,47 +289,32 @@
           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$,
-      ])
+      this.patchNum$.subscribe(patchNum => (this.patchNum = patchNum)),
+      combineLatest([this.patchNum$, this.changeNum$, this.userModel.loggedIn$])
         .pipe(
-          switchMap(([currentPatchNum, changeNum, loggedIn]) => {
-            if (!changeNum || !currentPatchNum || !loggedIn) {
+          switchMap(([patchNum, changeNum, loggedIn]) => {
+            if (!changeNum || !patchNum || !loggedIn) {
               this.updateStateReviewedFiles([]);
               return of(undefined);
             }
-            return from(this.fetchReviewedFiles(currentPatchNum, changeNum));
+            return from(this.fetchReviewedFiles(patchNum, 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});
+    this.updateState({diffPath});
   }
 
   updateStateReviewedFiles(reviewedFiles: string[]) {
-    const current = this.subject$.getValue();
-    this.setState({...current, reviewedFiles});
+    this.updateState({reviewedFiles});
   }
 
   updateStateFileReviewed(file: string, reviewed: boolean) {
-    const current = this.subject$.getValue();
+    const current = this.getState();
     if (current.reviewedFiles === undefined) {
       // Reviewed files haven't loaded yet.
       // TODO(dhruvsri): disable updating status if reviewed files are not loaded.
@@ -289,17 +333,14 @@
 
     if (reviewed) reviewedFiles.push(file);
     else reviewedFiles.splice(reviewedFiles.indexOf(file), 1);
-    this.setState({...current, reviewedFiles});
+    this.updateState({reviewedFiles});
   }
 
-  fetchReviewedFiles(currentPatchNum: PatchSetNum, changeNum: NumericChangeId) {
+  fetchReviewedFiles(patchNum: PatchSetNum, changeNum: NumericChangeId) {
     return this.restApiService
-      .getReviewedFiles(changeNum, currentPatchNum)
+      .getReviewedFiles(changeNum, patchNum)
       .then(files => {
-        if (
-          changeNum !== this.change?._number ||
-          currentPatchNum !== this.currentPatchNum
-        )
+        if (changeNum !== this.change?._number || patchNum !== this.patchNum)
           return;
         this.updateStateReviewedFiles(files ?? []);
       });
@@ -314,10 +355,7 @@
     return this.restApiService
       .saveFileReviewed(changeNum, patchNum, file, reviewed)
       .then(() => {
-        if (
-          changeNum !== this.change?._number ||
-          patchNum !== this.currentPatchNum
-        )
+        if (changeNum !== this.change?._number || patchNum !== this.patchNum)
           return;
         this.updateStateFileReviewed(file, reviewed);
       })
@@ -332,7 +370,7 @@
    * demand. So here it is for your convenience.
    */
   getChange() {
-    return this.subject$.getValue().change;
+    return this.getState().change;
   }
 
   /**
@@ -376,10 +414,9 @@
    * a new change number, but an old change.
    */
   private updateStateLoading(changeNum: NumericChangeId) {
-    const current = this.subject$.getValue();
+    const current = this.getState();
     const reloading = current.change?._number === changeNum;
-    this.setState({
-      ...current,
+    this.updateState({
       change: reloading ? current.change : undefined,
       loadingStatus: reloading
         ? LoadingStatus.RELOADING
@@ -389,17 +426,10 @@
 
   // Private but used in tests.
   updateStateChange(change?: ParsedChangeInfo) {
-    const current = this.subject$.getValue();
-    this.setState({
-      ...current,
+    this.updateState({
       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
index ceb87cc..fdf9e04 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -1,23 +1,11 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {Subject} from 'rxjs';
 import {ChangeStatus} from '../../constants/constants';
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import {
   createChange,
   createChangeMessageInfo,
@@ -32,15 +20,18 @@
 } from '../../test/test-utils';
 import {
   CommitId,
-  EditPatchSetNum,
+  EDIT,
   NumericChangeId,
+  PARENT,
   PatchSetNum,
+  PatchSetNumber,
 } 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';
+import {assert} from '@open-wc/testing';
 
 suite('updateChangeWithEdit() tests', () => {
   test('undefined change', async () => {
@@ -58,7 +49,7 @@
     change = updateChangeWithEdit(change, edit);
     const editRev = change?.revisions[`${edit.commit.commit}`];
     assert.isDefined(editRev);
-    assert.equal(editRev?._number, EditPatchSetNum);
+    assert.equal(editRev?._number, EDIT);
     assert.equal(editRev?.basePatchNum, edit.base_patch_set_number);
     assert.equal(change?.current_revision, edit.commit.commit);
   });
@@ -73,7 +64,7 @@
   });
 });
 
-suite('change service tests', () => {
+suite('change model tests', () => {
   let changeModel: ChangeModel;
   let knownChange: ParsedChangeInfo;
   const testCompleted = new Subject<void>();
@@ -100,12 +91,12 @@
         sha1: {
           ...createRevision(1),
           description: 'patch 1',
-          _number: 1 as PatchSetNum,
+          _number: 1 as PatchSetNumber,
         },
         sha2: {
           ...createRevision(2),
           description: 'patch 2',
-          _number: 2 as PatchSetNum,
+          _number: 2 as PatchSetNumber,
         },
       },
       status: ChangeStatus.NEW,
@@ -252,7 +243,7 @@
         sha3: {
           ...createRevision(3),
           description: 'patch 3',
-          _number: 3 as PatchSetNum,
+          _number: 3 as PatchSetNumber,
         },
       },
     };
@@ -289,4 +280,25 @@
       message: 'blah blah',
     });
   });
+
+  // At some point we had forgotten the `select()` wrapper for this selector.
+  // And the missing `replay` led to a bug that was hard to find. That is why
+  // we are testing this explicitly here.
+  test('basePatchNum$ selector', async () => {
+    const spy = sinon.spy();
+    changeModel.basePatchNum$.subscribe(spy);
+
+    // test replay
+    assert.equal(spy.callCount, 1);
+    assert.equal(spy.lastCall.firstArg, PARENT);
+
+    // test update
+    changeModel.routerModel.updateState({basePatchNum: 1 as PatchSetNumber});
+    assert.equal(spy.callCount, 2);
+    assert.equal(spy.lastCall.firstArg, 1 as PatchSetNumber);
+
+    // test distinctUntilChanged
+    changeModel.updateStateChange(createParsedChange());
+    assert.equal(spy.callCount, 2);
+  });
 });
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
new file mode 100644
index 0000000..6922f6d
--- /dev/null
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -0,0 +1,199 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  BasePatchSetNum,
+  FileInfo,
+  FileNameToFileInfoMap,
+  PARENT,
+  PatchRange,
+  PatchSetNumber,
+  RevisionPatchSetNum,
+} from '../../types/common';
+import {combineLatest, of, from} from 'rxjs';
+import {switchMap, map} from 'rxjs/operators';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {Finalizable} from '../../services/registry';
+import {select} from '../../utils/observable-util';
+import {FileInfoStatus, SpecialFilePath} from '../../constants/constants';
+import {specialFilePathCompare} from '../../utils/path-list-util';
+import {Model} from '../model';
+import {define} from '../dependency';
+import {ChangeModel} from './change-model';
+import {CommentsModel} from '../comments/comments-model';
+
+export interface NormalizedFileInfo extends FileInfo {
+  __path: string;
+  // Compared to `FileInfo` these four props are required here.
+  lines_inserted: number;
+  lines_deleted: number;
+  size_delta: number; // in bytes
+  size: number; // in bytes
+}
+
+export function normalize(file: FileInfo, path: string): NormalizedFileInfo {
+  return {
+    __path: path,
+    // These 4 props are required in NormalizedFileInfo, but optional in
+    // FileInfo. So let's set a default value, if not already set.
+    lines_inserted: 0,
+    lines_deleted: 0,
+    size_delta: 0,
+    size: 0,
+    ...file,
+  };
+}
+
+function mapToList(map?: FileNameToFileInfoMap): NormalizedFileInfo[] {
+  const list: NormalizedFileInfo[] = [];
+  for (const [key, value] of Object.entries(map ?? {})) {
+    list.push(normalize(value, key));
+  }
+  list.sort((f1, f2) => specialFilePathCompare(f1.__path, f2.__path));
+  return list;
+}
+
+export function addUnmodified(
+  files: NormalizedFileInfo[],
+  commentedPaths: string[]
+) {
+  const combined = [...files];
+  for (const commentedPath of commentedPaths) {
+    if (commentedPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) continue;
+    if (files.some(f => f.__path === commentedPath)) continue;
+    if (
+      files.some(
+        f => f.status === FileInfoStatus.RENAMED && f.old_path === commentedPath
+      )
+    ) {
+      continue;
+    }
+    combined.push(
+      normalize({status: FileInfoStatus.UNMODIFIED}, commentedPath)
+    );
+  }
+  combined.sort((f1, f2) => specialFilePathCompare(f1.__path, f2.__path));
+  return combined;
+}
+
+export interface FilesState {
+  // TODO: Move reviewed files from change model into here. Change model is
+  // already large and complex, so the files model is a better fit.
+
+  /**
+   * Basic file and diff information of all files for the currently chosen
+   * patch range.
+   */
+  files: NormalizedFileInfo[];
+
+  /**
+   * Basic file and diff information of all files for the left chosen patchset
+   * compared against its base (aka parent).
+   *
+   * Empty if the left chosen patchset is PARENT.
+   */
+  filesLeftBase: NormalizedFileInfo[];
+
+  /**
+   * Basic file and diff information of all files for the right chosen patchset
+   * compared against its base (aka parent).
+   *
+   * Empty if the left chosen patchset is PARENT.
+   */
+  filesRightBase: NormalizedFileInfo[];
+}
+
+const initialState: FilesState = {
+  files: [],
+  filesLeftBase: [],
+  filesRightBase: [],
+};
+
+export const filesModelToken = define<FilesModel>('files-model');
+
+export class FilesModel extends Model<FilesState> implements Finalizable {
+  public readonly files$ = select(this.state$, state => state.files);
+
+  public readonly filesWithUnmodified$ = select(
+    combineLatest([this.files$, this.commentsModel.commentedPaths$]),
+    ([files, commentedPaths]) => addUnmodified(files, commentedPaths)
+  );
+
+  public readonly filesLeftBase$ = select(
+    this.state$,
+    state => state.filesLeftBase
+  );
+
+  public readonly filesRightBase$ = select(
+    this.state$,
+    state => state.filesRightBase
+  );
+
+  constructor(
+    readonly changeModel: ChangeModel,
+    readonly commentsModel: CommentsModel,
+    readonly restApiService: RestApiService
+  ) {
+    super(initialState);
+    this.subscriptions = [
+      this.subscribeToFiles(
+        (psLeft, psRight) => {
+          return {basePatchNum: psLeft, patchNum: psRight};
+        },
+        files => {
+          return {files: [...files]};
+        }
+      ),
+      this.subscribeToFiles(
+        (psLeft, _) => {
+          if (psLeft === PARENT || psLeft <= 0) return undefined;
+          return {basePatchNum: PARENT, patchNum: psLeft as PatchSetNumber};
+        },
+        files => {
+          return {filesLeftBase: [...files]};
+        }
+      ),
+      this.subscribeToFiles(
+        (psLeft, psRight) => {
+          if (psLeft === PARENT || psLeft <= 0) return undefined;
+          return {basePatchNum: PARENT, patchNum: psRight as PatchSetNumber};
+        },
+        files => {
+          return {filesRightBase: [...files]};
+        }
+      ),
+    ];
+  }
+
+  private subscribeToFiles(
+    rangeChooser: (
+      basePatchNum: BasePatchSetNum,
+      patchNum: RevisionPatchSetNum
+    ) => PatchRange | undefined,
+    filesToState: (files: NormalizedFileInfo[]) => Partial<FilesState>
+  ) {
+    return combineLatest([
+      this.changeModel.reload$,
+      this.changeModel.changeNum$,
+      this.changeModel.basePatchNum$,
+      this.changeModel.patchNum$,
+    ])
+      .pipe(
+        switchMap(([_, changeNum, basePatchNum, patchNum]) => {
+          if (!changeNum || !patchNum) return of({});
+          const range = rangeChooser(basePatchNum, patchNum);
+          if (!range) return of({});
+          return from(
+            this.restApiService.getChangeOrEditFiles(changeNum, range)
+          );
+        }),
+        map(mapToList),
+        map(filesToState)
+      )
+      .subscribe(state => {
+        this.updateState(state);
+      });
+  }
+}
diff --git a/polygerrit-ui/app/models/checks/checks-fakes.ts b/polygerrit-ui/app/models/checks/checks-fakes.ts
index a16af7b..d50ddba 100644
--- a/polygerrit-ui/app/models/checks/checks-fakes.ts
+++ b/polygerrit-ui/app/models/checks/checks-fakes.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {
   Action,
@@ -31,6 +20,7 @@
 export const fakeRun0: CheckRun = {
   pluginName: 'f0',
   internalRunId: 'f0',
+  patchset: 1,
   checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder',
   labelName: 'Presubmit',
   isSingleAttempt: true,
@@ -99,7 +89,7 @@
   checkName: 'FAKE Super Check',
   startedTimestamp: new Date(new Date().getTime() - 5 * 60 * 1000),
   finishedTimestamp: new Date(new Date().getTime() + 5 * 60 * 1000),
-  patchset: 2,
+  patchset: 1,
   labelName: 'Verified',
   isSingleAttempt: true,
   isLatestAttempt: true,
@@ -180,6 +170,23 @@
           },
         },
       ],
+      fixes: [
+        {
+          description: 'This is the way to do it.',
+          replacements: [
+            {
+              path: 'BUILD',
+              range: {
+                start_line: 1,
+                start_character: 0,
+                end_line: 1,
+                end_character: 0,
+              },
+              replacement: '# This is now fixed.\n',
+            },
+          ],
+        },
+      ],
       links: [],
     },
   ],
@@ -189,6 +196,7 @@
 export const fakeRun2: CheckRun = {
   pluginName: 'f2',
   internalRunId: 'f2',
+  patchset: 1,
   checkName: 'FAKE Mega Analysis',
   statusDescription: 'This run is nearly completed, but not quite.',
   statusLink: 'https://www.google.com/',
@@ -298,6 +306,7 @@
 export const fakeRun4_4: CheckRun = {
   pluginName: 'f4',
   internalRunId: 'f4',
+  patchset: 1,
   checkName: 'FAKE Elimination Long Long Long Long Long',
   checkDescription: 'Shows you the possible eliminations.',
   checkLink: 'https://www.google.com',
@@ -316,6 +325,23 @@
       internalResultId: 'f44r0',
       category: Category.INFO,
       summary: 'Dont be afraid. All TODOs will be eliminated.',
+      fixes: [
+        {
+          description: 'This is the way to do it.',
+          replacements: [
+            {
+              path: 'BUILD',
+              range: {
+                start_line: 1,
+                start_character: 0,
+                end_line: 1,
+                end_character: 0,
+              },
+              replacement: '# This is now fixed.\n',
+            },
+          ],
+        },
+      ],
       actions: [
         {
           name: 'Re-Run',
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 7b3b95f..6b35056 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -1,20 +1,15 @@
 /**
  * @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 {AttemptDetail, createAttemptMap} from './checks-util';
+import {
+  AttemptChoice,
+  AttemptDetail,
+  createAttemptMap,
+  LATEST_ATTEMPT,
+  sortAttemptDetails,
+} from './checks-util';
 import {assertIsDefined} from '../../utils/common-util';
 import {select} from '../../utils/observable-util';
 import {Finalizable} from '../../services/registry';
@@ -25,7 +20,6 @@
   Observable,
   of,
   Subject,
-  Subscription,
   timer,
 } from 'rxjs';
 import {
@@ -35,6 +29,7 @@
   take,
   takeUntil,
   takeWhile,
+  timeout,
   throttleTime,
   withLatestFrom,
 } from 'rxjs/operators';
@@ -66,6 +61,7 @@
   ChecksUpdate,
   PluginsModel,
 } from '../plugins/plugins-model';
+import {ChangeViewModel} from '../views/change';
 
 /**
  * The checks model maintains the state of checks for two patchsets: the latest
@@ -141,11 +137,6 @@
 }
 
 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;
@@ -160,6 +151,13 @@
 }
 
 /**
+ * Android's Checks Plugin has a 15s timeout internally. So we are using
+ * something slightly larger, so that we get a proper error from the plugin,
+ * if they run into timeout issues.
+ */
+const FETCH_RESULT_TIMEOUT_MS = 16000;
+
+/**
  * Can be used in `reduce()` to collect all results from all runs from all
  * providers into one array.
  */
@@ -191,9 +189,11 @@
 
   private checkToPluginMap = new Map<string, string>();
 
-  private changeNum?: NumericChangeId;
+  // visible for testing
+  changeNum?: NumericChangeId;
 
-  private latestPatchNum?: PatchSetNumber;
+  // visible for testing
+  latestPatchNum?: PatchSetNumber;
 
   private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
 
@@ -201,19 +201,29 @@
 
   private readonly visibilityChangeListener: () => void;
 
-  private subscriptions: Subscription[] = [];
-
   public checksSelectedPatchsetNumber$ = select(
-    this.state$,
-    state => state.patchsetNumberSelected
+    this.changeViewModel.checksPatchset$,
+    ps => ps
+  );
+
+  public checksSelectedAttemptNumber$ = select(
+    this.changeViewModel.attempt$,
+    attempt => attempt ?? LATEST_ATTEMPT
+  );
+
+  public runFilterRegexp$ = select(
+    this.changeViewModel.filter$,
+    filter => filter ?? ''
   );
 
   public checksLatest$ = select(this.state$, state => state.pluginStateLatest);
 
-  public checksSelected$ = select(this.state$, state =>
-    state.patchsetNumberSelected
-      ? state.pluginStateSelected
-      : state.pluginStateLatest
+  public checksSelected$ = select(
+    combineLatest([this.state$, this.changeViewModel.checksPatchset$]),
+    ([state, ps]) => {
+      const checksPs = ps ? ChecksPatchset.SELECTED : ChecksPatchset.LATEST;
+      return this.getPluginState(state, checksPs);
+    }
   );
 
   public aPluginHasRegistered$ = select(
@@ -283,7 +293,7 @@
     const messages = Object.values(state).map(
       providerState => providerState.summaryMessage
     );
-    return messages.filter(m => m !== undefined) as string[];
+    return messages.filter(m => !!m) as string[];
   });
 
   public topLevelActionsSelected$ = select(this.checksSelected$, state =>
@@ -365,6 +375,7 @@
 
   constructor(
     readonly routerModel: RouterModel,
+    readonly changeViewModel: ChangeViewModel,
     readonly changeModel: ChangeModel,
     readonly reporting: ReportingService,
     readonly pluginsModel: PluginsModel
@@ -376,6 +387,9 @@
     this.reporting.time(Timing.CHECKS_LOAD);
     this.subscriptions = [
       this.changeModel.changeNum$.subscribe(x => (this.changeNum = x)),
+      this.changeModel.latestPatchNum$.subscribe(
+        x => (this.latestPatchNum = x)
+      ),
       this.pluginsModel.checksPlugins$.subscribe(plugins => {
         for (const plugin of plugins) {
           this.register(plugin);
@@ -388,19 +402,6 @@
       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),
@@ -460,23 +461,19 @@
     this.reporting.reportInteraction(Interaction.CHECKS_STATS, stats);
   }
 
-  finalize() {
+  override finalize() {
     document.removeEventListener('reload', this.reloadListener);
     document.removeEventListener(
       'visibilitychange',
       this.visibilityChangeListener
     );
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
-    this.subject$.complete();
+    super.finalize();
   }
 
   // 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 nextState = {...this.getState()};
     const pluginState = this.getPluginState(nextState, patchset);
     pluginState[pluginName] = {
       pluginName,
@@ -486,7 +483,7 @@
       actions: [],
       links: [],
     };
-    this.subject$.next(nextState);
+    this.setState(nextState);
   }
 
   getPluginState(
@@ -503,13 +500,13 @@
   }
 
   updateStateSetLoading(pluginName: string, patchset: ChecksPatchset) {
-    const nextState = {...this.subject$.getValue()};
+    const nextState = {...this.getState()};
     const pluginState = this.getPluginState(nextState, patchset);
     pluginState[pluginName] = {
       ...pluginState[pluginName],
       loading: true,
     };
-    this.subject$.next(nextState);
+    this.setState(nextState);
   }
 
   updateStateSetError(
@@ -517,7 +514,7 @@
     errorMessage: string,
     patchset: ChecksPatchset
   ) {
-    const nextState = {...this.subject$.getValue()};
+    const nextState = {...this.getState()};
     const pluginState = this.getPluginState(nextState, patchset);
     pluginState[pluginName] = {
       ...pluginState[pluginName],
@@ -528,7 +525,7 @@
       runs: [],
       actions: [],
     };
-    this.subject$.next(nextState);
+    this.setState(nextState);
   }
 
   updateStateSetNotLoggedIn(
@@ -536,7 +533,7 @@
     loginCallback: () => void,
     patchset: ChecksPatchset
   ) {
-    const nextState = {...this.subject$.getValue()};
+    const nextState = {...this.getState()};
     const pluginState = this.getPluginState(nextState, patchset);
     pluginState[pluginName] = {
       ...pluginState[pluginName],
@@ -547,7 +544,7 @@
       runs: [],
       actions: [],
     };
-    this.subject$.next(nextState);
+    this.setState(nextState);
   }
 
   updateStateSetResults(
@@ -558,15 +555,13 @@
     summaryMessage: string | undefined,
     patchset: ChecksPatchset
   ) {
+    // Protect against plugins not respecting required fields.
+    runs = runs.filter(run => !!run.checkName && !!run.status);
     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)
-      );
+      attemptInfo.attempts.sort(sortAttemptDetails);
     }
-    const nextState = {...this.subject$.getValue()};
+    const nextState = {...this.getState()};
     const pluginState = this.getPluginState(nextState, patchset);
     const oldState = pluginState[pluginName];
     pluginState[pluginName] = {
@@ -581,9 +576,10 @@
         assertIsDefined(attemptInfo, 'attemptInfo');
         return {
           ...run,
+          attempt: run.attempt ?? 0,
           pluginName,
           internalRunId: runId,
-          isLatestAttempt: attemptInfo.latestAttempt === run.attempt,
+          isLatestAttempt: attemptInfo.latestAttempt === (run.attempt ?? 0),
           isSingleAttempt: attemptInfo.isSingleAttempt,
           attemptDetails: attemptInfo.attempts,
           results: (run.results ?? []).map((result, i) => {
@@ -598,7 +594,7 @@
       links: [...links],
       summaryMessage,
     };
-    this.subject$.next(nextState);
+    this.setState(nextState);
   }
 
   updateStateUpdateResult(
@@ -607,7 +603,7 @@
     updatedResult: CheckResultApi,
     patchset: ChecksPatchset
   ) {
-    const nextState = {...this.subject$.getValue()};
+    const nextState = {...this.getState()};
     const pluginState = this.getPluginState(nextState, patchset);
     let runUpdated = false;
     const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
@@ -637,17 +633,21 @@
       ...pluginState[pluginName],
       runs,
     };
-    this.subject$.next(nextState);
+    this.setState(nextState);
   }
 
-  updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
-    const nextState = {...this.subject$.getValue()};
-    nextState.patchsetNumberSelected = patchsetNumber;
-    this.subject$.next(nextState);
+  updateStateSetPatchset(num?: PatchSetNumber) {
+    this.changeViewModel.updateState({
+      checksPatchset: num === this.latestPatchNum ? undefined : num,
+    });
   }
 
-  setPatchset(num?: PatchSetNumber) {
-    this.updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
+  updateStateSetAttempt(attemptNumberSelected: AttemptChoice) {
+    this.changeViewModel.updateState({attempt: attemptNumberSelected});
+  }
+
+  updateStateSetRunFilter(runFilterRegexp: string) {
+    this.changeViewModel.updateState({filter: runFilterRegexp});
   }
 
   reload(pluginName: string) {
@@ -753,8 +753,10 @@
         patchset === ChecksPatchset.LATEST
           ? this.changeModel.latestPatchNum$
           : this.checksSelectedPatchsetNumber$,
-        this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
-        timer(0, pollIntervalMs),
+        this.reloadSubjects[pluginName].pipe(
+          throttleTime(1000, undefined, {trailing: true, leading: true})
+        ),
+        pollIntervalMs === 0 ? from([0]) : timer(0, pollIntervalMs),
         this.documentVisibilityChange$,
       ])
         .pipe(
@@ -782,7 +784,7 @@
             // 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}`);
+            this.reporting.error(`checks-model crash for ${pluginName}`, e);
             return of(this.createErrorResponse(pluginName, e));
           })
         )
@@ -835,15 +837,12 @@
     };
   }
 
-  private createErrorResponse(
-    pluginName: string,
-    message: object
-  ): FetchResponse {
+  private createErrorResponse(pluginName: string, error: Error): FetchResponse {
     return {
       responseCode: ResponseCode.ERROR,
       errorMessage:
         `Error message from plugin '${pluginName}':` +
-        ` ${JSON.stringify(message)}`,
+        ` ${JSON.stringify(error)}`,
     };
   }
 
@@ -860,8 +859,9 @@
         timer.end({pluginName});
         return response;
       });
-    return from(fetchPromise).pipe(
-      catchError(e => of(this.createErrorResponse(pluginName, e)))
-    );
+
+    return from(fetchPromise)
+      .pipe(timeout(FETCH_RESULT_TIMEOUT_MS))
+      .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
index fd3e38b..3489c5a 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -1,23 +1,13 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import './checks-model';
 import {ChecksModel, ChecksPatchset, ChecksProviderState} from './checks-model';
 import {
+  Action,
   Category,
   CheckRun,
   ChecksApiConfig,
@@ -30,6 +20,10 @@
 import {waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {ParsedChangeInfo} from '../../types/types';
 import {changeModelToken} from '../change/change-model';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {changeViewModelToken} from '../views/change';
+import {NumericChangeId, PatchSetNumber} from '../../api/rest-api';
 
 const PLUGIN_NAME = 'test-plugin';
 
@@ -50,8 +44,12 @@
   },
 ];
 
-const CONFIG: ChecksApiConfig = {
-  fetchPollingIntervalSeconds: 1000,
+const CONFIG_POLLING_5S: ChecksApiConfig = {
+  fetchPollingIntervalSeconds: 5,
+};
+
+const CONFIG_POLLING_NONE: ChecksApiConfig = {
+  fetchPollingIntervalSeconds: 0,
 };
 
 function createProvider(): ChecksProvider {
@@ -72,6 +70,7 @@
   setup(() => {
     model = new ChecksModel(
       getAppContext().routerModel,
+      testResolver(changeViewModelToken),
       testResolver(changeModelToken),
       getAppContext().reportingService,
       getAppContext().pluginsModel
@@ -89,13 +88,67 @@
     const provider = createProvider();
     const fetchSpy = sinon.spy(provider, 'fetch');
 
-    model.register({pluginName: 'test-plugin', provider, config: CONFIG});
+    model.register({
+      pluginName: 'test-plugin',
+      provider,
+      config: CONFIG_POLLING_NONE,
+    });
     await waitUntil(() => change === undefined);
 
     const testChange = createParsedChange();
     model.changeModel.updateStateChange(testChange);
     await waitUntil(() => change === testChange);
     await waitUntilCalled(fetchSpy, 'fetch');
+
+    assert.equal(
+      model.latestPatchNum,
+      testChange.revisions[testChange.current_revision]
+        ._number as PatchSetNumber
+    );
+    assert.equal(model.changeNum, testChange._number);
+  });
+
+  test('reload throttle', async () => {
+    const clock = sinon.useFakeTimers();
+    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_POLLING_NONE,
+    });
+    await waitUntil(() => change === undefined);
+
+    const testChange = createParsedChange();
+    model.changeModel.updateStateChange(testChange);
+    await waitUntil(() => change === testChange);
+    clock.tick(1);
+    assert.equal(fetchSpy.callCount, 1);
+
+    // The second reload call will be processed, but only after a 1s throttle.
+    model.reload('test-plugin');
+    clock.tick(100);
+    assert.equal(fetchSpy.callCount, 1);
+    // 2000 ms is greater than the 1000 ms throttle time.
+    clock.tick(2000);
+    assert.equal(fetchSpy.callCount, 2);
+  });
+
+  test('triggerAction', async () => {
+    model.changeNum = 314 as NumericChangeId;
+    model.latestPatchNum = 13 as PatchSetNumber;
+    const action: Action = {
+      name: 'test action',
+      callback: () => undefined,
+    };
+    const spy = sinon.spy(action, 'callback');
+    model.triggerAction(action, undefined, 'none');
+    assert.isTrue(spy.calledOnce);
+    assert.equal(spy.lastCall.args[0], 314);
+    assert.equal(spy.lastCall.args[1], 13);
   });
 
   test('model.updateStateSetProvider', () => {
@@ -156,6 +209,35 @@
     assert.lengthOf(current.runs[0].results!, 1);
   });
 
+  test('model.updateStateSetResults ignore empty name or status', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      [
+        {
+          checkName: 'test-check-name',
+          status: RunStatus.COMPLETED,
+        },
+        // Will be ignored, because the checkName is empty.
+        {
+          checkName: undefined as unknown as string,
+          status: RunStatus.COMPLETED,
+        },
+        // Will be ignored, because the status is empty.
+        {
+          checkName: 'test-check-name',
+          status: undefined as unknown as RunStatus,
+        },
+      ],
+      [],
+      [],
+      undefined,
+      ChecksPatchset.LATEST
+    );
+    // 2 out of 3 runs are ignored.
+    assert.lengthOf(current.runs, 1);
+  });
+
   test('model.updateStateUpdateResult', () => {
     model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
     model.updateStateSetResults(
@@ -182,4 +264,58 @@
     assert.lengthOf(current.runs[0].results!, 1);
     assert.equal(current.runs[0].results![0].summary, 'new');
   });
+
+  test('polls for changes', async () => {
+    const clock = sinon.useFakeTimers();
+    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_POLLING_5S,
+    });
+    await waitUntil(() => change === undefined);
+    clock.tick(1);
+    const testChange = createParsedChange();
+    model.changeModel.updateStateChange(testChange);
+    await waitUntil(() => change === testChange);
+    await waitUntilCalled(fetchSpy, 'fetch');
+    clock.tick(1);
+    const pollCount = fetchSpy.callCount;
+
+    // polling should continue while we wait
+    clock.tick(CONFIG_POLLING_5S.fetchPollingIntervalSeconds * 1000 * 2);
+
+    assert.isTrue(fetchSpy.callCount > pollCount);
+  });
+
+  test('does not poll when config specifies 0 seconds', async () => {
+    const clock = sinon.useFakeTimers();
+    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_POLLING_NONE,
+    });
+    await waitUntil(() => change === undefined);
+    clock.tick(1);
+    const testChange = createParsedChange();
+    model.changeModel.updateStateChange(testChange);
+    await waitUntil(() => change === testChange);
+    await waitUntilCalled(fetchSpy, 'fetch');
+    clock.tick(1);
+    const pollCount = fetchSpy.callCount;
+
+    // polling should not happen
+    clock.tick(60 * 1000);
+
+    assert.equal(fetchSpy.callCount, pollCount);
+  });
 });
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 2f2fd9e..7ccdf91 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -1,57 +1,59 @@
 /**
  * @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 {
   Action,
   Category,
   CheckResult as CheckResultApi,
   CheckRun as CheckRunApi,
+  Fix,
   Link,
   LinkIcon,
+  Replacement,
   RunStatus,
 } from '../../api/checks';
-import {assertNever} from '../../utils/common-util';
-import {CheckResult, CheckRun} from './checks-model';
+import {PatchSetNumber} from '../../api/rest-api';
+import {FixSuggestionInfo, FixReplacementInfo} from '../../types/common';
+import {OpenFixPreviewEventDetail} from '../../types/events';
+import {notUndefined} from '../../types/types';
+import {PROVIDED_FIX_ID} from '../../utils/comment-util';
+import {assert, assertNever} from '../../utils/common-util';
+import {fire} from '../../utils/event-util';
+import {CheckResult, CheckRun, RunResult} from './checks-model';
 
-export function iconForLink(linkIcon?: LinkIcon) {
-  if (linkIcon === undefined) return 'launch';
+export interface ChecksIcon {
+  name: string;
+  filled?: boolean;
+}
+
+export function iconForLink(linkIcon?: LinkIcon): ChecksIcon {
+  if (linkIcon === undefined) return {name: 'open_in_new'};
   switch (linkIcon) {
     case LinkIcon.EXTERNAL:
-      return 'launch';
+      return {name: 'open_in_new'};
     case LinkIcon.IMAGE:
-      return 'insert-photo';
+      return {name: 'image', filled: true};
     case LinkIcon.HISTORY:
-      return 'restore';
+      return {name: 'history'};
     case LinkIcon.DOWNLOAD:
-      return 'download';
+      return {name: 'download'};
     case LinkIcon.DOWNLOAD_MOBILE:
-      return 'system-update';
+      return {name: 'system_update'};
     case LinkIcon.HELP_PAGE:
-      return 'help-outline';
+      return {name: 'help'};
     case LinkIcon.REPORT_BUG:
-      return 'bug';
+      return {name: 'bug_report', filled: true};
     case LinkIcon.CODE:
-      return 'code';
+      return {name: 'code'};
     case LinkIcon.FILE_PRESENT:
-      return 'file-present';
+      return {name: 'file_present'};
     default:
       // We don't throw an assertion error here, because plugins don't have to
       // be written in TypeScript, so we may encounter arbitrary strings for
       // linkIcon.
-      return 'launch';
+      return {name: 'open_in_new'};
   }
 }
 
@@ -84,6 +86,59 @@
   }
 }
 
+export function createFixAction(
+  target: EventTarget,
+  result?: RunResult
+): Action | undefined {
+  if (!result?.patchset) return;
+  if (!result?.fixes) return;
+  const fixSuggestions = result.fixes
+    .map(f => rectifyFix(f, result?.checkName))
+    .filter(notUndefined);
+  if (fixSuggestions.length === 0) return;
+  const eventDetail: OpenFixPreviewEventDetail = {
+    patchNum: result.patchset as PatchSetNumber,
+    fixSuggestions,
+  };
+  return {
+    name: 'Show Fix',
+    callback: () => {
+      fire(target, 'open-fix-preview', eventDetail);
+      return undefined;
+    },
+  };
+}
+
+export function rectifyFix(
+  fix: Fix | undefined,
+  checkName: string
+): FixSuggestionInfo | undefined {
+  if (!fix?.replacements) return undefined;
+  const replacements = fix.replacements
+    .map(rectifyReplacement)
+    .filter(notUndefined);
+  if (replacements.length === 0) return undefined;
+
+  return {
+    description: fix.description ?? `Fix provided by ${checkName}`,
+    fix_id: PROVIDED_FIX_ID,
+    replacements,
+  };
+}
+
+export function rectifyReplacement(
+  r: Replacement | undefined
+): FixReplacementInfo | undefined {
+  if (!r?.path) return undefined;
+  if (!r?.range) return undefined;
+  if (r?.replacement === undefined) return undefined;
+  if (!Number.isInteger(r.range.start_line)) return undefined;
+  if (!Number.isInteger(r.range.end_line)) return undefined;
+  if (!Number.isInteger(r.range.start_character)) return undefined;
+  if (!Number.isInteger(r.range.end_character)) return undefined;
+  return r;
+}
+
 export function worstCategory(run: CheckRun) {
   if (hasResultsOf(run, Category.ERROR)) return Category.ERROR;
   if (hasResultsOf(run, Category.WARNING)) return Category.WARNING;
@@ -135,25 +190,25 @@
   }
 }
 
-export function iconFor(catStat: Category | RunStatus) {
+export function iconFor(catStat: Category | RunStatus): ChecksIcon {
   switch (catStat) {
     case Category.ERROR:
-      return 'error';
+      return {name: 'error', filled: true};
     case Category.INFO:
-      return 'info-outline';
+      return {name: 'info'};
     case Category.WARNING:
-      return 'warning';
+      return {name: 'warning', filled: true};
     case Category.SUCCESS:
-      return 'check-circle-outline';
+      return {name: 'check_circle'};
     // Note that this is only for COMPLETED without results!
     case RunStatus.COMPLETED:
-      return 'check-circle-outline';
+      return {name: 'check_circle'};
     case RunStatus.RUNNABLE:
-      return 'placeholder';
+      return {name: ''};
     case RunStatus.RUNNING:
-      return 'timelapse';
+      return {name: 'timelapse'};
     case RunStatus.SCHEDULED:
-      return 'scheduled';
+      return {name: 'pending_actions'};
     default:
       assertNever(catStat, `Unsupported category/status: ${catStat}`);
   }
@@ -305,27 +360,79 @@
 }
 
 export interface AttemptDetail {
-  attempt: number | undefined;
-  icon: string;
+  attempt?: AttemptChoice;
+  icon?: ChecksIcon;
 }
 
 export interface AttemptInfo {
-  latestAttempt: number | undefined;
+  latestAttempt: AttemptChoice;
   isSingleAttempt: boolean;
   attempts: AttemptDetail[];
 }
 
+export type AttemptChoice = number | 'latest' | 'all';
+export const ALL_ATTEMPTS = 'all' as AttemptChoice;
+export const LATEST_ATTEMPT = 'latest' as AttemptChoice;
+
+export function isAttemptChoice(x: number | string): x is AttemptChoice {
+  if (typeof x === 'string') {
+    return x === ALL_ATTEMPTS || x === LATEST_ATTEMPT;
+  }
+  if (typeof x === 'number') {
+    return x >= 0;
+  }
+  return false;
+}
+
+export function stringToAttemptChoice(
+  s?: string | null
+): AttemptChoice | undefined {
+  if (s === undefined) return undefined;
+  if (s === null) return undefined;
+  if (s === '') return undefined;
+  if (isAttemptChoice(s)) return s;
+  const n = Number(s);
+  if (isAttemptChoice(n)) return n;
+  return undefined;
+}
+
+export function attemptChoiceLabel(attempt: AttemptChoice): string {
+  if (attempt === LATEST_ATTEMPT) return 'Latest Attempt';
+  if (attempt === ALL_ATTEMPTS) return 'All Attempts';
+  return `Attempt ${attempt}`;
+}
+
+export function sortAttemptDetails(a: AttemptDetail, b: AttemptDetail): number {
+  return sortAttemptChoices(a.attempt, b.attempt);
+}
+
+export function sortAttemptChoices(
+  a?: AttemptChoice,
+  b?: AttemptChoice
+): number {
+  if (a === b) return 0;
+  if (a === undefined) return -1;
+  if (b === undefined) return 1;
+  if (a === LATEST_ATTEMPT) return -1;
+  if (b === LATEST_ATTEMPT) return 1;
+  if (a === ALL_ATTEMPTS) return -1;
+  if (b === ALL_ATTEMPTS) return 1;
+  assert(typeof a === 'number', `unexpected attempt ${a}`);
+  assert(typeof b === 'number', `unexpected attempt ${b}`);
+  return a - b;
+}
+
 export function createAttemptMap(runs: CheckRunApi[]) {
   const map = new Map<string, AttemptInfo>();
   for (const run of runs) {
     const value = map.get(run.checkName);
-    const detail = {
-      attempt: run.attempt,
+    const detail: AttemptDetail = {
+      attempt: run.attempt ?? 0,
       icon: iconForRun(fromApiToInternalRun(run)),
     };
     if (value === undefined) {
       map.set(run.checkName, {
-        latestAttempt: run.attempt,
+        latestAttempt: run.attempt ?? 0,
         isSingleAttempt: true,
         attempts: [detail],
       });
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
new file mode 100644
index 0000000..c237c59
--- /dev/null
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -0,0 +1,110 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import './checks-model';
+import {assert} from '@open-wc/testing';
+import {
+  ALL_ATTEMPTS,
+  AttemptChoice,
+  LATEST_ATTEMPT,
+  rectifyFix,
+  sortAttemptChoices,
+  stringToAttemptChoice,
+} from './checks-util';
+import {Fix, Replacement} from '../../api/checks';
+import {CommentRange} from '../../api/core';
+import {PROVIDED_FIX_ID} from '../../utils/comment-util';
+
+suite('checks-util tests', () => {
+  setup(() => {});
+
+  teardown(() => {});
+
+  test('stringToAttemptChoice', () => {
+    assert.equal(stringToAttemptChoice('0'), 0);
+    assert.equal(stringToAttemptChoice('1'), 1);
+    assert.equal(stringToAttemptChoice('999'), 999);
+    assert.equal(stringToAttemptChoice('latest'), 'latest');
+    assert.equal(stringToAttemptChoice('all'), 'all');
+
+    assert.equal(stringToAttemptChoice(undefined), undefined);
+    assert.equal(stringToAttemptChoice(''), undefined);
+    assert.equal(stringToAttemptChoice('asdf'), undefined);
+    assert.equal(stringToAttemptChoice('-1'), undefined);
+    assert.equal(stringToAttemptChoice('1x'), undefined);
+  });
+
+  test('rectifyFix', () => {
+    assert.isUndefined(rectifyFix(undefined, 'name'));
+    assert.isUndefined(rectifyFix({} as Fix, 'name'));
+    assert.isUndefined(
+      rectifyFix({description: 'asdf', replacements: []}, 'name')
+    );
+    assert.isUndefined(
+      rectifyFix(
+        {description: 'asdf', replacements: [{} as Replacement]},
+        'test-check-name'
+      )
+    );
+    assert.isUndefined(
+      rectifyFix(
+        {
+          description: 'asdf',
+          replacements: [
+            {
+              path: 'test-path',
+              range: {} as CommentRange,
+              replacement: 'test-replacement-string',
+            },
+          ],
+        },
+        'test-check-name'
+      )
+    );
+    const rectified = rectifyFix(
+      {
+        replacements: [
+          {
+            path: 'test-path',
+            range: {
+              start_line: 1,
+              end_line: 1,
+              start_character: 0,
+              end_character: 1,
+            } as CommentRange,
+            replacement: 'test-replacement-string',
+          },
+        ],
+      },
+      'test-check-name'
+    );
+    assert.isDefined(rectified);
+    assert.equal(rectified?.description, 'Fix provided by test-check-name');
+    assert.equal(rectified?.fix_id, PROVIDED_FIX_ID);
+  });
+
+  test('sortAttemptChoices', () => {
+    const unsorted: (AttemptChoice | undefined)[] = [
+      3,
+      1,
+      LATEST_ATTEMPT,
+      ALL_ATTEMPTS,
+      undefined,
+      0,
+      999,
+    ];
+    const sortedExpected: (AttemptChoice | undefined)[] = [
+      LATEST_ATTEMPT,
+      ALL_ATTEMPTS,
+      0,
+      1,
+      3,
+      999,
+      undefined,
+    ];
+    assert.deepEqual(unsorted.sort(sortAttemptChoices), sortedExpected);
+  });
+});
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index 769d7af..b0ad417 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
 import {
   CommentBasics,
@@ -26,11 +14,13 @@
   PathToCommentsInfoMap,
   RobotCommentInfo,
   PathToRobotCommentsInfoMap,
+  AccountInfo,
 } from '../../types/common';
 import {
   addPath,
   DraftInfo,
   isDraft,
+  isDraftThread,
   isUnsaved,
   reportingDetails,
   UnsavedInfo,
@@ -40,7 +30,7 @@
 import {RouterModel} from '../../services/router/router-model';
 import {Finalizable} from '../../services/registry';
 import {define} from '../dependency';
-import {combineLatest, Subscription} from 'rxjs';
+import {combineLatest, forkJoin, from, Observable, of} 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';
@@ -52,6 +42,17 @@
 import {ReportingService} from '../../services/gr-reporting/gr-reporting';
 import {Model} from '../model';
 import {Deduping} from '../../api/reporting';
+import {extractMentionedUsers, getUserId} from '../../utils/account-util';
+import {EventType} from '../../types/events';
+import {SpecialFilePath} from '../../constants/constants';
+import {AccountsModel} from '../accounts-model/accounts-model';
+import {
+  distinctUntilChanged,
+  map,
+  shareReplay,
+  switchMap,
+} from 'rxjs/operators';
+import {notUndefined} from '../../types/types';
 
 export interface CommentState {
   /** undefined means 'still loading' */
@@ -238,11 +239,26 @@
     commentState => commentState.comments
   );
 
+  public readonly robotComments$ = select(
+    this.state$,
+    commentState => commentState.robotComments
+  );
+
+  public readonly robotCommentCount$ = select(
+    this.robotComments$,
+    robotComments => Object.values(robotComments ?? {}).flat().length
+  );
+
   public readonly drafts$ = select(
     this.state$,
     commentState => commentState.drafts
   );
 
+  public readonly draftsCount$ = select(
+    this.drafts$,
+    drafts => Object.values(drafts ?? {}).flat().length
+  );
+
   public readonly portedComments$ = select(
     this.state$,
     commentState => commentState.portedComments
@@ -253,6 +269,68 @@
     commentState => commentState.discardedDrafts
   );
 
+  public readonly patchsetLevelDrafts$ = select(this.drafts$, drafts =>
+    Object.values(drafts ?? {})
+      .flat()
+      .filter(
+        draft =>
+          draft.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS &&
+          !draft.in_reply_to
+      )
+  );
+
+  public readonly mentionedUsersInDrafts$: Observable<AccountInfo[]> =
+    this.drafts$.pipe(
+      switchMap(drafts => {
+        const users: AccountInfo[] = [];
+        const comments = Object.values(drafts ?? {}).flat();
+        for (const comment of comments) {
+          users.push(...extractMentionedUsers(comment.message));
+        }
+        const uniqueUsers = users.filter(
+          (user, index) =>
+            index === users.findIndex(u => getUserId(u) === getUserId(user))
+        );
+        // forkJoin only emits value when the array is non-empty
+        if (uniqueUsers.length === 0) {
+          return of(uniqueUsers);
+        }
+        const filledUsers$: Observable<AccountInfo | undefined>[] =
+          uniqueUsers.map(user => from(this.accountsModel.fillDetails(user)));
+        return forkJoin(filledUsers$);
+      }),
+      map(users => users.filter(notUndefined)),
+      distinctUntilChanged(deepEqual),
+      shareReplay(1)
+    );
+
+  public readonly mentionedUsersInUnresolvedDrafts$: Observable<AccountInfo[]> =
+    this.drafts$.pipe(
+      switchMap(drafts => {
+        const users: AccountInfo[] = [];
+        const comments = Object.values(drafts ?? {})
+          .flat()
+          .filter(c => c.unresolved);
+        for (const comment of comments) {
+          users.push(...extractMentionedUsers(comment.message));
+        }
+        const uniqueUsers = users.filter(
+          (user, index) =>
+            index === users.findIndex(u => getUserId(u) === getUserId(user))
+        );
+        // forkJoin only emits value when the array is non-empty
+        if (uniqueUsers.length === 0) {
+          return of(uniqueUsers);
+        }
+        const filledUsers$: Observable<AccountInfo | undefined>[] =
+          uniqueUsers.map(user => from(this.accountsModel.fillDetails(user)));
+        return forkJoin(filledUsers$);
+      }),
+      map(users => users.filter(notUndefined)),
+      distinctUntilChanged(deepEqual),
+      shareReplay(1)
+    );
+
   // 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(
@@ -271,6 +349,23 @@
     changeComments.getAllThreadsForChange()
   );
 
+  public readonly draftThreads$ = select(this.threads$, threads =>
+    threads.filter(isDraftThread)
+  );
+
+  public readonly commentedPaths$ = select(
+    combineLatest([
+      this.changeComments$,
+      this.changeModel.basePatchNum$,
+      this.changeModel.patchNum$,
+    ]),
+    ([changeComments, basePatchNum, patchNum]) => {
+      if (!patchNum) return [];
+      const pathsMap = changeComments.getPaths({basePatchNum, patchNum});
+      return Object.keys(pathsMap);
+    }
+  );
+
   public thread$(id: UrlEncodedCommentId) {
     return select(this.threads$, threads => threads.find(t => t.rootId === id));
   }
@@ -283,8 +378,6 @@
 
   private readonly reloadListener: () => void;
 
-  private readonly subscriptions: Subscription[] = [];
-
   private drafts: {[path: string]: DraftInfo[]} = {};
 
   private draftToastTask?: DelayedTask;
@@ -294,6 +387,7 @@
   constructor(
     readonly routerModel: RouterModel,
     readonly changeModel: ChangeModel,
+    readonly accountsModel: AccountsModel,
     readonly restApiService: RestApiService,
     readonly reporting: ReportingService
   ) {
@@ -305,7 +399,7 @@
       this.drafts$.subscribe(x => (this.drafts = x ?? {}))
     );
     this.subscriptions.push(
-      this.changeModel.currentPatchNum$.subscribe(x => (this.patchNum = x))
+      this.changeModel.patchNum$.subscribe(x => (this.patchNum = x))
     );
     this.subscriptions.push(
       this.routerModel.routerChangeNum$.subscribe(changeNum => {
@@ -317,7 +411,7 @@
     this.subscriptions.push(
       combineLatest([
         this.changeModel.changeNum$,
-        this.changeModel.currentPatchNum$,
+        this.changeModel.patchNum$,
       ]).subscribe(([changeNum, patchNum]) => {
         this.changeNum = changeNum;
         this.patchNum = patchNum;
@@ -331,12 +425,9 @@
     document.addEventListener('reload', this.reloadListener);
   }
 
-  finalize() {
+  override finalize() {
     document.removeEventListener('reload', this.reloadListener);
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions.splice(0, this.subscriptions.length);
+    super.finalize();
   }
 
   // Note that this does *not* reload ported comments.
@@ -359,19 +450,13 @@
   }
 
   // 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);
+  modifyState(reducer: (state: CommentState) => CommentState) {
+    this.setState(reducer({...this.getState()}));
   }
 
   async reloadComments(changeNum: NumericChangeId): Promise<void> {
     const comments = await this.restApiService.getDiffComments(changeNum);
-    this.updateState(s => setComments(s, comments));
+    this.modifyState(s => setComments(s, comments));
   }
 
   async reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
@@ -379,7 +464,7 @@
       changeNum
     );
     this.reportRobotCommentStats(robotComments);
-    this.updateState(s => setRobotComments(s, robotComments));
+    this.modifyState(s => setRobotComments(s, robotComments));
   }
 
   private reportRobotCommentStats(obj?: PathToRobotCommentsInfoMap) {
@@ -412,7 +497,7 @@
 
   async reloadDrafts(changeNum: NumericChangeId): Promise<void> {
     const drafts = await this.restApiService.getDiffDrafts(changeNum);
-    this.updateState(s => setDrafts(s, drafts));
+    this.modifyState(s => setDrafts(s, drafts));
   }
 
   async reloadPortedComments(
@@ -423,7 +508,7 @@
       changeNum,
       patchNum
     );
-    this.updateState(s => setPortedComments(s, portedComments));
+    this.modifyState(s => setPortedComments(s, portedComments));
   }
 
   async reloadPortedDrafts(
@@ -434,7 +519,7 @@
       changeNum,
       patchNum
     );
-    this.updateState(s => setPortedDrafts(s, portedDrafts));
+    this.modifyState(s => setPortedDrafts(s, portedDrafts));
   }
 
   async restoreDraft(id: UrlEncodedCommentId) {
@@ -448,7 +533,7 @@
       __unsaved: true,
     };
     await this.saveDraft(newDraft);
-    this.updateState(s => deleteDiscardedDraft(s, id));
+    this.modifyState(s => deleteDiscardedDraft(s, id));
   }
 
   /**
@@ -493,7 +578,7 @@
     };
     timer.end({id: updatedDraft.id});
     if (showToast) this.showEndRequest();
-    this.updateState(s => setDraft(s, updatedDraft));
+    this.modifyState(s => setDraft(s, updatedDraft));
     this.report(Interaction.COMMENT_SAVED, updatedDraft);
     return updatedDraft;
   }
@@ -525,10 +610,10 @@
       );
     }
     this.showEndRequest();
-    this.updateState(s => deleteDraft(s, draft));
+    this.modifyState(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', {
+      fire(document, EventType.SHOW_ALERT, {
         message: 'Draft Discarded',
         action: 'Undo',
         callback: () => this.restoreDraft(draft.id),
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index 01e2f6b..32ea1bc 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -1,27 +1,21 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-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 '../../test/common-test-setup';
+import {
+  createAccountWithEmail,
+  createDraft,
+} from '../../test/test-data-generators';
+import {
+  AccountInfo,
+  EmailAddress,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../types/common';
+import {CommentsModel, deleteDraft} from './comments-model';
 import {Subscription} from 'rxjs';
-import '../../test/common-test-setup-karma';
 import {
   createComment,
   createParsedChange,
@@ -32,6 +26,8 @@
 import {GerritView} from '../../services/router/router-model';
 import {PathToCommentsInfoMap} from '../../types/common';
 import {changeModelToken} from '../change/change-model';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
 
 suite('comments model tests', () => {
   test('updateStateDeleteDraft', () => {
@@ -75,6 +71,7 @@
     const model = new CommentsModel(
       getAppContext().routerModel,
       testResolver(changeModelToken),
+      getAppContext().accountsModel,
       getAppContext().restApiService,
       getAppContext().reportingService
     );
@@ -100,7 +97,7 @@
       model.portedComments$.subscribe(c => (portedComments = c ?? {}))
     );
 
-    model.routerModel.updateState({
+    model.routerModel.setState({
       view: GerritView.CHANGE,
       changeNum: TEST_NUMERIC_CHANGE_ID,
     });
@@ -125,4 +122,68 @@
     assert.equal(portedComments['foo.c'].length, 1);
     assert.equal(portedComments['foo.c'][0].id, '12345');
   });
+
+  test('duplicate mentions are filtered out', async () => {
+    const account = {
+      ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+      registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+    };
+    stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+    const model = new CommentsModel(
+      getAppContext().routerModel,
+      testResolver(changeModelToken),
+      getAppContext().accountsModel,
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
+    let mentionedUsers: AccountInfo[] = [];
+    const draft = {...createDraft(), message: 'hey @abc@def.com'};
+    model.mentionedUsersInDrafts$.subscribe(x => (mentionedUsers = x));
+    model.setState({
+      drafts: {
+        'abc.txt': [draft, draft],
+      },
+      discardedDrafts: [],
+    });
+
+    await waitUntil(() => mentionedUsers.length > 0);
+
+    assert.deepEqual(mentionedUsers, [account]);
+  });
+
+  test('empty mentions are emitted', async () => {
+    const account = {
+      ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+      registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+    };
+    stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+    const model = new CommentsModel(
+      getAppContext().routerModel,
+      testResolver(changeModelToken),
+      getAppContext().accountsModel,
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
+    let mentionedUsers: AccountInfo[] = [];
+    const draft = {...createDraft(), message: 'hey @abc@def.com'};
+    model.mentionedUsersInDrafts$.subscribe(x => (mentionedUsers = x));
+    model.setState({
+      drafts: {
+        'abc.txt': [draft],
+      },
+      discardedDrafts: [],
+    });
+
+    await waitUntil(() => mentionedUsers.length > 0);
+
+    assert.deepEqual(mentionedUsers, [account]);
+
+    model.setState({
+      drafts: {
+        'abc.txt': [],
+      },
+      discardedDrafts: [],
+    });
+    await waitUntil(() => mentionedUsers.length === 0);
+  });
 });
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
index 2fd8a41..6e374d1 100644
--- a/polygerrit-ui/app/models/config/config-model.ts
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
-import {from, of, Subscription} from 'rxjs';
+import {from, of} from 'rxjs';
 import {switchMap} from 'rxjs/operators';
 import {Finalizable} from '../../services/registry';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
@@ -23,6 +12,7 @@
 import {select} from '../../utils/observable-util';
 import {Model} from '../model';
 import {define} from '../dependency';
+import {getDocsBaseUrl} from '../../utils/url-util';
 
 export interface ConfigState {
   repoConfig?: ConfigInfo;
@@ -46,7 +36,19 @@
     configState => configState.serverConfig
   );
 
-  private subscriptions: Subscription[];
+  public mergeabilityComputationBehavior$ = select(
+    this.serverConfig$,
+    serverConfig => serverConfig?.change?.mergeability_computation_behavior
+  );
+
+  public docsBaseUrl$ = select(
+    this.serverConfig$.pipe(
+      switchMap(serverConfig =>
+        from(getDocsBaseUrl(serverConfig, this.restApiService))
+      )
+    ),
+    url => url
+  );
 
   constructor(
     readonly changeModel: ChangeModel,
@@ -70,20 +72,13 @@
     ];
   }
 
+  // visible for testing
   updateRepoConfig(repoConfig?: ConfigInfo) {
-    const current = this.subject$.getValue();
-    this.subject$.next({...current, repoConfig});
+    this.updateState({repoConfig});
   }
 
+  // visible for testing
   updateServerConfig(serverConfig?: ServerInfo) {
-    const current = this.subject$.getValue();
-    this.subject$.next({...current, serverConfig});
-  }
-
-  finalize() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
+    this.updateState({serverConfig});
   }
 }
diff --git a/polygerrit-ui/app/models/dependency.ts b/polygerrit-ui/app/models/dependency.ts
index e7ac242c..5499db2 100644
--- a/polygerrit-ui/app/models/dependency.ts
+++ b/polygerrit-ui/app/models/dependency.ts
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {ReactiveController, ReactiveControllerHost} from 'lit';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 
 /**
  * This module provides the ability to do dependency injection in components.
@@ -102,7 +90,7 @@
  * Type Safety
  * ---
  *
- * Dependency injection is guaranteed npmtype-safe by construction due to the
+ * Dependency injection is guaranteed type-safe by construction due to the
  * typing of the token used to tie together dependency providers and dependency
  * consumers.
  *
@@ -133,16 +121,38 @@
  */
 export type Provider<T> = () => T;
 
+// Symbols to cache the providers and resolvers to avoid duplicate registration.
+const PROVIDERS_SYMBOL = Symbol('providers');
+const RESOLVERS_SYMBOL = Symbol('resolvers');
+
+interface Registrations {
+  [PROVIDERS_SYMBOL]?: Map<
+    DependencyToken<unknown>,
+    DependencyProvider<unknown>
+  >;
+  [RESOLVERS_SYMBOL]?: Map<DependencyToken<unknown>, Provider<unknown>>;
+}
 /**
  * A producer of a dependency expresses this as a need that results in a promise
  * for the given dependency.
  */
 export function provide<T>(
-  host: ReactiveControllerHost & HTMLElement,
+  host: ReactiveControllerHost & HTMLElement & Registrations,
   dependency: DependencyToken<T>,
   provider: Provider<T>
 ) {
-  host.addController(new DependencyProvider<T>(host, dependency, provider));
+  const hostProviders = (host[PROVIDERS_SYMBOL] ||= new Map<
+    DependencyToken<unknown>,
+    DependencyProvider<unknown>
+  >());
+  const oldController = hostProviders.get(dependency);
+  if (oldController) {
+    host.removeController(oldController);
+    oldController.hostDisconnected();
+  }
+  const controller = new DependencyProvider<T>(host, dependency, provider);
+  hostProviders.set(dependency, controller);
+  host.addController(controller);
 }
 
 /**
@@ -151,55 +161,21 @@
  * the injected value.
  */
 export function resolve<T>(
-  host: ReactiveControllerHost & HTMLElement,
+  host: ReactiveControllerHost & HTMLElement & Registrations,
   dependency: DependencyToken<T>
 ): Provider<T> {
-  const controller = new DependencySubscriber(host, dependency);
-  host.addController(controller);
-  return () => controller.get();
-}
-
-/**
- * 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();
+  const hostResolvers = (host[RESOLVERS_SYMBOL] ||= new Map<
+    DependencyToken<unknown>,
+    Provider<unknown>
+  >());
+  let resolver = hostResolvers.get(dependency);
+  if (!resolver) {
+    const controller = new DependencySubscriber(host, dependency);
+    host.addController(controller);
+    resolver = () => controller.get();
+    hostResolvers.set(dependency, resolver);
   }
-
-  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);
-  }
+  return resolver as Provider<T>;
 }
 
 /**
@@ -249,7 +225,7 @@
 }
 
 /**
- * A resolved dependency is valid within the econnectd lifetime of a component,
+ * A resolved dependency is valid within the connected lifetime of a component,
  * namely between connectedCallback and disconnectedCallback.
  */
 interface ResolvedDependency<T> {
@@ -280,6 +256,8 @@
   }
 
   hostConnected() {
+    this.value = undefined;
+    this.resolved = false;
     this.host.dispatchEvent(
       new DependencyRequestEvent(this.dependency, (value: T) => {
         this.resolved = true;
@@ -296,11 +274,6 @@
     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 {
diff --git a/polygerrit-ui/app/models/dependency_test.ts b/polygerrit-ui/app/models/dependency_test.ts
index fa7cc29..c52765d 100644
--- a/polygerrit-ui/app/models/dependency_test.ts
+++ b/polygerrit-ui/app/models/dependency_test.ts
@@ -1,26 +1,13 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {define, provide, resolve, DIPolymerElement} from './dependency';
+import {define, provide, resolve} 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';
+import {customElement, property, query} from 'lit/decorators.js';
+import '../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
 
 interface FooService {
   value: string;
@@ -67,33 +54,11 @@
   }
 }
 
-@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());
@@ -106,11 +71,7 @@
   }
 
   override render() {
-    if (this.showLit) {
-      return html`<leaf-lit-element></leaf-lit-element>`;
-    } else {
-      return html`<leaf-polymer-element></leaf-polymer-element>`;
-    }
+    return html`<leaf-lit-element></leaf-lit-element>`;
   }
 }
 
@@ -128,39 +89,18 @@
   }
 }
 
-@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;
+  let element: LitFooProviderElement;
+
+  setup(async () => {
+    element = await fixture(html`<lit-foo-provider></lit-foo-provider>`);
+  });
+
+  test('It instantiates', () => {
     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;
@@ -171,29 +111,12 @@
     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
index 88b2786..5e01373 100644
--- a/polygerrit-ui/app/models/di-provider-element.ts
+++ b/polygerrit-ui/app/models/di-provider-element.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {css, html, LitElement, TemplateResult} from 'lit';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
 import {DependencyToken, provide} from './dependency';
 
 /**
diff --git a/polygerrit-ui/app/models/di-provider-element_test.ts b/polygerrit-ui/app/models/di-provider-element_test.ts
index 83feac7..6f0ac4a 100644
--- a/polygerrit-ui/app/models/di-provider-element_test.ts
+++ b/polygerrit-ui/app/models/di-provider-element_test.ts
@@ -4,10 +4,10 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {html, LitElement} from 'lit';
-import {customElement, state} from 'lit/decorators';
+import {customElement, state} from 'lit/decorators.js';
 import {define, resolve} from './dependency';
-import '../test/common-test-setup-karma.js';
-import {fixture} from '@open-wc/testing-helpers';
+import '../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
 import {DIProviderElement, wrapInProvider} from './di-provider-element';
 import {BehaviorSubject} from 'rxjs';
 import {waitUntilObserved} from '../test/test-utils';
@@ -26,9 +26,13 @@
   @state()
   private injectedValue = '';
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.getModel(), value => (this.injectedValue = value));
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getModel(),
+      value => (this.injectedValue = value)
+    );
   }
 
   override render() {
@@ -56,13 +60,13 @@
   });
 
   test('provides values to the wrapped element', () => {
-    expect(element).shadowDom.to.equal('<div>foo</div>');
+    assert.shadowDom.equal(element, '<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>');
+    assert.shadowDom.equal(element, '<div>bar</div>');
   });
 });
diff --git a/polygerrit-ui/app/models/model.ts b/polygerrit-ui/app/models/model.ts
index 4a7f5ac..19b52fc 100644
--- a/polygerrit-ui/app/models/model.ts
+++ b/polygerrit-ui/app/models/model.ts
@@ -1,21 +1,11 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {BehaviorSubject, Observable} from 'rxjs';
+import {BehaviorSubject, Observable, Subscription} from 'rxjs';
+import {Finalizable} from '../services/registry';
+import {deepEqual} from '../utils/deep-util';
 
 /**
  * A Model stores a value <T> and controls changes to that value via `subject$`
@@ -23,20 +13,64 @@
  * Observable.
  *
  * Typically a given Model subclass will provide:
- *   1. an initial value
+ *   1. An initial value. If there is no good default to start with, then
+ *      include `undefined` in the type `T`.
  *   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
+ *      nested property from the value
  *
  *  Any new subscriber will immediately receive the current value.
  */
-export abstract class Model<T> {
-  protected subject$: BehaviorSubject<T>;
+export abstract class Model<T> implements Finalizable {
+  /**
+   * rxjs does not like `next()` being called on a subject during processing of
+   * another `next()` call. So make sure that state updates complete before
+   * starting another one.
+   */
+  private stateUpdateInProgress = false;
+
+  private subject$: BehaviorSubject<T>;
 
   public state$: Observable<T>;
 
+  protected subscriptions: Subscription[] = [];
+
   constructor(initialState: T) {
     this.subject$ = new BehaviorSubject(initialState);
     this.state$ = this.subject$.asObservable();
   }
+
+  getState() {
+    return this.subject$.getValue();
+  }
+
+  setState(state: T) {
+    if (this.stateUpdateInProgress) {
+      setTimeout(() => this.setState(state));
+      return;
+    }
+    if (deepEqual(state, this.getState())) return;
+    try {
+      this.stateUpdateInProgress = true;
+      this.subject$.next(state);
+    } finally {
+      this.stateUpdateInProgress = false;
+    }
+  }
+
+  updateState(state: Partial<T>) {
+    if (this.stateUpdateInProgress) {
+      setTimeout(() => this.updateState(state));
+      return;
+    }
+    this.setState({...this.getState(), ...state});
+  }
+
+  finalize() {
+    this.subject$.complete();
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+  }
 }
diff --git a/polygerrit-ui/app/models/model_test.ts b/polygerrit-ui/app/models/model_test.ts
new file mode 100644
index 0000000..3fa88e7
--- /dev/null
+++ b/polygerrit-ui/app/models/model_test.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert, waitUntil} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {Model} from './model';
+
+interface TestModelState {
+  prop1?: string;
+  prop2?: string;
+  prop3?: string;
+}
+
+export class TestModel extends Model<TestModelState> {
+  constructor() {
+    super({});
+  }
+}
+
+suite('model tests', () => {
+  test('setState update in progress', async () => {
+    const model = new TestModel();
+    let firstUpdateCompleted = false;
+    let secondUpdateCompleted = false;
+    model.state$.subscribe(s => {
+      if (s.prop2 === 'set') {
+        // Otherwise this would be a clear indication of a nested `setState()`
+        // call, which `stateUpdateInProgress` is supposed to avoid.
+        assert.isTrue(firstUpdateCompleted);
+        secondUpdateCompleted = true;
+      }
+      if (s.prop1 === 'set' && s.prop2 !== 'set')
+        model.setState({prop2: 'set'});
+    });
+
+    // This call should return before the subscriber calls `setState()` again.
+    model.setState({prop1: 'set'});
+    firstUpdateCompleted = true;
+
+    await waitUntil(() => secondUpdateCompleted);
+  });
+
+  test('updateState update in progress', async () => {
+    const model = new TestModel();
+    let completed = false;
+    model.state$.subscribe(s => {
+      if (s.prop1 !== 'go') return;
+      if (s.prop2 !== 'set' && s.prop3 !== 'set')
+        model.updateState({prop2: 'set'});
+      if (s.prop2 === 'set' && s.prop3 === 'set') completed = true;
+    });
+    model.state$.subscribe(s => {
+      if (s.prop1 !== 'go') return;
+      if (s.prop2 !== 'set' && s.prop3 !== 'set')
+        model.updateState({prop3: 'set'});
+      if (s.prop2 === 'set' && s.prop3 === 'set') completed = true;
+    });
+
+    model.updateState({prop1: 'go'});
+
+    await waitUntil(() => completed);
+  });
+});
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 7b58cd1..7826c45 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {Finalizable} from '../../services/registry';
 import {Observable, Subject} from 'rxjs';
@@ -71,12 +60,8 @@
     });
   }
 
-  finalize() {
-    this.subject$.complete();
-  }
-
   checksRegister(plugin: ChecksPlugin) {
-    const nextState = {...this.subject$.getValue()};
+    const nextState = {...this.getState()};
     nextState.checksPlugins = [...nextState.checksPlugins];
     const alreadysRegistered = nextState.checksPlugins.some(
       p => p.pluginName === plugin.pluginName
@@ -88,11 +73,11 @@
       return;
     }
     nextState.checksPlugins.push(plugin);
-    this.subject$.next(nextState);
+    this.setState(nextState);
   }
 
   checksUpdate(update: ChecksUpdate) {
-    const plugins = this.subject$.getValue().checksPlugins;
+    const plugins = this.getState().checksPlugins;
     const plugin = plugins.find(p => p.pluginName === update.pluginName);
     if (!plugin) {
       console.warn(
@@ -104,7 +89,7 @@
   }
 
   checksAnnounce(pluginName: string) {
-    const plugins = this.subject$.getValue().checksPlugins;
+    const plugins = this.getState().checksPlugins;
     const plugin = plugins.find(p => p.pluginName === pluginName);
     if (!plugin) {
       console.warn(
diff --git a/polygerrit-ui/app/models/plugins/plugins-model_test.ts b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
index 8927772..639afc69 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model_test.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
@@ -1,24 +1,14 @@
 /**
  * @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.
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import './plugins-model';
 import {ChecksApiConfig, ChecksProvider, ResponseCode} from '../../api/checks';
 import {ChecksPlugin, ChecksUpdate, PluginsModel} from './plugins-model';
 import {createRunResult} from '../../test/test-data-generators';
+import {assert} from '@open-wc/testing';
 
 const PLUGIN_NAME = 'test-plugin';
 
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index fb18928..fa00a0b 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -1,20 +1,9 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {from, of, Observable, Subscription} from 'rxjs';
+import {from, of, Observable} from 'rxjs';
 import {switchMap} from 'rxjs/operators';
 import {
   DiffPreferencesInfo as DiffPreferencesInfoAPI,
@@ -30,6 +19,7 @@
   createDefaultPreferences,
   createDefaultDiffPrefs,
   createDefaultEditPrefs,
+  AppTheme,
 } from '../../constants/constants';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {DiffPreferencesInfo} from '../../types/diff';
@@ -88,7 +78,10 @@
     preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE
   );
 
-  private subscriptions: Subscription[] = [];
+  readonly preferenceTheme$: Observable<AppTheme> = select(
+    this.preferences$,
+    preference => preference.theme
+  );
 
   constructor(readonly restApiService: RestApiService) {
     super({
@@ -145,19 +138,13 @@
     ];
   }
 
-  finalize() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
-  }
-
   updatePreferences(prefs: Partial<PreferencesInfo>) {
-    this.restApiService
+    return this.restApiService
       .savePreferences(prefs)
       .then((newPrefs: PreferencesInfo | undefined) => {
         if (!newPrefs) return;
         this.setPreferences(newPrefs);
+        return newPrefs;
       });
   }
 
@@ -193,27 +180,22 @@
   }
 
   setPreferences(preferences: PreferencesInfo) {
-    const current = this.subject$.getValue();
-    this.subject$.next({...current, preferences});
+    this.updateState({preferences});
   }
 
   setDiffPreferences(diffPreferences: DiffPreferencesInfo) {
-    const current = this.subject$.getValue();
-    this.subject$.next({...current, diffPreferences});
+    this.updateState({diffPreferences});
   }
 
   setEditPreferences(editPreferences: EditPreferencesInfo) {
-    const current = this.subject$.getValue();
-    this.subject$.next({...current, editPreferences});
+    this.updateState({editPreferences});
   }
 
   setCapabilities(capabilities?: AccountCapabilityInfo) {
-    const current = this.subject$.getValue();
-    this.subject$.next({...current, capabilities});
+    this.updateState({capabilities});
   }
 
-  private setAccount(account?: AccountDetailInfo) {
-    const current = this.subject$.getValue();
-    this.subject$.next({...current, account});
+  setAccount(account?: AccountDetailInfo) {
+    this.updateState({account});
   }
 }
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
new file mode 100644
index 0000000..2ad95a2
--- /dev/null
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export enum AdminChildView {
+  REPOS = 'gr-repo-list',
+  GROUPS = 'gr-admin-group-list',
+  PLUGINS = 'gr-plugin-list',
+}
+export interface AdminViewState extends ViewState {
+  view: GerritView.ADMIN;
+  adminView: AdminChildView;
+  openCreateModal?: boolean;
+  filter?: string | null;
+  offset?: number | string;
+}
+
+export const adminViewModelToken = define<AdminViewModel>('admin-view-model');
+
+export class AdminViewModel extends Model<AdminViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/agreement.ts b/polygerrit-ui/app/models/views/agreement.ts
new file mode 100644
index 0000000..839699c
--- /dev/null
+++ b/polygerrit-ui/app/models/views/agreement.ts
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface AgreementViewState extends ViewState {
+  view: GerritView.AGREEMENTS;
+}
+
+const DEFAULT_STATE: AgreementViewState = {view: GerritView.AGREEMENTS};
+
+export const agreementViewModelToken = define<AgreementViewModel>(
+  'agreement-view-model'
+);
+
+export class AgreementViewModel extends Model<AgreementViewState> {
+  constructor() {
+    super(DEFAULT_STATE);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/base.ts b/polygerrit-ui/app/models/views/base.ts
new file mode 100644
index 0000000..065495d
--- /dev/null
+++ b/polygerrit-ui/app/models/views/base.ts
@@ -0,0 +1,10 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+
+export interface ViewState {
+  view: GerritView;
+}
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
new file mode 100644
index 0000000..100c46b
--- /dev/null
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -0,0 +1,199 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  NumericChangeId,
+  RepoName,
+  RevisionPatchSetNum,
+  BasePatchSetNum,
+  ChangeInfo,
+  PatchSetNumber,
+} from '../../api/rest-api';
+import {Tab} from '../../constants/constants';
+import {GerritView} from '../../services/router/router-model';
+import {UrlEncodedCommentId} from '../../types/common';
+import {toggleSet} from '../../utils/common-util';
+import {select} from '../../utils/observable-util';
+import {
+  encodeURL,
+  getBaseUrl,
+  getPatchRangeExpression,
+} from '../../utils/url-util';
+import {AttemptChoice} from '../checks/checks-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface ChangeViewState extends ViewState {
+  view: GerritView.CHANGE;
+
+  changeNum: NumericChangeId;
+  project: RepoName;
+  edit?: boolean;
+  patchNum?: RevisionPatchSetNum;
+  basePatchNum?: BasePatchSetNum;
+  commentId?: UrlEncodedCommentId;
+  /** This can be a string only for plugin provided tabs. */
+  tab?: Tab | string;
+
+  /** Checks related view state */
+
+  /** selected patchset for check runs (undefined=latest) */
+  checksPatchset?: PatchSetNumber;
+  /** regular expression for filtering check runs */
+  filter?: string;
+  /** selected attempt for check runs (undefined=latest) */
+  attempt?: AttemptChoice;
+  /** selected check runs identified by `checkName` */
+  checksRunsSelected?: Set<string>;
+  /** regular expression for filtering check results */
+  checksResultsFilter?: string;
+
+  /** State properties that trigger one-time actions */
+
+  /** for scrolling a Change Log message into view in gr-change-view */
+  messageHash?: string;
+  /** for logging where the user came from */
+  usp?: string;
+  /** triggers all change related data to be reloaded */
+  forceReload?: boolean;
+  /** triggers opening the reply dialog */
+  openReplyDialog?: boolean;
+}
+
+/**
+ * This is a convenience type such that you can pass a `ChangeInfo` object
+ * as the `change` property instead of having to set both the `changeNum` and
+ * `project` properties explicitly.
+ */
+export type CreateChangeUrlObject = Omit<
+  ChangeViewState,
+  'view' | 'changeNum' | 'project'
+> & {
+  change: Pick<ChangeInfo, '_number' | 'project'>;
+};
+
+export function isCreateChangeUrlObject(
+  state: CreateChangeUrlObject | Omit<ChangeViewState, 'view'>
+): state is CreateChangeUrlObject {
+  return !!(state as CreateChangeUrlObject).change;
+}
+
+export function objToState(
+  obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view'>
+): ChangeViewState {
+  if (isCreateChangeUrlObject(obj)) {
+    return {
+      ...obj,
+      view: GerritView.CHANGE,
+      changeNum: obj.change._number,
+      project: obj.change.project,
+    };
+  }
+  return {...obj, view: GerritView.CHANGE};
+}
+
+export function createChangeUrl(
+  obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view'>
+) {
+  const state: ChangeViewState = objToState(obj);
+  let range = getPatchRangeExpression(state);
+  if (range.length) {
+    range = '/' + range;
+  }
+  let suffix = `${range}`;
+  const queries = [];
+  if (state.checksPatchset && state.checksPatchset > 0) {
+    queries.push(`checksPatchset=${state.checksPatchset}`);
+  }
+  if (state.attempt) {
+    if (state.attempt !== 'latest') queries.push(`attempt=${state.attempt}`);
+  }
+  if (state.filter) {
+    queries.push(`filter=${state.filter}`);
+  }
+  if (state.checksResultsFilter) {
+    queries.push(`checksResultsFilter=${state.checksResultsFilter}`);
+  }
+  if (state.checksRunsSelected && state.checksRunsSelected.size > 0) {
+    queries.push(`checksRunsSelected=${[...state.checksRunsSelected].sort()}`);
+  }
+  if (state.tab && state.tab !== Tab.FILES) {
+    queries.push(`tab=${state.tab}`);
+  }
+  if (state.forceReload) {
+    queries.push('forceReload=true');
+  }
+  if (state.openReplyDialog) {
+    queries.push('openReplyDialog=true');
+  }
+  if (state.usp) {
+    queries.push(`usp=${state.usp}`);
+  }
+  if (state.edit) {
+    suffix += ',edit';
+  }
+  if (state.commentId) {
+    suffix = suffix + `/comments/${state.commentId}`;
+  }
+  if (queries.length > 0) {
+    suffix += '?' + queries.join('&');
+  }
+  if (state.messageHash) {
+    suffix += state.messageHash;
+  }
+  if (state.project) {
+    const encodedProject = encodeURL(state.project, true);
+    return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
+  } else {
+    return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
+  }
+}
+
+export const changeViewModelToken =
+  define<ChangeViewModel>('change-view-model');
+
+export class ChangeViewModel extends Model<ChangeViewState | undefined> {
+  public readonly tab$ = select(this.state$, state => state?.tab);
+
+  public readonly checksPatchset$ = select(
+    this.state$,
+    state => state?.checksPatchset
+  );
+
+  public readonly attempt$ = select(this.state$, state => state?.attempt);
+
+  public readonly filter$ = select(this.state$, state => state?.filter);
+
+  public readonly checksResultsFilter$ = select(
+    this.state$,
+    state => state?.checksResultsFilter ?? ''
+  );
+
+  public readonly checksRunsSelected$ = select(
+    this.state$,
+    state => state?.checksRunsSelected ?? new Set<string>()
+  );
+
+  constructor() {
+    super(undefined);
+    this.state$.subscribe(s => {
+      if (s?.usp || s?.forceReload || s?.openReplyDialog) {
+        this.updateState({
+          usp: undefined,
+          forceReload: undefined,
+          openReplyDialog: undefined,
+        });
+      }
+    });
+  }
+
+  toggleSelectedCheckRun(checkName: string) {
+    const current = this.getState()?.checksRunsSelected ?? new Set();
+    const next = new Set(current);
+    toggleSet(next, checkName);
+    this.updateState({checksRunsSelected: next});
+  }
+}
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
new file mode 100644
index 0000000..24ced82
--- /dev/null
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {
+  BasePatchSetNum,
+  NumericChangeId,
+  RepoName,
+  RevisionPatchSetNum,
+} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
+import '../../test/common-test-setup';
+import {createChangeUrl, ChangeViewState} from './change';
+
+const STATE: ChangeViewState = {
+  view: GerritView.CHANGE,
+  changeNum: 1234 as NumericChangeId,
+  project: 'test' as RepoName,
+};
+
+suite('change view state tests', () => {
+  test('createChangeUrl()', () => {
+    const state: ChangeViewState = {...STATE};
+
+    assert.equal(createChangeUrl(state), '/c/test/+/1234');
+
+    state.patchNum = 10 as RevisionPatchSetNum;
+    assert.equal(createChangeUrl(state), '/c/test/+/1234/10');
+
+    state.basePatchNum = 5 as BasePatchSetNum;
+    assert.equal(createChangeUrl(state), '/c/test/+/1234/5..10');
+
+    state.messageHash = '#123';
+    assert.equal(createChangeUrl(state), '/c/test/+/1234/5..10#123');
+  });
+
+  test('createChangeUrl() baseUrl', () => {
+    window.CANONICAL_PATH = '/base';
+    const state: ChangeViewState = {...STATE};
+    assert.equal(createChangeUrl(state).substring(0, 5), '/base');
+    window.CANONICAL_PATH = undefined;
+  });
+
+  test('createChangeUrl() checksRunsSelected', () => {
+    const state: ChangeViewState = {
+      ...STATE,
+      checksRunsSelected: new Set(['asdf']),
+    };
+
+    assert.equal(
+      createChangeUrl(state),
+      '/c/test/+/1234?checksRunsSelected=asdf'
+    );
+  });
+
+  test('createChangeUrl() checksResultsFilter', () => {
+    const state: ChangeViewState = {
+      ...STATE,
+      checksResultsFilter: 'asdf.*qwer',
+    };
+
+    assert.equal(
+      createChangeUrl(state),
+      '/c/test/+/1234?checksResultsFilter=asdf.*qwer'
+    );
+  });
+
+  test('createChangeUrl() with repo name encoding', () => {
+    const state: ChangeViewState = {
+      view: GerritView.CHANGE,
+      changeNum: 1234 as NumericChangeId,
+      project: 'x+/y+/z+/w' as RepoName,
+    };
+    assert.equal(createChangeUrl(state), '/c/x%252B/y%252B/z%252B/w/+/1234');
+  });
+});
diff --git a/polygerrit-ui/app/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
new file mode 100644
index 0000000..d9ff2d2
--- /dev/null
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {RepoName} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
+import {DashboardId} from '../../types/common';
+import {DashboardSection} from '../../utils/dashboard-util';
+import {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface DashboardViewState extends ViewState {
+  view: GerritView.DASHBOARD;
+  project?: RepoName;
+  dashboard?: DashboardId;
+  user?: string;
+  sections?: DashboardSection[];
+  title?: string;
+}
+
+const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;
+
+function 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.
+    const query = repoName
+      ? section.query.replace(REPO_TOKEN_PATTERN, repoName)
+      : section.query;
+    return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
+  });
+}
+
+export function createDashboardUrl(state: Omit<DashboardViewState, 'view'>) {
+  const repoName = state.project || undefined;
+  if (state.sections) {
+    // Custom dashboard.
+    const queryParams = sectionsToEncodedParams(state.sections, repoName);
+    if (state.title) {
+      queryParams.push('title=' + encodeURIComponent(state.title));
+    }
+    const user = state.user ? state.user : '';
+    return `${getBaseUrl()}/dashboard/${user}?${queryParams.join('&')}`;
+  } else if (repoName) {
+    // Project dashboard.
+    const encodedRepo = encodeURL(repoName, true);
+    return `${getBaseUrl()}/p/${encodedRepo}/+/dashboard/${state.dashboard}`;
+  } else {
+    // User dashboard.
+    return `${getBaseUrl()}/dashboard/${state.user || 'self'}`;
+  }
+}
+
+export const dashboardViewModelToken = define<DashboardViewModel>(
+  'dashboard-view-model'
+);
+
+export class DashboardViewModel extends Model<DashboardViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/dashboard_test.ts b/polygerrit-ui/app/models/views/dashboard_test.ts
new file mode 100644
index 0000000..86bb5c0
--- /dev/null
+++ b/polygerrit-ui/app/models/views/dashboard_test.ts
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {RepoName} from '../../api/rest-api';
+import '../../test/common-test-setup';
+import {DashboardId} from '../../types/common';
+import {createDashboardUrl} from './dashboard';
+
+suite('dashboard view state tests', () => {
+  suite('createDashboardUrl()', () => {
+    test('self dashboard', () => {
+      assert.equal(createDashboardUrl({}), '/dashboard/self');
+    });
+
+    test('baseUrl', () => {
+      window.CANONICAL_PATH = '/base';
+      assert.equal(createDashboardUrl({}).substring(0, 5), '/base');
+      window.CANONICAL_PATH = undefined;
+    });
+
+    test('user dashboard', () => {
+      assert.equal(createDashboardUrl({user: 'user'}), '/dashboard/user');
+    });
+
+    test('custom self dashboard, no title', () => {
+      const state = {
+        sections: [
+          {name: 'section 1', query: 'query 1'},
+          {name: 'section 2', query: 'query 2'},
+        ],
+      };
+      assert.equal(
+        createDashboardUrl(state),
+        '/dashboard/?section%201=query%201&section%202=query%202'
+      );
+    });
+
+    test('custom repo dashboard', () => {
+      const state = {
+        sections: [
+          {name: 'section 1', query: 'query 1 ${project}'},
+          {name: 'section 2', query: 'query 2 ${repo}'},
+        ],
+        project: 'repo-name' as RepoName,
+      };
+      assert.equal(
+        createDashboardUrl(state),
+        '/dashboard/?section%201=query%201%20repo-name&' +
+          'section%202=query%202%20repo-name'
+      );
+    });
+
+    test('custom user dashboard, with title', () => {
+      const state = {
+        user: 'user',
+        sections: [{name: 'name', query: 'query'}],
+        title: 'custom dashboard',
+      };
+      assert.equal(
+        createDashboardUrl(state),
+        '/dashboard/user?name=query&title=custom%20dashboard'
+      );
+    });
+
+    test('repo dashboard', () => {
+      const state = {
+        project: 'gerrit/repo' as RepoName,
+        dashboard: 'default:main' as DashboardId,
+      };
+      assert.equal(
+        createDashboardUrl(state),
+        '/p/gerrit/repo/+/dashboard/default:main'
+      );
+    });
+
+    test('project dashboard (legacy)', () => {
+      const state = {
+        project: 'gerrit/project' as RepoName,
+        dashboard: 'default:main' as DashboardId,
+      };
+      assert.equal(
+        createDashboardUrl(state),
+        '/p/gerrit/project/+/dashboard/default:main'
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/models/views/diff.ts b/polygerrit-ui/app/models/views/diff.ts
new file mode 100644
index 0000000..3cc107a
--- /dev/null
+++ b/polygerrit-ui/app/models/views/diff.ts
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  NumericChangeId,
+  RepoName,
+  RevisionPatchSetNum,
+  BasePatchSetNum,
+  ChangeInfo,
+} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
+import {UrlEncodedCommentId} from '../../types/common';
+import {
+  encodeURL,
+  getBaseUrl,
+  getPatchRangeExpression,
+} from '../../utils/url-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface DiffViewState extends ViewState {
+  view: GerritView.DIFF;
+  changeNum: NumericChangeId;
+  project?: RepoName;
+  commentId?: UrlEncodedCommentId;
+  path?: string;
+  patchNum?: RevisionPatchSetNum;
+  basePatchNum?: BasePatchSetNum;
+  lineNum?: number;
+  leftSide?: boolean;
+  commentLink?: boolean;
+}
+
+/**
+ * This is a convenience type such that you can pass a `ChangeInfo` object
+ * as the `change` property instead of having to set both the `changeNum` and
+ * `project` properties explicitly.
+ */
+export type CreateChangeUrlObject = Omit<
+  DiffViewState,
+  'view' | 'changeNum' | 'project'
+> & {
+  change: Pick<ChangeInfo, '_number' | 'project'>;
+};
+
+export function isCreateChangeUrlObject(
+  state: CreateChangeUrlObject | Omit<DiffViewState, 'view'>
+): state is CreateChangeUrlObject {
+  return !!(state as CreateChangeUrlObject).change;
+}
+
+export function objToState(
+  obj: CreateChangeUrlObject | Omit<DiffViewState, 'view'>
+): DiffViewState {
+  if (isCreateChangeUrlObject(obj)) {
+    return {
+      ...obj,
+      view: GerritView.DIFF,
+      changeNum: obj.change._number,
+      project: obj.change.project,
+    };
+  }
+  return {...obj, view: GerritView.DIFF};
+}
+
+export function createDiffUrl(
+  obj: CreateChangeUrlObject | Omit<DiffViewState, 'view'>
+) {
+  const state: DiffViewState = objToState(obj);
+  let range = getPatchRangeExpression(state);
+  if (range.length) range = '/' + range;
+
+  let suffix = `${range}/${encodeURL(state.path || '', true)}`;
+
+  if (state.lineNum) {
+    suffix += '#';
+    if (state.leftSide) {
+      suffix += 'b';
+    }
+    suffix += state.lineNum;
+  }
+
+  if (state.commentId) {
+    suffix = `/comment/${state.commentId}` + suffix;
+  }
+
+  if (state.project) {
+    const encodedProject = encodeURL(state.project, true);
+    return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
+  } else {
+    return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
+  }
+}
+
+export const diffViewModelToken = define<DiffViewModel>('diff-view-model');
+
+export class DiffViewModel extends Model<DiffViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/diff_test.ts b/polygerrit-ui/app/models/views/diff_test.ts
new file mode 100644
index 0000000..b0f91bb
--- /dev/null
+++ b/polygerrit-ui/app/models/views/diff_test.ts
@@ -0,0 +1,64 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {
+  BasePatchSetNum,
+  NumericChangeId,
+  RepoName,
+  RevisionPatchSetNum,
+} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
+import '../../test/common-test-setup';
+import {createDiffUrl, DiffViewState} from './diff';
+
+suite('diff view state tests', () => {
+  test('createDiffUrl', () => {
+    const params: DiffViewState = {
+      view: GerritView.DIFF,
+      changeNum: 42 as NumericChangeId,
+      path: 'x+y/path.cpp' as RepoName,
+      patchNum: 12 as RevisionPatchSetNum,
+      project: '' as RepoName,
+    };
+    assert.equal(createDiffUrl(params), '/c/42/12/x%252By/path.cpp');
+
+    window.CANONICAL_PATH = '/base';
+    assert.equal(createDiffUrl(params).substring(0, 5), '/base');
+    window.CANONICAL_PATH = undefined;
+
+    params.project = 'test' as RepoName;
+    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
+
+    params.basePatchNum = 6 as BasePatchSetNum;
+    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
+
+    params.path = 'foo bar/my+file.txt%';
+    params.patchNum = 2 as RevisionPatchSetNum;
+    delete params.basePatchNum;
+    assert.equal(
+      createDiffUrl(params),
+      '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
+    );
+
+    params.path = 'file.cpp';
+    params.lineNum = 123;
+    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#123');
+
+    params.leftSide = true;
+    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#b123');
+  });
+
+  test('diff with repo name encoding', () => {
+    const params: DiffViewState = {
+      view: GerritView.DIFF,
+      changeNum: 42 as NumericChangeId,
+      path: 'x+y/path.cpp',
+      patchNum: 12 as RevisionPatchSetNum,
+      project: 'x+/y' as RepoName,
+    };
+    assert.equal(createDiffUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+  });
+});
diff --git a/polygerrit-ui/app/models/views/documentation.ts b/polygerrit-ui/app/models/views/documentation.ts
new file mode 100644
index 0000000..b564d64
--- /dev/null
+++ b/polygerrit-ui/app/models/views/documentation.ts
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface DocumentationViewState extends ViewState {
+  view: GerritView.DOCUMENTATION_SEARCH;
+  filter?: string | null;
+}
+
+export const documentationViewModelToken = define<DocumentationViewModel>(
+  'documentation-view-model'
+);
+
+export class DocumentationViewModel extends Model<
+  DocumentationViewState | undefined
+> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/edit.ts b/polygerrit-ui/app/models/views/edit.ts
new file mode 100644
index 0000000..c63c8ce
--- /dev/null
+++ b/polygerrit-ui/app/models/views/edit.ts
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  EDIT,
+  NumericChangeId,
+  RepoName,
+  RevisionPatchSetNum,
+} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
+import {
+  encodeURL,
+  getBaseUrl,
+  getPatchRangeExpression,
+} from '../../utils/url-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface EditViewState extends ViewState {
+  view: GerritView.EDIT;
+  changeNum: NumericChangeId;
+  project: RepoName;
+  path: string;
+  patchNum: RevisionPatchSetNum;
+  lineNum?: number;
+}
+
+export function createEditUrl(state: Omit<EditViewState, 'view'>): string {
+  if (state.patchNum === undefined) {
+    state = {...state, patchNum: EDIT};
+  }
+  let range = getPatchRangeExpression(state);
+  if (range.length) range = '/' + range;
+
+  let suffix = `${range}/${encodeURL(state.path || '', true)}`;
+  suffix += ',edit';
+
+  if (state.lineNum) {
+    suffix += '#';
+    suffix += state.lineNum;
+  }
+
+  if (state.project) {
+    const encodedProject = encodeURL(state.project, true);
+    return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
+  } else {
+    return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
+  }
+}
+
+export const editViewModelToken = define<EditViewModel>('edit-view-model');
+
+export class EditViewModel extends Model<EditViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/edit_test.ts b/polygerrit-ui/app/models/views/edit_test.ts
new file mode 100644
index 0000000..2912063
--- /dev/null
+++ b/polygerrit-ui/app/models/views/edit_test.ts
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {
+  NumericChangeId,
+  RepoName,
+  RevisionPatchSetNum,
+} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
+import '../../test/common-test-setup';
+import {createEditUrl, EditViewState} from './edit';
+
+suite('edit view state tests', () => {
+  test('createEditUrl', () => {
+    const params: EditViewState = {
+      view: GerritView.EDIT,
+      changeNum: 42 as NumericChangeId,
+      project: 'test-project' as RepoName,
+      path: 'x+y/path.cpp' as RepoName,
+      patchNum: 12 as RevisionPatchSetNum,
+      lineNum: 31,
+    };
+    assert.equal(
+      createEditUrl(params),
+      '/c/test-project/+/42/12/x%252By/path.cpp,edit#31'
+    );
+
+    window.CANONICAL_PATH = '/base';
+    assert.equal(createEditUrl(params).substring(0, 5), '/base');
+    window.CANONICAL_PATH = undefined;
+  });
+});
diff --git a/polygerrit-ui/app/models/views/group.ts b/polygerrit-ui/app/models/views/group.ts
new file mode 100644
index 0000000..277bcff
--- /dev/null
+++ b/polygerrit-ui/app/models/views/group.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {GroupId} from '../../types/common';
+import {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export enum GroupDetailView {
+  MEMBERS = 'members',
+  LOG = 'log',
+}
+
+export interface GroupViewState extends ViewState {
+  view: GerritView.GROUP;
+  groupId: GroupId;
+  detail?: GroupDetailView;
+}
+
+export function createGroupUrl(state: Omit<GroupViewState, 'view'>) {
+  let url = `/admin/groups/${encodeURL(`${state.groupId}`, true)}`;
+  if (state.detail === GroupDetailView.MEMBERS) {
+    url += ',members';
+  } else if (state.detail === GroupDetailView.LOG) {
+    url += ',audit-log';
+  }
+  return getBaseUrl() + url;
+}
+
+export const groupViewModelToken = define<GroupViewModel>('group-view-model');
+
+export class GroupViewModel extends Model<GroupViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/group_test.ts b/polygerrit-ui/app/models/views/group_test.ts
new file mode 100644
index 0000000..e1fbe66
--- /dev/null
+++ b/polygerrit-ui/app/models/views/group_test.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {GroupId} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
+import '../../test/common-test-setup';
+import {createGroupUrl, GroupDetailView, GroupViewState} from './group';
+
+suite('group view state tests', () => {
+  test('createGroupUrl() info', () => {
+    const params: GroupViewState = {
+      view: GerritView.GROUP,
+      groupId: '1234' as GroupId,
+    };
+    assert.equal(createGroupUrl(params), '/admin/groups/1234');
+  });
+
+  test('createGroupUrl() members', () => {
+    const params: GroupViewState = {
+      view: GerritView.GROUP,
+      groupId: '1234' as GroupId,
+      detail: 'members' as GroupDetailView,
+    };
+    assert.equal(createGroupUrl(params), '/admin/groups/1234,members');
+  });
+
+  test('createGroupUrl() audit log', () => {
+    const params: GroupViewState = {
+      view: GerritView.GROUP,
+      groupId: '1234' as GroupId,
+      detail: 'log' as GroupDetailView,
+    };
+    assert.equal(createGroupUrl(params), '/admin/groups/1234,audit-log');
+  });
+});
diff --git a/polygerrit-ui/app/models/views/plugin.ts b/polygerrit-ui/app/models/views/plugin.ts
new file mode 100644
index 0000000..ac7e925
--- /dev/null
+++ b/polygerrit-ui/app/models/views/plugin.ts
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface PluginViewState extends ViewState {
+  view: GerritView.PLUGIN_SCREEN;
+  plugin?: string;
+  screen?: string;
+}
+
+const DEFAULT_STATE: PluginViewState = {view: GerritView.PLUGIN_SCREEN};
+
+export const pluginViewModelToken =
+  define<PluginViewModel>('plugin-view-model');
+
+export class PluginViewModel extends Model<PluginViewState> {
+  constructor() {
+    super(DEFAULT_STATE);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/repo.ts b/polygerrit-ui/app/models/views/repo.ts
new file mode 100644
index 0000000..02fd17d
--- /dev/null
+++ b/polygerrit-ui/app/models/views/repo.ts
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {RepoName} from '../../types/common';
+import {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export enum RepoDetailView {
+  GENERAL = 'general',
+  ACCESS = 'access',
+  BRANCHES = 'branches',
+  COMMANDS = 'commands',
+  DASHBOARDS = 'dashboards',
+  TAGS = 'tags',
+}
+
+export interface RepoViewState extends ViewState {
+  view: GerritView.REPO;
+  detail?: RepoDetailView;
+  repo?: RepoName;
+  filter?: string | null;
+  offset?: number | string;
+}
+
+export function createRepoUrl(state: Omit<RepoViewState, 'view'>) {
+  let url = `/admin/repos/${encodeURL(`${state.repo}`, true)}`;
+  if (state.detail === RepoDetailView.GENERAL) {
+    url += ',general';
+  } else if (state.detail === RepoDetailView.ACCESS) {
+    url += ',access';
+  } else if (state.detail === RepoDetailView.BRANCHES) {
+    url += ',branches';
+  } else if (state.detail === RepoDetailView.TAGS) {
+    url += ',tags';
+  } else if (state.detail === RepoDetailView.COMMANDS) {
+    url += ',commands';
+  } else if (state.detail === RepoDetailView.DASHBOARDS) {
+    url += ',dashboards';
+  }
+  return getBaseUrl() + url;
+}
+
+export const repoViewModelToken = define<RepoViewModel>('repo-view-model');
+
+export class RepoViewModel extends Model<RepoViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/repo_test.ts b/polygerrit-ui/app/models/views/repo_test.ts
new file mode 100644
index 0000000..2875ea7
--- /dev/null
+++ b/polygerrit-ui/app/models/views/repo_test.ts
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {RepoName} from '../../api/rest-api';
+import '../../test/common-test-setup';
+import {createRepoUrl, RepoDetailView} from './repo';
+
+suite('repo view state tests', () => {
+  test('createRepoUrl', () => {
+    assert.equal(createRepoUrl({}), '/admin/repos/undefined');
+    assert.equal(
+      createRepoUrl({repo: 'asdf' as RepoName}),
+      '/admin/repos/asdf'
+    );
+    assert.equal(
+      createRepoUrl({
+        repo: 'asdf' as RepoName,
+        detail: RepoDetailView.ACCESS,
+      }),
+      '/admin/repos/asdf,access'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/models/views/search.ts b/polygerrit-ui/app/models/views/search.ts
new file mode 100644
index 0000000..13de8f3
--- /dev/null
+++ b/polygerrit-ui/app/models/views/search.ts
@@ -0,0 +1,93 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {RepoName, BranchName, TopicName} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
+import {addQuotesWhen} from '../../utils/string-util';
+import {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface SearchViewState extends ViewState {
+  view: GerritView.SEARCH;
+  query?: string;
+  offset?: string;
+}
+
+export interface SearchUrlOptions {
+  query?: string;
+  offset?: number;
+  project?: RepoName;
+  branch?: BranchName;
+  topic?: TopicName;
+  statuses?: string[];
+  hashtag?: string;
+  owner?: string;
+}
+
+export function createSearchUrl(params: SearchUrlOptions): string {
+  let offsetExpr = '';
+  if (params.offset && params.offset > 0) {
+    offsetExpr = `,${params.offset}`;
+  }
+
+  if (params.query) {
+    return `${getBaseUrl()}/q/${encodeURL(params.query, true)}${offsetExpr}`;
+  }
+
+  const operators: string[] = [];
+  if (params.owner) {
+    operators.push('owner:' + encodeURL(params.owner, false));
+  }
+  if (params.project) {
+    operators.push('project:' + encodeURL(params.project, false));
+  }
+  if (params.branch) {
+    operators.push('branch:' + encodeURL(params.branch, false));
+  }
+  if (params.topic) {
+    operators.push(
+      'topic:' +
+        addQuotesWhen(
+          encodeURL(params.topic, false),
+          /[\s:]/.test(params.topic)
+        )
+    );
+  }
+  if (params.hashtag) {
+    operators.push(
+      'hashtag:' +
+        addQuotesWhen(
+          encodeURL(params.hashtag.toLowerCase(), false),
+          /[\s:]/.test(params.hashtag)
+        )
+    );
+  }
+  if (params.statuses) {
+    if (params.statuses.length === 1) {
+      operators.push('status:' + encodeURL(params.statuses[0], false));
+    } else if (params.statuses.length > 1) {
+      operators.push(
+        '(' +
+          params.statuses
+            .map(s => `status:${encodeURL(s, false)}`)
+            .join(' OR ') +
+          ')'
+      );
+    }
+  }
+
+  return `${getBaseUrl()}/q/${operators.join('+')}${offsetExpr}`;
+}
+
+export const searchViewModelToken =
+  define<SearchViewModel>('search-view-model');
+
+export class SearchViewModel extends Model<SearchViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/search_test.ts b/polygerrit-ui/app/models/views/search_test.ts
new file mode 100644
index 0000000..d48667b
--- /dev/null
+++ b/polygerrit-ui/app/models/views/search_test.ts
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {BranchName, RepoName, TopicName} from '../../api/rest-api';
+import '../../test/common-test-setup';
+import {createSearchUrl, SearchUrlOptions} from './search';
+
+suite('search view state tests', () => {
+  test('createSearchUrl', () => {
+    let options: SearchUrlOptions = {
+      owner: 'a%b',
+      project: 'c%d' as RepoName,
+      branch: 'e%f' as BranchName,
+      topic: 'g%h' as TopicName,
+      statuses: ['op%en'],
+    };
+    assert.equal(
+      createSearchUrl(options),
+      '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+        'topic:g%2525h+status:op%2525en'
+    );
+
+    window.CANONICAL_PATH = '/base';
+    assert.equal(createSearchUrl(options).substring(0, 5), '/base');
+    window.CANONICAL_PATH = undefined;
+
+    options.offset = 100;
+    assert.equal(
+      createSearchUrl(options),
+      '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+        'topic:g%2525h+status:op%2525en,100'
+    );
+    delete options.offset;
+
+    // The presence of the query param overrides other options.
+    options.query = 'foo$bar';
+    assert.equal(createSearchUrl(options), '/q/foo%2524bar');
+
+    options.offset = 100;
+    assert.equal(createSearchUrl(options), '/q/foo%2524bar,100');
+
+    options = {statuses: ['a', 'b', 'c']};
+    assert.equal(
+      createSearchUrl(options),
+      '/q/(status:a OR status:b OR status:c)'
+    );
+
+    options = {topic: 'test' as TopicName};
+    assert.equal(createSearchUrl(options), '/q/topic:test');
+
+    options = {topic: 'test test' as TopicName};
+    assert.equal(createSearchUrl(options), '/q/topic:"test+test"');
+
+    options = {topic: 'test:test' as TopicName};
+    assert.equal(createSearchUrl(options), '/q/topic:"test:test"');
+  });
+});
diff --git a/polygerrit-ui/app/models/views/settings.ts b/polygerrit-ui/app/models/views/settings.ts
new file mode 100644
index 0000000..c1a8c08
--- /dev/null
+++ b/polygerrit-ui/app/models/views/settings.ts
@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {select} from '../../utils/observable-util';
+import {getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface SettingsViewState extends ViewState {
+  view: GerritView.SETTINGS;
+  emailToken?: string;
+}
+
+export function createSettingsUrl() {
+  return getBaseUrl() + '/settings';
+}
+
+export const settingsViewModelToken = define<SettingsViewModel>(
+  'settings-view-model'
+);
+
+export class SettingsViewModel extends Model<SettingsViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+
+  public emailToken$ = select(this.state$, state => state?.emailToken);
+
+  clearToken() {
+    this.updateState({emailToken: undefined});
+  }
+}
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 188bf79..b5b313e 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -1,44 +1,37 @@
 /**
  * @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
  */
 
 // Ugly import path due to the following bugs:
 // https://github.com/bazelbuild/rules_nodejs/issues/1522
 // https://github.com/bazelbuild/rules_nodejs/issues/1380
-import {PackageInfo, LicenseType, LicenseInfo} from "../../../tools/node_tools/node_modules_licenses/package-license-info";
-import * as path from "path";
+import {
+  PackageInfo,
+  LicenseType,
+  LicenseInfo,
+} from '../../../tools/node_tools/node_modules_licenses/package-license-info';
+import * as path from 'path';
 
 class LicenseTypes {
   public static Mit: LicenseType = {
-    name: "MIT",
-    allowed: true
+    name: 'MIT',
+    allowed: true,
   };
   public static Apache2_0: LicenseType = {
-    name: "Apache 2.0",
-    allowed: true
+    name: 'Apache 2.0',
+    allowed: true,
   };
 
   public static Bsd3: LicenseType = {
-    name: "BSD-3-Clause",
-    allowed: true
+    name: 'BSD-3-Clause',
+    allowed: true,
   };
 
   public static BsdZeroClause: LicenseType = {
-    name: "BSD-Zero-Clause",
-    allowed: true
+    name: 'BSD-Zero-Clause',
+    allowed: true,
   };
 }
 
@@ -46,413 +39,452 @@
  * in package. For details - see comments for {@link LicenseInfo} and {@link PackageInfo} */
 class SharedLicenses {
   public static Lit: LicenseInfo = {
-    name: "Lit",
+    name: 'Lit',
     type: LicenseTypes.Bsd3,
-    sharedLicenseFile: "lit.txt",
+    sharedLicenseFile: 'lit.txt',
   };
 
   public static Polymer2014: LicenseInfo = {
-    name: "Polymer-2014",
+    name: 'Polymer-2014',
     type: LicenseTypes.Bsd3,
-    sharedLicenseFile: "polymer-2014.txt",
+    sharedLicenseFile: 'polymer-2014.txt',
   };
 
   public static Polymer2015: LicenseInfo = {
-    name: "Polymer-2015",
+    name: 'Polymer-2015',
     type: LicenseTypes.Bsd3,
-    sharedLicenseFile: "polymer-2015.txt",
+    sharedLicenseFile: 'polymer-2015.txt',
   };
 
   public static Polymer2016: LicenseInfo = {
-    name: "Polymer-2016",
+    name: 'Polymer-2016',
     type: LicenseTypes.Bsd3,
-    sharedLicenseFile: "polymer-2016.txt",
+    sharedLicenseFile: 'polymer-2016.txt',
   };
 
   public static Polymer2017: LicenseInfo = {
-    name: "Polymer-2017",
+    name: 'Polymer-2017',
     type: LicenseTypes.Bsd3,
-    sharedLicenseFile: "polymer-2017.txt",
+    sharedLicenseFile: 'polymer-2017.txt',
   };
 
   public static Polymer2018: LicenseInfo = {
-    name: "Polymer-2018",
+    name: 'Polymer-2018',
     type: LicenseTypes.Bsd3,
-    sharedLicenseFile: "polymer-2018.txt",
+    sharedLicenseFile: 'polymer-2018.txt',
   };
 
   public static IsArray: LicenseInfo = {
-    name: "isarray",
+    name: 'isarray',
     type: LicenseTypes.Mit,
-    sharedLicenseFile: "isarray.txt"
+    sharedLicenseFile: 'isarray.txt',
   };
 
   public static Page: LicenseInfo = {
-    name: "page",
+    name: 'page',
     type: LicenseTypes.Mit,
-    sharedLicenseFile: "page.txt"
-  }
+    sharedLicenseFile: 'page.txt',
+  };
 }
 
 const fontsRobotoFilter = (fileName: string) =>
-    fileName.startsWith("fonts/roboto/") && path.basename(fileName) !== "DESCRIPTION.en_us.html";
+  fileName.startsWith('fonts/roboto/') &&
+  path.basename(fileName) !== 'DESCRIPTION.en_us.html';
 
 const fontsRobotomonoFilter = (fileName: string) =>
-    fileName.startsWith("fonts/robotomono/") && path.basename(fileName) !== "DESCRIPTION.en_us.html";
-
+  fileName.startsWith('fonts/robotomono/') &&
+  path.basename(fileName) !== 'DESCRIPTION.en_us.html';
 
 const packages: PackageInfo[] = [
   {
-    name: "@lit/reactive-element",
+    name: '@lit/reactive-element',
     license: SharedLicenses.Lit,
   },
   {
-    name: "@polymer/decorators",
+    name: '@polymer/decorators',
     license: SharedLicenses.Polymer2017,
   },
   {
-    name: "@polymer/font-roboto",
+    name: '@polymer/font-roboto',
     license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/font-roboto-local",
+    name: '@polymer/font-roboto-local',
     license: SharedLicenses.Polymer2015,
-    filesFilter: fileName => !fontsRobotoFilter(fileName) && !fontsRobotomonoFilter(fileName)
+    filesFilter: fileName =>
+      !fontsRobotoFilter(fileName) && !fontsRobotomonoFilter(fileName),
   },
   {
-    name: "@polymer/font-roboto-local",
+    name: '@polymer/font-roboto-local',
     license: {
-      name: "font-roboto-local-fonts-roboto",
+      name: 'font-roboto-local-fonts-roboto',
       type: LicenseTypes.Apache2_0,
-      packageLicenseFile: "fonts/roboto/LICENSE.txt"
+      packageLicenseFile: 'fonts/roboto/LICENSE.txt',
     },
-    filesFilter: fontsRobotoFilter
+    filesFilter: fontsRobotoFilter,
   },
   {
-    name: "@polymer/font-roboto-local",
+    name: '@polymer/font-roboto-local',
     license: {
-      name: "font-roboto-local-fonts-robotomono",
+      name: 'font-roboto-local-fonts-robotomono',
       type: LicenseTypes.Apache2_0,
-      packageLicenseFile: "fonts/robotomono/LICENSE.txt"
+      packageLicenseFile: 'fonts/robotomono/LICENSE.txt',
     },
-    filesFilter: fontsRobotomonoFilter
+    filesFilter: fontsRobotomonoFilter,
   },
   {
-    name: "@polymer/iron-a11y-announcer",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-a11y-announcer',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-a11y-keys-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-a11y-keys-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-autogrow-textarea",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-autogrow-textarea',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-behaviors",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-behaviors',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-checked-element-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-checked-element-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-dropdown",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-dropdown',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-fit-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-fit-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-flex-layout",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-flex-layout',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-form-element-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-form-element-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-icon",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-icon',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-iconset-svg",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-iconset-svg',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-image",
-    license: SharedLicenses.Polymer2016
+    name: '@polymer/iron-image',
+    license: SharedLicenses.Polymer2016,
   },
   {
-    name: "@polymer/iron-input",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-input',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-menu-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-menu-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-meta",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-meta',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-overlay-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-overlay-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-resizable-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-resizable-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-selector",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-selector',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-validatable-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-validatable-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/neon-animation",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/marked-element',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-behaviors",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/neon-animation',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-button",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-behaviors',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-card",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-button',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-checkbox",
-    license: SharedLicenses.Polymer2016
+    name: '@polymer/paper-card',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-dialog",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-checkbox',
+    license: SharedLicenses.Polymer2016,
   },
   {
-    name: "@polymer/paper-dialog-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-dialog',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-dialog-scrollable",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-dialog-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-dropdown-menu",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-dialog-scrollable',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-fab",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-dropdown-menu',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-icon-button",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-fab',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-input",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-icon-button',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-item",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-input',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-listbox",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-item',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-menu-button",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-listbox',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-ripple",
-    license: SharedLicenses.Polymer2014
+    name: '@polymer/paper-menu-button',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-styles",
-    license: SharedLicenses.Polymer2014
+    name: '@polymer/paper-ripple',
+    license: SharedLicenses.Polymer2014,
   },
   {
-    name: "@polymer/paper-tabs",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-styles',
+    license: SharedLicenses.Polymer2014,
   },
   {
-    name: "@polymer/paper-toggle-button",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-tabs',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-tooltip",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-toggle-button',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/polymer",
-    license: SharedLicenses.Polymer2017
+    name: '@polymer/paper-tooltip',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@types/resemblejs",
+    name: '@polymer/polymer',
+    license: SharedLicenses.Polymer2017,
+  },
+  {
+    name: '@types/resemblejs',
     license: {
       name: 'DefinitelyTyped',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "@types/resize-observer-browser",
+    name: '@types/resize-observer-browser',
     license: {
       name: 'DefinitelyTyped',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "@types/trusted-types",
+    name: '@types/trusted-types',
     license: {
       name: 'DefinitelyTyped',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "@webcomponents/shadycss",
-    license: SharedLicenses.Polymer2017
+    name: '@webcomponents/shadycss',
+    license: SharedLicenses.Polymer2017,
   },
   {
-    name: "@webcomponents/webcomponentsjs",
-    license: SharedLicenses.Polymer2018
+    name: '@webcomponents/webcomponentsjs',
+    license: SharedLicenses.Polymer2018,
   },
   {
-    name: "ba-linkify",
+    name: 'ba-linkify',
     license: {
-      name: "ba-linkify",
+      name: 'ba-linkify',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE-MIT",
-    }
+      packageLicenseFile: 'LICENSE-MIT',
+    },
   },
   {
-    name: "codemirror-minified",
+    name: 'codemirror-minified',
     license: {
-      name: "codemirror-minified",
+      name: 'codemirror-minified',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE",
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "isarray",
-    license: SharedLicenses.IsArray
+    name: 'isarray',
+    license: SharedLicenses.IsArray,
   },
   {
-    name: "page",
-    license: SharedLicenses.Page
+    name: 'page',
+    license: SharedLicenses.Page,
   },
   {
-    name: "shadow-selection-polyfill",
+    name: 'shadow-selection-polyfill',
     license: {
-      name: "shadow-selection-polyfill",
+      name: 'shadow-selection-polyfill',
       type: LicenseTypes.Apache2_0,
-      packageLicenseFile: "LICENSE"
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "path-to-regexp",
+    name: 'path-to-regexp',
     license: {
-      name: "path-to-regexp",
+      name: 'path-to-regexp',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "polymer-resin",
-    license: SharedLicenses.Polymer2018
+    name: 'polymer-resin',
+    license: SharedLicenses.Polymer2018,
   },
   {
-    name: "polymer-bridges",
-    license: SharedLicenses.Polymer2018
+    name: 'polymer-bridges',
+    license: SharedLicenses.Polymer2018,
   },
   {
-    name: "rxjs",
+    name: 'web-vitals',
     license: {
-      name: "rxjs",
+      name: 'web-vitals',
       type: LicenseTypes.Apache2_0,
-      packageLicenseFile: "LICENSE.txt"
+      packageLicenseFile: 'LICENSE',
+    },
+  },
+  {
+    name: 'rxjs',
+    license: {
+      name: 'rxjs',
+      type: LicenseTypes.Apache2_0,
+      packageLicenseFile: 'LICENSE.txt',
     },
     // The following directories are not real packages, but contains package.json
     nonPackages: [
-      "ajax", "fetch", "internal-compatibility", "operators", "testing",
-      "webSocket", "src/ajax", "src/fetch", "src/internal-compatibility",
-      "src/operators", "src/testing", "src/webSocket"],
+      'ajax',
+      'fetch',
+      'internal-compatibility',
+      'operators',
+      'testing',
+      'webSocket',
+      'src/ajax',
+      'src/fetch',
+      'src/internal-compatibility',
+      'src/operators',
+      'src/testing',
+      'src/webSocket',
+    ],
   },
   {
-    name: "lit",
+    name: 'lit',
     license: SharedLicenses.Lit,
   },
   {
-    name: "lit-element",
+    name: 'lit-element',
     license: SharedLicenses.Lit,
   },
   {
-    name: "lit-html",
+    name: 'lit-html',
     license: SharedLicenses.Lit,
   },
   {
-    name: "tslib",
+    name: 'tslib',
     license: {
-      name: "tslib",
+      name: 'tslib',
       type: LicenseTypes.BsdZeroClause,
-      packageLicenseFile: "LICENSE.txt"
+      packageLicenseFile: 'LICENSE.txt',
     },
-    nonPackages: ["modules", "test/validateModuleExportsMatchCommonJS"],
+    nonPackages: ['modules', 'test/validateModuleExportsMatchCommonJS'],
   },
   {
-    name: "resemblejs",
+    name: 'resemblejs',
     license: {
-      name: "resemblejs",
+      name: 'resemblejs',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE",
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "immer",
+    name: 'immer',
     license: {
-      name: "immer",
+      name: 'immer',
       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",
+      packageLicenseFile: 'LICENSE',
     },
   },
   {
-    name: "highlightjs-structured-text",
+    name: 'highlight.js',
     license: {
-      name: "highlightjs-structured-text",
+      name: 'highlight.js',
       type: LicenseTypes.Bsd3,
-      packageLicenseFile: "LICENSE",
+      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',
+    },
+  },
+  {
+    name: 'marked',
+    license: {
+      name: 'marked',
+      type: LicenseTypes.Mit,
+      packageLicenseFile: 'LICENSE.md',
+    },
+  },
+  {
+    name: 'safevalues',
+    license: {
+      name: 'safevalues',
+      type: LicenseTypes.Apache2_0,
+      packageLicenseFile: 'LICENSE',
+    },
+  },
 ];
 
 export default packages;
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 37f7a9f..3df17c1 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -14,6 +14,7 @@
     "@polymer/iron-input": "^3.0.1",
     "@polymer/iron-overlay-behavior": "^3.0.3",
     "@polymer/iron-selector": "^3.0.1",
+    "@polymer/marked-element": "^3.0.1",
     "@polymer/paper-button": "^3.0.1",
     "@polymer/paper-card": "^3.0.1",
     "@polymer/paper-checkbox": "^3.1.0",
@@ -36,16 +37,18 @@
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "^1.0.1",
     "codemirror-minified": "^5.65.0",
-    "immer": "^9.0.5",
     "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",
+    "immer": "^9.0.5",
     "lit": "^2.2.3",
     "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
     "polymer-resin": "^2.0.1",
     "resemblejs": "^4.0.0",
-    "rxjs": "^6.6.7"
+    "rxjs": "^6.6.7",
+    "safevalues": "^0.3.1",
+    "web-vitals": "^2.1.4"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/app/plugins/README.md b/polygerrit-ui/app/plugins/README.md
new file mode 100644
index 0000000..6de8ee3
--- /dev/null
+++ b/polygerrit-ui/app/plugins/README.md
@@ -0,0 +1,3 @@
+This directory exists for loading plugins from.
+
+It should not contain actual code as it's .gitignore'd.
diff --git a/polygerrit-ui/app/polylint_test.sh b/polygerrit-ui/app/polylint_test.sh
deleted file mode 100755
index 0c7118d..0000000
--- a/polygerrit-ui/app/polylint_test.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/bin/bash
-
-set -ex
-
-DIR=$(pwd)
-ln -s $RUNFILES_DIR/ui_npm/node_modules $TEST_TMPDIR/node_modules
-cp $2 $TEST_TMPDIR/polymer.json
-cp -R -L polygerrit-ui/app/_pg_ts_out/* $TEST_TMPDIR
-
-#Can't use --root with polymer.json - see https://github.com/Polymer/tools/issues/2616
-#Change current directory to the root folder
-cd $TEST_TMPDIR/
-$DIR/$1 lint --verbose
diff --git a/polygerrit-ui/app/polymer.json b/polygerrit-ui/app/polymer.json
deleted file mode 100644
index 4348ba8..0000000
--- a/polygerrit-ui/app/polymer.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
-  "shell": "elements/gr-app.js",
-  "sources": [
-    "elements/**/*",
-    "mixins/**/*",
-    "scripts/**/*",
-    "styles/*",
-    "types/**/*"
-  ],
-  "lint": {
-    "rules": ["polymer-3"],
-    "ignoreWarnings": [
-      "deprecated-dom-call",
-      "multiple-global-declarations"
-    ],
-    "filesToIgnore": [
-      "**/gr-plugin-rest-api.js",
-      "**/.cache/**/gr-plugin-rest-api.js"
-    ]
-  }
-}
diff --git a/polygerrit-ui/app/rollup.config.js b/polygerrit-ui/app/rollup.config.js
index d93b5ea..be60a63 100644
--- a/polygerrit-ui/app/rollup.config.js
+++ b/polygerrit-ui/app/rollup.config.js
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 const path = require('path');
@@ -35,12 +24,13 @@
 // file as rollup node module.
 function requirePlugin(id) {
   const rollupBinDir = path.dirname(process.argv[1]);
-  const pluginPath = require.resolve(id, {paths: [__dirname, rollupBinDir] });
+  const pluginPath = require.resolve(id, {paths: [__dirname, rollupBinDir]});
   return require(pluginPath);
 }
 
 const resolve = requirePlugin('rollup-plugin-node-resolve');
 const {terser} = requirePlugin('rollup-plugin-terser');
+const define = requirePlugin('rollup-plugin-define');
 
 // @polymer/font-roboto-local uses import.meta.url value
 // as a base path to fonts. We should substitute a correct javascript
@@ -48,13 +38,13 @@
 const importLocalFontMetaUrlResolver = function() {
   return {
     name: 'import-meta-url-resolver',
-    resolveImportMeta: function (property, data) {
-      if(property === 'url' && data.moduleId.endsWith('/@polymer/font-roboto-local/roboto.js')) {
+    resolveImportMeta(property, data) {
+      if (property === 'url' && data.moduleId.endsWith('/@polymer/font-roboto-local/roboto.js')) {
         return 'new URL("..", document.baseURI).href';
       }
       return null;
-    }
-  }
+    },
+  };
 };
 
 export default {
@@ -72,12 +62,12 @@
     plugins: [
       terser({
         output: {
-          comments: false
-        }
-      })
-    ]
+          comments: false,
+        },
+      }),
+    ],
   },
-  //Context must be set to window to correctly processing global variables
+  // Context must be set to window to correctly processing global variables
   context: 'window',
   plugins: [resolve({
     customResolveOptions: {
@@ -85,6 +75,12 @@
       // when importing 'page/page'.
       extensions: ['.js'],
       moduleDirectory: 'external/ui_npm/node_modules',
-    }
-  }), importLocalFontMetaUrlResolver()],
+    },
+  }),
+  define({
+     replacements: {
+       'process.env.NODE_ENV': JSON.stringify('production'),
+     },
+  }),
+  importLocalFontMetaUrlResolver()],
 };
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index a0f7d34..9ab0f64 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -29,6 +29,7 @@
         silent = True,
         sourcemap = "hidden",
         deps = [
+            "@tools_npm//rollup-plugin-define",
             "@tools_npm//rollup-plugin-node-resolve",
         ],
     )
@@ -42,6 +43,21 @@
         silent = True,
         sourcemap = "hidden",
         deps = [
+            "@tools_npm//rollup-plugin-define",
+            "@tools_npm//rollup-plugin-node-resolve",
+        ],
+    )
+
+    rollup_bundle(
+        name = "service-worker",
+        srcs = [app_name + "-full-src"],
+        config_file = ":rollup.config.js",
+        entry_point = "_pg_ts_out/workers/service-worker.js",
+        rollup_bin = "//tools/node_tools:rollup-bin",
+        silent = True,
+        sourcemap = "hidden",
+        deps = [
+            "@tools_npm//rollup-plugin-define",
             "@tools_npm//rollup-plugin-node-resolve",
         ],
     )
@@ -62,6 +78,7 @@
         name = name + "_worker_sources",
         srcs = [
             "syntax-worker.js",
+            "service-worker.js",
         ],
     )
 
@@ -81,6 +98,7 @@
             name + "_css_sources",
             name + "_top_sources",
             name + "_worker_sources",
+            "//lib/fonts:material-icons",
             "//lib/fonts:robotofonts",
             "//lib/js:highlightjs__files",
             "@ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js",
@@ -95,6 +113,7 @@
             "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/",
+            "cp $(locations //lib/fonts:material-icons) $$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",
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
index 0ceca3c..bd87000 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -9,8 +9,10 @@
 # At least temporarily we want to know what is going on even when all tests are
 # passing, so we have a better chance of debugging what happens in CI test runs
 # that were supposed to catch test failures, but did not.
+# Run type checker before testing
+${bazel_bin} build //polygerrit-ui/app:compile_pg_with_tests && \
 ${bazel_bin} test \
       "$@" \
       --test_verbose_timeout_warnings \
       --test_output=all \
-      //polygerrit-ui:karma_test
+      //polygerrit-ui:web-test-runner
diff --git a/polygerrit-ui/app/scripts/bundled-polymer.ts b/polygerrit-ui/app/scripts/bundled-polymer.ts
index 75c99d5..ad183c1 100644
--- a/polygerrit-ui/app/scripts/bundled-polymer.ts
+++ b/polygerrit-ui/app/scripts/bundled-polymer.ts
@@ -1,18 +1,7 @@
 /**
  * @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 is a replacement for the
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
deleted file mode 100644
index 5818003..0000000
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {getAccountDisplayName} from '../../utils/display-name-util';
-import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {AccountInfo} from '../../types/common';
-
-export class GrEmailSuggestionsProvider {
-  constructor(private restAPI: RestApiService) {}
-
-  getSuggestions(input: string) {
-    return this.restAPI.getSuggestedAccounts(`${input}`).then(accounts => {
-      if (!accounts) {
-        return [];
-      }
-      return accounts;
-    });
-  }
-
-  makeSuggestionItem(account: AccountInfo) {
-    return {
-      name: getAccountDisplayName(undefined, account),
-      value: {account, count: 1},
-    };
-  }
-}
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.ts b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.ts
deleted file mode 100644
index 465ba3f..0000000
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma';
-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: GrEmailSuggestionsProvider;
-  const account1 = {
-    name: 'Some name',
-    email: 'some@example.com' as EmailAddress,
-  };
-  const account2 = {
-    email: 'other@example.com' as EmailAddress,
-    _account_id: 3 as AccountId,
-  };
-
-  setup(() => {
-    provider = new GrEmailSuggestionsProvider(getAppContext().restApiService);
-  });
-
-  test('getSuggestions', async () => {
-    const getSuggestedAccountsStub = stubRestApi(
-      'getSuggestedAccounts'
-    ).returns(Promise.resolve([account1, account2]));
-
-    const res = await provider.getSuggestions('Some input');
-    assert.deepEqual(res, [account1, account2]);
-    assert.isTrue(getSuggestedAccountsStub.calledOnce);
-    assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-  });
-
-  test('makeSuggestionItem', () => {
-    assert.deepEqual(provider.makeSuggestionItem(account1), {
-      name: 'Some name <some@example.com>',
-      value: {
-        account: account1,
-        count: 1,
-      },
-    });
-
-    assert.deepEqual(provider.makeSuggestionItem(account2), {
-      name: 'other@example.com <other@example.com>',
-      value: {
-        account: account2,
-        count: 1,
-      },
-    });
-  });
-});
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
deleted file mode 100644
index ff113fb..0000000
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {GroupBaseInfo} from '../../types/common';
-
-export class GrGroupSuggestionsProvider {
-  constructor(private restAPI: RestApiService) {}
-
-  getSuggestions(input: string) {
-    return this.restAPI.getSuggestedGroups(`${input}`).then(groups => {
-      if (!groups) {
-        return [];
-      }
-      const keys = Object.keys(groups);
-      return keys.map(key => {
-        return {...groups[key], name: key};
-      });
-    });
-  }
-
-  makeSuggestionItem(suggestion: GroupBaseInfo) {
-    return {
-      name: suggestion.name,
-      value: {group: {name: suggestion.name, id: suggestion.id}},
-    };
-  }
-}
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.ts b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.ts
deleted file mode 100644
index 41441f3..0000000
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma';
-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: GrGroupSuggestionsProvider;
-  const group1 = {
-    name: 'Some name' as GroupName,
-    id: '1' as GroupId,
-  };
-  const group2 = {
-    name: 'Other name' as GroupName,
-    id: '3' as GroupId,
-    url: 'abcd',
-  };
-
-  setup(() => {
-    provider = new GrGroupSuggestionsProvider(getAppContext().restApiService);
-  });
-
-  test('getSuggestions', async () => {
-    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]);
-    assert.isTrue(getSuggestedAccountsStub.calledOnce);
-    assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-  });
-
-  test('makeSuggestionItem', () => {
-    assert.deepEqual(provider.makeSuggestionItem(group1), {
-      name: 'Some name' as GroupName,
-      value: {
-        group: {
-          name: 'Some name' as GroupName,
-          id: '1' as GroupId,
-        },
-      },
-    });
-
-    assert.deepEqual(provider.makeSuggestionItem(group2), {
-      name: 'Other name' as GroupName,
-      value: {
-        group: {
-          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 78cff25..0bb02d8 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
@@ -1,18 +1,7 @@
 /**
  * @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 {
   getAccountDisplayName,
@@ -25,105 +14,87 @@
   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
-export enum SUGGESTIONS_PROVIDERS_USERS_TYPES {
-  REVIEWER = 'reviewers',
-  CC = 'ccs',
-  ANY = 'any',
-}
-
-export function isAccountSuggestions(s: Suggestion): s is AccountInfo {
-  return (s as AccountInfo)._account_id !== undefined;
-}
-
-type ApiCallCallback = (input: string) => Promise<Suggestion[] | void>;
+import {allSettled, isFulfilled} from '../../utils/async-util';
+import {notUndefined, ParsedChangeInfo} from '../../types/types';
+import {accountKey} from '../../utils/account-util';
+import {
+  AccountId,
+  ChangeInfo,
+  EmailAddress,
+  GroupId,
+  ReviewerState,
+} from '../../api/rest-api';
 
 export interface ReviewerSuggestionsProvider {
-  init(): void;
   getSuggestions(input: string): Promise<Suggestion[]>;
-  makeSuggestionItem(suggestion: Suggestion): AutocompleteSuggestion;
+  makeSuggestionItem(
+    suggestion: Suggestion
+  ): AutocompleteSuggestion<SuggestedReviewerInfo>;
 }
 
 export class GrReviewerSuggestionsProvider
   implements ReviewerSuggestionsProvider
 {
-  static create(
-    restApi: RestApiService,
-    changeNumber: NumericChangeId,
-    userType: SUGGESTIONS_PROVIDERS_USERS_TYPES
+  private changes: (ChangeInfo | ParsedChangeInfo)[];
+
+  constructor(
+    private restApi: RestApiService,
+    private type: ReviewerState.REVIEWER | ReviewerState.CC,
+    private config: ServerInfo | undefined,
+    private loggedIn: boolean,
+    ...changes: (ChangeInfo | ParsedChangeInfo)[]
   ) {
-    switch (userType) {
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getChangeSuggestedReviewers(changeNumber, input)
-        );
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getChangeSuggestedCCs(changeNumber, input)
-        );
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getSuggestedAccounts(`cansee:${changeNumber} ${input}`)
-        );
-      default:
-        throw new Error(`Unknown users type: ${userType}`);
-    }
+    this.changes = changes;
   }
 
-  private initPromise?: Promise<void>;
+  async getSuggestions(input: string): Promise<Suggestion[]> {
+    if (!this.loggedIn) return [];
 
-  private config?: ServerInfo;
-
-  private loggedIn = false;
-
-  private initialized = false;
-
-  private constructor(
-    private readonly _restAPI: RestApiService,
-    private readonly _apiCall: ApiCallCallback
-  ) {}
-
-  init() {
-    if (this.initPromise) {
-      return this.initPromise;
-    }
-    const getConfigPromise = this._restAPI.getConfig().then(cfg => {
-      this.config = cfg;
-    });
-    const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
-      this.loggedIn = loggedIn;
-    });
-    this.initPromise = Promise.all([getConfigPromise, getLoggedInPromise]).then(
-      () => {
-        this.initialized = true;
-      }
+    const resultsByChangeIndex = await allSettled(
+      this.changes.map(change =>
+        this.getSuggestionsForChange(change._number, input)
+      )
     );
-    return this.initPromise;
-  }
-
-  getSuggestions(input: string): Promise<Suggestion[]> {
-    if (!this.initialized || !this.loggedIn) {
-      return Promise.resolve([]);
+    const suggestionsByChangeIndex = resultsByChangeIndex
+      .filter(isFulfilled)
+      .map(result => result.value)
+      .filter(notUndefined);
+    if (suggestionsByChangeIndex.length !== resultsByChangeIndex.length) {
+      // one of the requests failed, so don't allow any suggestions.
+      return [];
     }
 
-    return this._apiCall(input).then(reviewers => reviewers || []);
+    // Pass the union of all the suggestions through each change, keeping only
+    // suggestions where either:
+    //   A) the change had the suggestion too, or
+    //   B) the suggestion is already a reviewer/CC on the change (depending on
+    //      this.type).
+    return this.changes.reduce((suggestions, change, changeIndex) => {
+      const reviewerAndSuggestionKeys = new Set<
+        AccountId | EmailAddress | GroupId | undefined
+      >([
+        ...(change.reviewers[this.type]?.map(accountKey) ?? []),
+        ...suggestionsByChangeIndex[changeIndex].map(suggestionKey),
+      ]);
+      return suggestions.filter(suggestion =>
+        reviewerAndSuggestionKeys.has(suggestionKey(suggestion))
+      );
+    }, uniqueSuggestions(suggestionsByChangeIndex.flat()));
   }
 
-  // this can be retyped to AutocompleteSuggestion<SuggestedReviewerInfo> but
-  // this would need to change generics of gr-autocomplete.
-  makeSuggestionItem(suggestion: Suggestion): AutocompleteSuggestion {
+  makeSuggestionItem(
+    suggestion: Suggestion
+  ): AutocompleteSuggestion<SuggestedReviewerInfo> {
     if (isReviewerAccountSuggestion(suggestion)) {
       // Reviewer is an account suggestion from getChangeSuggestedReviewers.
       return {
         name: getAccountDisplayName(this.config, suggestion.account),
-        // TODO(TS) this is temporary hack to avoid cascade of ts issues
-        value: suggestion as unknown as string,
+        value: suggestion,
       };
     }
 
@@ -131,19 +102,51 @@
       // Reviewer is a group suggestion from getChangeSuggestedReviewers.
       return {
         name: getGroupDisplayName(suggestion.group),
-        // TODO(TS) this is temporary hack to avoid cascade of ts issues
-        value: suggestion as unknown as string,
+        value: suggestion,
       };
     }
 
-    if (isAccountSuggestions(suggestion)) {
+    if (isAccountSuggestion(suggestion)) {
       // Reviewer is an account suggestion from getSuggestedAccounts.
       return {
         name: getAccountDisplayName(this.config, suggestion),
-        // TODO(TS) this is temporary hack to avoid cascade of ts issues
-        value: {account: suggestion, count: 1} as unknown as string,
+        value: {account: suggestion, count: 1},
       };
     }
     assertNever(suggestion, 'Received an incorrect suggestion');
   }
+
+  private getSuggestionsForChange(
+    changeNumber: NumericChangeId,
+    input: string
+  ): Promise<SuggestedReviewerInfo[] | undefined> {
+    return this.type === ReviewerState.REVIEWER
+      ? this.restApi.getChangeSuggestedReviewers(changeNumber, input)
+      : this.restApi.getChangeSuggestedCCs(changeNumber, input);
+  }
+}
+
+function uniqueSuggestions(suggestions: Suggestion[]): Suggestion[] {
+  return suggestions.filter(
+    (suggestion, index) =>
+      index ===
+      suggestions.findIndex(
+        other => suggestionKey(suggestion) === suggestionKey(other)
+      )
+  );
+}
+
+function suggestionKey(suggestion: Suggestion) {
+  if (isReviewerAccountSuggestion(suggestion)) {
+    return accountKey(suggestion.account);
+  } else if (isReviewerGroupSuggestion(suggestion)) {
+    return suggestion.group.id;
+  } else if (isAccountSuggestion(suggestion)) {
+    return accountKey(suggestion);
+  }
+  return undefined;
+}
+
+function isAccountSuggestion(s: Suggestion): s is AccountInfo {
+  return (s as AccountInfo)._account_id !== undefined;
 }
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
deleted file mode 100644
index 1916822..0000000
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
+++ /dev/null
@@ -1,225 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma.js';
-import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from './gr-reviewer-suggestions-provider.js';
-import {getAppContext} from '../../services/app-context.js';
-import {stubRestApi} from '../../test/test-utils.js';
-
-suite('GrReviewerSuggestionsProvider tests', () => {
-  let _nextAccountId = 0;
-  const makeAccount = function(opt_status) {
-    const accountId = ++_nextAccountId;
-    return {
-      _account_id: accountId,
-      name: 'name ' + accountId,
-      email: 'email ' + accountId,
-      status: opt_status,
-    };
-  };
-  let _nextAccountId2 = 0;
-  const makeAccount2 = function(opt_status) {
-    const accountId2 = ++_nextAccountId2;
-    return {
-      _account_id: accountId2,
-      name: 'name ' + accountId2,
-      status: opt_status,
-    };
-  };
-
-  let owner;
-  let existingReviewer1;
-  let existingReviewer2;
-  let suggestion1;
-  let suggestion2;
-  let suggestion3;
-  let provider;
-
-  let redundantSuggestion1;
-  let redundantSuggestion2;
-  let redundantSuggestion3;
-  let change;
-
-  setup(async () => {
-    owner = makeAccount();
-    existingReviewer1 = makeAccount();
-    existingReviewer2 = makeAccount();
-    suggestion1 = {account: makeAccount()};
-    suggestion2 = {account: makeAccount()};
-    suggestion3 = {
-      group: {
-        id: 'suggested group id',
-        name: 'suggested group',
-      },
-    };
-
-    stubRestApi('getConfig').returns(Promise.resolve({}));
-
-    change = {
-      _number: 42,
-      owner,
-      reviewers: {
-        CC: [existingReviewer1],
-        REVIEWER: [existingReviewer2],
-      },
-    };
-
-    await flush();
-  });
-
-  suite('allowAnyUser set to false', () => {
-    setup(async () => {
-      provider = GrReviewerSuggestionsProvider.create(
-          getAppContext().restApiService, change._number,
-          SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
-      await provider.init();
-    });
-    suite('stubbed values for _getReviewerSuggestions', () => {
-      let getChangeSuggestedReviewersStub;
-      setup(() => {
-        getChangeSuggestedReviewersStub =
-            stubRestApi('getChangeSuggestedReviewers').callsFake(() => {
-              redundantSuggestion1 = {account: existingReviewer1};
-              redundantSuggestion2 = {account: existingReviewer2};
-              redundantSuggestion3 = {account: owner};
-              return Promise.resolve([
-                redundantSuggestion1, redundantSuggestion2,
-                redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
-            });
-      });
-
-      test('makeSuggestionItem formats account or group accordingly', () => {
-        let account = makeAccount();
-        const account3 = makeAccount2();
-        let suggestion = provider.makeSuggestionItem({account});
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '>',
-          value: {account},
-        });
-
-        const group = {name: 'test'};
-        suggestion = provider.makeSuggestionItem({group});
-        assert.deepEqual(suggestion, {
-          name: group.name + ' (group)',
-          value: {group},
-        });
-
-        suggestion = provider.makeSuggestionItem(account);
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '>',
-          value: {account, count: 1},
-        });
-
-        suggestion = provider.makeSuggestionItem({account: {}});
-        assert.deepEqual(suggestion, {
-          name: 'Anonymous',
-          value: {account: {}},
-        });
-
-        provider.config = {
-          user: {
-            anonymous_coward_name: 'Anonymous Coward Name',
-          },
-        };
-
-        suggestion = provider.makeSuggestionItem({account: {}});
-        assert.deepEqual(suggestion, {
-          name: 'Anonymous Coward Name',
-          value: {account: {}},
-        });
-
-        account = makeAccount('OOO');
-
-        suggestion = provider.makeSuggestionItem({account});
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account},
-        });
-
-        suggestion = provider.makeSuggestionItem(account);
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account, count: 1},
-        });
-
-        account3.email = undefined;
-
-        suggestion = provider.makeSuggestionItem(account3);
-        assert.deepEqual(suggestion, {
-          name: account3.name,
-          value: {account: account3, count: 1},
-        });
-      });
-
-      test('getSuggestions', async () => {
-        const reviewers = await provider.getSuggestions();
-
-        // Default is no filtering.
-        assert.equal(reviewers.length, 6);
-        assert.deepEqual(reviewers,
-            [redundantSuggestion1, redundantSuggestion2,
-              redundantSuggestion3, suggestion1,
-              suggestion2, suggestion3]);
-      });
-
-      test('getSuggestions short circuits when logged out', () => {
-        provider.loggedIn = false;
-        return provider.getSuggestions('').then(() => {
-          assert.isFalse(getChangeSuggestedReviewersStub.called);
-          provider.loggedIn = true;
-          return provider.getSuggestions('').then(() => {
-            assert.isTrue(getChangeSuggestedReviewersStub.called);
-          });
-        });
-      });
-    });
-
-    test('getChangeSuggestedReviewers is used', async () => {
-      const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
-          .returns(Promise.resolve([]));
-      const suggestAccountStub = stubRestApi('getSuggestedAccounts')
-          .returns(Promise.resolve([]));
-
-      await provider.getSuggestions('');
-      assert.isTrue(suggestReviewerStub.calledOnce);
-      assert.isTrue(suggestReviewerStub.calledWith(42, ''));
-      assert.isFalse(suggestAccountStub.called);
-    });
-  });
-
-  suite('allowAnyUser set to true', () => {
-    setup(async () => {
-      provider = GrReviewerSuggestionsProvider.create(
-          getAppContext().restApiService, change._number,
-          SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
-      await provider.init();
-    });
-
-    test('getSuggestedAccounts is used', async () => {
-      const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
-          .returns(Promise.resolve([]));
-      const suggestAccountStub = stubRestApi('getSuggestedAccounts')
-          .returns(Promise.resolve([]));
-
-      await provider.getSuggestions('');
-      assert.isFalse(suggestReviewerStub.called);
-      assert.isTrue(suggestAccountStub.calledOnce);
-      assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
new file mode 100644
index 0000000..15f3d24
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
@@ -0,0 +1,231 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {GrReviewerSuggestionsProvider} from './gr-reviewer-suggestions-provider';
+import {getAppContext} from '../../services/app-context';
+import {stubRestApi} from '../../test/test-utils';
+import {
+  AccountDetailInfo,
+  ChangeInfo,
+  GroupId,
+  GroupName,
+  NumericChangeId,
+  ReviewerState,
+} from '../../api/rest-api';
+import {Suggestion} from '../../types/common';
+import {
+  createAccountDetailWithIdNameAndEmail,
+  createChange,
+  createServerInfo,
+} from '../../test/test-data-generators';
+import {assert} from '@open-wc/testing';
+
+const accounts: AccountDetailInfo[] = [
+  createAccountDetailWithIdNameAndEmail(1),
+  createAccountDetailWithIdNameAndEmail(2),
+  createAccountDetailWithIdNameAndEmail(3),
+  createAccountDetailWithIdNameAndEmail(4),
+  createAccountDetailWithIdNameAndEmail(5),
+];
+const suggestions: Suggestion[] = [
+  {account: accounts[0], count: 1},
+  {account: accounts[1], count: 1},
+  {
+    group: {
+      id: 'suggested group id' as GroupId,
+      name: 'suggested group' as GroupName,
+    },
+    count: 4,
+  },
+  {account: accounts[2], count: 1},
+];
+const changes: ChangeInfo[] = [
+  {...createChange(), reviewers: {REVIEWER: [accounts[2]], CC: [accounts[2]]}},
+  {...createChange(), _number: 43 as NumericChangeId},
+];
+
+suite('GrReviewerSuggestionsProvider tests', () => {
+  let getChangeSuggestedReviewersStub: sinon.SinonStub;
+  let getChangeSuggestedCCsStub: sinon.SinonStub;
+  let provider: GrReviewerSuggestionsProvider;
+
+  setup(() => {
+    getChangeSuggestedReviewersStub = stubRestApi(
+      'getChangeSuggestedReviewers'
+    );
+    getChangeSuggestedCCsStub = stubRestApi('getChangeSuggestedCCs');
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      true,
+      ...changes
+    );
+  });
+
+  test('getSuggestions', async () => {
+    getChangeSuggestedReviewersStub.resolves([
+      suggestions[0],
+      suggestions[1],
+      suggestions[2],
+    ]);
+    const reviewers = await provider.getSuggestions('');
+
+    assert.sameDeepMembers(reviewers, [
+      suggestions[0],
+      suggestions[1],
+      suggestions[2],
+    ]);
+  });
+
+  test('getSuggestions short circuits when logged out', async () => {
+    // logged in
+    getChangeSuggestedReviewersStub.resolves([]);
+    await provider.getSuggestions('');
+    assert.isTrue(getChangeSuggestedReviewersStub.calledTwice);
+
+    // not logged in
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      false,
+      ...changes
+    );
+
+    await provider.getSuggestions('');
+
+    // no additional calls are made
+    assert.isTrue(getChangeSuggestedReviewersStub.calledTwice);
+  });
+
+  test('only returns REVIEWER suggestions shared by all changes', async () => {
+    getChangeSuggestedReviewersStub
+      .onFirstCall()
+      .resolves([suggestions[0], suggestions[1], suggestions[2]])
+      .onSecondCall()
+      .resolves([suggestions[1], suggestions[2], suggestions[3]]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      true,
+      ...changes
+    );
+
+    // suggestions[0] is excluded because it is not returned for the second
+    // change.
+    // suggestions[3] is included because the first change has the suggestion
+    // as a reviewer already.
+    assert.sameDeepMembers(await provider.getSuggestions('s'), [
+      suggestions[1],
+      suggestions[2],
+      suggestions[3],
+    ]);
+  });
+
+  test('only returns CC suggestions shared by all changes', async () => {
+    getChangeSuggestedCCsStub
+      .onFirstCall()
+      .resolves([suggestions[0], suggestions[1], suggestions[2]])
+      .onSecondCall()
+      .resolves([suggestions[1], suggestions[2], suggestions[3]]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.CC,
+      createServerInfo(),
+      true,
+      ...changes
+    );
+
+    // suggestions[0] is excluded because it is not returned for the second
+    // change.
+    // suggestions[3] is included because the first change has the suggestion
+    // as a CC already.
+    assert.sameDeepMembers(await provider.getSuggestions('s'), [
+      suggestions[1],
+      suggestions[2],
+      suggestions[3],
+    ]);
+  });
+
+  test('makeSuggestionItem formats account or group accordingly', () => {
+    let suggestion = provider.makeSuggestionItem({
+      account: accounts[0],
+      count: 1,
+    });
+    assert.deepEqual(suggestion, {
+      name: `${accounts[0].name} <${accounts[0].email}>`,
+      value: {account: accounts[0], count: 1},
+    });
+
+    const group = {name: 'test' as GroupName, id: '5' as GroupId};
+    suggestion = provider.makeSuggestionItem({group, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${group.name} (group)`,
+      value: {group, count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem(accounts[0]);
+    assert.deepEqual(suggestion, {
+      name: `${accounts[0].name} <${accounts[0].email}>`,
+      value: {account: accounts[0], count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem({account: {}, count: 1});
+    assert.deepEqual(suggestion, {
+      name: 'Name of user not set',
+      value: {account: {}, count: 1},
+    });
+
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      {
+        ...createServerInfo(),
+        user: {
+          anonymous_coward_name: 'Anonymous Coward Name',
+        },
+      },
+      true,
+      ...changes
+    );
+
+    suggestion = provider.makeSuggestionItem({account: {}, count: 1});
+    assert.deepEqual(suggestion, {
+      name: 'Anonymous Coward Name',
+      value: {account: {}, count: 1},
+    });
+
+    const oooAccount = {
+      ...createAccountDetailWithIdNameAndEmail(3),
+      status: 'OOO',
+    };
+
+    suggestion = provider.makeSuggestionItem({account: oooAccount, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${oooAccount.name} <${oooAccount.email}> (OOO)`,
+      value: {account: oooAccount, count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem(oooAccount);
+    assert.deepEqual(suggestion, {
+      name: `${oooAccount.name} <${oooAccount.email}> (OOO)`,
+      value: {account: oooAccount, count: 1},
+    });
+
+    const accountWithoutEmail = {
+      ...createAccountDetailWithIdNameAndEmail(3),
+      email: undefined,
+    };
+
+    suggestion = provider.makeSuggestionItem(accountWithoutEmail);
+    assert.deepEqual(suggestion, {
+      name: accountWithoutEmail.name,
+      value: {account: accountWithoutEmail, count: 1},
+    });
+  });
+});
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.ts b/polygerrit-ui/app/scripts/hiddenscroll.ts
index b4364be..e95a362 100644
--- a/polygerrit-ui/app/scripts/hiddenscroll.ts
+++ b/polygerrit-ui/app/scripts/hiddenscroll.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
  */
 
 let hiddenscroll: boolean | undefined = undefined;
diff --git a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
index 7041300..01ecf50 100644
--- a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 // We can't convert bundled-polymer.js to ts. To allow import
diff --git a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
index d04b533..573b24a 100644
--- a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
@@ -1,18 +1,7 @@
 /**
  * @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 can't be converted to TS - it imports some .js file which
diff --git a/polygerrit-ui/app/scripts/polymer-resin-install.ts b/polygerrit-ui/app/scripts/polymer-resin-install.ts
index ee03171..527df12 100644
--- a/polygerrit-ui/app/scripts/polymer-resin-install.ts
+++ b/polygerrit-ui/app/scripts/polymer-resin-install.ts
@@ -1,20 +1,8 @@
 /**
  * @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 'polymer-resin/standalone/polymer-resin';
 
 export type SafeTypeBridge = (
diff --git a/polygerrit-ui/app/scripts/rootElement.ts b/polygerrit-ui/app/scripts/rootElement.ts
index 2217bf9..1dbb2a1 100644
--- a/polygerrit-ui/app/scripts/rootElement.ts
+++ b/polygerrit-ui/app/scripts/rootElement.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
  */
 
 /**
diff --git a/polygerrit-ui/app/scripts/util.ts b/polygerrit-ui/app/scripts/util.ts
index bf7120f..b785a71 100644
--- a/polygerrit-ui/app/scripts/util.ts
+++ b/polygerrit-ui/app/scripts/util.ts
@@ -1,77 +1,47 @@
 /**
  * @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
  */
 
 export interface CancelablePromise<T> extends Promise<T> {
   cancel(): void;
 }
 
-// TODO (dmfilippov): Each function must be exported separately. According to
-// the code style guide, a namespacing is not allowed.
-export const util = {
-  getCookie(name: string) {
-    const key = name + '=';
-    const cookies = document.cookie.split(';');
-    for (let i = 0; i < cookies.length; i++) {
-      let c = cookies[i];
-      while (c.charAt(0) === ' ') {
-        c = c.substring(1);
-      }
-      if (c.startsWith(key)) {
-        return c.substring(key.length, c.length);
-      }
+/**
+ * Make the promise cancelable.
+ *
+ * Returns a promise with a `cancel()` method wrapped around `promise`.
+ * Calling `cancel()` will reject the returned promise with
+ * {isCancelled: true} synchronously. If the inner promise for a cancelled
+ * promise resolves or rejects this is ignored.
+ */
+export function makeCancelable<T>(promise: Promise<T>) {
+  // True if the promise is either resolved or reject (possibly cancelled)
+  let isDone = false;
+
+  let rejectPromise: (reason?: unknown) => void;
+
+  const wrappedPromise: CancelablePromise<T> = new Promise(
+    (resolve, reject) => {
+      rejectPromise = reject;
+      promise.then(
+        val => {
+          if (!isDone) resolve(val);
+          isDone = true;
+        },
+        error => {
+          if (!isDone) reject(error);
+          isDone = true;
+        }
+      );
     }
-    return '';
-  },
+  ) as CancelablePromise<T>;
 
-  /**
-   * Make the promise cancelable.
-   *
-   * Returns a promise with a `cancel()` method wrapped around `promise`.
-   * Calling `cancel()` will reject the returned promise with
-   * {isCancelled: true} synchronously. If the inner promise for a cancelled
-   * promise resolves or rejects this is ignored.
-   */
-  makeCancelable<T>(promise: Promise<T>) {
-    // True if the promise is either resolved or reject (possibly cancelled)
-    let isDone = false;
-
-    let rejectPromise: (reason?: unknown) => void;
-
-    const wrappedPromise: CancelablePromise<T> = new Promise(
-      (resolve, reject) => {
-        rejectPromise = reject;
-        promise.then(
-          val => {
-            if (!isDone) resolve(val);
-            isDone = true;
-          },
-          error => {
-            if (!isDone) reject(error);
-            isDone = true;
-          }
-        );
-      }
-    ) as CancelablePromise<T>;
-
-    wrappedPromise.cancel = () => {
-      if (isDone) return;
-      rejectPromise({isCanceled: true});
-      isDone = true;
-    };
-    return wrappedPromise;
-  },
-};
+  wrappedPromise.cancel = () => {
+    if (isDone) return;
+    rejectPromise({isCanceled: true});
+    isDone = true;
+  };
+  return wrappedPromise;
+}
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index cc52fc8..2e1b817 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {AppContext} from './app-context';
 import {create, Finalizable, Registry} from './registry';
@@ -23,6 +12,7 @@
 import {Auth} from './gr-auth/gr-auth_impl';
 import {GrRestApiServiceImpl} from './gr-rest-api/gr-rest-api-impl';
 import {ChangeModel, changeModelToken} from '../models/change/change-model';
+import {FilesModel, filesModelToken} from '../models/change/files-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';
@@ -32,12 +22,42 @@
   commentsModelToken,
 } from '../models/comments/comments-model';
 import {RouterModel} from './router/router-model';
-import {ShortcutsService} from './shortcuts/shortcuts-service';
+import {
+  ShortcutsService,
+  shortcutsServiceToken,
+} from './shortcuts/shortcuts-service';
 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';
+import {AccountsModel} from '../models/accounts-model/accounts-model';
+import {
+  DashboardViewModel,
+  dashboardViewModelToken,
+} from '../models/views/dashboard';
+import {
+  SettingsViewModel,
+  settingsViewModelToken,
+} from '../models/views/settings';
+import {GrRouter, routerToken} from '../elements/core/gr-router/gr-router';
+import {AdminViewModel, adminViewModelToken} from '../models/views/admin';
+import {
+  AgreementViewModel,
+  agreementViewModelToken,
+} from '../models/views/agreement';
+import {ChangeViewModel, changeViewModelToken} from '../models/views/change';
+import {DiffViewModel, diffViewModelToken} from '../models/views/diff';
+import {
+  DocumentationViewModel,
+  documentationViewModelToken,
+} from '../models/views/documentation';
+import {EditViewModel, editViewModelToken} from '../models/views/edit';
+import {GroupViewModel, groupViewModelToken} from '../models/views/group';
+import {PluginViewModel, pluginViewModelToken} from '../models/views/plugin';
+import {RepoViewModel, repoViewModelToken} from '../models/views/repo';
+import {SearchViewModel, searchViewModelToken} from '../models/views/search';
+import {navigationToken} from '../elements/core/gr-navigation/gr-navigation';
 
 /**
  * The AppContext lazy initializator for all services
@@ -58,7 +78,8 @@
     },
     restApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.authService, 'authService');
-      return new GrRestApiServiceImpl(ctx.authService);
+      assertIsDefined(ctx.flagsService, 'flagsService');
+      return new GrRestApiServiceImpl(ctx.authService, ctx.flagsService);
     },
     jsApiService: (ctx: Partial<AppContext>) => {
       const reportingService = ctx.reportingService;
@@ -70,10 +91,9 @@
       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);
+    accountsModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.restApiService, 'restApiService');
+      return new AccountsModel(ctx.restApiService);
     },
     pluginsModel: (_ctx: Partial<AppContext>) => new PluginsModel(),
     highlightService: (ctx: Partial<AppContext>) => {
@@ -91,6 +111,51 @@
   const browserModel = new BrowserModel(appContext.userModel);
   dependencies.set(browserModelToken, browserModel);
 
+  const adminViewModel = new AdminViewModel();
+  dependencies.set(adminViewModelToken, adminViewModel);
+  const agreementViewModel = new AgreementViewModel();
+  dependencies.set(agreementViewModelToken, agreementViewModel);
+  const changeViewModel = new ChangeViewModel();
+  dependencies.set(changeViewModelToken, changeViewModel);
+  const dashboardViewModel = new DashboardViewModel();
+  dependencies.set(dashboardViewModelToken, dashboardViewModel);
+  const diffViewModel = new DiffViewModel();
+  dependencies.set(diffViewModelToken, diffViewModel);
+  const documentationViewModel = new DocumentationViewModel();
+  dependencies.set(documentationViewModelToken, documentationViewModel);
+  const editViewModel = new EditViewModel();
+  dependencies.set(editViewModelToken, editViewModel);
+  const groupViewModel = new GroupViewModel();
+  dependencies.set(groupViewModelToken, groupViewModel);
+  const pluginViewModel = new PluginViewModel();
+  dependencies.set(pluginViewModelToken, pluginViewModel);
+  const repoViewModel = new RepoViewModel();
+  dependencies.set(repoViewModelToken, repoViewModel);
+  const searchViewModel = new SearchViewModel();
+  dependencies.set(searchViewModelToken, searchViewModel);
+  const settingsViewModel = new SettingsViewModel();
+  dependencies.set(settingsViewModelToken, settingsViewModel);
+
+  const router = new GrRouter(
+    appContext.reportingService,
+    appContext.routerModel,
+    appContext.restApiService,
+    adminViewModel,
+    agreementViewModel,
+    changeViewModel,
+    dashboardViewModel,
+    diffViewModel,
+    documentationViewModel,
+    editViewModel,
+    groupViewModel,
+    pluginViewModel,
+    repoViewModel,
+    searchViewModel,
+    settingsViewModel
+  );
+  dependencies.set(routerToken, router);
+  dependencies.set(navigationToken, router);
+
   const changeModel = new ChangeModel(
     appContext.routerModel,
     appContext.restApiService,
@@ -98,19 +163,30 @@
   );
   dependencies.set(changeModelToken, changeModel);
 
+  const accountsModel = new AccountsModel(appContext.restApiService);
+
   const commentsModel = new CommentsModel(
     appContext.routerModel,
     changeModel,
+    accountsModel,
     appContext.restApiService,
     appContext.reportingService
   );
   dependencies.set(commentsModelToken, commentsModel);
 
+  const filesModel = new FilesModel(
+    changeModel,
+    commentsModel,
+    appContext.restApiService
+  );
+  dependencies.set(filesModelToken, filesModel);
+
   const configModel = new ConfigModel(changeModel, appContext.restApiService);
   dependencies.set(configModelToken, configModel);
 
   const checksModel = new ChecksModel(
     appContext.routerModel,
+    changeViewModel,
     changeModel,
     appContext.reportingService,
     appContext.pluginsModel
@@ -118,5 +194,11 @@
 
   dependencies.set(checksModelToken, checksModel);
 
+  const shortcutsService = new ShortcutsService(
+    appContext.userModel,
+    appContext.reportingService
+  );
+  dependencies.set(shortcutsServiceToken, shortcutsService);
+
   return dependencies;
 }
diff --git a/polygerrit-ui/app/services/app-context-init_test.ts b/polygerrit-ui/app/services/app-context-init_test.ts
index 626c571..7834e53 100644
--- a/polygerrit-ui/app/services/app-context-init_test.ts
+++ b/polygerrit-ui/app/services/app-context-init_test.ts
@@ -1,24 +1,13 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma.js';
-import {AppContext} from './app-context.js';
+import '../test/common-test-setup';
+import {AppContext} from './app-context';
 import {Finalizable} from './registry';
-import {createTestAppContext} from '../test/test-app-context-init.js';
+import {createTestAppContext} from '../test/test-app-context-init';
+import {assert} from '@open-wc/testing';
 
 suite('app context initializer tests', () => {
   let appContext: AppContext & Finalizable;
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index ba21734..5f47c43 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {Finalizable} from './registry';
 import {FlagsService} from './flags/flags';
@@ -24,9 +13,9 @@
 import {StorageService} from './storage/gr-storage';
 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';
+import {AccountsModel} from '../models/accounts-model/accounts-model';
 
 export interface AppContext {
   routerModel: RouterModel;
@@ -38,7 +27,7 @@
   jsApiService: JsApiService;
   storageService: StorageService;
   userModel: UserModel;
-  shortcutsService: ShortcutsService;
+  accountsModel: AccountsModel;
   pluginsModel: PluginsModel;
   highlightService: HighlightService;
 }
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 3ddff60..0bf1522 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -1,20 +1,8 @@
 /**
  * @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 {Finalizable} from '../registry';
 
 export interface FlagsService extends Finalizable {
@@ -28,8 +16,13 @@
 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',
+  PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
   DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
+  PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
+  SUGGEST_EDIT = 'UiFeature__suggest_edit',
+  CHECKS_FIXES = 'UiFeature__checks_fixes',
+  MENTION_USERS = 'UiFeature__mention_users',
+  RENDER_MARKDOWN = 'UiFeature__render_markdown',
+  AUTO_APP_THEME = 'UiFeature__auto_app_theme',
+  COPY_LINK_DIALOG = 'UiFeature__copy_link_dialog',
 }
diff --git a/polygerrit-ui/app/services/flags/flags_impl.ts b/polygerrit-ui/app/services/flags/flags_impl.ts
index 9767d1a..4ef55a2 100644
--- a/polygerrit-ui/app/services/flags/flags_impl.ts
+++ b/polygerrit-ui/app/services/flags/flags_impl.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {FlagsService} from './flags';
 import {Finalizable} from '../registry';
diff --git a/polygerrit-ui/app/services/flags/flags_test.ts b/polygerrit-ui/app/services/flags/flags_test.ts
index 4ae11bf..341d49d 100644
--- a/polygerrit-ui/app/services/flags/flags_test.ts
+++ b/polygerrit-ui/app/services/flags/flags_test.ts
@@ -1,21 +1,10 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../../test/common-test-setup';
 import {FlagsServiceImplementation} from './flags_impl';
 
 suite('flags tests', () => {
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth.ts b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
index ac63d6a..1dc4a84 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth.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 {Finalizable} from '../registry';
 export enum AuthType {
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
index 5f77e8a..8a4e51f 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.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 {getBaseUrl} from '../../utils/url-util';
 import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
@@ -62,7 +51,7 @@
 
   static CREDS_EXPIRED_MSG = 'Credentials expired.';
 
-  private authCheckPromise?: Promise<Response>;
+  private authCheckPromise?: Promise<boolean>;
 
   private _last_auth_check_time: number = Date.now();
 
@@ -100,37 +89,37 @@
       Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS
     ) {
       // Refetch after last check expired
-      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
+      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`)
+        .then(res => {
+          // Make a call that requires loading the body of the request. This makes it so that the browser
+          // can close the request even though callers of this method might only ever read headers.
+          // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
+          try {
+            res.clone().text();
+          } catch {
+            // Ignore error
+          }
+
+          // auth-check will return 204 if authed
+          // treat the rest as unauthed
+          if (res.status === 204) {
+            this._setStatus(Auth.STATUS.AUTHED);
+            return true;
+          } else {
+            this._setStatus(Auth.STATUS.NOT_AUTHED);
+            return false;
+          }
+        })
+        .catch(() => {
+          this._setStatus(AuthStatus.ERROR);
+          // Reset authCheckPromise to avoid caching the failed promise
+          this.authCheckPromise = undefined;
+          return false;
+        });
       this._last_auth_check_time = Date.now();
     }
 
-    return this.authCheckPromise
-      .then(res => {
-        // Make a call that requires loading the body of the request. This makes it so that the browser
-        // can close the request even though callers of this method might only ever read headers.
-        // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
-        try {
-          res.clone().text();
-        } catch {
-          // Ignore error
-        }
-
-        // auth-check will return 204 if authed
-        // treat the rest as unauthed
-        if (res.status === 204) {
-          this._setStatus(Auth.STATUS.AUTHED);
-          return true;
-        } else {
-          this._setStatus(Auth.STATUS.NOT_AUTHED);
-          return false;
-        }
-      })
-      .catch(() => {
-        this._setStatus(AuthStatus.ERROR);
-        // Reset authCheckPromise to avoid caching the failed promise
-        this.authCheckPromise = undefined;
-        return false;
-      });
+    return this.authCheckPromise;
   }
 
   clearCache() {
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
index e5331f1..cc34681e 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
@@ -1,20 +1,8 @@
 /**
  * @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 {EventEmitterService} from '../gr-event-interface/gr-event-interface';
 import {
   AuthRequestInit,
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
index 2b54bde..4552dad 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
@@ -1,27 +1,16 @@
 /**
  * @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 '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 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';
+import {assert} from '@open-wc/testing';
 
 suite('gr-auth', () => {
   let auth: Auth;
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 057cd3e..4153b3d 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
@@ -1,18 +1,7 @@
 /**
  * @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 {Finalizable} from '../registry';
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
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 055687f..7228282 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
@@ -1,18 +1,7 @@
 /**
  * @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 {Finalizable} from '../registry';
 import {
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
index 54a0f72e..a63eda3 100644
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
@@ -1,23 +1,12 @@
 /**
  * @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.js';
-import {mockPromise} from '../../test/test-utils.js';
-import {EventEmitter} from './gr-event-interface_impl.js';
+import '../../test/common-test-setup';
+import {mockPromise} from '../../test/test-utils';
+import {EventEmitter} from './gr-event-interface_impl';
+import {assert} from '@open-wc/testing';
 
 suite('gr-event-interface tests', () => {
   let gerrit;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index aeb1614..f552762 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {Finalizable} from '../registry';
 import {NumericChangeId} from '../../types/common';
@@ -57,7 +46,7 @@
   pluginLoaded(name: string): void;
   pluginsLoaded(pluginsList?: string[]): void;
   pluginsFailed(pluginsList?: string[]): void;
-  error(err: Error, reporter?: string, details?: EventDetails): void;
+  error(errorSource: string, error: Error, details?: EventDetails): void;
   /**
    * Reset named timer.
    */
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 f27ab96..dadf9e4 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {FlagsService} from '../flags/flags';
 import {EventValue, ReportingService, Timer} from './gr-reporting';
@@ -27,6 +16,7 @@
   LifeCycle,
   Timing,
 } from '../../constants/reporting';
+import {getCLS, getFID, getLCP, Metric} from 'web-vitals';
 
 // Latency reporting constants.
 
@@ -130,7 +120,7 @@
       line = line ?? error.lineNumber;
       column = column ?? error.columnNumber;
     }
-    reportingService.error(normalizeError(error), 'onError', {
+    reportingService.error('onError', normalizeError(error), {
       line,
       column,
       url,
@@ -153,7 +143,7 @@
     context.addEventListener(
       'unhandledrejection',
       (e: PromiseRejectionEvent) => {
-        reportingService.error(normalizeError(e.reason), 'unhandledrejection');
+        reportingService.error('unhandledrejection', normalizeError(e.reason));
       }
     );
   };
@@ -172,8 +162,8 @@
     if (supportedEntryTypes.includes('longtask')) {
       const catchLongJsTasks = new PerformanceObserver(list => {
         for (const task of list.getEntries()) {
-          // We are interested in longtask longer than 200 ms (default is 50 ms)
-          if (task.duration > 200) {
+          // We are interested in longtask longer than 400 ms (default is 50 ms)
+          if (task.duration > 400) {
             reportingService.reporter(
               TIMING.TYPE,
               TIMING.CATEGORY.UI_LATENCY,
@@ -196,6 +186,23 @@
   });
 }
 
+export function initWebVitals(reportingService: ReportingService) {
+  function reportWebVitalMetric(name: Timing, metric: Metric) {
+    reportingService.reporter(
+      TIMING.TYPE,
+      TIMING.CATEGORY.UI_LATENCY,
+      name,
+      metric.value,
+      JSON.stringify(metric),
+      false
+    );
+  }
+
+  getCLS(metric => reportWebVitalMetric(Timing.CLS, metric));
+  getFID(metric => reportWebVitalMetric(Timing.FID, metric));
+  getLCP(metric => reportWebVitalMetric(Timing.LCP, metric));
+}
+
 // Calculates the time of Gerrit being in a background tab. When Gerrit reports
 // a pageLoad metric it’s attached to its details for latency analysis.
 // It resets on locationChange.
@@ -369,11 +376,15 @@
     }
     if (type !== ERROR.TYPE) {
       if (value !== undefined) {
-        console.debug(`Reporting: ${name}: ${value}`);
+        console.debug(
+          `Reporting(${new Date().toISOString()}): ${name}: ${value}`
+        );
       } else if (eventDetails !== undefined) {
-        console.debug(`Reporting: ${name}: ${eventDetails}`);
+        console.debug(
+          `Reporting(${new Date().toISOString()}): ${name}: ${eventDetails}`
+        );
       } else {
-        console.debug(`Reporting: ${name}`);
+        console.debug(`Reporting(${new Date().toISOString()}): ${name}`);
       }
     }
   }
@@ -851,16 +862,20 @@
     this.reportExecution(Execution.PLUGIN_API, {plugin, object, method});
   }
 
-  error(error: Error, errorSource?: string, details?: EventDetails) {
-    const eventDetails = details ?? {};
-    const message = `${errorSource ? errorSource + ': ' : ''}${error.message}`;
+  error(errorSource: string, error: Error, details?: EventDetails) {
+    const message = `${errorSource}: ${error.message}`;
+    const eventDetails = {
+      errorMessage: message,
+      ...details,
+      stack: error.stack,
+    };
 
     this.reporter(
       ERROR.TYPE,
       ERROR.CATEGORY.EXCEPTION,
-      message,
+      errorSource,
       {error},
-      {...eventDetails, stack: error.stack}
+      eventDetails
     );
   }
 
@@ -868,8 +883,9 @@
     this.reporter(
       ERROR.TYPE,
       ERROR.CATEGORY.ERROR_DIALOG,
-      'ErrorDialog: ' + message,
-      {error: new Error(message)}
+      'ErrorDialog',
+      {error: new Error(message)},
+      {errorMessage: 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 f361782..d4efbcc 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {ReportingService, Timer} from './gr-reporting';
 import {EventDetails} from '../../api/reporting';
@@ -34,8 +23,8 @@
   }
 }
 
-const log = function (msg: string) {
-  console.info(`ReportingMock.${msg}`);
+const log = function (msg: string, e?: unknown) {
+  console.info(`ReportingMock.${msg} ${e}`);
 };
 
 export const grReportingMock: ReportingService & Finalizable = {
@@ -62,8 +51,8 @@
   reportErrorDialog: (message: string) => {
     log(`reportErrorDialog: ${message}`);
   },
-  error: () => {
-    log('error');
+  error: (label, e) => {
+    log(`error ${label}:`, e);
   },
   reportExecution: (_id: Execution, _details?: EventDetails) => {},
   trackApi: (_pluginApi: PluginApi, _object: string, _method: string) => {},
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts
index b9615b8..2b34627 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts
@@ -1,21 +1,10 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../../test/common-test-setup';
 import {GrReporting} from './gr-reporting_impl';
 import {grReportingMock} from './gr-reporting_mock';
 
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
index 13c96c8..9c5e20d 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import {
   GrReporting,
   DEFAULT_STARTUP_TIMERS,
@@ -24,6 +12,7 @@
 import {getAppContext} from '../app-context';
 import {Deduping} from '../../api/reporting';
 import {SinonFakeTimers} from 'sinon';
+import {assert} from '@open-wc/testing';
 
 suite('gr-reporting tests', () => {
   // We have to type as any because we access
@@ -544,7 +533,14 @@
       const error = new Error('bar');
       error.stack = undefined;
       emulateThrow('bar', 'http://url', 4, 2, error);
-      assert.isTrue(reporter.calledWith('error', 'exception', 'onError: bar'));
+      assert.isTrue(reporter.calledWith('error', 'exception', 'onError'));
+    });
+
+    test('is reported with message', () => {
+      const error = new Error('bar');
+      emulateThrow('bar', 'http://url', 4, 2, error);
+      const eventDetails = reporter.lastCall.args[4];
+      assert.equal(eventDetails.errorMessage, 'onError: bar');
     });
 
     test('is reported with stack', () => {
@@ -562,7 +558,7 @@
       const newError = new Error('bar');
       fakeWindow.handlers['unhandledrejection']({reason: newError});
       assert.isTrue(
-        reporter.calledWith('error', 'exception', 'unhandledrejection: bar')
+        reporter.calledWith('error', 'exception', 'unhandledrejection')
       );
     });
   });
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 2729e69..841d22e 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 /* NB: Order is important, because of namespaced classes. */
 
@@ -74,7 +63,7 @@
   DiffPreferenceInput,
   DocResult,
   EditInfo,
-  EditPatchSetNum,
+  EDIT,
   EditPreferencesInfo,
   EmailAddress,
   EmailInfo,
@@ -97,11 +86,10 @@
   HashtagsInput,
   ImagesForDiff,
   IncludedInInfo,
-  LabelNameToLabelTypeInfoMap,
   MergeableInfo,
   NameToProjectInfoMap,
   NumericChangeId,
-  ParentPatchSetNum,
+  PARENT,
   ParsedJSON,
   Password,
   PatchRange,
@@ -131,13 +119,12 @@
   TagInput,
   TopMenuEntryInfo,
   UrlEncodedCommentId,
-  UrlEncodedRepoName,
+  FixReplacementInfo,
 } from '../../types/common';
 import {
   DiffInfo,
   DiffPreferencesInfo,
   IgnoreWhitespaceType,
-  WebLinkInfo,
 } from '../../types/diff';
 import {
   CancelConditionCallback,
@@ -151,7 +138,6 @@
   createDefaultEditPrefs,
   createDefaultPreferences,
   HttpMethod,
-  ProjectState,
   ReviewerState,
 } from '../../constants/constants';
 import {firePageError, fireServerError} from '../../utils/event-util';
@@ -160,6 +146,7 @@
 import {addDraftProp, DraftInfo} from '../../utils/comment-util';
 import {BaseScheduler} from '../scheduler/scheduler';
 import {MaxInFlightScheduler} from '../scheduler/max-in-flight-scheduler';
+import {FlagsService} from '../flags/flags';
 
 const MAX_PROJECT_RESULTS = 25;
 
@@ -239,9 +226,9 @@
 
 interface QueryAccountsParams {
   [paramName: string]: string | undefined | null | number;
-  suggest: null;
   q: string;
   n?: number;
+  o?: string;
 }
 
 interface QueryGroupsParams {
@@ -296,15 +283,13 @@
   readonly _projectLookup = projectLookup; // Shared across instances.
 
   // The value is set in created, before any other actions
-  private authService: AuthService;
-
-  // The value is set in created, before any other actions
   private readonly _restApiHelper: GrRestApiHelper;
 
-  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 ?? getAppContext().authService;
+  constructor(
+    private readonly authService: AuthService,
+    // @ts-ignore: it's ok.
+    private readonly _flagsService: FlagsService
+  ) {
     this._restApiHelper = new GrRestApiHelper(
       this._cache,
       this.authService,
@@ -774,10 +759,14 @@
     }) as Promise<unknown>;
   }
 
-  getAccountDetails(userId: AccountId): Promise<AccountDetailInfo | undefined> {
+  getAccountDetails(
+    userId: AccountId | EmailAddress,
+    errFn?: ErrorCallback
+  ): Promise<AccountDetailInfo | undefined> {
     return this._restApiHelper.fetchJSON({
       url: `/accounts/${encodeURIComponent(userId)}/detail`,
       anonymizedUrl: '/accounts/*/detail',
+      errFn,
     }) as Promise<AccountDetailInfo | undefined>;
   }
 
@@ -910,7 +899,6 @@
     }) as Promise<string | undefined>;
   }
 
-  // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#list-groups
   getAccountGroups() {
     return this._restApiHelper.fetchJSON({
       url: '/accounts/self/groups',
@@ -1129,7 +1117,7 @@
         ListChangesOption.CURRENT_ACTIONS,
         ListChangesOption.CURRENT_REVISION,
         ListChangesOption.DETAILED_LABELS,
-        // TODO: remove this option and merge requirements from dashbaord req
+        // TODO: remove this option and merge requirements from dashboard req
         ListChangesOption.SUBMIT_REQUIREMENTS
       )
     );
@@ -1295,7 +1283,7 @@
     let params = undefined;
     if (isMergeParent(patchRange.basePatchNum)) {
       params = {parent: getParentIndex(patchRange.basePatchNum)};
-    } else if (patchRange.basePatchNum !== ParentPatchSetNum) {
+    } else if (patchRange.basePatchNum !== PARENT) {
       params = {base: patchRange.basePatchNum};
     }
     return this._getChangeURLAndFetch({
@@ -1314,7 +1302,7 @@
   ): Promise<{files: FileNameToFileInfoMap} | undefined> {
     let endpoint = '/edit?list';
     let anonymizedEndpoint = endpoint;
-    if (patchRange.basePatchNum !== ParentPatchSetNum) {
+    if (patchRange.basePatchNum !== PARENT) {
       endpoint += '&base=' + encodeURIComponent(`${patchRange.basePatchNum}`);
       anonymizedEndpoint += '&base=*';
     }
@@ -1342,7 +1330,7 @@
     changeNum: NumericChangeId,
     patchRange: PatchRange
   ): Promise<FileNameToFileInfoMap | undefined> {
-    if (patchRange.patchNum === EditPatchSetNum) {
+    if (patchRange.patchNum === EDIT) {
       return this.getChangeEditFiles(changeNum, patchRange).then(
         res => res && res.files
       );
@@ -1480,7 +1468,7 @@
     }) as Promise<GroupNameToGroupInfoMap | undefined>;
   }
 
-  getRepos(
+  async getRepos(
     filter: string | undefined,
     reposPerPage: number,
     offset?: number
@@ -1489,43 +1477,28 @@
 
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
-    // If query then return directly as the result will be expected to be an array
+
+    // If the request is a query then return the response directly as the result
+    // will already be the expected array. If it is not a query, transform the
+    // map to an array.
     if (isQuery) {
       return this._fetchSharedCacheURL({
-        url, // The url contains query,so the response is an array, not map
+        url,
         anonymizedUrl: '/projects/?*',
       }) as Promise<ProjectInfoWithName[] | undefined>;
-    }
-    const result: Promise<NameToProjectInfoMap[] | undefined> =
-      this._fetchSharedCacheURL({
-        url, // The url contains query,so the response is an array, not map
+    } else {
+      const result = await (this._fetchSharedCacheURL({
+        url,
         anonymizedUrl: '/projects/?*',
-      }) as Promise<NameToProjectInfoMap[] | undefined>;
-    return this._transformToArray(result);
-  }
-
-  _transformToArray(
-    res: Promise<NameToProjectInfoMap[] | undefined>
-  ): Promise<ProjectInfoWithName[] | undefined> {
-    return res.then(response => {
-      const reposList: ProjectInfoWithName[] = [];
-      for (const [name, project] of Object.entries(response ?? {})) {
-        const projectInfo: ProjectInfoWithName = {
-          id: project.id as unknown as UrlEncodedRepoName,
+      }) as Promise<NameToProjectInfoMap | undefined>);
+      if (result === undefined) return [];
+      return Object.entries(result).map(([name, project]) => {
+        return {
+          ...project,
           name: name as RepoName,
-          parent: project.parent as unknown as RepoName,
-          description: project.description as unknown as string,
-          state: project.state as unknown as ProjectState,
-          branches: project.branches as unknown as {
-            [branchName: string]: CommitId;
-          },
-          labels: project.labels as unknown as LabelNameToLabelTypeInfoMap,
-          web_links: project.web_links as unknown as WebLinkInfo[],
         };
-        reposList.push(projectInfo);
-      }
-      return reposList;
-    });
+      });
+    }
   }
 
   setRepoHead(repo: RepoName, ref: GitRef) {
@@ -1679,12 +1652,24 @@
 
   getSuggestedAccounts(
     inputVal: string,
-    n?: number
+    n?: number,
+    canSee?: NumericChangeId,
+    filterActive?: boolean
   ): Promise<AccountInfo[] | undefined> {
-    if (!inputVal) {
-      return Promise.resolve([]);
+    const params: QueryAccountsParams = {o: 'DETAILS', q: ''};
+    const queryParams = [];
+    inputVal = inputVal?.trim() ?? '';
+    if (inputVal.length > 0) {
+      queryParams.push(inputVal);
     }
-    const params: QueryAccountsParams = {suggest: null, q: inputVal};
+    if (canSee) {
+      queryParams.push(`cansee:${canSee}`);
+    }
+    if (filterActive) {
+      queryParams.push('is:active');
+    }
+    params.q = queryParams.join(' and ');
+    if (!params.q) return Promise.resolve([]);
     if (n) {
       params.n = n;
     }
@@ -1745,9 +1730,10 @@
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
   ): Promise<RelatedChangesInfo | undefined> {
+    const options = '?o=SUBMITTABLE';
     return this._getChangeURLAndFetch({
       changeNum,
-      endpoint: '/related',
+      endpoint: `/related${options}`,
       revision: patchNum,
       reportEndpointAsIs: true,
     }) as Promise<RelatedChangesInfo | undefined>;
@@ -1844,7 +1830,7 @@
   }
 
   getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> {
-    const query = [`intopic:"${topic}"`].join(' ');
+    const query = `intopic:"${topic}"`;
     return this._restApiHelper.fetchJSON({
       url: '/changes/',
       params: {q: query},
@@ -1852,6 +1838,17 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
+  getChangesWithSimilarHashtag(
+    hashtag: string
+  ): Promise<ChangeInfo[] | undefined> {
+    const query = `inhashtag:"${hashtag}"`;
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params: {q: query},
+      anonymizedUrl: '/changes/inhashtag:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
   getReviewedFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
@@ -1967,7 +1964,7 @@
     // 404s indicate the file does not exist yet in the revision, so suppress
     // them.
     const promise =
-      patchNum === EditPatchSetNum
+      patchNum === EDIT
         ? this._getFileInChangeEdit(changeNum, path)
         : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
 
@@ -2095,6 +2092,23 @@
     });
   }
 
+  getFixPreview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    fixReplacementInfos: FixReplacementInfo[]
+  ): Promise<FilePathToDiffInfoMap | undefined> {
+    return this._getChangeURLAndSend({
+      method: HttpMethod.POST,
+      changeNum,
+      patchNum,
+      endpoint: '/fix:preview',
+      reportEndpointAsId: true,
+      headers: {Accept: 'application/json'},
+      parseResponse: true,
+      body: {fix_replacement_infos: fixReplacementInfos},
+    }) as Promise<FilePathToDiffInfoMap | undefined>;
+  }
+
   getRobotCommentFixPreview(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
@@ -2111,6 +2125,22 @@
   applyFixSuggestion(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
+    fixReplacementInfos: FixReplacementInfo[]
+  ): Promise<Response> {
+    return this._getChangeURLAndSend({
+      method: HttpMethod.POST,
+      changeNum,
+      patchNum,
+      endpoint: '/fix:apply',
+      reportEndpointAsId: true,
+      headers: {Accept: 'application/json'},
+      body: {fix_replacement_infos: fixReplacementInfos},
+    });
+  }
+
+  applyRobotFixSuggestion(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
     fixId: string
   ): Promise<Response> {
     return this._getChangeURLAndSend({
@@ -2122,17 +2152,6 @@
     });
   }
 
-  // Deprecated, prefer to use putChangeCommitMessage instead.
-  saveChangeCommitMessageEdit(changeNum: NumericChangeId, message: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      endpoint: '/edit:message',
-      body: {message},
-      reportEndpointAsIs: true,
-    });
-  }
-
   publishChangeEdit(changeNum: NumericChangeId) {
     return this._getChangeURLAndSend({
       changeNum,
@@ -2225,12 +2244,6 @@
     });
   }
 
-  /**
-   * @param basePatchNum Negative values specify merge parent
-   * index.
-   * @param whitespace the ignore-whitespace level for the diff
-   * algorithm.
-   */
   getDiff(
     changeNum: NumericChangeId,
     basePatchNum: PatchSetNum,
@@ -2245,7 +2258,7 @@
     };
     if (isMergeParent(basePatchNum)) {
       params.parent = getParentIndex(basePatchNum);
-    } else if (basePatchNum !== ParentPatchSetNum) {
+    } else if (basePatchNum !== PARENT) {
       params.base = basePatchNum;
     }
     const endpoint = `/files/${encodeURIComponent(path)}/diff`;
@@ -2259,7 +2272,7 @@
     };
 
     // Invalidate the cache if its edit patch to make sure we always get latest.
-    if (patchNum === EditPatchSetNum) {
+    if (patchNum === EDIT) {
       if (!req.fetchOptions) req.fetchOptions = {};
       if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
       req.fetchOptions.headers.append('Cache-Control', 'no-cache');
@@ -2332,11 +2345,6 @@
     );
   }
 
-  /**
-   * If the user is logged in, fetch the user's draft diff comments. If there
-   * is no logged in user, the request is not made and the promise yields an
-   * empty object.
-   */
   async getDiffDrafts(
     changeNum: NumericChangeId
   ): Promise<{[path: string]: DraftInfo[]} | undefined> {
@@ -2461,7 +2469,7 @@
       // in a single pass.
       comments = this._setRanges(comments);
 
-      if (basePatchNum === ParentPatchSetNum) {
+      if (basePatchNum === PARENT) {
         baseComments = comments.filter(onlyParent);
         baseComments.forEach(setPath);
       }
@@ -2471,7 +2479,7 @@
     });
     promises.push(fetchPromise);
 
-    if (basePatchNum !== ParentPatchSetNum) {
+    if (basePatchNum !== PARENT) {
       fetchPromise = fetchComments(basePatchNum).then(response => {
         baseComments = ((response && path && response[path]) || []).filter(
           withoutParent
@@ -2561,18 +2569,11 @@
     );
   }
 
-  /**
-   * @return Whether there are pending diff draft sends.
-   */
   hasPendingDiffDrafts(): number {
     const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
     return promises && promises.length;
   }
 
-  /**
-   * @return A promise that resolves when all pending
-   * diff draft sends have resolved.
-   */
   awaitPendingDiffDrafts(): Promise<void> {
     return Promise.all(
       this._pendingRequests[Requests.SEND_DIFF_DRAFT] || []
@@ -2687,7 +2688,7 @@
     let promiseB;
 
     if (diff.meta_a?.content_type.startsWith('image/')) {
-      if (patchRange.basePatchNum === ParentPatchSetNum) {
+      if (patchRange.basePatchNum === PARENT) {
         // Note: we only attempt to get the image from the first parent.
         promiseA = this.getB64FileContents(
           changeNum,
@@ -2718,20 +2719,22 @@
 
     return Promise.all([promiseA, promiseB]).then(results => {
       // Sometimes the server doesn't send back the content type.
-      const baseImage: Base64ImageFile | null = results[0]
-        ? {
-            ...results[0],
-            _expectedType: diff.meta_a.content_type,
-            _name: diff.meta_a.name,
-          }
-        : null;
-      const revisionImage: Base64ImageFile | null = results[1]
-        ? {
-            ...results[1],
-            _expectedType: diff.meta_b.content_type,
-            _name: diff.meta_b.name,
-          }
-        : null;
+      const baseImage: Base64ImageFile | null =
+        results[0] && diff.meta_a
+          ? {
+              ...results[0],
+              _expectedType: diff.meta_a.content_type,
+              _name: diff.meta_a.name,
+            }
+          : null;
+      const revisionImage: Base64ImageFile | null =
+        results[1] && diff.meta_b
+          ? {
+              ...results[1],
+              _expectedType: diff.meta_b.content_type,
+              _name: diff.meta_b.name,
+            }
+          : null;
       const imagesForDiff: ImagesForDiff = {baseImage, revisionImage};
       return imagesForDiff;
     });
@@ -3008,20 +3011,20 @@
     }) as unknown as Promise<CommentInfo>;
   }
 
-  /**
-   * Given a changeNum, gets the change.
-   */
   getChange(
     changeNum: ChangeId | NumericChangeId,
     errFn: ErrorCallback
   ): Promise<ChangeInfo | null> {
     // Cannot use _changeBaseURL, as this function is used by _projectLookup.
     return this._restApiHelper
-      .fetchJSON({
-        url: `/changes/?q=change:${changeNum}`,
-        errFn,
-        anonymizedUrl: '/changes/?q=change:*',
-      })
+      .fetchJSON(
+        {
+          url: `/changes/?q=change:${changeNum}`,
+          errFn,
+          anonymizedUrl: '/changes/?q=change:*',
+        },
+        /* noAcceptHeader */ true
+      )
       .then(res => {
         const changeInfos = res as ChangeInfo[] | undefined;
         if (!changeInfos || !changeInfos.length) {
@@ -3042,11 +3045,6 @@
     this._projectLookup[changeNum] = Promise.resolve(project);
   }
 
-  /**
-   * Checks in _projectLookup for the changeNum. If it exists, returns the
-   * project. If not, calls the restAPI to get the change, populates
-   * _projectLookup with the project for that change, and returns the project.
-   */
   getFromProjectLookup(
     changeNum: NumericChangeId
   ): Promise<RepoName | undefined> {
@@ -3157,9 +3155,6 @@
     errFn: ErrorCallback
   ): Promise<Response | undefined>;
 
-  /**
-   * Execute a change action or revision action on a change.
-   */
   executeChangeAction(
     changeNum: NumericChangeId,
     method: HttpMethod | undefined,
@@ -3178,12 +3173,6 @@
     });
   }
 
-  /**
-   * Get blame information for the given diff.
-   *
-   * @param base If true, requests blame for the base of the
-   *     diff, rather than the revision.
-   */
   getBlame(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
@@ -3234,10 +3223,6 @@
     });
   }
 
-  /**
-   * Fetch a project dashboard definition.
-   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
-   */
   getDashboard(
     project: RepoName,
     dashboard: DashboardId,
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
index d8c3ff2..5af123a 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
@@ -1,30 +1,33 @@
 /**
  * @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 {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';
+import '../../test/common-test-setup';
+import {
+  addListenerForTest,
+  mockPromise,
+  stubAuth,
+  waitEventLoop,
+} from '../../test/test-utils';
+import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  ListChangesOption,
+  listChangesOptionsToHex,
+} from '../../utils/change-util';
+import {getAppContext} from '../app-context';
+import {createChange} from '../../test/test-data-generators';
+import {CURRENT} from '../../utils/patch-set-util';
+import {
+  parsePrefixedJSON,
+  readResponsePayload,
+  JSON_PREFIX,
+  // eslint-disable-next-line max-len
+} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {GrRestApiServiceImpl} from './gr-rest-api-impl';
+import {CommentSide} from '../../constants/constants';
+import {EDIT, PARENT} from '../../types/common';
+import {assert} from '@open-wc/testing';
 
 const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
     ListChangesOption.CHANGE_ACTIONS,
@@ -47,14 +50,17 @@
     window.CANONICAL_PATH = `test${ctr}`;
 
     const testJSON = ')]}\'\n{"hello": "bonjour"}';
-    sinon.stub(window, 'fetch').returns(Promise.resolve({
-      ok: true,
-      text() {
-        return Promise.resolve(testJSON);
-      },
-    }));
+    sinon.stub(window, 'fetch').returns(
+        Promise.resolve({
+          ok: true,
+          text() {
+            return Promise.resolve(testJSON);
+          },
+        })
+    );
     // fake auth
-    sinon.stub(getAppContext().authService, 'authCheck')
+    sinon
+        .stub(getAppContext().authService, 'authCheck')
         .returns(Promise.resolve(true));
     element = new GrRestApiServiceImpl(
         getAppContext().authService,
@@ -68,27 +74,28 @@
   });
 
   test('parent diff comments are properly grouped', () => {
-    sinon.stub(element._restApiHelper, 'fetchJSON')
-        .callsFake(() => Promise.resolve({
-          '/COMMIT_MSG': [],
-          'sieve.go': [
-            {
-              updated: '2017-02-03 22:32:28.000000000',
-              message: 'this isn’t quite right',
-            },
-            {
-              side: 'PARENT',
-              message: 'how did this work in the first place?',
-              updated: '2017-02-03 22:33:28.000000000',
-            },
-          ],
-        }));
-    return element._getDiffComments('42', '', undefined, 'PARENT', 1,
-        'sieve.go').then(
-        obj => {
+    sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(() =>
+      Promise.resolve({
+        '/COMMIT_MSG': [],
+        'sieve.go': [
+          {
+            updated: '2017-02-03 22:32:28.000000000',
+            message: 'this isn’t quite right',
+          },
+          {
+            side: CommentSide.PARENT,
+            message: 'how did this work in the first place?',
+            updated: '2017-02-03 22:33:28.000000000',
+          },
+        ],
+      })
+    );
+    return element
+        ._getDiffComments('42', '', undefined, PARENT, 1, 'sieve.go')
+        .then(obj => {
           assert.equal(obj.baseComments.length, 1);
           assert.deepEqual(obj.baseComments[0], {
-            side: 'PARENT',
+            side: CommentSide.PARENT,
             message: 'how did this work in the first place?',
             path: 'sieve.go',
             updated: '2017-02-03 22:33:28.000000000',
@@ -106,7 +113,7 @@
     const comments = [
       {
         id: 1,
-        side: 'PARENT',
+        side: CommentSide.PARENT,
         message: 'how did this work in the first place?',
         updated: '2017-02-03 22:32:28.000000000',
         range: {
@@ -155,7 +162,7 @@
       },
       {
         id: 1,
-        side: 'PARENT',
+        side: CommentSide.PARENT,
         message: 'how did this work in the first place?',
         updated: '2017-02-03 22:32:28.000000000',
         range: {
@@ -169,7 +176,7 @@
     const expectedResult = [
       {
         id: 1,
-        side: 'PARENT',
+        side: CommentSide.PARENT,
         message: 'how did this work in the first place?',
         updated: '2017-02-03 22:32:28.000000000',
         range: {
@@ -208,7 +215,8 @@
   });
 
   test('differing patch diff comments are properly grouped', () => {
-    sinon.stub(element, 'getFromProjectLookup')
+    sinon
+        .stub(element, 'getFromProjectLookup')
         .returns(Promise.resolve('test'));
     sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(request => {
       const url = request.url;
@@ -221,7 +229,7 @@
               updated: '2017-02-03 22:32:28.000000000',
             },
             {
-              side: 'PARENT',
+              side: CommentSide.PARENT,
               message: 'how did this work in the first place?',
               updated: '2017-02-03 22:33:28.000000000',
             },
@@ -236,7 +244,7 @@
               updated: '2017-02-03 22:32:28.000000000',
             },
             {
-              side: 'PARENT',
+              side: CommentSide.PARENT,
               message: 'Yeah not sure how this worked either?',
               updated: '2017-02-03 22:33:28.000000000',
             },
@@ -248,8 +256,9 @@
         });
       }
     });
-    return element._getDiffComments('42', '', undefined, 1, 2, 'sieve.go').then(
-        obj => {
+    return element
+        ._getDiffComments('42', '', undefined, 1, 2, 'sieve.go')
+        .then(obj => {
           assert.equal(obj.baseComments.length, 1);
           assert.deepEqual(obj.baseComments[0], {
             message: 'this isn’t quite right',
@@ -277,17 +286,21 @@
       addListenerForTest(document, 'server-error', resolve);
     });
 
-    return Promise.all([element._restApiHelper.fetchJSON({}).then(response => {
-      assert.isUndefined(response);
-      assert.isTrue(getResponseObjectStub.notCalled);
-    }), serverErrorEventPromise]);
+    return Promise.all([
+      element._restApiHelper.fetchJSON({}).then(response => {
+        assert.isUndefined(response);
+        assert.isTrue(getResponseObjectStub.notCalled);
+      }),
+      serverErrorEventPromise,
+    ]);
   });
 
   test('legacy n,z key in change url is replaced', async () => {
     sinon.stub(element, 'getConfig').callsFake(async () => {
       return {};
     });
-    const stub = sinon.stub(element._restApiHelper, 'fetchJSON')
+    const stub = sinon
+        .stub(element._restApiHelper, 'fetchJSON')
         .returns(Promise.resolve([]));
     await element.getChanges(1, null, 'n,z');
     assert.equal(stub.lastCall.args[0].params.S, 0);
@@ -302,9 +315,47 @@
     assert.isFalse(element._restApiHelper._cache.has(cacheKey));
   });
 
+  suite('getAccountSuggestions', () => {
+    let fetchStub;
+    setup(() => {
+      fetchStub = sinon
+          .stub(element._restApiHelper, 'fetch')
+          .returns(Promise.resolve(new Response()));
+    });
+
+    test('url with just email', () => {
+      element.getSuggestedAccounts('bro');
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+          fetchStub.firstCall.args[0].url,
+          'test52/accounts/?o=DETAILS&q=bro'
+      );
+    });
+
+    test('url with email and canSee changeId', () => {
+      element.getSuggestedAccounts('bro', undefined, 341682);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+          fetchStub.firstCall.args[0].url,
+          'test53/accounts/?o=DETAILS&q=bro%20and%20cansee%3A341682'
+      );
+    });
+
+    test('url with email and canSee changeId and isActive', () => {
+      element.getSuggestedAccounts('bro', undefined, 341682, true);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+          fetchStub.firstCall.args[0].url,
+          'test54/accounts/?o=DETAILS&q=bro%20and%20' +
+          'cansee%3A341682%20and%20is%3Aactive'
+      );
+    });
+  });
+
   test('getAccount when resp is null does not add to cache', async () => {
     const cacheKey = '/accounts/self/detail';
-    const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL')
+    const stub = sinon
+        .stub(element._restApiHelper, 'fetchCacheURL')
         .callsFake(() => Promise.resolve());
 
     await element.getAccount();
@@ -317,7 +368,8 @@
 
   test('getAccount does not add to cache when status is 403', async () => {
     const cacheKey = '/accounts/self/detail';
-    const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL')
+    const stub = sinon
+        .stub(element._restApiHelper, 'fetchCacheURL')
         .callsFake(() => Promise.resolve());
 
     await element.getAccount();
@@ -330,8 +382,9 @@
 
   test('getAccount when resp is successful', async () => {
     const cacheKey = '/accounts/self/detail';
-    const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL').callsFake(
-        () => Promise.resolve());
+    const stub = sinon
+        .stub(element._restApiHelper, 'fetchCacheURL')
+        .callsFake(() => Promise.resolve());
 
     await element.getAccount();
 
@@ -342,53 +395,51 @@
   });
 
   const preferenceSetup = function(testJSON, loggedIn) {
-    sinon.stub(element, 'getLoggedIn')
+    sinon
+        .stub(element, 'getLoggedIn')
         .callsFake(() => Promise.resolve(loggedIn));
-    sinon.stub(
-        element._restApiHelper,
-        'fetchCacheURL')
+    sinon
+        .stub(element._restApiHelper, 'fetchCacheURL')
         .callsFake(() => Promise.resolve(testJSON));
   };
 
-  test('getPreferences returns correctly logged in',
-      () => {
-        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
-        const loggedIn = true;
+  test('getPreferences returns correctly logged in', () => {
+    const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+    const loggedIn = true;
 
-        preferenceSetup(testJSON, loggedIn);
+    preferenceSetup(testJSON, loggedIn);
 
-        return element.getPreferences().then(obj => {
-          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-        });
-      });
+    return element.getPreferences().then(obj => {
+      assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+    });
+  });
 
-  test('getPreferences returns correctly on larger screens logged in',
-      () => {
-        const testJSON = {diff_view: 'UNIFIED_DIFF'};
-        const loggedIn = true;
+  test('getPreferences returns correctly on larger screens logged in', () => {
+    const testJSON = {diff_view: 'UNIFIED_DIFF'};
+    const loggedIn = true;
 
-        preferenceSetup(testJSON, loggedIn);
+    preferenceSetup(testJSON, loggedIn);
 
-        return element.getPreferences().then(obj => {
-          assert.equal(obj.diff_view, 'UNIFIED_DIFF');
-        });
-      });
+    return element.getPreferences().then(obj => {
+      assert.equal(obj.diff_view, 'UNIFIED_DIFF');
+    });
+  });
 
-  test('getPreferences returns correctly on larger screens not logged in',
-      () => {
-        const testJSON = {diff_view: 'UNIFIED_DIFF'};
-        const loggedIn = false;
+  test('getPreferences returns correctly on larger screens no login', () => {
+    const testJSON = {diff_view: 'UNIFIED_DIFF'};
+    const loggedIn = false;
 
-        preferenceSetup(testJSON, loggedIn);
+    preferenceSetup(testJSON, loggedIn);
 
-        return element.getPreferences().then(obj => {
-          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-        });
-      });
+    return element.getPreferences().then(obj => {
+      assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+    });
+  });
 
   test('savPreferences normalizes download scheme', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send').returns(
-        Promise.resolve(new Response()));
+    const sendStub = sinon
+        .stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve(new Response()));
     element.savePreferences({download_scheme: 'HTTP'});
     assert.isTrue(sendStub.called);
     assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
@@ -454,32 +505,32 @@
     element.confirmEmail('foo');
     assert.isTrue(sendStub.calledOnce);
     assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-    assert.equal(sendStub.lastCall.args[0].url,
-        '/config/server/email.confirm');
+    assert.equal(sendStub.lastCall.args[0].url, '/config/server/email.confirm');
     assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
   });
 
   test('setAccountStatus', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send')
+    const sendStub = sinon
+        .stub(element._restApiHelper, 'send')
         .returns(Promise.resolve('OOO'));
     element._cache.set('/accounts/self/detail', {});
     return element.setAccountStatus('OOO').then(() => {
       assert.isTrue(sendStub.calledOnce);
       assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-      assert.equal(sendStub.lastCall.args[0].url,
-          '/accounts/self/status');
-      assert.deepEqual(sendStub.lastCall.args[0].body,
-          {status: 'OOO'});
+      assert.equal(sendStub.lastCall.args[0].url, '/accounts/self/status');
+      assert.deepEqual(sendStub.lastCall.args[0].body, {status: 'OOO'});
       assert.deepEqual(
           element._restApiHelper._cache.get('/accounts/self/detail'),
-          {status: 'OOO'});
+          {status: 'OOO'}
+      );
     });
   });
 
   suite('draft comments', () => {
     test('_sendDiffDraftRequest pending requests tracked', () => {
       const obj = element._pendingRequests;
-      sinon.stub(element, '_getChangeURLAndSend')
+      sinon
+          .stub(element, '_getChangeURLAndSend')
           .callsFake(() => mockPromise());
       assert.notOk(element.hasPendingDiffDrafts());
 
@@ -505,7 +556,8 @@
       test('_sendDiffDraftRequest checks for 200 on create', () => {
         const sendPromise = Promise.resolve();
         sinon.stub(element, '_getChangeURLAndSend').returns(sendPromise);
-        const failStub = sinon.stub(element, '_failForCreate200')
+        const failStub = sinon
+            .stub(element, '_failForCreate200')
             .returns(Promise.resolve());
         return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
           assert.isTrue(failStub.calledOnce);
@@ -514,11 +566,12 @@
       });
 
       test('_sendDiffDraftRequest no checks for 200 on non create', () => {
-        sinon.stub(element, '_getChangeURLAndSend')
+        sinon.stub(element, '_getChangeURLAndSend').returns(Promise.resolve());
+        const failStub = sinon
+            .stub(element, '_failForCreate200')
             .returns(Promise.resolve());
-        const failStub = sinon.stub(element, '_failForCreate200')
-            .returns(Promise.resolve());
-        return element._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
+        return element
+            ._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
             .then(() => {
               assert.isFalse(failStub.called);
             });
@@ -535,7 +588,8 @@
             ],
           },
         };
-        return element._failForCreate200(Promise.resolve(result))
+        return element
+            ._failForCreate200(Promise.resolve(result))
             .then(() => {
               assert.fail('Error expected.');
             })
@@ -563,20 +617,29 @@
     const change_num = '1';
     const file_name = 'index.php';
     const file_contents = '<?php';
-    sinon.stub(element._restApiHelper, 'send').returns(
-        Promise.resolve([change_num, file_name, file_contents]));
-    sinon.stub(element, 'getResponseObject')
+    sinon
+        .stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve([change_num, file_name, file_contents]));
+    sinon
+        .stub(element, 'getResponseObject')
         .returns(Promise.resolve([change_num, file_name, file_contents]));
     element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
-    return element.saveChangeEdit(change_num, file_name, file_contents)
+    return element
+        .saveChangeEdit(change_num, file_name, file_contents)
         .then(() => {
           assert.isTrue(element._restApiHelper.send.calledOnce);
-          assert.equal(element._restApiHelper.send.lastCall.args[0].method,
-              'PUT');
-          assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-              '/changes/test~1/edit/' + file_name);
-          assert.equal(element._restApiHelper.send.lastCall.args[0].body,
-              file_contents);
+          assert.equal(
+              element._restApiHelper.send.lastCall.args[0].method,
+              'PUT'
+          );
+          assert.equal(
+              element._restApiHelper.send.lastCall.args[0].url,
+              '/changes/test~1/edit/' + file_name
+          );
+          assert.equal(
+              element._restApiHelper.send.lastCall.args[0].body,
+              file_contents
+          );
         });
   });
 
@@ -584,18 +647,23 @@
     element._projectLookup = {1: Promise.resolve('test')};
     const change_num = '1';
     const message = 'this is a commit message';
-    sinon.stub(element._restApiHelper, 'send').returns(
-        Promise.resolve([change_num, message]));
-    sinon.stub(element, 'getResponseObject')
+    sinon
+        .stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve([change_num, message]));
+    sinon
+        .stub(element, 'getResponseObject')
         .returns(Promise.resolve([change_num, message]));
     element._cache.set('/changes/' + change_num + '/message', {});
     return element.putChangeCommitMessage(change_num, message).then(() => {
       assert.isTrue(element._restApiHelper.send.calledOnce);
       assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
-      assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-          '/changes/test~1/message');
-      assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body,
-          {message});
+      assert.equal(
+          element._restApiHelper.send.lastCall.args[0].url,
+          '/changes/test~1/message'
+      );
+      assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body, {
+        message,
+      });
     });
   });
 
@@ -603,9 +671,11 @@
     element._projectLookup = {1: Promise.resolve('test')};
     const change_num = '1';
     const messageId = 'abc';
-    sinon.stub(element._restApiHelper, 'send').returns(
-        Promise.resolve([change_num, messageId]));
-    sinon.stub(element, 'getResponseObject')
+    sinon
+        .stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve([change_num, messageId]));
+    sinon
+        .stub(element, 'getResponseObject')
         .returns(Promise.resolve([change_num, messageId]));
     return element.deleteChangeCommitMessage(change_num, messageId).then(() => {
       assert.isTrue(element._restApiHelper.send.calledOnce);
@@ -613,13 +683,16 @@
           element._restApiHelper.send.lastCall.args[0].method,
           'DELETE'
       );
-      assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-          '/changes/test~1/messages/abc');
+      assert.equal(
+          element._restApiHelper.send.lastCall.args[0].url,
+          '/changes/test~1/messages/abc'
+      );
     });
   });
 
   test('startWorkInProgress', () => {
-    const sendStub = sinon.stub(element, '_getChangeURLAndSend')
+    const sendStub = sinon
+        .stub(element, '_getChangeURLAndSend')
         .returns(Promise.resolve('ok'));
     element.startWorkInProgress('42');
     assert.isTrue(sendStub.calledOnce);
@@ -635,29 +708,36 @@
     assert.equal(sendStub.lastCall.args[0].method, 'POST');
     assert.isNotOk(sendStub.lastCall.args[0].patchNum);
     assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
-    assert.deepEqual(sendStub.lastCall.args[0].body,
-        {message: 'revising...'});
+    assert.deepEqual(sendStub.lastCall.args[0].body, {
+      message: 'revising...',
+    });
   });
 
   test('deleteComment', () => {
-    const sendStub = sinon.stub(element, '_getChangeURLAndSend')
+    const sendStub = sinon
+        .stub(element, '_getChangeURLAndSend')
         .returns(Promise.resolve('some response'));
-    return element.deleteComment('foo', 'bar', '01234', 'removal reason')
+    return element
+        .deleteComment('foo', 'bar', '01234', 'removal reason')
         .then(response => {
           assert.equal(response, 'some response');
           assert.isTrue(sendStub.calledOnce);
           assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
           assert.equal(sendStub.lastCall.args[0].method, 'POST');
           assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
-          assert.equal(sendStub.lastCall.args[0].endpoint,
-              '/comments/01234/delete');
-          assert.deepEqual(sendStub.lastCall.args[0].body,
-              {reason: 'removal reason'});
+          assert.equal(
+              sendStub.lastCall.args[0].endpoint,
+              '/comments/01234/delete'
+          );
+          assert.deepEqual(sendStub.lastCall.args[0].body, {
+            reason: 'removal reason',
+          });
         });
   });
 
   test('createRepo encodes name', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send')
+    const sendStub = sinon
+        .stub(element._restApiHelper, 'send')
         .returns(Promise.resolve());
     return element.createRepo({name: 'x/y'}).then(() => {
       assert.isTrue(sendStub.calledOnce);
@@ -666,30 +746,41 @@
   });
 
   test('queryChangeFiles', () => {
-    const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+    const fetchStub = sinon
+        .stub(element, '_getChangeURLAndFetch')
         .returns(Promise.resolve());
-    return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
+    return element.queryChangeFiles('42', EDIT, 'test/path.js').then(() => {
       assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
-      assert.equal(fetchStub.lastCall.args[0].endpoint,
-          '/files?q=test%2Fpath.js');
-      assert.equal(fetchStub.lastCall.args[0].revision, 'edit');
+      assert.equal(
+          fetchStub.lastCall.args[0].endpoint,
+          '/files?q=test%2Fpath.js'
+      );
+      assert.equal(fetchStub.lastCall.args[0].revision, EDIT);
     });
   });
 
   test('normal use', () => {
     const defaultQuery = '';
 
-    assert.equal(element._getReposUrl('test', 25).toString(),
-        [false, '/projects/?n=26&S=0&d=&m=test'].toString());
+    assert.equal(
+        element._getReposUrl('test', 25).toString(),
+        [false, '/projects/?n=26&S=0&d=&m=test'].toString()
+    );
 
-    assert.equal(element._getReposUrl(null, 25).toString(),
-        [false, `/projects/?n=26&S=0&d=&m=${defaultQuery}`].toString());
+    assert.equal(
+        element._getReposUrl(null, 25).toString(),
+        [false, `/projects/?n=26&S=0&d=&m=${defaultQuery}`].toString()
+    );
 
-    assert.equal(element._getReposUrl('test', 25, 25).toString(),
-        [false, '/projects/?n=26&S=25&d=&m=test'].toString());
+    assert.equal(
+        element._getReposUrl('test', 25, 25).toString(),
+        [false, '/projects/?n=26&S=25&d=&m=test'].toString()
+    );
 
-    assert.equal(element._getReposUrl('inname:test', 25, 25).toString(),
-        [true, '/projects/?n=26&S=25&query=inname%3Atest'].toString());
+    assert.equal(
+        element._getReposUrl('inname:test', 25, 25).toString(),
+        [true, '/projects/?n=26&S=25&query=inname%3Atest'].toString()
+    );
   });
 
   test('invalidateReposCache', () => {
@@ -720,83 +811,105 @@
     const defaultQuery = '';
     let fetchCacheURLStub;
     setup(() => {
-      fetchCacheURLStub =
-          sinon.stub(element._restApiHelper, 'fetchCacheURL')
-              .returns(Promise.resolve([]));
+      fetchCacheURLStub = sinon
+          .stub(element._restApiHelper, 'fetchCacheURL')
+          .returns(Promise.resolve([]));
     });
 
     test('normal use', () => {
       element.getRepos('test', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=test');
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&d=&m=test'
+      );
 
       element.getRepos(null, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&d=&m=${defaultQuery}`);
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          `/projects/?n=26&S=0&d=&m=${defaultQuery}`
+      );
 
       element.getRepos('test', 25, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=25&d=&m=test');
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=25&d=&m=test'
+      );
     });
 
     test('with blank', () => {
       element.getRepos('test/test', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=test%2Ftest');
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&d=&m=test%2Ftest'
+      );
     });
 
     test('with hyphen', () => {
       element.getRepos('foo-bar', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=foo-bar');
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&d=&m=foo-bar'
+      );
     });
 
     test('with leading hyphen', () => {
       element.getRepos('-bar', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=-bar');
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&d=&m=-bar'
+      );
     });
 
     test('with trailing hyphen', () => {
       element.getRepos('foo-bar-', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=foo-bar-');
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&d=&m=foo-bar-'
+      );
     });
 
     test('with underscore', () => {
       element.getRepos('foo_bar', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=foo_bar');
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&d=&m=foo_bar'
+      );
     });
 
     test('with underscore', () => {
       element.getRepos('foo_bar', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=foo_bar');
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&d=&m=foo_bar'
+      );
     });
 
     test('hyphen only', () => {
       element.getRepos('-', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&d=&m=-`);
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          `/projects/?n=26&S=0&d=&m=-`
+      );
     });
 
-    test('using query', () =>{
+    test('using query', () => {
       element.getRepos('description:project', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&query=description%3Aproject`);
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          `/projects/?n=26&S=0&query=description%3Aproject`
+      );
     });
   });
 
   test('_getGroupsUrl normal use', () => {
-    assert.equal(element._getGroupsUrl('test', 25),
-        '/groups/?n=26&S=0&m=test');
+    assert.equal(element._getGroupsUrl('test', 25), '/groups/?n=26&S=0&m=test');
 
-    assert.equal(element._getGroupsUrl(null, 25),
-        '/groups/?n=26&S=0');
+    assert.equal(element._getGroupsUrl(null, 25), '/groups/?n=26&S=0');
 
-    assert.equal(element._getGroupsUrl('test', 25, 25),
-        '/groups/?n=26&S=25&m=test');
+    assert.equal(
+        element._getGroupsUrl('test', 25, 25),
+        '/groups/?n=26&S=25&m=test'
+    );
   });
 
   test('invalidateGroupsCache', () => {
@@ -814,32 +927,38 @@
   suite('getGroups', () => {
     let fetchCacheURLStub;
     setup(() => {
-      fetchCacheURLStub =
-          sinon.stub(element._restApiHelper, 'fetchCacheURL');
+      fetchCacheURLStub = sinon.stub(element._restApiHelper, 'fetchCacheURL');
     });
 
     test('normal use', () => {
       element.getGroups('test', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0&m=test');
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=0&m=test'
+      );
 
       element.getGroups(null, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0');
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url, '/groups/?n=26&S=0');
 
       element.getGroups('test', 25, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=25&m=test');
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=25&m=test'
+      );
     });
 
     test('regex', () => {
       element.getGroups('^test.*', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0&r=%5Etest.*');
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=0&r=%5Etest.*'
+      );
 
       element.getGroups('^test.*', 25, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=25&r=%5Etest.*');
+      assert.equal(
+          fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=25&r=%5Etest.*'
+      );
     });
   });
 
@@ -858,12 +977,13 @@
   });
 
   test('_fetchJSON gets called by getSuggestedAccounts', () => {
-    const _fetchJSONStub = sinon.stub(element._restApiHelper, 'fetchJSON')
+    const _fetchJSONStub = sinon
+        .stub(element._restApiHelper, 'fetchJSON')
         .callsFake(() => Promise.resolve());
     return element.getSuggestedAccounts('own').then(() => {
       assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
         q: 'own',
-        suggest: null,
+        o: 'DETAILS',
       });
     });
   });
@@ -871,8 +991,9 @@
   suite('getChangeDetail', () => {
     suite('change detail options', () => {
       setup(() => {
-        sinon.stub(element, '_getChangeDetail').callsFake(
-            async (changeNum, options) => {
+        sinon
+            .stub(element, '_getChangeDetail')
+            .callsFake(async (changeNum, options) => {
               return {changeNum, options};
             });
       });
@@ -884,7 +1005,8 @@
         const {changeNum, options} = await element.getChangeDetail(123);
         assert.strictEqual(123, changeNum);
         assert.isNotOk(
-            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES));
+            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
+        );
       });
 
       test('signed pushes enabled', async () => {
@@ -894,13 +1016,15 @@
         const {changeNum, options} = await element.getChangeDetail(123);
         assert.strictEqual(123, changeNum);
         assert.ok(
-            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES));
+            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
+        );
       });
     });
 
     test('GrReviewerUpdatesParser.parse is used', () => {
-      sinon.stub(GrReviewerUpdatesParser, 'parse').returns(
-          Promise.resolve('foo'));
+      sinon
+          .stub(GrReviewerUpdatesParser, 'parse')
+          .returns(Promise.resolve('foo'));
       return element.getChangeDetail(42).then(result => {
         assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
         assert.equal(result, 'foo');
@@ -911,21 +1035,20 @@
       const changeNum = 4321;
       element._projectLookup[changeNum] = Promise.resolve('test');
       const expectedUrl =
-          window.CANONICAL_PATH + '/changes/test~4321/detail?O=516714';
+        window.CANONICAL_PATH + '/changes/test~4321/detail?O=516714';
       sinon.stub(element._etags, 'getOptions');
       sinon.stub(element._etags, 'collect');
       return element._getChangeDetail(changeNum, '516714').then(() => {
-        assert.isTrue(element._etags.getOptions.calledWithExactly(
-            expectedUrl));
+        assert.isTrue(element._etags.getOptions.calledWithExactly(expectedUrl));
         assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
       });
     });
 
     test('_getChangeDetail calls errFn on 500', () => {
       const errFn = sinon.stub();
-      sinon.stub(element, 'getChangeActionURL')
-          .returns(Promise.resolve(''));
-      sinon.stub(element._restApiHelper, 'fetchRawJSON')
+      sinon.stub(element, 'getChangeActionURL').returns(Promise.resolve(''));
+      sinon
+          .stub(element._restApiHelper, 'fetchRawJSON')
           .returns(Promise.resolve({ok: false, status: 500}));
       return element._getChangeDetail(123, '516714', errFn).then(() => {
         assert.isTrue(errFn.called);
@@ -933,14 +1056,14 @@
     });
 
     test('_getChangeDetail populates _projectLookup', async () => {
-      sinon.stub(element, 'getChangeActionURL')
-          .returns(Promise.resolve(''));
-      sinon.stub(element._restApiHelper, 'fetchRawJSON')
-          .returns(Promise.resolve({
+      sinon.stub(element, 'getChangeActionURL').returns(Promise.resolve(''));
+      sinon.stub(element._restApiHelper, 'fetchRawJSON').returns(
+          Promise.resolve({
             ok: true,
             status: 200,
             text: () => Promise.resolve(`)]}'{"_number":1,"project":"test"}`),
-          }));
+          })
+      );
       await element._getChangeDetail(1, '516714');
       assert.equal(Object.keys(element._projectLookup).length, 1);
       const project = await element._projectLookup[1];
@@ -956,21 +1079,22 @@
         requestUrl = '/foo/bar';
         const mockResponse = {foo: 'bar', baz: 42};
         mockResponseSerial = JSON_PREFIX + JSON.stringify(mockResponse);
-        sinon.stub(element._restApiHelper, 'urlWithParams')
-            .returns(requestUrl);
-        sinon.stub(element, 'getChangeActionURL')
+        sinon.stub(element._restApiHelper, 'urlWithParams').returns(requestUrl);
+        sinon
+            .stub(element, 'getChangeActionURL')
             .returns(Promise.resolve(requestUrl));
         collectSpy = sinon.spy(element._etags, 'collect');
       });
 
       test('contributes to cache', () => {
         const getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
-        sinon.stub(element._restApiHelper, 'fetchRawJSON')
-            .returns(Promise.resolve({
+        sinon.stub(element._restApiHelper, 'fetchRawJSON').returns(
+            Promise.resolve({
               text: () => Promise.resolve(mockResponseSerial),
               status: 200,
               ok: true,
-            }));
+            })
+        );
 
         return element._getChangeDetail(123, '516714').then(detail => {
           assert.isFalse(getPayloadSpy.called);
@@ -983,12 +1107,13 @@
       test('uses cache on HTTP 304', () => {
         const getPayloadStub = sinon.stub(element._etags, 'getCachedPayload');
         getPayloadStub.returns(mockResponseSerial);
-        sinon.stub(element._restApiHelper, 'fetchRawJSON')
-            .returns(Promise.resolve({
+        sinon.stub(element._restApiHelper, 'fetchRawJSON').returns(
+            Promise.resolve({
               text: () => Promise.resolve(''),
               status: 304,
               ok: true,
-            }));
+            })
+        );
 
         return element._getChangeDetail(123, '').then(detail => {
           assert.isFalse(collectSpy.called);
@@ -1012,7 +1137,8 @@
     });
 
     test('getChange succeeds with project', () => {
-      sinon.stub(element, 'getChange')
+      sinon
+          .stub(element, 'getChange')
           .returns(Promise.resolve({project: 'project'}));
       const projectLookup = element.getFromProjectLookup('test');
       return projectLookup.then(val => {
@@ -1024,15 +1150,15 @@
 
   suite('getChanges populates _projectLookup', () => {
     test('multiple queries', async () => {
-      sinon.stub(element._restApiHelper, 'fetchJSON')
-          .returns(Promise.resolve([
+      sinon.stub(element._restApiHelper, 'fetchJSON').returns(
+          Promise.resolve([
             [
               {_number: 1, project: 'test'},
               {_number: 2, project: 'test'},
-            ], [
-              {_number: 3, project: 'test/test'},
             ],
-          ]));
+            [{_number: 3, project: 'test/test'}],
+          ])
+      );
       // When opt_query instanceof Array, _fetchJSON returns
       // Array<Array<Object>>.
       await element.getChangesForMultipleQueries(null, []);
@@ -1046,12 +1172,13 @@
     });
 
     test('no query', async () => {
-      sinon.stub(element._restApiHelper, 'fetchJSON')
-          .returns(Promise.resolve([
+      sinon.stub(element._restApiHelper, 'fetchJSON').returns(
+          Promise.resolve([
             {_number: 1, project: 'test'},
             {_number: 2, project: 'test'},
             {_number: 3, project: 'test/test'},
-          ]));
+          ])
+      );
 
       // When opt_query !instanceof Array, _fetchJSON returns
       // Array<Object>.
@@ -1071,33 +1198,37 @@
     c1._number = 1;
     const c2 = createChange();
     c2._number = 2;
-    const getChangesStub = sinon.stub(element, 'getChanges').callsFake(
-        (changesPerPage, query, offset, options) => {
+    const getChangesStub = sinon
+        .stub(element, 'getChanges')
+        .callsFake((changesPerPage, query, offset, options) => {
           assert.isUndefined(changesPerPage);
           assert.strictEqual(query, 'change:1 OR change:2');
           assert.isUndefined(offset);
           assert.strictEqual(options, EXPECTED_QUERY_OPTIONS);
           return Promise.resolve([]);
-        }
-    );
+        });
     await element.getDetailedChangesWithActions([c1._number, c2._number]);
     assert.isTrue(getChangesStub.calledOnce);
   });
 
   test('_getChangeURLAndFetch', () => {
     element._projectLookup = {1: Promise.resolve('test')};
-    const fetchStub = sinon.stub(element._restApiHelper, 'fetchJSON')
+    const fetchStub = sinon
+        .stub(element._restApiHelper, 'fetchJSON')
         .returns(Promise.resolve());
     const req = {changeNum: 1, endpoint: '/test', revision: 1};
     return element._getChangeURLAndFetch(req).then(() => {
-      assert.equal(fetchStub.lastCall.args[0].url,
-          '/changes/test~1/revisions/1/test');
+      assert.equal(
+          fetchStub.lastCall.args[0].url,
+          '/changes/test~1/revisions/1/test'
+      );
     });
   });
 
   test('_getChangeURLAndSend', () => {
     element._projectLookup = {1: Promise.resolve('test')};
-    const sendStub = sinon.stub(element._restApiHelper, 'send')
+    const sendStub = sinon
+        .stub(element._restApiHelper, 'send')
         .returns(Promise.resolve());
 
     const req = {
@@ -1109,8 +1240,10 @@
     return element._getChangeURLAndSend(req).then(() => {
       assert.isTrue(sendStub.calledOnce);
       assert.equal(sendStub.lastCall.args[0].method, 'POST');
-      assert.equal(sendStub.lastCall.args[0].url,
-          '/changes/test~1/revisions/1/test');
+      assert.equal(
+          sendStub.lastCall.args[0].url,
+          '/changes/test~1/revisions/1/test'
+      );
     });
   });
 
@@ -1158,9 +1291,10 @@
 
   suite('getChangeFiles', () => {
     test('patch only', () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+      const fetchStub = sinon
+          .stub(element, '_getChangeURLAndFetch')
           .returns(Promise.resolve());
-      const range = {basePatchNum: 'PARENT', patchNum: 2};
+      const range = {basePatchNum: PARENT, patchNum: 2};
       return element.getChangeFiles(123, range).then(() => {
         assert.isTrue(fetchStub.calledOnce);
         assert.equal(fetchStub.lastCall.args[0].revision, 2);
@@ -1169,7 +1303,8 @@
     });
 
     test('simple range', () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+      const fetchStub = sinon
+          .stub(element, '_getChangeURLAndFetch')
           .returns(Promise.resolve());
       const range = {basePatchNum: 4, patchNum: 5};
       return element.getChangeFiles(123, range).then(() => {
@@ -1182,7 +1317,8 @@
     });
 
     test('parent index', () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+      const fetchStub = sinon
+          .stub(element, '_getChangeURLAndFetch')
           .returns(Promise.resolve());
       const range = {basePatchNum: -3, patchNum: 5};
       return element.getChangeFiles(123, range).then(() => {
@@ -1197,9 +1333,10 @@
 
   suite('getDiff', () => {
     test('patchOnly', () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+      const fetchStub = sinon
+          .stub(element, '_getChangeURLAndFetch')
           .returns(Promise.resolve());
-      return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
+      return element.getDiff(123, PARENT, 2, 'foo/bar.baz').then(() => {
         assert.isTrue(fetchStub.calledOnce);
         assert.equal(fetchStub.lastCall.args[0].revision, 2);
         assert.isOk(fetchStub.lastCall.args[0].params);
@@ -1209,7 +1346,8 @@
     });
 
     test('simple range', () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+      const fetchStub = sinon
+          .stub(element, '_getChangeURLAndFetch')
           .returns(Promise.resolve());
       return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
         assert.isTrue(fetchStub.calledOnce);
@@ -1221,7 +1359,8 @@
     });
 
     test('parent index', () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+      const fetchStub = sinon
+          .stub(element, '_getChangeURLAndFetch')
           .returns(Promise.resolve());
       return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
         assert.isTrue(fetchStub.calledOnce);
@@ -1234,18 +1373,21 @@
   });
 
   test('getDashboard', () => {
-    const fetchCacheURLStub = sinon.stub(element._restApiHelper,
-        'fetchCacheURL');
+    const fetchCacheURLStub = sinon.stub(
+        element._restApiHelper,
+        'fetchCacheURL'
+    );
     element.getDashboard('gerrit/project', 'default:main');
     assert.isTrue(fetchCacheURLStub.calledOnce);
     assert.equal(
         fetchCacheURLStub.lastCall.args[0].url,
-        '/projects/gerrit%2Fproject/dashboards/default%3Amain');
+        '/projects/gerrit%2Fproject/dashboards/default%3Amain'
+    );
   });
 
   test('getFileContent', () => {
-    sinon.stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve({
+    sinon.stub(element, '_getChangeURLAndSend').returns(
+        Promise.resolve({
           ok: 'true',
           headers: {
             get(header) {
@@ -1254,19 +1396,27 @@
               }
             },
           },
-        }));
+        })
+    );
 
-    sinon.stub(element, 'getResponseObject')
+    sinon
+        .stub(element, 'getResponseObject')
         .returns(Promise.resolve('new content'));
 
     const edit = element.getFileContent('1', 'tst/path', 'EDIT').then(res => {
-      assert.deepEqual(res,
-          {content: 'new content', type: 'text/java', ok: true});
+      assert.deepEqual(res, {
+        content: 'new content',
+        type: 'text/java',
+        ok: true,
+      });
     });
 
     const normal = element.getFileContent('1', 'tst/path', '3').then(res => {
-      assert.deepEqual(res,
-          {content: 'new content', type: 'text/java', ok: true});
+      assert.deepEqual(res, {
+        content: 'new content',
+        type: 'text/java',
+        ok: true,
+      });
     });
 
     return Promise.all([edit, normal]);
@@ -1276,12 +1426,14 @@
     const res = {status: 404};
     const spy = sinon.spy();
     addListenerForTest(document, 'server-error', spy);
-    sinon.stub(getAppContext().authService, 'fetch')
+    sinon
+        .stub(getAppContext().authService, 'fetch')
         .returns(Promise.resolve(res));
     sinon.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
-    return element.getFileContent('1', 'tst/path', '1')
+    return element
+        .getFileContent('1', 'tst/path', '1')
+        .then(() => waitEventLoop())
         .then(() => {
-          flush();
           assert.isFalse(spy.called);
 
           res.status = 500;
@@ -1295,12 +1447,14 @@
 
   test('getChangeFilesOrEditFiles is edit-sensitive', () => {
     const fn = element.getChangeOrEditFiles.bind(element);
-    const getChangeFilesStub = sinon.stub(element, 'getChangeFiles')
+    const getChangeFilesStub = sinon
+        .stub(element, 'getChangeFiles')
         .returns(Promise.resolve({}));
-    const getChangeEditFilesStub = sinon.stub(element, 'getChangeEditFiles')
+    const getChangeEditFilesStub = sinon
+        .stub(element, 'getChangeEditFiles')
         .returns(Promise.resolve({}));
 
-    return fn('1', {patchNum: 'edit'}).then(() => {
+    return fn('1', {patchNum: EDIT}).then(() => {
       assert.isTrue(getChangeEditFilesStub.calledOnce);
       assert.isFalse(getChangeFilesStub.called);
       return fn('1', {patchNum: '1'}).then(() => {
@@ -1326,7 +1480,7 @@
     });
   });
 
-  test('_logCall only reports requests with anonymized URLss', () => {
+  test('_logCall only reports requests with anonymized URLss', async () => {
     sinon.stub(Date, 'now').returns(200);
     const handler = sinon.stub();
     addListenerForTest(document, 'gr-rpc-log', handler);
@@ -1334,9 +1488,12 @@
     element._restApiHelper._logCall({url: 'url'}, 100, 200);
     assert.isFalse(handler.called);
 
-    element._restApiHelper
-        ._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
-    flush();
+    element._restApiHelper._logCall(
+        {url: 'url', anonymizedUrl: 'not url'},
+        100,
+        200
+    );
+    await waitEventLoop();
     assert.isTrue(handler.calledOnce);
   });
 
@@ -1344,8 +1501,11 @@
     const change = createChange();
     const handler = sinon.stub();
     addListenerForTest(document, 'server-error', handler);
-    sinon.stub(element._restApiHelper, 'fetchJSON').returns(Promise.resolve({
-      ok: false}));
+    sinon.stub(element._restApiHelper, 'fetchJSON').returns(
+        Promise.resolve({
+          ok: false,
+        })
+    );
 
     element.getPortedComments(change._number, CURRENT);
 
@@ -1355,8 +1515,10 @@
   test('ported drafts are not requested user is not logged in', () => {
     const change = createChange();
     sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(false));
-    const getChangeURLAndFetchStub = sinon.stub(element,
-        '_getChangeURLAndFetch');
+    const getChangeURLAndFetchStub = sinon.stub(
+        element,
+        '_getChangeURLAndFetch'
+    );
 
     element.getPortedDrafts(change._number, CURRENT);
 
@@ -1364,10 +1526,12 @@
   });
 
   test('saveChangeStarred', async () => {
-    sinon.stub(element, 'getFromProjectLookup')
+    sinon
+        .stub(element, 'getFromProjectLookup')
         .returns(Promise.resolve('test'));
-    const sendStub =
-        sinon.stub(element._restApiHelper, 'send').returns(Promise.resolve());
+    const sendStub = sinon
+        .stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve());
 
     await element.saveChangeStarred(123, true);
     assert.isTrue(sendStub.calledOnce);
@@ -1386,4 +1550,3 @@
     });
   });
 });
-
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 e727216..f0a7377 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
@@ -1,20 +1,8 @@
 /**
  * @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 {HttpMethod} from '../../constants/constants';
 import {Finalizable} from '../registry';
 import {
@@ -53,6 +41,7 @@
   FileNameToFileInfoMap,
   FilePathToDiffInfoMap,
   FixId,
+  FixReplacementInfo,
   GitRef,
   GpgKeyId,
   GpgKeyInfo,
@@ -101,6 +90,7 @@
   TagInput,
   TopMenuEntryInfo,
   UrlEncodedCommentId,
+  UserId,
 } from '../../types/common';
 import {
   DiffInfo,
@@ -168,15 +158,24 @@
     changeNum: NumericChangeId,
     input: string
   ): Promise<SuggestedReviewerInfo[] | undefined>;
+  /**
+   * Request list of accounts via https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#query-account
+   * Operators defined here https://gerrit-review.googlesource.com/Documentation/user-search-accounts.html#_search_operators
+   */
   getSuggestedAccounts(
     input: string,
-    n?: number
+    n?: number,
+    canSee?: NumericChangeId,
+    filterActive?: boolean
   ): Promise<AccountInfo[] | undefined>;
   getSuggestedGroups(
     input: string,
     project?: RepoName,
     n?: number
   ): Promise<GroupNameToGroupInfoMap | undefined>;
+  /**
+   * Execute a change action or revision action on a change.
+   */
   executeChangeAction(
     changeNum: NumericChangeId,
     method: HttpMethod | undefined,
@@ -199,6 +198,9 @@
     opt_cancelCondition?: Function
   ): Promise<ParsedChangeInfo | undefined>;
 
+  /**
+   * Given a changeNum, gets the change.
+   */
   getChange(
     changeNum: ChangeId | NumericChangeId,
     errFn?: ErrorCallback
@@ -382,6 +384,12 @@
     changeNum: NumericChangeId
   ): Promise<IncludedInInfo | undefined>;
 
+  /**
+   * Checks in projectLookup map shared across instances for the changeNum.
+   * If it exists, returns the project. If not, calls the restAPI to get the
+   * change, populates projectLookup with the project for that change, and
+   * returns the project.
+   */
   getFromProjectLookup(
     changeNum: NumericChangeId
   ): Promise<RepoName | undefined>;
@@ -438,6 +446,11 @@
     | Promise<GetDiffRobotCommentsOutput>
     | Promise<PathToRobotCommentsInfoMap | undefined>;
 
+  /**
+   * If the user is logged in, fetch the user's draft diff comments. If there
+   * is no logged in user, the request is not made and the promise yields an
+   * empty object.
+   */
   getDiffDrafts(
     changeNum: NumericChangeId
   ): Promise<{[path: string]: DraftInfo[]} | undefined>;
@@ -472,9 +485,15 @@
 
   getAccountAgreements(): Promise<ContributorAgreementInfo[] | undefined>;
 
+  /**
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#list-groups
+   */
   getAccountGroups(): Promise<GroupInfo[] | undefined>;
 
-  getAccountDetails(userId: AccountId): Promise<AccountDetailInfo | undefined>;
+  getAccountDetails(
+    userId: UserId,
+    errFn?: ErrorCallback
+  ): Promise<AccountDetailInfo | undefined>;
 
   getAccountStatus(userId: AccountId): Promise<string | undefined>;
 
@@ -634,22 +653,68 @@
     }
   ): Promise<ChangeInfo[] | undefined>;
   getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
+  getChangesWithSimilarHashtag(
+    hashtag: string
+  ): Promise<ChangeInfo[] | undefined>;
 
+  /**
+   * @return Whether there are pending diff draft sends.
+   */
   hasPendingDiffDrafts(): number;
+  /**
+   * @return A promise that resolves when all pending
+   * diff draft sends have resolved.
+   */
   awaitPendingDiffDrafts(): Promise<void>;
 
+  /**
+   * Preview Stored Fix
+   * Gets the diffs of all files for a certain {fix-id} associated with apply fix.
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#preview-stored-fix
+   */
   getRobotCommentFixPreview(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     fixId: FixId
   ): Promise<FilePathToDiffInfoMap | undefined>;
 
+  /**
+   * Preview Provided fix
+   * Gets the diffs of all files for a provided fix replacements infos
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#preview-provided-fix
+   */
+  getFixPreview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    fixReplacementInfos: FixReplacementInfo[]
+  ): Promise<FilePathToDiffInfoMap | undefined>;
+
+  /**
+   * Apply Provided Fix
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#apply-provided-fix
+   */
   applyFixSuggestion(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
+    fixReplacementInfos: FixReplacementInfo[]
+  ): Promise<Response>;
+
+  /**
+   * Apply Stored Fix
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#apply-stored-fix
+   */
+  applyRobotFixSuggestion(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
     fixId: string
   ): Promise<Response>;
 
+  /**
+   * @param basePatchNum Negative values specify merge parent
+   * index.
+   * @param whitespace the ignore-whitespace level for the diff
+   * algorithm.
+   */
   getDiff(
     changeNum: NumericChangeId,
     basePatchNum: PatchSetNum,
@@ -659,6 +724,12 @@
     errFn?: ErrorCallback
   ): Promise<DiffInfo | undefined>;
 
+  /**
+   * Get blame information for the given diff.
+   *
+   * @param base If true, requests blame for the base of the
+   *     diff, rather than the revision.
+   */
   getBlame(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
@@ -688,6 +759,10 @@
     starred: boolean
   ): Promise<Response>;
 
+  /**
+   * Fetch a project dashboard definition.
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
+   */
   getDashboard(
     project: RepoName,
     dashboard: DashboardId,
diff --git a/polygerrit-ui/app/services/highlight/highlight-service.ts b/polygerrit-ui/app/services/highlight/highlight-service.ts
index 80da260..bfaa263 100644
--- a/polygerrit-ui/app/services/highlight/highlight-service.ts
+++ b/polygerrit-ui/app/services/highlight/highlight-service.ts
@@ -118,7 +118,10 @@
   private handleResult(worker: Worker, result: SyntaxWorkerResult) {
     this.moveBusyToIdle(worker);
     if (result.error) {
-      this.reporting.error(new Error(`syntax worker failed: ${result.error}`));
+      this.reporting.error(
+        'Diff Syntax Layer',
+        new Error(`syntax worker failed: ${result.error}`)
+      );
     }
     const resolver = this.queueForResult.get(worker);
     this.queueForResult.delete(worker);
diff --git a/polygerrit-ui/app/services/highlight/highlight-service_test.ts b/polygerrit-ui/app/services/highlight/highlight-service_test.ts
index 4c38a68..61d5fb1 100644
--- a/polygerrit-ui/app/services/highlight/highlight-service_test.ts
+++ b/polygerrit-ui/app/services/highlight/highlight-service_test.ts
@@ -3,7 +3,8 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../../test/common-test-setup';
 import {waitUntil} from '../../test/test-utils';
 import {grReportingMock} from '../gr-reporting/gr-reporting_mock';
 import {MockHighlightServiceManual} from './highlight-service-mock';
diff --git a/polygerrit-ui/app/services/registry.ts b/polygerrit-ui/app/services/registry.ts
index e7de1ef..48a5241 100644
--- a/polygerrit-ui/app/services/registry.ts
+++ b/polygerrit-ui/app/services/registry.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // A finalizable object has a single method `finalize` that is called when
@@ -28,8 +17,9 @@
 ) => 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>;
+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>(
@@ -44,7 +34,7 @@
             (this[name] as unknown as Finalizable).finalize();
           }
         } catch (e) {
-          console.info(`Failed to finalize ${name}`);
+          console.info(`Failed to finalize ${String(name)}`);
           throw e;
         }
       }
@@ -73,7 +63,7 @@
             initializing = true;
             initialized.set(name, factory(context));
           } catch (e) {
-            console.error(`Failed to initialize ${name}`, e);
+            console.error(`Failed to initialize ${String(name)}`, e);
           } finally {
             initializing = false;
           }
diff --git a/polygerrit-ui/app/services/registry_test.ts b/polygerrit-ui/app/services/registry_test.ts
index 3bb584a..639cd64 100644
--- a/polygerrit-ui/app/services/registry_test.ts
+++ b/polygerrit-ui/app/services/registry_test.ts
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {create, Finalizable, Registry} from './registry';
-import '../test/common-test-setup-karma.js';
+import '../test/common-test-setup';
+import {assert} from '@open-wc/testing';
 
 class Foo implements Finalizable {
   constructor(private readonly final: string[]) {}
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index 221e55b..f8bc778 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -1,25 +1,17 @@
 /**
  * @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 {Observable} from 'rxjs';
-import {distinctUntilChanged, map} from 'rxjs/operators';
 import {Finalizable} from '../registry';
-import {NumericChangeId, PatchSetNum} from '../../types/common';
+import {
+  NumericChangeId,
+  RevisionPatchSetNum,
+  BasePatchSetNum,
+} from '../../types/common';
 import {Model} from '../../models/model';
+import {select} from '../../utils/observable-util';
 
 export enum GerritView {
   ADMIN = 'admin',
@@ -32,51 +24,36 @@
   GROUP = 'group',
   PLUGIN_SCREEN = 'plugin-screen',
   REPO = 'repo',
-  ROOT = 'root',
   SEARCH = 'search',
   SETTINGS = 'settings',
 }
 
 export interface RouterState {
+  // Note that this router model view must be updated before view model state.
   view?: GerritView;
   changeNum?: NumericChangeId;
-  patchNum?: PatchSetNum;
+  patchNum?: RevisionPatchSetNum;
+  basePatchNum?: BasePatchSetNum;
 }
 
 export class RouterModel extends Model<RouterState> implements Finalizable {
-  readonly routerView$: Observable<GerritView | undefined>;
+  readonly routerView$: Observable<GerritView | undefined> = select(
+    this.state$,
+    state => state.view
+  );
 
-  readonly routerChangeNum$: Observable<NumericChangeId | undefined>;
+  readonly routerChangeNum$: Observable<NumericChangeId | undefined> = select(
+    this.state$,
+    state => state.changeNum
+  );
 
-  readonly routerPatchNum$: Observable<PatchSetNum | undefined>;
+  readonly routerPatchNum$: Observable<RevisionPatchSetNum | undefined> =
+    select(this.state$, state => state.patchNum);
+
+  readonly routerBasePatchNum$: Observable<BasePatchSetNum | undefined> =
+    select(this.state$, state => state.basePatchNum);
 
   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,
-    });
   }
 }
diff --git a/polygerrit-ui/app/services/scheduler/fake-scheduler_test.ts b/polygerrit-ui/app/services/scheduler/fake-scheduler_test.ts
index 6a0e56d..b31b194 100644
--- a/polygerrit-ui/app/services/scheduler/fake-scheduler_test.ts
+++ b/polygerrit-ui/app/services/scheduler/fake-scheduler_test.ts
@@ -3,9 +3,9 @@
  * 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 {assert} from '@open-wc/testing';
+import '../../test/common-test-setup';
+import {assertFails} from '../../test/test-utils';
 import {FakeScheduler} from './fake-scheduler';
 
 suite('fake scheduler', () => {
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
index 9e76821..3fbc4e9 100644
--- a/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler_test.ts
+++ b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler_test.ts
@@ -3,11 +3,12 @@
  * 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 '../../test/common-test-setup';
+import {assertFails, waitEventLoop} from '../../test/test-utils';
 import {Scheduler} from './scheduler';
 import {MaxInFlightScheduler} from './max-in-flight-scheduler';
 import {FakeScheduler} from './fake-scheduler';
+import {assert} from '@open-wc/testing';
 
 suite('max-in-flight scheduler', () => {
   let fakeScheduler: FakeScheduler<number>;
@@ -66,7 +67,7 @@
     assert.equal(fakeScheduler.scheduled.length, 2);
     fakeScheduler.resolve();
     assert.equal(fakeScheduler.scheduled.length, 1);
-    await flush();
+    await waitEventLoop();
     assert.equal(fakeScheduler.scheduled.length, 2);
   });
 
@@ -77,7 +78,7 @@
     assert.equal(fakeScheduler.scheduled.length, 2);
     fakeScheduler.reject(new Error('Fake Error'));
     assert.equal(fakeScheduler.scheduled.length, 1);
-    await flush();
+    await waitEventLoop();
     assert.equal(fakeScheduler.scheduled.length, 2);
   });
 
@@ -88,7 +89,7 @@
     }
     for (let i = 0; i < 3; ++i) {
       fakeScheduler.resolve();
-      await flush();
+      await waitEventLoop();
     }
     const res = await Promise.all(promises);
     assert.deepEqual(res, [0, 1, 2]);
diff --git a/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts b/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
index 988b167..041aed2 100644
--- a/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
+++ b/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
@@ -3,12 +3,13 @@
  * 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 '../../test/common-test-setup';
+import {assertFails, waitEventLoop} from '../../test/test-utils';
 import {Scheduler} from './scheduler';
 import {RetryScheduler, RetryError} from './retry-scheduler';
 import {FakeScheduler} from './fake-scheduler';
 import {SinonFakeTimers} from 'sinon';
+import {assert} from '@open-wc/testing';
 
 suite('retry scheduler', () => {
   let clock: SinonFakeTimers;
@@ -22,11 +23,11 @@
 
   async function waitForRetry(ms: number) {
     // Flush the promise so that we can reach untilTimeout
-    await flush();
+    await waitEventLoop();
     // Advance the clock.
     clock.tick(ms);
     // Flush the promise that waits for the clock.
-    await flush();
+    await waitEventLoop();
   }
 
   test('executes tasks', async () => {
diff --git a/polygerrit-ui/app/services/scheduler/scheduler_test.ts b/polygerrit-ui/app/services/scheduler/scheduler_test.ts
index 3edbdab..a59b30f 100644
--- a/polygerrit-ui/app/services/scheduler/scheduler_test.ts
+++ b/polygerrit-ui/app/services/scheduler/scheduler_test.ts
@@ -3,9 +3,9 @@
  * 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 {assert} from '@open-wc/testing';
+import '../../test/common-test-setup';
+import {assertFails} from '../../test/test-utils';
 import {BaseScheduler} from './scheduler';
 
 suite('naive scheduler', () => {
diff --git a/polygerrit-ui/app/services/service-worker-installer.ts b/polygerrit-ui/app/services/service-worker-installer.ts
new file mode 100644
index 0000000..53cd325
--- /dev/null
+++ b/polygerrit-ui/app/services/service-worker-installer.ts
@@ -0,0 +1,114 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {FlagsService, KnownExperimentId} from './flags/flags';
+import {
+  areNotificationsEnabled,
+  registerServiceWorker,
+} from '../utils/worker-util';
+import {UserModel} from '../models/user/user-model';
+import {AccountDetailInfo} from '../api/rest-api';
+import {until} from '../utils/async-util';
+
+/** Type of incoming messages for ServiceWorker. */
+export enum ServiceWorkerMessageType {
+  TRIGGER_NOTIFICATIONS = 'TRIGGER_NOTIFICATIONS',
+  USER_PREFERENCE_CHANGE = 'USER_PREFERENCE_CHANGE',
+}
+
+export const TRIGGER_NOTIFICATION_UPDATES_MS = 5 * 60 * 1000;
+
+export class ServiceWorkerInstaller {
+  initialized = false;
+
+  account?: AccountDetailInfo;
+
+  allowBrowserNotificationsPreference?: boolean;
+
+  constructor(
+    private readonly flagsService: FlagsService,
+    private readonly userModel: UserModel
+  ) {
+    if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
+      return;
+    }
+    this.userModel.account$.subscribe(acc => (this.account = acc));
+    this.userModel.preferences$.subscribe(prefs => {
+      if (
+        this.allowBrowserNotificationsPreference !==
+        prefs.allow_browser_notifications
+      ) {
+        this.allowBrowserNotificationsPreference =
+          prefs.allow_browser_notifications;
+        navigator.serviceWorker.controller?.postMessage({
+          type: ServiceWorkerMessageType.USER_PREFERENCE_CHANGE,
+          allowBrowserNotificationsPreference:
+            this.allowBrowserNotificationsPreference,
+        });
+      }
+    });
+    Promise.all([
+      until(this.userModel.account$, account => !!account),
+      until(
+        this.userModel.preferences$,
+        prefs => !!prefs.allow_browser_notifications
+      ),
+    ]).then(() => {
+      this.init();
+    });
+  }
+
+  private async init() {
+    if (this.initialized) return;
+    if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
+      return;
+    }
+    if (!this.areNotificationsEnabled()) return;
+
+    if (!('serviceWorker' in navigator)) {
+      console.error('Service worker API not available');
+      return;
+    }
+    await registerServiceWorker('/service-worker.js');
+    const permission = await Notification.requestPermission();
+    if (this.isPermitted(permission)) this.startTriggerTimer();
+    this.initialized = true;
+  }
+
+  areNotificationsEnabled() {
+    // Push Notification developer can have notification enabled even if they
+    // are disabled for this.account.
+    if (
+      !this.flagsService.isEnabled(
+        KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
+      ) &&
+      !areNotificationsEnabled(this.account)
+    ) {
+      return false;
+    }
+
+    return this.allowBrowserNotificationsPreference;
+  }
+
+  /**
+   * Every 5 minutes, we trigger service-worker to get
+   * latest updates in attention set and service-worker will create
+   * notifications.
+   */
+  startTriggerTimer() {
+    setTimeout(() => {
+      this.startTriggerTimer();
+      navigator.serviceWorker.controller?.postMessage({
+        type: ServiceWorkerMessageType.TRIGGER_NOTIFICATIONS,
+        account: this.account,
+      });
+    }, TRIGGER_NOTIFICATION_UPDATES_MS);
+  }
+
+  isPermitted(permission: NotificationPermission) {
+    return permission === 'granted';
+  }
+}
diff --git a/polygerrit-ui/app/services/service-worker-installer_test.ts b/polygerrit-ui/app/services/service-worker-installer_test.ts
new file mode 100644
index 0000000..e8fd233
--- /dev/null
+++ b/polygerrit-ui/app/services/service-worker-installer_test.ts
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {getAppContext} from './app-context';
+import '../test/common-test-setup';
+import {ServiceWorkerInstaller} from './service-worker-installer';
+import {assert} from '@open-wc/testing';
+import {createDefaultPreferences} from '../constants/constants';
+import {waitUntilObserved} from '../test/test-utils';
+
+suite('service worker installer tests', () => {
+  test('init', async () => {
+    const registerStub = sinon.stub(window.navigator.serviceWorker, 'register');
+    const flagsService = getAppContext().flagsService;
+    const userModel = getAppContext().userModel;
+    sinon.stub(flagsService, 'isEnabled').returns(true);
+    new ServiceWorkerInstaller(flagsService, userModel);
+    const prefs = {
+      ...createDefaultPreferences(),
+      allow_browser_notifications: true,
+    };
+    userModel.setPreferences(prefs);
+    await waitUntilObserved(
+      userModel.preferences$,
+      pref => pref.allow_browser_notifications === true
+    );
+    await waitUntilObserved(
+      userModel.preferences$,
+      pref => pref.allow_browser_notifications === true
+    );
+    assert.isTrue(registerStub.called);
+  });
+});
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
index ed68f15..da61c41 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /** Enum for all special shortcuts */
@@ -59,6 +48,7 @@
 
   OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
   OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
+  OPEN_COPY_LINKS_DROPDOWN = 'OPEN_COPY_LINKS_DROPDOWN',
   EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
   COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
   UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
@@ -108,9 +98,11 @@
   OPEN_LAST_FILE = 'OPEN_LAST_FILE',
 
   SEARCH = 'SEARCH',
-  SEND_REPLY = 'SEND_REPLY',
   EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
+  MENTIONS_DROPDOWN = 'MENTIONS_DROPDOWN',
   TOGGLE_BLAME = 'TOGGLE_BLAME',
+
+  TOGGLE_CHECKBOX = 'TOGGLE_CHECKBOX',
 }
 
 export interface ShortcutHelpItem {
@@ -119,400 +111,416 @@
   bindings: Binding[];
 }
 
-export const config = new Map<ShortcutSection, ShortcutHelpItem[]>();
+export function createShortcutConfig() {
+  const config = new Map<ShortcutSection, ShortcutHelpItem[]>();
+  function describe(
+    shortcut: Shortcut,
+    section: ShortcutSection,
+    text: string,
+    binding: Binding,
+    ...moreBindings: Binding[]
+  ) {
+    if (!config.has(section)) {
+      config.set(section, []);
+    }
+    const shortcuts = config.get(section);
+    if (shortcuts) {
+      shortcuts.push({shortcut, text, bindings: [binding, ...moreBindings]});
+    }
+  }
 
-function describe(
-  shortcut: Shortcut,
-  section: ShortcutSection,
-  text: string,
-  binding: Binding,
-  ...moreBindings: Binding[]
-) {
-  if (!config.has(section)) {
-    config.set(section, []);
-  }
-  const shortcuts = config.get(section);
-  if (shortcuts) {
-    shortcuts.push({shortcut, text, bindings: [binding, ...moreBindings]});
-  }
+  describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search', {key: '/'});
+  describe(
+    Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
+    ShortcutSection.EVERYWHERE,
+    'Show this dialog',
+    {key: '?'}
+  );
+  describe(
+    Shortcut.GO_TO_USER_DASHBOARD,
+    ShortcutSection.EVERYWHERE,
+    'Go to User Dashboard',
+    {key: 'i', combo: ComboKey.G}
+  );
+  describe(
+    Shortcut.GO_TO_OPENED_CHANGES,
+    ShortcutSection.EVERYWHERE,
+    'Go to Opened Changes',
+    {key: 'o', combo: ComboKey.G}
+  );
+  describe(
+    Shortcut.GO_TO_MERGED_CHANGES,
+    ShortcutSection.EVERYWHERE,
+    'Go to Merged Changes',
+    {key: 'm', combo: ComboKey.G}
+  );
+  describe(
+    Shortcut.GO_TO_ABANDONED_CHANGES,
+    ShortcutSection.EVERYWHERE,
+    'Go to Abandoned Changes',
+    {key: 'a', combo: ComboKey.G}
+  );
+  describe(
+    Shortcut.GO_TO_WATCHED_CHANGES,
+    ShortcutSection.EVERYWHERE,
+    'Go to Watched Changes',
+    {key: 'w', combo: ComboKey.G}
+  );
+  describe(
+    Shortcut.TOGGLE_CHECKBOX,
+    ShortcutSection.ACTIONS,
+    'Toggle checkbox',
+    {key: 'x'}
+  );
+  describe(
+    Shortcut.CURSOR_NEXT_CHANGE,
+    ShortcutSection.ACTIONS,
+    'Select next change',
+    {key: 'j', allowRepeat: true}
+  );
+  describe(
+    Shortcut.CURSOR_PREV_CHANGE,
+    ShortcutSection.ACTIONS,
+    'Select previous change',
+    {key: 'k', allowRepeat: true}
+  );
+  describe(
+    Shortcut.OPEN_CHANGE,
+    ShortcutSection.ACTIONS,
+    'Show selected change',
+    {key: 'o'}
+  );
+  describe(
+    Shortcut.NEXT_PAGE,
+    ShortcutSection.ACTIONS,
+    'Go to next page',
+    {key: 'n'},
+    {key: ']'}
+  );
+  describe(
+    Shortcut.PREV_PAGE,
+    ShortcutSection.ACTIONS,
+    'Go to previous page',
+    {key: 'p'},
+    {key: '['}
+  );
+  describe(
+    Shortcut.OPEN_REPLY_DIALOG,
+    ShortcutSection.ACTIONS,
+    'Open reply dialog to publish comments and add reviewers',
+    {key: 'a'}
+  );
+  describe(
+    Shortcut.OPEN_DOWNLOAD_DIALOG,
+    ShortcutSection.ACTIONS,
+    'Open download overlay',
+    {key: 'd'}
+  );
+  describe(
+    Shortcut.OPEN_COPY_LINKS_DROPDOWN,
+    ShortcutSection.ACTIONS,
+    'Open link dialog',
+    {key: 'l'}
+  );
+  describe(
+    Shortcut.EXPAND_ALL_MESSAGES,
+    ShortcutSection.ACTIONS,
+    'Expand all messages',
+    {key: 'x'}
+  );
+  describe(
+    Shortcut.COLLAPSE_ALL_MESSAGES,
+    ShortcutSection.ACTIONS,
+    'Collapse all messages',
+    {key: 'z'}
+  );
+  describe(
+    Shortcut.REFRESH_CHANGE,
+    ShortcutSection.ACTIONS,
+    'Reload the change at the latest patch',
+    {key: 'R'}
+  );
+  describe(
+    Shortcut.TOGGLE_FILE_REVIEWED,
+    ShortcutSection.ACTIONS,
+    'Toggle review flag on selected file',
+    {key: 'r'}
+  );
+  describe(
+    Shortcut.REFRESH_CHANGE_LIST,
+    ShortcutSection.ACTIONS,
+    'Refresh list of changes',
+    {key: 'R'}
+  );
+  describe(
+    Shortcut.TOGGLE_CHANGE_STAR,
+    ShortcutSection.ACTIONS,
+    'Star/unstar change',
+    {key: 's'}
+  );
+  describe(
+    Shortcut.OPEN_SUBMIT_DIALOG,
+    ShortcutSection.ACTIONS,
+    'Open submit dialog',
+    {key: 'S'}
+  );
+  describe(
+    Shortcut.TOGGLE_ATTENTION_SET,
+    ShortcutSection.ACTIONS,
+    'Toggle attention set status',
+    {key: 'T'}
+  );
+  describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic', {
+    key: 't',
+  });
+  describe(
+    Shortcut.DIFF_AGAINST_BASE,
+    ShortcutSection.DIFFS,
+    'Diff against base',
+    {key: Key.DOWN, combo: ComboKey.V},
+    {key: 's', combo: ComboKey.V}
+  );
+  describe(
+    Shortcut.DIFF_AGAINST_LATEST,
+    ShortcutSection.DIFFS,
+    'Diff against latest patchset',
+    {key: Key.UP, combo: ComboKey.V},
+    {key: 'w', combo: ComboKey.V}
+  );
+  describe(
+    Shortcut.DIFF_BASE_AGAINST_LEFT,
+    ShortcutSection.DIFFS,
+    'Diff base against left',
+    {key: Key.LEFT, combo: ComboKey.V},
+    {key: 'a', combo: ComboKey.V}
+  );
+  describe(
+    Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+    ShortcutSection.DIFFS,
+    'Diff right against latest',
+    {key: Key.RIGHT, combo: ComboKey.V},
+    {key: 'd', combo: ComboKey.V}
+  );
+  describe(
+    Shortcut.DIFF_BASE_AGAINST_LATEST,
+    ShortcutSection.DIFFS,
+    'Diff base against latest',
+    {key: 'b', combo: ComboKey.V}
+  );
+
+  describe(
+    Shortcut.NEXT_LINE,
+    ShortcutSection.DIFFS,
+    'Go to next line',
+    {key: 'j', allowRepeat: true},
+    {key: Key.DOWN, allowRepeat: true}
+  );
+  describe(
+    Shortcut.PREV_LINE,
+    ShortcutSection.DIFFS,
+    'Go to previous line',
+    {key: 'k', allowRepeat: true},
+    {key: Key.UP, allowRepeat: true}
+  );
+  describe(
+    Shortcut.VISIBLE_LINE,
+    ShortcutSection.DIFFS,
+    'Move cursor to currently visible code',
+    {key: '.'}
+  );
+  describe(
+    Shortcut.NEXT_CHUNK,
+    ShortcutSection.DIFFS,
+    'Go to next diff chunk',
+    {
+      key: 'n',
+    }
+  );
+  describe(
+    Shortcut.PREV_CHUNK,
+    ShortcutSection.DIFFS,
+    'Go to previous diff chunk',
+    {key: 'p'}
+  );
+  describe(
+    Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
+    ShortcutSection.DIFFS,
+    'Toggle all diff context',
+    {key: 'X'}
+  );
+  describe(
+    Shortcut.NEXT_COMMENT_THREAD,
+    ShortcutSection.DIFFS,
+    'Go to next comment thread',
+    {key: 'N'}
+  );
+  describe(
+    Shortcut.PREV_COMMENT_THREAD,
+    ShortcutSection.DIFFS,
+    'Go to previous comment thread',
+    {key: 'P'}
+  );
+  describe(
+    Shortcut.EXPAND_ALL_COMMENT_THREADS,
+    ShortcutSection.DIFFS,
+    'Expand all comment threads',
+    {key: 'e', docOnly: true}
+  );
+  describe(
+    Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+    ShortcutSection.DIFFS,
+    'Collapse all comment threads',
+    {key: 'E', docOnly: true}
+  );
+  describe(
+    Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+    ShortcutSection.DIFFS,
+    'Hide/Display all comment threads',
+    {key: 'h'}
+  );
+  describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane', {
+    key: Key.LEFT,
+    modifiers: [Modifier.SHIFT_KEY],
+  });
+  describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane', {
+    key: Key.RIGHT,
+    modifiers: [Modifier.SHIFT_KEY],
+  });
+  describe(
+    Shortcut.TOGGLE_LEFT_PANE,
+    ShortcutSection.DIFFS,
+    'Hide/show left diff',
+    {key: 'A'}
+  );
+  describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', {
+    key: 'c',
+  });
+  describe(
+    Shortcut.SAVE_COMMENT,
+    ShortcutSection.DIFFS,
+    'Save comment',
+    {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
+    {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
+    {key: 's', modifiers: [Modifier.CTRL_KEY]},
+    {key: 's', modifiers: [Modifier.META_KEY]}
+  );
+  describe(
+    Shortcut.OPEN_DIFF_PREFS,
+    ShortcutSection.DIFFS,
+    'Show diff preferences',
+    {key: ','}
+  );
+  describe(
+    Shortcut.TOGGLE_DIFF_REVIEWED,
+    ShortcutSection.DIFFS,
+    'Mark/unmark file as reviewed',
+    {key: 'r'}
+  );
+  describe(
+    Shortcut.TOGGLE_DIFF_MODE,
+    ShortcutSection.DIFFS,
+    'Toggle unified/side-by-side diff',
+    {key: 'm'}
+  );
+  describe(
+    Shortcut.NEXT_UNREVIEWED_FILE,
+    ShortcutSection.DIFFS,
+    'Mark file as reviewed and go to next unreviewed file',
+    {key: 'M'}
+  );
+  describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame', {
+    key: 'b',
+  });
+  describe(Shortcut.OPEN_FILE_LIST, ShortcutSection.DIFFS, 'Open file list', {
+    key: 'f',
+  });
+  describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file', {
+    key: ']',
+  });
+  describe(
+    Shortcut.PREV_FILE,
+    ShortcutSection.NAVIGATION,
+    'Go to previous file',
+    {key: '['}
+  );
+  describe(
+    Shortcut.NEXT_FILE_WITH_COMMENTS,
+    ShortcutSection.NAVIGATION,
+    'Go to next file that has comments',
+    {key: 'J'}
+  );
+  describe(
+    Shortcut.PREV_FILE_WITH_COMMENTS,
+    ShortcutSection.NAVIGATION,
+    'Go to previous file that has comments',
+    {key: 'K'}
+  );
+  describe(
+    Shortcut.OPEN_FIRST_FILE,
+    ShortcutSection.NAVIGATION,
+    'Go to first file',
+    {key: ']'}
+  );
+  describe(
+    Shortcut.OPEN_LAST_FILE,
+    ShortcutSection.NAVIGATION,
+    'Go to last file',
+    {key: '['}
+  );
+  describe(
+    Shortcut.UP_TO_DASHBOARD,
+    ShortcutSection.NAVIGATION,
+    'Up to dashboard',
+    {key: 'u'}
+  );
+  describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change', {
+    key: 'u',
+  });
+
+  describe(
+    Shortcut.CURSOR_NEXT_FILE,
+    ShortcutSection.FILE_LIST,
+    'Select next file',
+    {key: 'j', allowRepeat: true},
+    {key: Key.DOWN, allowRepeat: true}
+  );
+  describe(
+    Shortcut.CURSOR_PREV_FILE,
+    ShortcutSection.FILE_LIST,
+    'Select previous file',
+    {key: 'k', allowRepeat: true},
+    {key: Key.UP, allowRepeat: true}
+  );
+  describe(
+    Shortcut.OPEN_FILE,
+    ShortcutSection.FILE_LIST,
+    'Go to selected file',
+    {key: 'o'},
+    {key: Key.ENTER}
+  );
+  describe(
+    Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+    ShortcutSection.FILE_LIST,
+    'Show/hide all inline diffs',
+    {key: 'I'}
+  );
+  describe(
+    Shortcut.TOGGLE_INLINE_DIFF,
+    ShortcutSection.FILE_LIST,
+    'Show/hide selected inline diff',
+    {key: 'i'}
+  );
+  describe(
+    Shortcut.EMOJI_DROPDOWN,
+    ShortcutSection.REPLY_DIALOG,
+    'Emoji dropdown',
+    {key: ':', docOnly: true}
+  );
+  describe(
+    Shortcut.MENTIONS_DROPDOWN,
+    ShortcutSection.REPLY_DIALOG,
+    'Mentions dropdown',
+    {key: '@', docOnly: true}
+  );
+  return config;
 }
-
-describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search', {key: '/'});
-describe(
-  Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
-  ShortcutSection.EVERYWHERE,
-  'Show this dialog',
-  {key: '?'}
-);
-describe(
-  Shortcut.GO_TO_USER_DASHBOARD,
-  ShortcutSection.EVERYWHERE,
-  'Go to User Dashboard',
-  {key: 'i', combo: ComboKey.G}
-);
-describe(
-  Shortcut.GO_TO_OPENED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Opened Changes',
-  {key: 'o', combo: ComboKey.G}
-);
-describe(
-  Shortcut.GO_TO_MERGED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Merged Changes',
-  {key: 'm', combo: ComboKey.G}
-);
-describe(
-  Shortcut.GO_TO_ABANDONED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Abandoned Changes',
-  {key: 'a', combo: ComboKey.G}
-);
-describe(
-  Shortcut.GO_TO_WATCHED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Watched Changes',
-  {key: 'w', combo: ComboKey.G}
-);
-
-describe(
-  Shortcut.CURSOR_NEXT_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Select next change',
-  {key: 'j', allowRepeat: true}
-);
-describe(
-  Shortcut.CURSOR_PREV_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Select previous change',
-  {key: 'k', allowRepeat: true}
-);
-describe(
-  Shortcut.OPEN_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Show selected change',
-  {key: 'o'}
-);
-describe(
-  Shortcut.NEXT_PAGE,
-  ShortcutSection.ACTIONS,
-  'Go to next page',
-  {key: 'n'},
-  {key: ']'}
-);
-describe(
-  Shortcut.PREV_PAGE,
-  ShortcutSection.ACTIONS,
-  'Go to previous page',
-  {key: 'p'},
-  {key: '['}
-);
-describe(
-  Shortcut.OPEN_REPLY_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open reply dialog to publish comments and add reviewers',
-  {key: 'a'}
-);
-describe(
-  Shortcut.OPEN_DOWNLOAD_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open download overlay',
-  {key: 'd'}
-);
-describe(
-  Shortcut.EXPAND_ALL_MESSAGES,
-  ShortcutSection.ACTIONS,
-  'Expand all messages',
-  {key: 'x'}
-);
-describe(
-  Shortcut.COLLAPSE_ALL_MESSAGES,
-  ShortcutSection.ACTIONS,
-  'Collapse all messages',
-  {key: 'z'}
-);
-describe(
-  Shortcut.REFRESH_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Reload the change at the latest patch',
-  {key: 'R'}
-);
-describe(
-  Shortcut.TOGGLE_FILE_REVIEWED,
-  ShortcutSection.ACTIONS,
-  'Toggle review flag on selected file',
-  {key: 'r'}
-);
-describe(
-  Shortcut.REFRESH_CHANGE_LIST,
-  ShortcutSection.ACTIONS,
-  'Refresh list of changes',
-  {key: 'R'}
-);
-describe(
-  Shortcut.TOGGLE_CHANGE_STAR,
-  ShortcutSection.ACTIONS,
-  'Star/unstar change',
-  {key: 's'}
-);
-describe(
-  Shortcut.OPEN_SUBMIT_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open submit dialog',
-  {key: 'S'}
-);
-describe(
-  Shortcut.TOGGLE_ATTENTION_SET,
-  ShortcutSection.ACTIONS,
-  'Toggle attention set status',
-  {key: 'T'}
-);
-describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic', {
-  key: 't',
-});
-describe(
-  Shortcut.DIFF_AGAINST_BASE,
-  ShortcutSection.DIFFS,
-  'Diff against base',
-  {key: Key.DOWN, combo: ComboKey.V},
-  {key: 's', combo: ComboKey.V}
-);
-describe(
-  Shortcut.DIFF_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff against latest patchset',
-  {key: Key.UP, combo: ComboKey.V},
-  {key: 'w', combo: ComboKey.V}
-);
-describe(
-  Shortcut.DIFF_BASE_AGAINST_LEFT,
-  ShortcutSection.DIFFS,
-  'Diff base against left',
-  {key: Key.LEFT, combo: ComboKey.V},
-  {key: 'a', combo: ComboKey.V}
-);
-describe(
-  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff right against latest',
-  {key: Key.RIGHT, combo: ComboKey.V},
-  {key: 'd', combo: ComboKey.V}
-);
-describe(
-  Shortcut.DIFF_BASE_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff base against latest',
-  {key: 'b', combo: ComboKey.V}
-);
-
-describe(
-  Shortcut.NEXT_LINE,
-  ShortcutSection.DIFFS,
-  'Go to next line',
-  {key: 'j', allowRepeat: true},
-  {key: Key.DOWN, allowRepeat: true}
-);
-describe(
-  Shortcut.PREV_LINE,
-  ShortcutSection.DIFFS,
-  'Go to previous line',
-  {key: 'k', allowRepeat: true},
-  {key: Key.UP, allowRepeat: true}
-);
-describe(
-  Shortcut.VISIBLE_LINE,
-  ShortcutSection.DIFFS,
-  'Move cursor to currently visible code',
-  {key: '.'}
-);
-describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS, 'Go to next diff chunk', {
-  key: 'n',
-});
-describe(
-  Shortcut.PREV_CHUNK,
-  ShortcutSection.DIFFS,
-  'Go to previous diff chunk',
-  {key: 'p'}
-);
-describe(
-  Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
-  ShortcutSection.DIFFS,
-  'Toggle all diff context',
-  {key: 'X'}
-);
-describe(
-  Shortcut.NEXT_COMMENT_THREAD,
-  ShortcutSection.DIFFS,
-  'Go to next comment thread',
-  {key: 'N'}
-);
-describe(
-  Shortcut.PREV_COMMENT_THREAD,
-  ShortcutSection.DIFFS,
-  'Go to previous comment thread',
-  {key: 'P'}
-);
-describe(
-  Shortcut.EXPAND_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Expand all comment threads',
-  {key: 'e', docOnly: true}
-);
-describe(
-  Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Collapse all comment threads',
-  {key: 'E', docOnly: true}
-);
-describe(
-  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Hide/Display all comment threads',
-  {key: 'h'}
-);
-describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane', {
-  key: Key.LEFT,
-  modifiers: [Modifier.SHIFT_KEY],
-});
-describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane', {
-  key: Key.RIGHT,
-  modifiers: [Modifier.SHIFT_KEY],
-});
-describe(
-  Shortcut.TOGGLE_LEFT_PANE,
-  ShortcutSection.DIFFS,
-  'Hide/show left diff',
-  {key: 'A'}
-);
-describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', {
-  key: 'c',
-});
-describe(
-  Shortcut.SAVE_COMMENT,
-  ShortcutSection.DIFFS,
-  'Save comment',
-  {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
-  {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
-  {key: 's', modifiers: [Modifier.CTRL_KEY]},
-  {key: 's', modifiers: [Modifier.META_KEY]}
-);
-describe(
-  Shortcut.OPEN_DIFF_PREFS,
-  ShortcutSection.DIFFS,
-  'Show diff preferences',
-  {key: ','}
-);
-describe(
-  Shortcut.TOGGLE_DIFF_REVIEWED,
-  ShortcutSection.DIFFS,
-  'Mark/unmark file as reviewed',
-  {key: 'r'}
-);
-describe(
-  Shortcut.TOGGLE_DIFF_MODE,
-  ShortcutSection.DIFFS,
-  'Toggle unified/side-by-side diff',
-  {key: 'm'}
-);
-describe(
-  Shortcut.NEXT_UNREVIEWED_FILE,
-  ShortcutSection.DIFFS,
-  'Mark file as reviewed and go to next unreviewed file',
-  {key: 'M'}
-);
-describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame', {
-  key: 'b',
-});
-describe(Shortcut.OPEN_FILE_LIST, ShortcutSection.DIFFS, 'Open file list', {
-  key: 'f',
-});
-describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file', {
-  key: ']',
-});
-describe(
-  Shortcut.PREV_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to previous file',
-  {key: '['}
-);
-describe(
-  Shortcut.NEXT_FILE_WITH_COMMENTS,
-  ShortcutSection.NAVIGATION,
-  'Go to next file that has comments',
-  {key: 'J'}
-);
-describe(
-  Shortcut.PREV_FILE_WITH_COMMENTS,
-  ShortcutSection.NAVIGATION,
-  'Go to previous file that has comments',
-  {key: 'K'}
-);
-describe(
-  Shortcut.OPEN_FIRST_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to first file',
-  {key: ']'}
-);
-describe(
-  Shortcut.OPEN_LAST_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to last file',
-  {key: '['}
-);
-describe(
-  Shortcut.UP_TO_DASHBOARD,
-  ShortcutSection.NAVIGATION,
-  'Up to dashboard',
-  {key: 'u'}
-);
-describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change', {
-  key: 'u',
-});
-
-describe(
-  Shortcut.CURSOR_NEXT_FILE,
-  ShortcutSection.FILE_LIST,
-  'Select next file',
-  {key: 'j', allowRepeat: true},
-  {key: Key.DOWN, allowRepeat: true}
-);
-describe(
-  Shortcut.CURSOR_PREV_FILE,
-  ShortcutSection.FILE_LIST,
-  'Select previous file',
-  {key: 'k', allowRepeat: true},
-  {key: Key.UP, allowRepeat: true}
-);
-describe(
-  Shortcut.OPEN_FILE,
-  ShortcutSection.FILE_LIST,
-  'Go to selected file',
-  {key: 'o'},
-  {key: Key.ENTER}
-);
-describe(
-  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-  ShortcutSection.FILE_LIST,
-  'Show/hide all inline diffs',
-  {key: 'I'}
-);
-describe(
-  Shortcut.TOGGLE_INLINE_DIFF,
-  ShortcutSection.FILE_LIST,
-  'Show/hide selected inline diff',
-  {key: 'i'}
-);
-
-describe(
-  Shortcut.SEND_REPLY,
-  ShortcutSection.REPLY_DIALOG,
-  'Send reply',
-  {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY], docOnly: true},
-  {key: Key.ENTER, modifiers: [Modifier.META_KEY], docOnly: true}
-);
-describe(
-  Shortcut.EMOJI_DROPDOWN,
-  ShortcutSection.REPLY_DIALOG,
-  'Emoji dropdown',
-  {key: ':', docOnly: true}
-);
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index de28a60..dac9b92 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -1,23 +1,12 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {Subscription} from 'rxjs';
 import {map, distinctUntilChanged} from 'rxjs/operators';
 import {
-  config,
+  createShortcutConfig,
   Shortcut,
   ShortcutHelpItem,
   ShortcutSection,
@@ -30,10 +19,14 @@
   Modifier,
   Binding,
   shouldSuppress,
+  ShortcutOptions,
 } from '../../utils/dom-util';
 import {ReportingService} from '../gr-reporting/gr-reporting';
 import {Finalizable} from '../registry';
 import {UserModel} from '../../models/user/user-model';
+import {define} from '../../models/dependency';
+
+export {Shortcut, ShortcutSection};
 
 export type SectionView = Array<{binding: string[][]; text: string}>;
 
@@ -62,6 +55,9 @@
 
 export const COMBO_TIMEOUT_MS = 1000;
 
+export const shortcutsServiceToken =
+  define<ShortcutsService>('shortcuts-service');
+
 /**
  * Shortcuts service, holds all hosts, bindings and listeners.
  */
@@ -73,12 +69,6 @@
    */
   private readonly activeShortcuts = new Set<Shortcut>();
 
-  /**
-   * Keeps track of cleanup callbacks (which remove keyboard listeners) that
-   * have to be invoked when a component unregisters itself.
-   */
-  private readonly cleanupsPerHost = new Map<HTMLElement, (() => void)[]>();
-
   /** Static map built in the constructor by iterating over the config. */
   private readonly bindings = new Map<Shortcut, Binding[]>();
 
@@ -93,18 +83,22 @@
   private comboKeyLastPressed: {key?: ComboKey; timestampMs?: number} = {};
 
   /** Keeps track of the corresponding user preference. */
-  private shortcutsDisabled = false;
+  // visible for testing
+  shortcutsDisabled = false;
 
   private readonly keydownListener: (e: KeyboardEvent) => void;
 
   private readonly subscriptions: Subscription[] = [];
 
+  private readonly config: Map<ShortcutSection, ShortcutHelpItem[]>;
+
   constructor(
     readonly userModel: UserModel,
     readonly reporting?: ReportingService
   ) {
-    for (const section of config.keys()) {
-      const items = config.get(section) ?? [];
+    this.config = createShortcutConfig();
+    for (const section of this.config.keys()) {
+      const items = this.config.get(section) ?? [];
       for (const item of items) {
         this.bindings.set(item.shortcut, item.bindings);
       }
@@ -160,12 +154,10 @@
     element: HTMLElement,
     shortcut: Binding,
     listener: (e: KeyboardEvent) => void,
-    options: {
-      shouldSuppress: boolean;
-    } = {
-      shouldSuppress: true,
-    }
+    options?: ShortcutOptions
   ) {
+    const optShouldSuppress = options?.shouldSuppress ?? true;
+    const optPreventDefault = options?.preventDefault ?? true;
     const wrappedListener = (e: KeyboardEvent) => {
       if (e.repeat && !shortcut.allowRepeat) return;
       if (!eventMatchesShortcut(e, shortcut)) return;
@@ -174,13 +166,13 @@
       } else {
         if (this.isInComboKeyMode()) return;
       }
-      if (options.shouldSuppress && shouldSuppress(e)) return;
+      if (optShouldSuppress && 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();
+      if (optShouldSuppress && this.shortcutsDisabled) return;
+      if (optPreventDefault) e.preventDefault();
+      if (optPreventDefault) e.stopPropagation();
       this.reportTriggered(e);
       listener(e);
     };
@@ -222,7 +214,8 @@
    */
   addShortcutListener(
     shortcut: Shortcut,
-    listener: (e: KeyboardEvent) => void
+    listener: (e: KeyboardEvent) => void,
+    options?: ShortcutOptions
   ) {
     const cleanups: (() => void)[] = [];
     this.activeShortcuts.add(shortcut);
@@ -233,7 +226,9 @@
     const bindings = this.getBindingsForShortcut(shortcut);
     for (const binding of bindings ?? []) {
       if (binding.docOnly) continue;
-      cleanups.push(this.addShortcut(document.body, binding, listener));
+      cleanups.push(
+        this.addShortcut(document.body, binding, listener, options)
+      );
     }
     this.notifyViewListeners();
     return () => {
@@ -241,23 +236,6 @@
     };
   }
 
-  /**
-   * Being called by the Polymer specific KeyboardShortcutMixin.
-   */
-  attachHost(host: HTMLElement, shortcuts: ShortcutListener[]) {
-    const cleanups: (() => void)[] = [];
-    for (const s of shortcuts) {
-      cleanups.push(this.addShortcutListener(s.shortcut, s.listener));
-    }
-    this.cleanupsPerHost.set(host, cleanups);
-  }
-
-  detachHost(host: HTMLElement) {
-    const cleanups = this.cleanupsPerHost.get(host);
-    for (const cleanup of cleanups ?? []) cleanup();
-    return true;
-  }
-
   addListener(listener: ShortcutViewListener) {
     this.listeners.add(listener);
     listener(this.directoryView());
@@ -268,7 +246,7 @@
   }
 
   getDescription(section: ShortcutSection, shortcutName: Shortcut) {
-    const bindings = config.get(section);
+    const bindings = this.config.get(section);
     if (!bindings) return '';
     const binding = bindings.find(binding => binding.shortcut === shortcutName);
     return binding?.text ?? '';
@@ -287,7 +265,7 @@
       ShortcutSection,
       ShortcutHelpItem[]
     >();
-    config.forEach((shortcutList, section) => {
+    this.config.forEach((shortcutList, section) => {
       shortcutList.forEach(shortcutHelp => {
         if (this.activeShortcuts.has(shortcutHelp.shortcut)) {
           if (!activeShortcutsBySection.has(section)) {
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index 7dd3f75..5b38a8a 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -1,29 +1,22 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import {
   COMBO_TIMEOUT_MS,
   describeBinding,
   ShortcutsService,
 } from '../../services/shortcuts/shortcuts-service';
 import {Shortcut, ShortcutSection} from './shortcuts-config';
-import {SinonFakeTimers} from 'sinon';
-import {Key, Modifier} from '../../utils/dom-util';
+import {SinonFakeTimers, SinonSpy} from 'sinon';
+import {Binding, Key, Modifier} from '../../utils/dom-util';
 import {getAppContext} from '../app-context';
+import {pressKey} from '../../test/test-utils';
+import {assert} from '@open-wc/testing';
+
+const KEY_A: Binding = {key: 'a'};
 
 suite('shortcuts-service tests', () => {
   let service: ShortcutsService;
@@ -38,10 +31,65 @@
   test('getShortcut', () => {
     assert.equal(service.getShortcut(Shortcut.NEXT_FILE), ']');
     assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'A');
-    assert.equal(
-      service.getShortcut(Shortcut.SEND_REPLY),
-      'Ctrl+Enter,Meta/Cmd+Enter'
-    );
+  });
+
+  suite('addShortcut()', () => {
+    let el: HTMLElement;
+    let listener: SinonSpy<[KeyboardEvent], void>;
+
+    setup(() => {
+      el = document.createElement('div');
+      listener = sinon.spy() as SinonSpy<[KeyboardEvent], void>;
+    });
+
+    test('standard call', () => {
+      service.addShortcut(el, KEY_A, listener);
+      assert.isTrue(listener.notCalled);
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.calledOnce);
+    });
+
+    test('preventDefault option default false', () => {
+      service.addShortcut(el, KEY_A, listener);
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.calledOnce);
+      assert.isTrue(listener.lastCall.firstArg?.defaultPrevented);
+    });
+
+    test('preventDefault option force false', () => {
+      service.addShortcut(el, KEY_A, listener, {preventDefault: false});
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.calledOnce);
+      assert.isFalse(listener.lastCall.firstArg?.defaultPrevented);
+    });
+
+    test('preventDefault option force true', () => {
+      service.addShortcut(el, KEY_A, listener, {preventDefault: true});
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.calledOnce);
+      assert.isTrue(listener.lastCall.firstArg?.defaultPrevented);
+    });
+
+    test('shouldSuppress option default true', () => {
+      service.shortcutsDisabled = true;
+      service.addShortcut(el, KEY_A, listener);
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.notCalled);
+    });
+
+    test('shouldSuppress option force true', () => {
+      service.shortcutsDisabled = true;
+      service.addShortcut(el, KEY_A, listener, {shouldSuppress: true});
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.notCalled);
+    });
+
+    test('shouldSuppress option force false', () => {
+      service.shortcutsDisabled = true;
+      service.addShortcut(el, KEY_A, listener, {shouldSuppress: false});
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.calledOnce);
+    });
   });
 
   suite('binding descriptions', () => {
@@ -130,9 +178,7 @@
     test('active shortcuts by section', () => {
       assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {});
 
-      service.attachHost(document.createElement('div'), [
-        {shortcut: Shortcut.NEXT_FILE, listener: _ => {}},
-      ]);
+      service.addShortcutListener(Shortcut.NEXT_FILE, _ => {});
       assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
         [ShortcutSection.NAVIGATION]: [
           {
@@ -142,10 +188,7 @@
           },
         ],
       });
-
-      service.attachHost(document.createElement('div'), [
-        {shortcut: Shortcut.NEXT_LINE, listener: _ => {}},
-      ]);
+      service.addShortcutListener(Shortcut.NEXT_LINE, _ => {});
       assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
         [ShortcutSection.DIFFS]: [
           {
@@ -166,10 +209,8 @@
         ],
       });
 
-      service.attachHost(document.createElement('div'), [
-        {shortcut: Shortcut.SEARCH, listener: _ => {}},
-        {shortcut: Shortcut.GO_TO_OPENED_CHANGES, listener: _ => {}},
-      ]);
+      service.addShortcutListener(Shortcut.SEARCH, _ => {});
+      service.addShortcutListener(Shortcut.GO_TO_OPENED_CHANGES, _ => {});
       assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
         [ShortcutSection.DIFFS]: [
           {
@@ -206,13 +247,11 @@
     test('directory view', () => {
       assert.deepEqual(mapToObject(service.directoryView()), {});
 
-      service.attachHost(document.createElement('div'), [
-        {shortcut: Shortcut.GO_TO_OPENED_CHANGES, listener: _ => {}},
-        {shortcut: Shortcut.NEXT_FILE, listener: _ => {}},
-        {shortcut: Shortcut.NEXT_LINE, listener: _ => {}},
-        {shortcut: Shortcut.SAVE_COMMENT, listener: _ => {}},
-        {shortcut: Shortcut.SEARCH, listener: _ => {}},
-      ]);
+      service.addShortcutListener(Shortcut.GO_TO_OPENED_CHANGES, _ => {});
+      service.addShortcutListener(Shortcut.NEXT_FILE, _ => {});
+      service.addShortcutListener(Shortcut.NEXT_LINE, _ => {});
+      service.addShortcutListener(Shortcut.SAVE_COMMENT, _ => {});
+      service.addShortcutListener(Shortcut.SEARCH, _ => {});
       assert.deepEqual(mapToObject(service.directoryView()), {
         [ShortcutSection.DIFFS]: [
           {binding: [['j'], ['↓']], text: 'Go to next line'},
diff --git a/polygerrit-ui/app/services/storage/gr-storage.ts b/polygerrit-ui/app/services/storage/gr-storage.ts
index 3319906..b3b76d4 100644
--- a/polygerrit-ui/app/services/storage/gr-storage.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {CommentRange, NumericChangeId, PatchSetNum} from '../../types/common';
 import {Finalizable} from '../registry';
 
diff --git a/polygerrit-ui/app/services/storage/gr-storage_impl.ts b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
index 95bb4a5..0caffbc 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_impl.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_impl.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 {StorageLocation, StorageObject, StorageService} from './gr-storage';
 import {Finalizable} from '../registry';
 import {NumericChangeId} from '../../types/common';
diff --git a/polygerrit-ui/app/services/storage/gr-storage_mock.ts b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
index 97879e0..65e6a89 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_mock.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {NumericChangeId} from '../../types/common';
 import {StorageLocation, StorageObject, StorageService} from './gr-storage';
 
diff --git a/polygerrit-ui/app/services/storage/gr-storage_test.ts b/polygerrit-ui/app/services/storage/gr-storage_test.ts
index a019218..92d611e 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_test.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_test.ts
@@ -1,21 +1,10 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../../test/common-test-setup';
 import {PatchSetNum} from '../../types/common';
 import {StorageLocation} from './gr-storage';
 import {GrStorageService} from './gr-storage_impl';
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.ts b/polygerrit-ui/app/styles/dashboard-header-styles.ts
index 669f81f..cd45f8b 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.ts
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-a11y-styles.ts b/polygerrit-ui/app/styles/gr-a11y-styles.ts
index a1fa62b..ccf8b50 100644
--- a/polygerrit-ui/app/styles/gr-a11y-styles.ts
+++ b/polygerrit-ui/app/styles/gr-a11y-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index e0ce1d7..3763213 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {css} from 'lit';
 
@@ -35,8 +24,8 @@
   .cell {
     vertical-align: middle;
   }
-  .groupTitle td:not(.label):not(.endpoint),
-  .cell:not(.label):not(.endpoint) {
+  .groupTitle td:not(.label):not(.endpoint):not(.star),
+  .cell:not(.label):not(.endpoint):not(.star) {
     padding-right: 8px;
   }
   .groupTitle td {
@@ -68,10 +57,7 @@
     padding-top: var(--spacing-l);
   }
   .star {
-    padding: 0;
-  }
-  gr-change-star {
-    vertical-align: middle;
+    padding: 0 var(--spacing-s) 0 0;
   }
   .owner {
     --account-max-length: 100px;
@@ -89,15 +75,9 @@
   .repo {
     white-space: nowrap;
   }
-  .star {
-    vertical-align: middle;
-  }
   .leftPadding {
     width: var(--spacing-l);
   }
-  .star {
-    width: 30px;
-  }
   .reviewers div {
     overflow: hidden;
   }
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 aa16473..33aa2d7 100644
--- a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.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
  */
 
 // Mark the file as a module. Otherwise typescript assumes this is a script
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
index 145f0d5..67ee146 100644
--- a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 // Mark the file as a module. Otherwise typescript assumes this is a script
diff --git a/polygerrit-ui/app/styles/gr-font-styles.ts b/polygerrit-ui/app/styles/gr-font-styles.ts
index 422a7c5..a816f96 100644
--- a/polygerrit-ui/app/styles/gr-font-styles.ts
+++ b/polygerrit-ui/app/styles/gr-font-styles.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 {css} from 'lit';
 
@@ -45,6 +34,12 @@
     font-weight: var(--font-weight-h3);
     line-height: var(--line-height-h3);
   }
+  .heading-4 {
+    font-family: var(--header-font-family);
+    font-size: var(--font-size-normal);
+    font-weight: var(--font-weight-h4);
+    line-height: var(--line-height-normal);
+  }
   strong {
     font-weight: var(--font-weight-bold);
   }
diff --git a/polygerrit-ui/app/styles/gr-form-styles.ts b/polygerrit-ui/app/styles/gr-form-styles.ts
index 34a6936..cc89c3c 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.ts
+++ b/polygerrit-ui/app/styles/gr-form-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-hovercard-styles.ts b/polygerrit-ui/app/styles/gr-hovercard-styles.ts
index f214a9c..78eb3e6 100644
--- a/polygerrit-ui/app/styles/gr-hovercard-styles.ts
+++ b/polygerrit-ui/app/styles/gr-hovercard-styles.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 {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-icon-styles.ts b/polygerrit-ui/app/styles/gr-icon-styles.ts
new file mode 100644
index 0000000..9865825
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-icon-styles.ts
@@ -0,0 +1,15 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+export const iconStyles = css`
+  iron-icon {
+    display: inline-block;
+    vertical-align: top;
+    width: 20px;
+    height: 20px;
+  }
+`;
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
index 5f58571..7a44e79 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.ts
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
index 15ef5b8..b6e8f60 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.ts
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.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 {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-paper-styles.ts b/polygerrit-ui/app/styles/gr-paper-styles.ts
index 1ef7124..301c02d 100644
--- a/polygerrit-ui/app/styles/gr-paper-styles.ts
+++ b/polygerrit-ui/app/styles/gr-paper-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
@@ -21,6 +10,8 @@
     --paper-toggle-button-checked-bar-color: var(--link-color);
     --paper-toggle-button-checked-button-color: var(--link-color);
   }
+  /* prettier formatter removes semi-colons after css mixins. */
+  /* prettier-ignore */
   paper-tabs {
     font-size: var(--font-size-h3);
     font-weight: var(--font-weight-h3);
@@ -28,20 +19,20 @@
     --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;
diff --git a/polygerrit-ui/app/styles/gr-spinner-styles.ts b/polygerrit-ui/app/styles/gr-spinner-styles.ts
index 6015be4..1c7adc7 100644
--- a/polygerrit-ui/app/styles/gr-spinner-styles.ts
+++ b/polygerrit-ui/app/styles/gr-spinner-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts b/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts
index 8c5deef..948e9b0 100644
--- a/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts
+++ b/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts
@@ -1,28 +1,20 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
 export const submitRequirementsStyles = css`
-  iron-icon.check-circle-filled,
-  iron-icon.overridden {
+  gr-icon.check_circle,
+  gr-icon.published_with_changes {
     color: var(--success-foreground);
   }
-  iron-icon.block,
-  iron-icon.error {
+  gr-icon.block,
+  gr-icon.error {
     color: var(--deemphasized-text-color);
   }
+  gr-icon.cancel {
+    color: var(--error-foreground);
+  }
 `;
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.ts b/polygerrit-ui/app/styles/gr-subpage-styles.ts
index e426a7d..4408900 100644
--- a/polygerrit-ui/app/styles/gr-subpage-styles.ts
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-table-styles.ts b/polygerrit-ui/app/styles/gr-table-styles.ts
index ce01555..ff757cf 100644
--- a/polygerrit-ui/app/styles/gr-table-styles.ts
+++ b/polygerrit-ui/app/styles/gr-table-styles.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 {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts
index a623d99..e905941 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.ts
+++ b/polygerrit-ui/app/styles/gr-voting-styles.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
  */
 
 // Mark the file as a module. Otherwise typescript assumes this is a script
diff --git a/polygerrit-ui/app/styles/material-icons.css b/polygerrit-ui/app/styles/material-icons.css
new file mode 100644
index 0000000..4c0313c
--- /dev/null
+++ b/polygerrit-ui/app/styles/material-icons.css
@@ -0,0 +1,12 @@
+/**
+ * This file has been produced by downloading this file on Sep 6, 2022:
+ * https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0
+ * The corresponding ttf file was downloaded on Sep 6, 2022 from:
+ * https://fonts.gstatic.com/s/materialsymbolsoutlined/v51/kJF4BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJU22ZZLsYEpzC_1ver5Y0J1Llf.woff2
+ */
+@font-face {
+  font-family: 'Material Symbols Outlined';
+  font-style: normal;
+  font-weight: 100 700;
+  src: url(../fonts/material-icons.woff2) format('woff2');
+}
\ No newline at end of file
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index a83e897..839f612 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.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 {css} from 'lit';
 
@@ -123,6 +112,8 @@
     margin: 0;
     padding: var(--spacing-s);
   }
+  /* prettier formatter removes semi-colons after css mixins. */
+  /* prettier-ignore */
   iron-autogrow-textarea {
     background-color: inherit;
     color: var(--primary-text-color);
@@ -133,13 +124,13 @@
     /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
         css rule, which prevents overriding the border color. Clear that. */
     -webkit-appearance: none;
-
     --iron-autogrow-textarea: {
       box-sizing: border-box;
       padding: var(--spacing-s);
-    }
+    };
     --iron-autogrow-textarea_-_box-sizing: border-box;
     --iron-autogrow-textarea_-_padding: var(--spacing-s);
+    --iron-autogrow-textarea_-_white-space: pre-wrap;
   }
   a {
     color: var(--link-color);
@@ -191,10 +182,6 @@
   .separator.transparent {
     border-color: transparent;
   }
-  iron-autogrow-textarea {
-    /** This is needed for firefox */
-    --iron-autogrow-textarea_-_white-space: pre-wrap;
-  }
 
   /**
    * TODO: Remove these rules and change (plugin) users to rely on
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 0aae217..e148da9 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -39,6 +39,8 @@
     --blue-700-16: #1967d229;
     --blue-700-24: #1967d23d;
     --blue-400: #669df6;
+    --blue-300: #8ab4f8;
+    --blue-300-24: #8ab4f83D;
     --blue-200: #aecbfa;
     --blue-200-16: #aecbfa29;
     --blue-200-24: #aecbfa3d;
@@ -46,10 +48,13 @@
     --blue-50: #e8f0fe;
     --blue-tonal: #314972;
     --orange-900: #b06000;
+    --orange-800: #c26401;
     --orange-700: #d56e0c;
     --orange-700-04: #d56e0c0a;
     --orange-700-10: #d56e0c1a;
     --orange-700-12: #d56e0c1f;
+    --orange-400: #fa903e;
+    --orange-300: #fcad70;
     --orange-200: #fdc69c;
     --orange-50: #feefe3;
     --orange-tonal: #714625;
@@ -94,6 +99,8 @@
     --purple-100: #e9d2fd;
     --purple-50: #f3e8fd;
     --purple-tonal: #523272;
+    --deep-purple-800: #4527a0;
+    --deep-purple-600: #5e35b1;
     --pink-800: #b80672;
     --pink-500: #f538a0;
     --pink-50: #fde7f3;
@@ -120,6 +127,8 @@
       var(--red-50);
     --error-ripple: var(--red-700-10);
 
+    --code-review-warning-background: var(--blue-50);
+
     --warning-foreground: var(--orange-700);
     --warning-background: var(--orange-50);
     --warning-background-hover: linear-gradient(
@@ -155,6 +164,7 @@
 
     --selected-foreground: var(--blue-800);
     --selected-background: var(--blue-50);
+    --selected-chip-background: var(--blue-50);
 
     --success-foreground: var(--green-700);
     --success-background: var(--green-50);
@@ -287,6 +297,7 @@
     --border-color: var(--gray-300);
     --input-focus-border-color: var(--blue-800);
     --comment-separator-color: var(--gray-300);
+    --comment-quote-marker-color: var(--gray-500);
 
     /* checks tag colors */
     --tag-gray: var(--gray-200);
@@ -308,9 +319,13 @@
     --status-custom: var(--purple-900);
 
     /* file status colors */
+    --file-status-font-color: black;
     --file-status-added: var(--green-300);
-    --file-status-changed: var(--red-200);
+    --file-status-deleted: var(--red-200);
+    --file-status-modified: var(--gray-300);
+    --file-status-renamed: var(--orange-300);
     --file-status-unchanged: var(--gray-300);
+    --file-status-reverted: var(--gray-300);
 
     /* fonts */
     --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI',
@@ -338,7 +353,8 @@
     --font-weight-bold: 500;
     --font-weight-h1: 400;
     --font-weight-h2: 400;
-    --font-weight-h3: var(--font-weight-bold, 500);
+    --font-weight-h3: 400;
+    --font-weight-h4: 600;
     --context-control-button-font: var(--font-weight-normal)
       var(--font-size-normal) var(--font-family);
     --code-hint-font-weight: 500;
@@ -370,9 +386,20 @@
 
     /* diff colors */
     --dark-add-highlight-color: #aaf2aa;
-    --dark-rebased-add-highlight-color: #d7d7f9;
-    --dark-rebased-remove-highlight-color: #f7e8b7;
+    --light-add-highlight-color: #d8fed8;
     --dark-remove-highlight-color: #ffcdd2;
+    --light-remove-highlight-color: #ffebee;
+
+    --dark-rebased-add-highlight-color: #d7d7f9;
+    --light-rebased-add-highlight-color: #eef;
+    --dark-rebased-remove-highlight-color: #f7e8b7;
+    --light-rebased-remove-highlight-color: #fff8dc;
+
+    --diff-moved-in-background: var(--cyan-50);
+    --diff-moved-in-label-color: var(--cyan-900);
+    --diff-moved-out-background: var(--purple-50);
+    --diff-moved-out-label-color: var(--purple-900);
+
     --diff-blank-background-color: var(--background-color-secondary);
     --diff-context-control-background-color: #fff7d4;
     --diff-context-control-border-color: #f6e6a5;
@@ -383,14 +410,7 @@
     --diff-tab-indicator-color: var(--deemphasized-text-color);
     --diff-trailing-whitespace-indicator: #ff9ad2;
     --focused-line-outline-color: var(--blue-700);
-    --light-add-highlight-color: #d8fed8;
-    --light-rebased-add-highlight-color: #eef;
-    --diff-moved-in-background: var(--cyan-50);
-    --diff-moved-out-background: var(--purple-50);
-    --diff-moved-in-label-color: var(--cyan-900);
-    --diff-moved-out-label-color: var(--purple-900);
-    --light-remove-add-highlight-color: #fff8dc;
-    --light-remove-highlight-color: #ffebee;
+    --coverage-covered-line-num-color: var(--deemphasized-text-color);
     --coverage-covered: #e0f2f1;
     --coverage-not-covered: #ffd1a4;
     --ranged-comment-hint-text-color: var(--orange-900);
@@ -491,13 +511,15 @@
 // removed from Gerrit.
 
 const appThemeCssPolymerLegacy = safeStyleSheet`
+  /* prettier formatter removes semi-colons after css mixins. */
+  /* prettier-ignore */
   html {
     --paper-tooltip: {
       font-size: var(--font-size-small);
-    }
+    };
     --iron-overlay-backdrop: {
       transition: none;
-    }
+    };
   }
 `;
 
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 6f19924..2330041 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -28,6 +28,8 @@
       var(--red-tonal);
     --error-ripple: var(--white-10);
 
+    --code-review-warning-background: var(--blue-tonal);
+
     --warning-foreground: var(--orange-200);
     --warning-background: var(--orange-tonal);
     --warning-background-hover: linear-gradient(
@@ -57,6 +59,7 @@
 
     --selected-foreground: var(--blue-200);
     --selected-background: var(--blue-900);
+    --selected-chip-background: var(--blue-300-24);
 
     --success-foreground: var(--green-200);
     --success-background: var(--green-tonal);
@@ -91,12 +94,14 @@
     --not-working-hours-icon-background-color: var(--purple-tonal);
     --not-working-hours-icon-color: var(--purple-100);
     --unavailability-icon-color: var(--gray-500);
+    --unavailability-chip-icon-color: var(--orange-700);
+    --unavailability-chip-background-color: var(--orange-tonal);
 
     /* 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);
+    --deemphasized-text-color: var(--gray-400);
     --default-button-text-color: var(--gerrit-blue-dark);
     --chip-selected-text-color: var(--blue-100);
     --error-text-color: var(--red-200);
@@ -178,9 +183,12 @@
     --status-custom: var(--purple-400);
 
     /* file status colors */
-    --file-status-added: var(--green-tonal);
-    --file-status-changed: var(--red-tonal);
-    --file-status-unchanged: var(--gray-700);
+    --file-status-added: var(--green-400);
+    --file-status-deleted: var(--red-300);
+    --file-status-modified: var(--gray-500);
+    --file-status-renamed: var(--orange-400);
+    --file-status-unchanged: var(--gray-500);
+    --file-status-reverted: var(--gray-500);
 
     /* fonts */
     --font-weight-bold: 700; /* 700 is the same as 'bold' */
@@ -207,9 +215,20 @@
 
     /* diff colors */
     --dark-add-highlight-color: var(--green-tonal);
-    --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
-    --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
+    --light-add-highlight-color: #182b1f;
     --dark-remove-highlight-color: #62110f;
+    --light-remove-highlight-color: #320404;
+
+    --dark-rebased-add-highlight-color: var(--deep-purple-800);
+    --light-rebased-add-highlight-color: var(--deep-purple-600);
+    --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
+    --light-rebased-remove-highlight-color: #2f3f2f;
+
+    --diff-moved-in-background: #1d4042;
+    --diff-moved-in-label-color: var(--cyan-50);
+    --diff-moved-out-background: #230e34;
+    --diff-moved-out-label-color: var(--purple-50);
+
     --diff-blank-background-color: var(--background-color-secondary);
     --diff-context-control-background-color: #333311;
     --diff-context-control-border-color: var(--border-color);
@@ -220,15 +239,8 @@
     --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-covered: #37674a;
+    --coverage-covered-line-num-color: var(--gray-200);
     --coverage-not-covered: #6b3600;
     --ranged-comment-hint-text-color: var(--blue-50);
     --token-highlighting-color: var(--yellow-tonal);
@@ -276,8 +288,14 @@
 `;
 
 export function applyTheme() {
+  if (document.head.querySelector('#dark-theme')) return;
   const styleEl = document.createElement('style');
   styleEl.setAttribute('id', 'dark-theme');
   safeStyleEl.setTextContent(styleEl, darkThemeCss);
   document.head.appendChild(styleEl);
 }
+
+export function removeTheme() {
+  const styleEl = document.head.querySelector('#dark-theme');
+  styleEl?.remove();
+}
diff --git a/polygerrit-ui/app/test/a11y-test-utils.js b/polygerrit-ui/app/test/a11y-test-utils.js
deleted file mode 100644
index a687e07..0000000
--- a/polygerrit-ui/app/test/a11y-test-utils.js
+++ /dev/null
@@ -1,44 +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 './common-test-setup-karma.js';
-
-// Run a11y audit on test fixture
-// The code is inspired by the
-// https://github.com/Polymer/web-component-tester/blob/master/data/a11ySuite.js
-export async function runA11yAudit(fixture, ignoredRules) {
-  fixture.instantiate();
-  await flush();
-  const axsConfig = new axs.AuditConfiguration();
-  axsConfig.scope = document.body;
-  axsConfig.showUnsupportedRulesWarning = false;
-  axsConfig.auditRulesToIgnore = ignoredRules;
-
-  const auditResults = axs.Audit.run(axsConfig);
-  const errors = [];
-  auditResults.forEach((result, index) => {
-    // only show applicable tests
-    if (result.result === 'FAIL') {
-      const title = result.rule.heading;
-      // fail test if audit result is FAIL
-      const error = axs.Audit.accessibilityErrorMessage(result);
-      errors.push(`${title}: ${error}`);
-    }
-  });
-  if (errors.length > 0) {
-    assert.fail(errors.join('\n') + '\n');
-  }
-}
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.ts b/polygerrit-ui/app/test/common-test-setup-karma.ts
deleted file mode 100644
index 5c84b05..0000000
--- a/polygerrit-ui/app/test/common-test-setup-karma.ts
+++ /dev/null
@@ -1,212 +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 {testResolver as testResolverImpl} from './common-test-setup';
-import '@polymer/test-fixture/test-fixture';
-import 'chai/chai';
-
-declare global {
-  interface Window {
-    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
-let unhandledError: ErrorEvent;
-
-window.addEventListener('error', e => {
-  // 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;
-});
-
-let originalOnBeforeUnload: typeof window.onbeforeunload;
-
-suiteSetup(() => {
-  // This suiteSetup() method is called only once before all tests
-
-  // Can't use window.addEventListener("beforeunload",...) here,
-  // the handler is raised too late.
-  originalOnBeforeUnload = window.onbeforeunload;
-  window.onbeforeunload = function (e: BeforeUnloadEvent) {
-    // If a test reloads a page, we can't prevent it.
-    // However we can print an error and the stack trace with assert.fail
-    try {
-      throw new Error();
-    } catch (e) {
-      console.error('Page reloading attempt detected.');
-      console.error(e.stack.toString());
-    }
-    if (originalOnBeforeUnload) {
-      originalOnBeforeUnload.call(window, e);
-    }
-  };
-});
-
-suiteTeardown(() => {
-  // This suiteTeardown() method is called only once after all tests
-  window.onbeforeunload = originalOnBeforeUnload;
-  if (unhandledError) {
-    throw unhandledError;
-  }
-});
-
-// Tests can use fake timers (sandbox.useFakeTimers)
-// Keep the original one for use in test utils methods.
-const nativeSetTimeout = window.setTimeout;
-
-function flushImpl(): Promise<void>;
-function flushImpl(callback: () => void): void;
-/**
- * Triggers a flush of any pending events, observations, etc and calls you back
- * after they have been processed if callback is passed; otherwise returns
- * promise.
- */
-function flushImpl(callback?: () => void): Promise<void> | void {
-  // Ideally, this function would be a call to Polymer.dom.flush, but that
-  // doesn't support a callback yet
-  // (https://github.com/Polymer/polymer-dev/issues/851)
-  // The type is used only in one place, disable eslint warning instead of
-  // creating an interface
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  (window as any).Polymer.dom.flush();
-  if (callback) {
-    nativeSetTimeout(callback, 0);
-  } else {
-    return new Promise(resolve => {
-      nativeSetTimeout(resolve, 0);
-    });
-  }
-}
-
-self.flush = flushImpl;
-
-class TestFixtureIdProvider {
-  public static readonly instance: TestFixtureIdProvider =
-    new TestFixtureIdProvider();
-
-  private fixturesCount = 1;
-
-  generateNewFixtureId() {
-    this.fixturesCount++;
-    return `fixture-${this.fixturesCount}`;
-  }
-}
-
-interface TagTestFixture<T extends Element> {
-  instantiate(model?: unknown): T;
-}
-
-class TestFixture {
-  constructor(readonly fixtureId: string) {}
-
-  /**
-   * Create an instance of a fixture's template.
-   *
-   * @param model - see Data-bound sections at
-   *   https://www.webcomponents.org/element/@polymer/test-fixture
-   * @return - if the fixture's template contains
-   *   a single element, returns the appropriated instantiated element.
-   *   Otherwise, it return an array of all instantiated elements from the
-   *   template.
-   */
-  instantiate(model?: unknown): HTMLElement | HTMLElement[] {
-    // The window.fixture method is defined in common-test-setup.js
-    return window.fixture(this.fixtureId, model);
-  }
-}
-
-/**
- * Wraps provided template to a test-fixture tag and adds test-fixture to
- * the document. You can use the html function to create a template.
- *
- * Example:
- * import {html} from '@polymer/polymer/lib/utils/html-tag.js';
- *
- * // Create fixture at the root level of a test file
- * const basicTestFixture = fixtureFromTemplate(html`
- *   <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
- *   <ul>
- *    <li>A</li>
- *    <li>B</li>
- *    <li>C</li>
- *    <li>D</li>
- *   </ul>
- * `);
- * ...
- * // Instantiate fixture when needed:
- *
- * suite('example') {
- *   let elements;
- *   setup(() => {
- *     elements = basicTestFixture.instantiate();
- *   });
- * }
- *
- * @param template - a template for a fixture
- */
-function fixtureFromTemplateImpl(template: HTMLTemplateElement): TestFixture {
-  const fixtureId = TestFixtureIdProvider.instance.generateNewFixtureId();
-  const testFixture = document.createElement('test-fixture');
-  testFixture.setAttribute('id', fixtureId);
-  testFixture.appendChild(template);
-  document.body.appendChild(testFixture);
-  return new TestFixture(fixtureId);
-}
-
-/**
- * Wraps provided tag to a test-fixture/template tags and adds test-fixture
- * to the document.
- *
- * Example:
- *
- * // Create fixture at the root level of a test file
- * const basicTestFixture = fixtureFromElement('gr-diff-view');
- * ...
- * // Instantiate fixture when needed:
- *
- * suite('example') {
- *   let element;
- *   setup(() => {
- *     element = basicTestFixture.instantiate();
- *   });
- * }
- *
- * @param tagName - a template for a fixture is <tagName></tagName>
- */
-function fixtureFromElementImpl<T extends keyof HTMLElementTagNameMap>(
-  tagName: T
-): TagTestFixture<HTMLElementTagNameMap[T]> {
-  const template = document.createElement('template');
-  template.innerHTML = `<${tagName}></${tagName}>`;
-  return fixtureFromTemplate(template) as unknown as TagTestFixture<
-    HTMLElementTagNameMap[T]
-  >;
-}
-
-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 46ba4ab..306747b 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -1,26 +1,11 @@
 /**
  * @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
  */
-// This should be the first import to install handler before any other code
-import './source-map-support-install';
 // TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
 // https://github.com/Polymer/polymer-resin/issues/9 is resolved.
 import '../scripts/bundled-polymer';
-import '@polymer/iron-test-helpers/iron-test-helpers';
-import './test-router';
 import {AppContext, injectAppContext} from '../services/app-context';
 import {Finalizable} from '../services/registry';
 import {
@@ -33,16 +18,13 @@
 import {
   cleanupTestUtils,
   getCleanupsCount,
-  registerTestCleanup,
   addIronOverlayBackdropStyleEl,
   removeIronOverlayBackdropStyleEl,
   removeThemeStyles,
 } from './test-utils';
 import {safeTypesBridge} from '../utils/safe-types-util';
 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 {assert, fixtureCleanup} from '@open-wc/testing';
 import {
   _testOnly_defaultResinReportHandler,
   installPolymerResin,
@@ -55,24 +37,15 @@
   DependencyToken,
   Provider,
 } from '../models/dependency';
+import * as sinon from 'sinon';
+import '../styles/themes/app-theme.ts';
 
 declare global {
   interface Window {
-    assert: typeof chai.assert;
-    expect: typeof chai.expect;
-    fixture: typeof fixtureImpl;
-    stub: typeof stubImpl;
     sinon: typeof sinon;
-    chai: typeof chai;
   }
-  let assert: typeof chai.assert;
-  let expect: typeof chai.expect;
-  let stub: typeof stubImpl;
   let sinon: typeof sinon;
 }
-window.assert = chai.assert;
-window.expect = chai.expect;
-window.chai.use(chaiDomDiff);
 
 window.sinon = sinon;
 
@@ -80,30 +53,11 @@
   const log = _testOnly_defaultResinReportHandler;
   log(isViolation, fmt, ...args);
   if (isViolation) {
-    // This will cause the test to fail if there is a data binding
-    // violation.
+    // This will cause the test to fail if there is a data binding violation.
     throw new Error('polymer-resin violation: ' + fmt + JSON.stringify(args));
   }
 });
 
-interface TestFixtureElement extends HTMLElement {
-  restore(): void;
-  create(model?: unknown): HTMLElement | HTMLElement[];
-}
-
-function getFixtureElementById(fixtureId: string) {
-  return document.getElementById(fixtureId) as TestFixtureElement;
-}
-
-// For karma always set our implementation
-// (karma doesn't provide the fixture method)
-function fixtureImpl(fixtureId: string, model: unknown) {
-  // This method is inspired by web-component-tester method
-  registerTestCleanup(() => getFixtureElementById(fixtureId).restore());
-  return getFixtureElementById(fixtureId).create(model);
-}
-
-window.fixture = fixtureImpl;
 let testSetupTimestampMs = 0;
 let appContext: AppContext & Finalizable;
 
@@ -155,12 +109,10 @@
     injectDependency(token, provider);
   }
   document.addEventListener('request-dependency', resolveDependency);
-  // The following calls is nessecary to avoid influence of previously executed
+  // The following calls is necessary to avoid influence of previously executed
   // tests.
   initGlobalVariables(appContext);
 
-  const shortcuts = appContext.shortcutsService;
-  assert.isTrue(shortcuts._testOnly_isEmpty());
   const selection = document.getSelection();
   if (selection) {
     selection.removeAllRanges();
@@ -177,28 +129,10 @@
   _testOnlyResetGrRestApiSharedObjects();
 });
 
-// For karma always set our implementation
-// (karma doesn't provide the stub method)
-function stubImpl<
-  T extends keyof HTMLElementTagNameMap,
-  K extends keyof HTMLElementTagNameMap[T]
->(tagName: T, method: K) {
-  // This method is inspired by web-component-tester method
-  const proto = document.createElement(tagName).constructor
-    .prototype as HTMLElementTagNameMap[T];
-  const stub = sinon.stub(proto, method);
-  registerTestCleanup(() => {
-    stub.restore();
-  });
-  return stub;
-}
-
-window.stub = stubImpl;
-
 // Very simple function to catch unexpected elements in documents body.
 // It can't catch everything, but in most cases it is enough.
 function checkChildAllowed(element: Element) {
-  const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER'];
+  const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER', 'LINK'];
   if (allowedTags.includes(element.tagName)) {
     return;
   }
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
deleted file mode 100644
index fc4599d..0000000
--- a/polygerrit-ui/app/test/mocks/comment-api.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-
-/**
- * This is an "abstract" class for tests. The descendant must define a template
- * for this element and a tagName - see createCommentApiMockWithTemplateElement below
- */
-class CommentApiMock extends LegacyElementMixin(PolymerElement) {
-  static get properties() {
-    return {
-      _changeComments: Object,
-    };
-  }
-}
-
-/**
- * Creates a new element which is descendant of CommentApiMock with specified
- * template. Additionally, the method registers a tagName for this element.
- *
- * Each tagName must be a unique accross all tests.
- */
-export function createCommentApiMockWithTemplateElement(tagName, template) {
-  const elementClass = class extends CommentApiMock {
-    static get is() { return tagName; }
-
-    static get template() { return template; }
-  };
-  customElements.define(tagName, elementClass);
-  return elementClass;
-}
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index d91b438..492bd8d 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -1,19 +1,8 @@
 /* eslint-disable @typescript-eslint/no-unused-vars */
 /**
  * @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 {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {
@@ -104,6 +93,9 @@
   applyFixSuggestion(): Promise<Response> {
     return Promise.resolve(new Response());
   },
+  applyRobotFixSuggestion(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
   awaitPendingDiffDrafts(): Promise<void> {
     return Promise.resolve();
   },
@@ -280,6 +272,9 @@
   getChangesWithSimilarTopic(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
   },
+  getChangesWithSimilarHashtag(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
   getConfig(): Promise<ServerInfo | undefined> {
     return Promise.resolve(createServerInfo());
   },
@@ -405,6 +400,9 @@
   getReviewedFiles(): Promise<string[] | undefined> {
     return Promise.resolve([]);
   },
+  getFixPreview(): Promise<FilePathToDiffInfoMap | undefined> {
+    return Promise.resolve({});
+  },
   getRobotCommentFixPreview(): Promise<FilePathToDiffInfoMap | undefined> {
     return Promise.resolve({});
   },
diff --git a/polygerrit-ui/app/test/source-map-support-install.ts b/polygerrit-ui/app/test/source-map-support-install.ts
deleted file mode 100644
index b8798e2..0000000
--- a/polygerrit-ui/app/test/source-map-support-install.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.
- */
-
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and doesn't allow "declare global".
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
-
-declare global {
-  interface Window {
-    sourceMapSupport: {
-      install(): void;
-    };
-  }
-}
-
-// The karma.conf.js file loads required module before any other modules
-// The source-map-support.js can't be imported with import ... statement
-window.sourceMapSupport.install();
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 5795c4a..2cdd4a5 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 // Init app context before any other imports
@@ -27,6 +16,7 @@
 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 {FilesModel, filesModelToken} from '../models/change/files-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';
@@ -35,11 +25,44 @@
   commentsModelToken,
 } from '../models/comments/comments-model';
 import {RouterModel} from '../services/router/router-model';
-import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
+import {
+  ShortcutsService,
+  shortcutsServiceToken,
+} 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';
+import {
+  AccountsModel,
+  accountsModelToken,
+} from '../models/accounts-model/accounts-model';
+import {
+  DashboardViewModel,
+  dashboardViewModelToken,
+} from '../models/views/dashboard';
+import {
+  SettingsViewModel,
+  settingsViewModelToken,
+} from '../models/views/settings';
+import {GrRouter, routerToken} from '../elements/core/gr-router/gr-router';
+import {AdminViewModel, adminViewModelToken} from '../models/views/admin';
+import {
+  AgreementViewModel,
+  agreementViewModelToken,
+} from '../models/views/agreement';
+import {ChangeViewModel, changeViewModelToken} from '../models/views/change';
+import {DiffViewModel, diffViewModelToken} from '../models/views/diff';
+import {
+  DocumentationViewModel,
+  documentationViewModelToken,
+} from '../models/views/documentation';
+import {EditViewModel, editViewModelToken} from '../models/views/edit';
+import {GroupViewModel, groupViewModelToken} from '../models/views/group';
+import {PluginViewModel, pluginViewModelToken} from '../models/views/plugin';
+import {RepoViewModel, repoViewModelToken} from '../models/views/repo';
+import {SearchViewModel, searchViewModelToken} from '../models/views/search';
+import {navigationToken} from '../elements/core/gr-navigation/gr-navigation';
 
 export function createTestAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
@@ -62,8 +85,13 @@
       assertIsDefined(ctx.restApiService, 'restApiService');
       return new UserModel(ctx.restApiService);
     },
+    accountsModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.restApiService, 'restApiService');
+      return new AccountsModel(ctx.restApiService);
+    },
     shortcutsService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.userModel, 'userModel');
+      assertIsDefined(ctx.flagsService, 'flagsService');
       assertIsDefined(ctx.reportingService, 'reportingService');
       return new ShortcutsService(ctx.userModel, ctx.reportingService);
     },
@@ -86,10 +114,62 @@
   appContext: AppContext,
   resolver: <T>(token: DependencyToken<T>) => T
 ): Map<DependencyToken<unknown>, Creator<unknown>> {
-  const dependencies = new Map();
+  const dependencies = new Map<DependencyToken<unknown>, Creator<unknown>>();
   const browserModel = () => new BrowserModel(appContext.userModel);
   dependencies.set(browserModelToken, browserModel);
 
+  const adminViewModelCreator = () => new AdminViewModel();
+  dependencies.set(adminViewModelToken, adminViewModelCreator);
+  const agreementViewModelCreator = () => new AgreementViewModel();
+  dependencies.set(agreementViewModelToken, agreementViewModelCreator);
+  const changeViewModelCreator = () => new ChangeViewModel();
+  dependencies.set(changeViewModelToken, changeViewModelCreator);
+  const dashboardViewModelCreator = () => new DashboardViewModel();
+  dependencies.set(dashboardViewModelToken, dashboardViewModelCreator);
+  const diffViewModelCreator = () => new DiffViewModel();
+  dependencies.set(diffViewModelToken, diffViewModelCreator);
+  const documentationViewModelCreator = () => new DocumentationViewModel();
+  dependencies.set(documentationViewModelToken, documentationViewModelCreator);
+  const editViewModelCreator = () => new EditViewModel();
+  dependencies.set(editViewModelToken, editViewModelCreator);
+  const groupViewModelCreator = () => new GroupViewModel();
+  dependencies.set(groupViewModelToken, groupViewModelCreator);
+  const pluginViewModelCreator = () => new PluginViewModel();
+  dependencies.set(pluginViewModelToken, pluginViewModelCreator);
+  const repoViewModelCreator = () => new RepoViewModel();
+  dependencies.set(repoViewModelToken, repoViewModelCreator);
+  const searchViewModelCreator = () => new SearchViewModel();
+  dependencies.set(searchViewModelToken, searchViewModelCreator);
+  const settingsViewModelCreator = () => new SettingsViewModel();
+  dependencies.set(settingsViewModelToken, settingsViewModelCreator);
+
+  const routerCreator = () =>
+    new GrRouter(
+      appContext.reportingService,
+      appContext.routerModel,
+      appContext.restApiService,
+      resolver(adminViewModelToken),
+      resolver(agreementViewModelToken),
+      resolver(changeViewModelToken),
+      resolver(dashboardViewModelToken),
+      resolver(diffViewModelToken),
+      resolver(documentationViewModelToken),
+      resolver(editViewModelToken),
+      resolver(groupViewModelToken),
+      resolver(pluginViewModelToken),
+      resolver(repoViewModelToken),
+      resolver(searchViewModelToken),
+      resolver(settingsViewModelToken)
+    );
+  dependencies.set(routerToken, routerCreator);
+  dependencies.set(navigationToken, () => {
+    return {
+      setUrl: () => {},
+      replaceUrl: () => {},
+      finalize: () => {},
+    };
+  });
+
   const changeModelCreator = () =>
     new ChangeModel(
       appContext.routerModel,
@@ -98,15 +178,28 @@
     );
   dependencies.set(changeModelToken, changeModelCreator);
 
+  const accountsModelCreator = () =>
+    new AccountsModel(appContext.restApiService);
+  dependencies.set(accountsModelToken, accountsModelCreator);
+
   const commentsModelCreator = () =>
     new CommentsModel(
       appContext.routerModel,
       resolver(changeModelToken),
+      resolver(accountsModelToken),
       appContext.restApiService,
       appContext.reportingService
     );
   dependencies.set(commentsModelToken, commentsModelCreator);
 
+  const filesModelCreator = () =>
+    new FilesModel(
+      resolver(changeModelToken),
+      resolver(commentsModelToken),
+      appContext.restApiService
+    );
+  dependencies.set(filesModelToken, filesModelCreator);
+
   const configModelCreator = () =>
     new ConfigModel(resolver(changeModelToken), appContext.restApiService);
   dependencies.set(configModelToken, configModelCreator);
@@ -114,6 +207,7 @@
   const checksModelCreator = () =>
     new ChecksModel(
       appContext.routerModel,
+      resolver(changeViewModelToken),
       resolver(changeModelToken),
       appContext.reportingService,
       appContext.pluginsModel
@@ -121,5 +215,9 @@
 
   dependencies.set(checksModelToken, checksModelCreator);
 
+  const shortcutServiceCreator = () =>
+    new ShortcutsService(appContext.userModel, appContext.reportingService);
+  dependencies.set(shortcutsServiceToken, shortcutServiceCreator);
+
   return dependencies;
 }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 8c33c79..4e29f53 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {
   AccountDetailInfo,
@@ -22,6 +11,7 @@
   ApprovalInfo,
   AuthInfo,
   BasePatchSetNum,
+  BlameInfo,
   BranchName,
   ChangeConfigInfo,
   ChangeId,
@@ -39,7 +29,7 @@
   ConfigInfo,
   DownloadInfo,
   EditInfo,
-  EditPatchSetNum,
+  EDIT,
   EmailAddress,
   FixId,
   FixSuggestionInfo,
@@ -55,7 +45,8 @@
   MaxObjectSizeLimitInfo,
   MergeableInfo,
   NumericChangeId,
-  PatchSetNum,
+  PARENT,
+  PatchRange,
   PluginConfigInfo,
   PreferencesInfo,
   RelatedChangeAndCommitInfo,
@@ -65,6 +56,7 @@
   RequirementType,
   Reviewers,
   RevisionInfo,
+  RevisionPatchSetNum,
   RobotCommentInfo,
   RobotId,
   RobotRunId,
@@ -80,6 +72,7 @@
 } from '../types/common';
 import {
   AccountsVisibility,
+  AccountTag,
   AppTheme,
   AuthType,
   ChangeStatus,
@@ -98,10 +91,6 @@
 } from '../constants/constants';
 import {formatDate} from '../utils/date-util';
 import {GetDiffCommentsOutput} from '../services/gr-rest-api/gr-rest-api';
-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 {
@@ -114,17 +103,20 @@
 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 {GenerateUrlEditViewParameters} from '../elements/core/gr-navigation/gr-navigation';
 import {
   DetailedLabelInfo,
+  FileInfo,
   QuickLabelInfo,
   SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../api/rest-api';
-import {RunResult} from '../models/checks/checks-model';
+import {CheckResult, RunResult} from '../models/checks/checks-model';
 import {Category, RunStatus} from '../api/checks';
 import {DiffInfo} from '../api/diff';
+import {SearchViewState} from '../models/views/search';
+import {ChangeViewState} from '../models/views/change';
+import {EditViewState} from '../models/views/edit';
 
 const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN';
 export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
@@ -192,6 +184,14 @@
 export function createAccountWithId(id = 5): AccountInfo {
   return {
     _account_id: id as AccountId,
+    email: `${id}` as EmailAddress,
+  };
+}
+
+export function createServiceUserWithId(id = 5): AccountInfo {
+  return {
+    ...createAccountWithId(id),
+    tags: [AccountTag.SERVICE_USER],
   };
 }
 
@@ -205,6 +205,13 @@
 export function createAccountWithEmail(email = 'test@'): AccountInfo {
   return {
     email: email as EmailAddress,
+    _account_id: 1 as AccountId,
+  };
+}
+
+export function createAccountWithEmailOnly(email = 'test@'): AccountInfo {
+  return {
+    email: email as EmailAddress,
   };
 }
 
@@ -283,12 +290,22 @@
   };
 }
 
+export function createPatchRange(
+  basePatchNum?: number,
+  patchNum?: number
+): PatchRange {
+  return {
+    basePatchNum: (basePatchNum ?? PARENT) as BasePatchSetNum,
+    patchNum: (patchNum ?? 1) as RevisionPatchSetNum,
+  };
+}
+
 export function createRevision(
-  patchSetNum = 1,
+  patchSetNum: number | RevisionPatchSetNum = 1,
   description = ''
 ): RevisionInfo {
   return {
-    _number: patchSetNum as PatchSetNum,
+    _number: patchSetNum as RevisionPatchSetNum,
     commit: createCommit(),
     created: dateToTimestamp(TEST_CHANGE_CREATED),
     kind: RevisionKind.REWORK,
@@ -311,7 +328,7 @@
 
 export function createEditRevision(basePatchNum = 1): EditRevisionInfo {
   return {
-    _number: EditPatchSetNum,
+    _number: EDIT,
     basePatchNum: basePatchNum as BasePatchSetNum,
     commit: {
       ...createCommit(),
@@ -378,6 +395,13 @@
   return messages;
 }
 
+export function createFileInfo(): FileInfo {
+  return {
+    size: 314,
+    size_delta: 7,
+  };
+}
+
 export function createChange(): ChangeInfo {
   return {
     id: TEST_CHANGE_INFO_ID,
@@ -494,6 +518,24 @@
   };
 }
 
+export function createEmptyDiff(): DiffInfo {
+  return {
+    meta_a: {
+      name: 'empty-left.txt',
+      content_type: 'text/plain',
+      lines: 1,
+    },
+    meta_b: {
+      name: 'empty-right.txt',
+      content_type: 'text/plain',
+      lines: 1,
+    },
+    intraline_status: 'OK',
+    change_type: 'MODIFIED',
+    content: [],
+  };
+}
+
 export function createDiff(): DiffInfo {
   return {
     meta_a: {
@@ -549,10 +591,15 @@
         ],
       },
       {
-        ab: [
+        a: [
+          'Arcu eget, rhoncus amet cursus, ipsum elementum.  ',
+          'Eros suspendisse.  ',
+        ],
+        b: [
           'Arcu eget, rhoncus amet cursus, ipsum elementum.',
           'Eros suspendisse.',
         ],
+        common: true,
       },
       {
         a: ['Rhoncus tempor, ultricies aliquam ipsum.'],
@@ -610,6 +657,16 @@
   };
 }
 
+export function createBlame(): BlameInfo {
+  return {
+    author: 'test-author',
+    id: 'test-id',
+    time: 123,
+    commit_msg: 'test-commit-message',
+    ranges: [],
+  };
+}
+
 export function createMergeable(): MergeableInfo {
   return {
     submit_type: SubmitType.MERGE_IF_NECESSARY,
@@ -621,7 +678,7 @@
 export function createPreferences(): PreferencesInfo {
   return {
     changes_per_page: 10,
-    theme: AppTheme.LIGHT,
+    theme: AppTheme.AUTO,
     date_format: DateFormat.ISO,
     time_format: TimeFormat.HHMM_24,
     diff_view: DiffViewMode.SIDE_BY_SIDE,
@@ -629,14 +686,15 @@
     change_table: [],
     email_strategy: EmailStrategy.ENABLED,
     default_base_for_merges: DefaultBase.AUTO_MERGE,
+    allow_browser_notifications: true,
   };
 }
 
-export function createApproval(): ApprovalInfo {
-  return createAccountWithId();
+export function createApproval(account?: AccountInfo): ApprovalInfo {
+  return account ?? createAccountWithId();
 }
 
-export function createAppElementChangeViewParams(): AppElementChangeViewParams {
+export function createChangeViewState(): ChangeViewState {
   return {
     view: GerritView.CHANGE,
     changeNum: TEST_NUMERIC_CHANGE_ID,
@@ -644,7 +702,7 @@
   };
 }
 
-export function createAppElementSearchViewParams(): AppElementSearchParam {
+export function createAppElementSearchViewParams(): SearchViewState {
   return {
     view: GerritView.SEARCH,
     query: TEST_NUMERIC_CHANGE_ID.toString(),
@@ -652,11 +710,11 @@
   };
 }
 
-export function createGenerateUrlEditViewParameters(): GenerateUrlEditViewParameters {
+export function createEditViewState(): EditViewState {
   return {
     view: GerritView.EDIT,
     changeNum: TEST_NUMERIC_CHANGE_ID,
-    patchNum: EditPatchSetNum as PatchSetNum,
+    patchNum: EDIT,
     path: 'foo/bar.baz',
     project: TEST_PROJECT_NAME,
   };
@@ -691,7 +749,7 @@
   extra: Partial<CommentInfo | DraftInfo> = {}
 ): CommentInfo {
   return {
-    patch_set: 1 as PatchSetNum,
+    patch_set: 1 as RevisionPatchSetNum,
     id: '12345' as UrlEncodedCommentId,
     side: CommentSide.REVISION,
     line: 1,
@@ -751,7 +809,7 @@
       },
       {
         ...createComment(),
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         message: 'hello',
         updated: '2017-02-10 16:40:49' as Timestamp,
         id: '3' as UrlEncodedCommentId,
@@ -766,14 +824,14 @@
       },
       {
         ...createComment(),
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         message: 'wat!?',
         updated: '2017-02-09 16:40:49' as Timestamp,
         id: '5' as UrlEncodedCommentId,
       },
       {
         ...createComment(),
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         message: 'hi',
         updated: '2017-02-10 16:40:49' as Timestamp,
         id: '6' as UrlEncodedCommentId,
@@ -782,7 +840,7 @@
     'unresolved.file': [
       {
         ...createComment(),
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         message: 'wat!?',
         updated: '2017-02-09 16:40:49' as Timestamp,
         id: '7' as UrlEncodedCommentId,
@@ -790,7 +848,7 @@
       },
       {
         ...createComment(),
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         message: 'hi',
         updated: '2017-02-10 16:40:49' as Timestamp,
         id: '8' as UrlEncodedCommentId,
@@ -799,7 +857,7 @@
       },
       {
         ...createComment(),
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         message: 'good news!',
         updated: '2017-02-08 16:40:49' as Timestamp,
         id: '9' as UrlEncodedCommentId,
@@ -845,12 +903,14 @@
     rootId: 'test-root-id-comment-thread' as UrlEncodedCommentId,
     path: 'test-path-comment-thread',
     commentSide: CommentSide.REVISION,
-    patchNum: 1 as PatchSetNum,
+    patchNum: 1 as RevisionPatchSetNum,
     line: 314,
   };
 }
 
-export function createCommentThread(comments: Array<Partial<CommentInfo>>) {
+export function createCommentThread(
+  comments: Array<Partial<CommentInfo | DraftInfo>>
+) {
   if (!comments.length) {
     throw new Error('comment is required to create a thread');
   }
@@ -924,8 +984,8 @@
   return {
     expression,
     fulfilled: true,
-    passing_atoms: ['label2:verified=MAX'],
-    failing_atoms: ['label2:verified=MIN'],
+    passing_atoms: ['label:Verified=MAX'],
+    failing_atoms: ['label:Verified=MIN'],
   };
 }
 
@@ -967,6 +1027,14 @@
   };
 }
 
+export function createCheckResult(): CheckResult {
+  return {
+    category: Category.ERROR,
+    summary: 'error',
+    internalResultId: 'test-internal-result-id',
+  };
+}
+
 export function createDetailedLabelInfo(): DetailedLabelInfo {
   return {
     values: {
diff --git a/polygerrit-ui/app/test/test-router.ts b/polygerrit-ui/app/test/test-router.ts
deleted file mode 100644
index a378e2d..0000000
--- a/polygerrit-ui/app/test/test-router.ts
+++ /dev/null
@@ -1,28 +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 {GerritNav} from '../elements/core/gr-navigation/gr-navigation';
-
-GerritNav.setup(
-  () => {
-    /* noop */
-  },
-  () => '',
-  () => [],
-  () => {
-    return {};
-  }
-);
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 985bec1..d6ad434 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.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 '../types/globals';
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
@@ -24,13 +13,13 @@
 import {AuthService} from '../services/gr-auth/gr-auth';
 import {ReportingService} from '../services/gr-reporting/gr-reporting';
 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';
+import {assert} from '@open-wc/testing';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
 
 export interface MockPromise<T> extends Promise<T> {
@@ -124,10 +113,6 @@
   return sinon.stub(getAppContext().userModel, 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
 ) {
@@ -154,6 +139,20 @@
   return sinon.stub(getAppContext().flagsService, method);
 }
 
+export function stubElement<
+  T extends keyof HTMLElementTagNameMap,
+  K extends keyof HTMLElementTagNameMap[T]
+>(tagName: T, method: K) {
+  // This method is inspired by web-component-tester method
+  const proto = document.createElement(tagName).constructor
+    .prototype as HTMLElementTagNameMap[T];
+  const stub = sinon.stub(proto, method);
+  registerTestCleanup(() => {
+    stub.restore();
+  });
+  return stub;
+}
+
 export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
   Parameters<F>,
   ReturnType<F>
@@ -195,21 +194,22 @@
   return queryAndAssert<E>(el, selector);
 }
 
-export function waitUntil(
-  predicate: () => boolean,
-  message = 'The waitUntil() predicate is still false after 1000 ms.'
+export async function waitUntil(
+  predicate: (() => boolean) | (() => Promise<boolean>),
+  message = 'The waitUntil() predicate is still false after 1000 ms.',
+  timeout_ms = 1000
 ): Promise<void> {
   const start = Date.now();
   let sleep = 0;
-  if (predicate()) return Promise.resolve();
+  if (await predicate()) return Promise.resolve();
   const error = new Error(message);
   return new Promise((resolve, reject) => {
-    const waiter = () => {
-      if (predicate()) {
+    const waiter = async () => {
+      if (await predicate()) {
         resolve();
         return;
       }
-      if (Date.now() - start >= 1000) {
+      if (Date.now() - start >= timeout_ms) {
         reject(error);
         return;
       }
@@ -246,6 +246,20 @@
 }
 
 /**
+ * sinon.useFakeTimers() overwrites window.setTimeout with a controlled,
+ * synchronous version for tests to use. Keep the original one for use in
+ * waitEventLoop
+ */
+const nativeSetTimeout = window.setTimeout;
+/**
+ * Wait for the current event loop's tasks to complete by scheduling a promise
+ * to resolve during the next loop. Prefer other wait methods over this one to
+ * wait for specific work to be done or for specific states to exist.
+ */
+export function waitEventLoop(): Promise<void> {
+  return new Promise(resolve => nativeSetTimeout(resolve, 0));
+}
+/**
  * Promisify an event callback to simplify async...await tests.
  *
  * Use like this:
@@ -287,6 +301,7 @@
   const eventOptions = {
     key,
     bubbles: true,
+    cancelable: true,
     composed: true,
     altKey: modifiers.includes(Modifier.ALT_KEY),
     ctrlKey: modifiers.includes(Modifier.CTRL_KEY),
@@ -310,7 +325,7 @@
 }
 
 export function assertFails(promise: Promise<unknown>, error?: unknown) {
-  promise
+  return promise
     .then((_v: unknown) => {
       assert.fail('Promise resolved but should have failed');
     })
@@ -318,5 +333,22 @@
       if (error) {
         assert.equal(e, error);
       }
+      return e;
     });
 }
+
+export function logProxy<T extends object>(obj: T, name?: string): T {
+  const handler = {
+    get(target: object, prop: PropertyKey, receiver: any) {
+      const result = Reflect.get(target, prop, receiver);
+      if (result instanceof Function) {
+        return (...rest: unknown[]) => {
+          console.error(`${name}.${String(prop)}(${rest})`);
+          return result.apply(target, rest);
+        };
+      }
+      return result;
+    },
+  };
+  return new Proxy(obj, handler) as unknown as T;
+}
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 3e68d6c..0ddf130 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -20,6 +20,7 @@
     "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
     "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
     "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
+    "isolatedModules": true, /* Require re-exports of types to use "export type {...}" syntax, for dev server compatibility (esbuild) */
 
     /* Additional Checks */
     "noUnusedLocals": true, /* Report errors on unused locals. */
@@ -46,7 +47,7 @@
     "lib": [
       "dom",
       "dom.iterable",
-      "es2019",
+      "es2020",
       "webworker"
     ],
 
@@ -102,7 +103,6 @@
     "types/**/*",
     "utils/**/*",
     "test/**/*",
-    "workers/**/*",
-    "tmpl_out/**/*" //Created by template checker in dev-mode
+    "workers/**/*"
   ]
 }
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index 096bd2f..c6a940b 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -2,15 +2,9 @@
   "extends": "./tsconfig_bazel.json",
   "compilerOptions": {
     "typeRoots": [
-      "../../external/ui_dev_npm/node_modules/@polymer/iron-test-helpers",
       "../../external/ui_npm/node_modules/@types",
       "../../external/ui_dev_npm/node_modules/@types"
-    ],
-    "paths": {
-      "@polymer/iron-test-helpers/*": [
-        "../../ui_dev_npm/node_modules/@polymer/iron-test-helpers/*"
-      ]
-    }
+    ]
   },
   "include": [
     // Items below must be in sync with the src_dirs list in the BUILD file
diff --git a/polygerrit-ui/app/types/aria-mixin.ts b/polygerrit-ui/app/types/aria-mixin.ts
index 6ae8c2a..eb9f417 100644
--- a/polygerrit-ui/app/types/aria-mixin.ts
+++ b/polygerrit-ui/app/types/aria-mixin.ts
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 export {};
 
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index c36fa8a..008a8de 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {CommentRange} from '../api/core';
 import {
@@ -68,6 +57,8 @@
   DetailedLabelInfo,
   DownloadInfo,
   DownloadSchemeInfo,
+  EDIT,
+  EditPatchSet,
   EmailAddress,
   FetchInfo,
   FileInfo,
@@ -92,7 +83,9 @@
   MaxObjectSizeLimitInfo,
   NumericChangeId,
   ParentCommitInfo,
+  PARENT,
   PatchSetNum,
+  PatchSetNumber,
   PluginConfigInfo,
   PluginNameToPluginParametersMap,
   PluginParameterToConfigParameterInfoMap,
@@ -108,6 +101,7 @@
   ReviewerUpdateInfo,
   Reviewers,
   RevisionInfo,
+  RevisionPatchSetNum,
   SchemesInfoMap,
   ServerInfo,
   SubmitTypeInfo,
@@ -121,10 +115,11 @@
   WebLinkInfo,
   isDetailedLabelInfo,
   isQuickLabelInfo,
+  Base64FileContent,
 } from '../api/rest-api';
 import {DiffInfo, IgnoreWhitespaceType} from './diff';
 
-export {
+export type {
   AccountId,
   AccountDetailInfo,
   AccountInfo,
@@ -134,6 +129,7 @@
   ApprovalInfo,
   AuthInfo,
   AvatarInfo,
+  Base64FileContent,
   BasePatchSetNum,
   BranchName,
   BrandType,
@@ -158,6 +154,7 @@
   DetailedLabelInfo,
   DownloadInfo,
   DownloadSchemeInfo,
+  EditPatchSet,
   EmailAddress,
   FileInfo,
   GerritInfo,
@@ -182,6 +179,7 @@
   NumericChangeId,
   ParentCommitInfo,
   PatchSetNum,
+  PatchSetNumber,
   PluginConfigInfo,
   PluginNameToPluginParametersMap,
   PluginParameterToConfigParameterInfoMap,
@@ -196,6 +194,7 @@
   ReviewerUpdateInfo,
   Reviewers,
   RevisionInfo,
+  RevisionPatchSetNum,
   SchemesInfoMap,
   ServerInfo,
   SubmitTypeInfo,
@@ -207,9 +206,8 @@
   UserConfigInfo,
   VotingRangeInfo,
   WebLinkInfo,
-  isDetailedLabelInfo,
-  isQuickLabelInfo,
 };
+export {EDIT, PARENT, isDetailedLabelInfo, isQuickLabelInfo};
 
 /*
  * In T, make a set of properties whose keys are in the union K required
@@ -229,16 +227,6 @@
  */
 export type ParsedJSON = BrandType<unknown, '_parsedJSON'>;
 
-export type RevisionPatchSetNum = BrandType<'edit' | number, '_patchSet'>;
-
-export type PatchSetNumber = BrandType<number, '_patchSet'>;
-
-export const EditPatchSetNum = 'edit' as RevisionPatchSetNum;
-
-// TODO(TS): This is not correct, it is better to have a separate ApiPatchSetNum
-// without 'parent'.
-export const ParentPatchSetNum = 'PARENT' as BasePatchSetNum;
-
 export type RobotId = BrandType<string, '_robotId'>;
 
 export type RobotRunId = BrandType<string, '_robotRunId'>;
@@ -264,6 +252,8 @@
 // The Encoded UUID of the group
 export type EncodedGroupId = BrandType<string, '_encodedGroupId'>;
 
+export type UserId = AccountId | GroupId | EmailAddress;
+
 // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
 export interface ContributorAgreementInput {
   name?: string;
@@ -697,7 +687,7 @@
   id: UrlEncodedCommentId;
   updated: Timestamp;
   // TODO(TS): Make this required. Every comment must have patch_set set.
-  patch_set?: PatchSetNum;
+  patch_set?: RevisionPatchSetNum;
   path?: string;
   side?: CommentSide;
   parent?: number;
@@ -934,16 +924,6 @@
 }
 
 /**
- * Represent a file in a base64 encoding; GrRestApiInterface returns it from some
- * methods
- */
-export interface Base64FileContent {
-  content: string | null;
-  type: string | null;
-  ok: true;
-}
-
-/**
  * The WatchedProjectsInfo entity contains information about a project watch for a user
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#project-watch-info
  */
@@ -1142,6 +1122,7 @@
   work_in_progress_by_default?: boolean;
   // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
   email_format?: EmailFormat;
+  allow_browser_notifications?: boolean;
 }
 
 /**
@@ -1192,8 +1173,7 @@
  */
 export interface ReviewResult {
   labels?: unknown;
-  // type of key is (AccountId | GroupId | EmailAddress)
-  reviewers?: {[key: string]: AddReviewerResult};
+  reviewers?: {[key: UserId]: AddReviewerResult};
   ready?: boolean;
 }
 
@@ -1204,7 +1184,7 @@
  * TODO(paiking): update this to ReviewerResult while considering removals.
  */
 export interface AddReviewerResult {
-  input: AccountId | GroupId | EmailAddress;
+  input: UserId;
   reviewers?: AccountInfo[];
   ccs?: AccountInfo[];
   error?: string;
@@ -1265,6 +1245,15 @@
 }
 
 /**
+ * The ApplyProvidedFixInput entity contains information for applying fixes, provided in the
+ * request body, to a revision.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#apply-provided-fix
+ */
+export interface ApplyProvidedFixInput {
+  fix_replacement_infos: FixReplacementInfo[];
+}
+
+/**
  * The FixReplacementInfo entity describes how the content of a file should be replaced by another content
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fix-replacement-info
  */
@@ -1287,7 +1276,7 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#reviewer-input
  */
 export interface ReviewerInput {
-  reviewer: AccountId | GroupId | EmailAddress;
+  reviewer: UserId;
   state?: ReviewerState;
   confirmed?: boolean;
   notify?: NotifyType;
@@ -1299,7 +1288,7 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#attention-set-input
  */
 export interface AttentionSetInput {
-  user: AccountId;
+  user: UserId;
   reason: string;
   notify?: NotifyType;
   notify_details?: RecipientTypeToNotifyInfoMap;
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index 562d47f..c03a167 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -6,19 +6,8 @@
  * internal fields that Gerrit may use.
  *
  * @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 {WebLinkInfo} from '../api/rest-api';
 import {
@@ -35,7 +24,7 @@
   SkipLength,
 } from '../api/diff';
 
-export {
+export type {
   ChangeType,
   DiffIntralineInfo,
   DiffResponsiveMode,
@@ -48,9 +37,9 @@
 
 export interface DiffInfo extends DiffInfoApi {
   /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
-  meta_a: DiffFileMetaInfo;
+  meta_a?: DiffFileMetaInfo;
   /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
-  meta_b: DiffFileMetaInfo;
+  meta_b?: DiffFileMetaInfo;
 
   /**
    * Links to the file diff in external sites as a list of DiffWebLinkInfo
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 6ad9e71..496513d 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -1,21 +1,10 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PatchSetNum} from './common';
-import {ChangeMessage, Comment} from '../utils/comment-util';
+import {FixSuggestionInfo, PatchSetNum} from './common';
+import {ChangeMessage} from '../utils/comment-util';
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
@@ -47,7 +36,7 @@
   SHORTCUT_TRIGGERERD = 'shortcut-triggered',
   SHOW_ALERT = 'show-alert',
   SHOW_ERROR = 'show-error',
-  SHOW_PRIMARY_TAB = 'show-primary-tab',
+  SHOW_TAB = 'show-tab',
   SHOW_SECONDARY_TAB = 'show-secondary-tab',
   TAP_ITEM = 'tap-item',
   TITLE_CHANGE = 'title-change',
@@ -77,14 +66,14 @@
     'moved-link-clicked': MovedLinkClickedEvent;
     'open-fix-preview': OpenFixPreviewEvent;
     'close-fix-preview': CloseFixPreviewEvent;
-    'create-fix-comment': CreateFixCommentEvent;
+    'reply-to-comment': ReplyToCommentEvent;
     /* prettier-ignore */
     'reload': ReloadEvent;
     /* prettier-ignore */
     'reply': ReplyEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
-    'show-primary-tab': SwitchTabEvent;
+    'show-tab': SwitchTabEvent;
     'show-secondary-tab': SwitchTabEvent;
     'tap-item': TapItemEvent;
     'title-change': TitleChangeEvent;
@@ -105,7 +94,7 @@
 }
 
 export interface BindValueChangeEventDetail {
-  value: string;
+  value: string | undefined;
 }
 export type BindValueChangeEvent = CustomEvent<BindValueChangeEventDetail>;
 
@@ -166,8 +155,8 @@
 export type NetworkErrorEvent = CustomEvent<NetworkErrorEventDetail>;
 
 export interface OpenFixPreviewEventDetail {
-  patchNum?: PatchSetNum;
-  comment?: Comment;
+  patchNum: PatchSetNum;
+  fixSuggestions: FixSuggestionInfo[];
 }
 export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
 
@@ -175,11 +164,12 @@
   fixApplied: boolean;
 }
 export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
-export interface CreateFixCommentEventDetail {
-  patchNum?: PatchSetNum;
-  comment?: Comment;
+export interface ReplyToCommentEventDetail {
+  content: string;
+  userWantsToEdit: boolean;
+  unresolved: boolean;
 }
-export type CreateFixCommentEvent = CustomEvent<CreateFixCommentEventDetail>;
+export type ReplyToCommentEvent = CustomEvent<ReplyToCommentEventDetail>;
 
 export interface PageErrorEventDetail {
   response?: Response;
@@ -219,9 +209,7 @@
 // Type for the custom event to switch tab.
 export interface SwitchTabEventDetail {
   // name of the tab to set as active, from custom event
-  tab?: string;
-  // index of tab to set as active, from paper-tabs event
-  value?: number;
+  tab: string;
   // scroll into the tab afterwards, from custom event
   scrollIntoView?: boolean;
   // define state of tab after opening
@@ -235,16 +223,11 @@
   UNRESOLVED = 'unresolved',
   DRAFTS = 'drafts',
   SHOW_ALL = 'show all',
+  MENTIONS = 'mentions',
 }
 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>;
 
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index b5bd2aa..554fa23 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {ParsedJSON} from './common';
 import {HighlightJS} from './types';
@@ -29,12 +18,6 @@
       options: {callback: (text: string, href?: string) => void}
     ): void;
     ASSETS_PATH?: string;
-    // TODO(TS): define polymer type
-    Polymer: {
-      IronFocusablesHelper: {
-        getTabbableNodes: (el: Element) => Node[];
-      };
-    };
     // TODO(TS): remove page when better workaround is found
     // page shouldn't be exposed in window and it shouldn't be used
     // it's defined because of limitations from typescript, which don't import .mjs
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index ab78015..320ac66 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -1,39 +1,23 @@
 /**
  * @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 {DiffLayer as DiffLayerApi} from '../api/diff';
-import {DiffViewMode, MessageTag, Side} from '../constants/constants';
+import {MessageTag, Side} from '../constants/constants';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
-import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {
   AccountInfo,
   BasePatchSetNum,
-  ChangeId,
   ChangeViewChangeInfo,
   CommitId,
   CommitInfo,
-  NumericChangeId,
-  PatchRange,
+  EditPatchSet,
   PatchSetNum,
   ReviewerUpdateInfo,
   RevisionInfo,
   Timestamp,
 } from './common';
-import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {AuthRequestInit} from '../services/gr-auth/gr-auth';
 
 export function notUndefined<T>(x: T | undefined): x is T {
@@ -49,7 +33,8 @@
   commit: CommitId;
 }
 
-export {CoverageRange, CoverageType} from '../api/diff';
+export type {CoverageRange} from '../api/diff';
+export {CoverageType} from '../api/diff';
 
 export enum ErrorType {
   AUTH = 'AUTH',
@@ -57,101 +42,11 @@
   GENERIC = 'GENERIC',
 }
 
-/**
- * We would like to access the the typed `nativeInput` of PaperInputElement, so
- * we are creating this wrapper.
- */
-export type PaperInputElementExt = PaperInputElement & {
-  $: {nativeInput?: Element};
-};
-
-/**
- * If Polymer would have exported DomApiNative from its dom.js utility, then we
- * would probably not need this type. We just use it for casting the return
- * value of dom(element).
- */
-export interface PolymerDomWrapper {
-  getOwnerRoot(): Node & OwnerRoot;
-  getEffectiveChildNodes(): Node[];
-  observeNodes(
-    callback: (p0: {
-      target: HTMLElement;
-      addedNodes: Element[];
-      removedNodes: Element[];
-    }) => void
-  ): FlattenedNodesObserver;
-  unobserveNodes(observerHandle: FlattenedNodesObserver): void;
-}
-
+/*
 export interface OwnerRoot {
   host?: HTMLElement;
 }
 
-/**
- * Event type for an event fired by Polymer for an element generated from a
- * dom-repeat template.
- */
-export interface PolymerDomRepeatEvent<TModel = unknown> extends Event {
-  model: PolymerDomRepeatEventModel<TModel>;
-}
-
-/**
- * Event type for an event fired by Polymer for an element generated from a
- * dom-repeat template.
- */
-export interface PolymerDomRepeatCustomEvent<
-  TModel = unknown,
-  TDetail = unknown
-> extends CustomEvent<TDetail> {
-  model: PolymerDomRepeatEventModel<TModel>;
-}
-
-/**
- * Model containing additional information about the dom-repeat element
- * that fired an event.
- *
- * Note: This interface is valid only if both dom-repeat properties 'as' and
- * 'indexAs' have default values ('item' and 'index' correspondingly)
- */
-export interface PolymerDomRepeatEventModel<T> {
-  /**
-   * The item corresponding to the element in the dom-repeat.
-   */
-  item: T;
-
-  /**
-   * The index of the element in the dom-repeat.
-   */
-  index: number;
-  get(name: 'item'): T;
-  // Typed get for item.prop_name
-  get<K extends keyof T>(name: `item.${K extends string ? K : never}`): T[K];
-  // Typed get for item.prop_name.nested_prop_name
-  get<K1 extends keyof T, K2 extends keyof T[K1]>(
-    name: `item.${K1 extends string ? K1 : never}.${K2 extends string
-      ? K2
-      : never}`
-  ): T[K1][K2];
-  // Untyped get for other cases
-  get(name: string): unknown; // force get(...) as Type for nested properties
-
-  set(name: 'item', val: T): void;
-  // Typed set for item.prop_name
-  set<K extends keyof T>(
-    name: `item.${K extends string ? K : never}`,
-    val: T[K]
-  ): void;
-  // Typed get for item.prop_name.nested_prop_name
-  set<K1 extends keyof T, K2 extends keyof T[K1]>(
-    name: `item.${K1 extends string ? K1 : never}.${K2 extends string
-      ? K2
-      : never}`,
-    val: T[K1][K2]
-  ): void;
-  // Untyped set for other cases
-  set(name: string, val: unknown): void;
-}
-
 /** https://highlightjs.readthedocs.io/en/latest/api.html */
 export interface HighlightJSResult {
   value: string;
@@ -183,39 +78,6 @@
   removeListener?(listener: DiffLayerListener): void;
 }
 
-export interface ChangeViewState {
-  changeNum: NumericChangeId | null;
-  patchRange: PatchRange | null;
-  selectedFileIndex: number;
-  showReplyDialog: boolean;
-  diffMode: DiffViewMode | null;
-  numFilesShown: number | null;
-}
-
-export interface ChangeListViewState {
-  changeNum?: ChangeId;
-  patchRange?: PatchRange;
-  // TODO(TS): seems only one of 2 selected... is required
-  selectedFileIndex?: number;
-  selectedChangeIndex?: number;
-  showReplyDialog?: boolean;
-  diffMode?: DiffViewMode;
-  numFilesShown?: number;
-  scrollTop?: number;
-  query?: string | null;
-  offset?: number;
-}
-
-export interface DashboardViewState {
-  [key: string]: number;
-}
-
-export interface ViewState {
-  changeView: ChangeViewState;
-  changeListView: ChangeListViewState;
-  dashboardView: DashboardViewState;
-}
-
 export interface PatchSetFile {
   path: string;
   basePath?: string;
@@ -237,13 +99,6 @@
   path: string;
 }
 
-export function isPolymerSpliceChange<
-  T,
-  U extends Array<{} | null | undefined>
->(x: T | PolymerSpliceChange<U>): x is PolymerSpliceChange<U> {
-  return (x as PolymerSpliceChange<U>).indexSplices !== undefined;
-}
-
 export interface FetchRequest {
   url: string;
   fetchOptions?: AuthRequestInit;
@@ -260,7 +115,7 @@
 
 export interface EditRevisionInfo extends Partial<RevisionInfo> {
   // EditRevisionInfo has less required properties then RevisionInfo
-  _number: PatchSetNum;
+  _number: EditPatchSet;
   basePatchNum: BasePatchSetNum;
   commit: CommitInfo;
 }
diff --git a/polygerrit-ui/app/utils/access-util.ts b/polygerrit-ui/app/utils/access-util.ts
index e66bbb1..a567dd9 100644
--- a/polygerrit-ui/app/utils/access-util.ts
+++ b/polygerrit-ui/app/utils/access-util.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 {GitRef, LabelName} from '../types/common';
 
diff --git a/polygerrit-ui/app/utils/access-util_test.ts b/polygerrit-ui/app/utils/access-util_test.ts
index f098d89..be429bd 100644
--- a/polygerrit-ui/app/utils/access-util_test.ts
+++ b/polygerrit-ui/app/utils/access-util_test.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 '../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
 import {toSortedPermissionsArray} from './access-util';
 
 suite('access-util tests', () => {
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index b7cc77b..207152c 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -1,20 +1,8 @@
 /**
  * @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 {
   AccountId,
   AccountInfo,
@@ -23,47 +11,32 @@
   GroupId,
   GroupInfo,
   isAccount,
+  isDetailedLabelInfo,
   isGroup,
+  NumericChangeId,
   ReviewerInput,
   ServerInfo,
+  UserId,
 } from '../types/common';
 import {AccountTag, ReviewerState} from '../constants/constants';
-import {assertNever} from './common-util';
-import {AccountAddition} from '../elements/shared/gr-account-list/gr-account-list';
-import {getDisplayName} from './display-name-util';
+import {assertNever, hasOwnProperty} from './common-util';
+import {getAccountDisplayName, getDisplayName} from './display-name-util';
+import {getApprovalInfo} from './label-util';
+import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
+import {ParsedChangeInfo} from '../types/types';
 
 export const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
+const SUGGESTIONS_LIMIT = 15;
+// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
+export const MENTIONS_REGEX =
+  /(?:^|\s)@([a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?=\s+|$)/g;
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
-  if (account._account_id) return account._account_id;
+  if (account._account_id !== undefined) return account._account_id;
   if (account.email) return account.email;
   throw new Error('Account has neither _account_id nor email.');
 }
 
-export function mapReviewer(addition: AccountAddition): ReviewerInput {
-  if (addition.account) {
-    return {reviewer: accountKey(addition.account)};
-  }
-  if (addition.group) {
-    const reviewer = decodeURIComponent(addition.group.id) as GroupId;
-    const confirmed = addition.group.confirmed;
-    return {reviewer, confirmed};
-  }
-  throw new Error('Reviewer must be either an account or a group.');
-}
-
-export function isReviewerOrCC(
-  change: ChangeInfo,
-  reviewerAddition: AccountAddition
-): boolean {
-  const reviewers = [
-    ...(change.reviewers[ReviewerState.CC] ?? []),
-    ...(change.reviewers[ReviewerState.REVIEWER] ?? []),
-  ];
-  const reviewer = mapReviewer(reviewerAddition);
-  return reviewers.some(r => accountOrGroupKey(r) === reviewer.reviewer);
-}
-
 export function isServiceUser(account?: AccountInfo): boolean {
   return !!account?.tags?.includes(AccountTag.SERVICE_USER);
 }
@@ -80,12 +53,27 @@
   return account?.avatars?.[0]?.url === other?.avatars?.[0]?.url;
 }
 
-export function accountOrGroupKey(entry: AccountInfo | GroupInfo) {
+export function getUserId(entry: AccountInfo | GroupInfo): UserId {
   if (isAccount(entry)) return accountKey(entry);
   if (isGroup(entry)) return entry.id;
   assertNever(entry, 'entry must be account or group');
 }
 
+export function isAccountEmailOnly(entry: AccountInfo | GroupInfo) {
+  if (isGroup(entry)) return false;
+  return !entry._account_id;
+}
+
+export function isAccountNewlyAdded(
+  account: AccountInfo | GroupInfo,
+  state?: ReviewerState,
+  change?: ChangeInfo | ParsedChangeInfo
+) {
+  if (!change || !state) return false;
+  const accounts = [...(change.reviewers[state] ?? [])];
+  return !accounts.some(a => getUserId(a) === getUserId(account));
+}
+
 export function uniqueDefinedAvatar(
   account: AccountInfo,
   index: number,
@@ -96,6 +84,14 @@
   );
 }
 
+export function isDetailedAccount(account?: AccountInfo) {
+  // In case ChangeInfo is requested without DetailedAccount option, the
+  // reviewer entry is returned as just {_account_id: 123}
+  // This object should also be treated as not detailed account if they have
+  // an AccountId and no email
+  return !!account?.email && !!account?._account_id;
+}
+
 /**
  * Get account in pseudonymized form, that can be send to the backend.
  *
@@ -129,3 +125,112 @@
     }
   );
 }
+
+/**
+ * Returns max permitted score for reviewer.
+ */
+const getReviewerPermittedScore = (
+  change: ChangeInfo,
+  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) {
+    return NaN;
+  }
+  const detailedLabel = change.labels[label];
+  if (!isDetailedLabelInfo(detailedLabel) || !detailedLabel.all) {
+    return NaN;
+  }
+  const approvalInfo = getApprovalInfo(detailedLabel, reviewer);
+  if (!approvalInfo) {
+    return NaN;
+  }
+  if (hasOwnProperty(approvalInfo, 'permitted_voting_range')) {
+    if (!approvalInfo.permitted_voting_range) return NaN;
+    return approvalInfo.permitted_voting_range.max;
+  } else if (hasOwnProperty(approvalInfo, 'value')) {
+    // If present, user can vote on the label.
+    return 0;
+  }
+  return NaN;
+};
+
+/**
+ * Explains which labels the user can vote on and which score they can
+ * give.
+ */
+export function computeVoteableText(change: ChangeInfo, reviewer: AccountInfo) {
+  if (!change || !change.labels) {
+    return '';
+  }
+  const maxScores = [];
+  for (const label of Object.keys(change.labels)) {
+    const maxScore = getReviewerPermittedScore(change, reviewer, label);
+    if (isNaN(maxScore) || maxScore < 0) {
+      continue;
+    }
+    const scoreLabel = maxScore > 0 ? `+${maxScore}` : `${maxScore}`;
+    maxScores.push(`${label}: ${scoreLabel}`);
+  }
+  return maxScores.join(', ');
+}
+
+export function getAccountSuggestions(
+  input: string,
+  restApiService: RestApiService,
+  config?: ServerInfo,
+  canSee?: NumericChangeId,
+  filterActive = false
+) {
+  return restApiService
+    .getSuggestedAccounts(input, SUGGESTIONS_LIMIT, canSee, filterActive)
+    .then(accounts => {
+      if (!accounts) return [];
+      const accountSuggestions = [];
+      for (const account of accounts) {
+        accountSuggestions.push({
+          name: getAccountDisplayName(config, account),
+          value: account._account_id?.toString(),
+        });
+      }
+      return accountSuggestions;
+    });
+}
+
+/**
+ * Extracts mentioned users from a given text.
+ * A user can be mentioned by triggering the mentions dropdown in a comment
+ * by typing @ at the start of the comment or after a space.
+ * The Mentions Regex first looks start of sentence or whitespace (?:^|\s) then
+ * @ token which would have triggered the mentions dropdown and then looks
+ * for the email token ending with a whitespace or end of string.
+ */
+export function extractMentionedUsers(text?: string): AccountInfo[] {
+  if (!text) return [];
+  let match;
+  const users = [];
+  while ((match = MENTIONS_REGEX.exec(text))) {
+    users.push({
+      email: match[1] as EmailAddress,
+    });
+  }
+  return users;
+}
+
+export function toReviewInput(
+  account: AccountInfo | GroupInfo,
+  state: ReviewerState
+): ReviewerInput {
+  if (isAccount(account)) {
+    return {
+      reviewer: accountKey(account),
+      state,
+    };
+  } else if (isGroup(account)) {
+    const reviewer = decodeURIComponent(account.id) as GroupId;
+    return {reviewer, state};
+  }
+  throw new Error('Must be either an account or a group.');
+}
diff --git a/polygerrit-ui/app/utils/account-util_test.ts b/polygerrit-ui/app/utils/account-util_test.ts
index 8ec9181..3e61255 100644
--- a/polygerrit-ui/app/utils/account-util_test.ts
+++ b/polygerrit-ui/app/utils/account-util_test.ts
@@ -1,23 +1,15 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {
+  computeVoteableText,
+  extractMentionedUsers,
   getAccountTemplate,
+  isAccountEmailOnly,
+  isDetailedAccount,
   isServiceUser,
   removeServiceUsers,
   replaceTemplates,
@@ -27,8 +19,22 @@
   AccountTag,
   DefaultDisplayNameConfig,
 } from '../constants/constants';
-import {AccountId, AccountInfo, ServerInfo} from '../api/rest-api';
-import {createServerInfo} from '../test/test-data-generators';
+import {
+  AccountId,
+  AccountInfo,
+  EmailAddress,
+  ServerInfo,
+} from '../api/rest-api';
+import {
+  createAccountDetailWithId,
+  createAccountWithEmailOnly,
+  createAccountWithId,
+  createChange,
+  createDetailedLabelInfo,
+  createGroupInfo,
+  createServerInfo,
+} from '../test/test-data-generators';
+import {assert} from '@open-wc/testing';
 
 const EMPTY = {};
 const ERNIE = {name: 'Ernie'};
@@ -57,7 +63,7 @@
   },
 ];
 
-suite('account-util tests 3', () => {
+suite('account-util tests', () => {
   test('isServiceUser', () => {
     assert.isFalse(isServiceUser());
     assert.isFalse(isServiceUser(EMPTY));
@@ -66,6 +72,50 @@
     assert.isTrue(isServiceUser(BOTTY));
   });
 
+  test('extractMentionedUsers', () => {
+    let text =
+      'Hi @kamilm@google.com and @brohlfs@google.com can you take a look at this?';
+    assert.deepEqual(extractMentionedUsers(text), [
+      {email: 'kamilm@google.com' as EmailAddress},
+      {email: 'brohlfs@google.com' as EmailAddress},
+    ]);
+
+    // with extra @
+    text = '@@abc@google.com';
+    assert.deepEqual(extractMentionedUsers(text), []);
+
+    // with spaces in email
+    text = '@a bc@google.com';
+    assert.deepEqual(extractMentionedUsers(text), []);
+
+    // with invalid email
+    text = '@abcgoogle.com';
+    assert.deepEqual(extractMentionedUsers(text), []);
+
+    text = '@abc@googlecom';
+    assert.deepEqual(extractMentionedUsers(text), [
+      {email: 'abc@googlecom' as EmailAddress},
+    ]);
+
+    // with newline before email
+    text = '\n\n\n random text  \n\n@abc@google.com';
+    assert.deepEqual(extractMentionedUsers(text), [
+      {email: 'abc@google.com' as EmailAddress},
+    ]);
+
+    text = '@abc@google.com please take a look at this';
+    assert.deepEqual(extractMentionedUsers(text), [
+      {email: 'abc@google.com' as EmailAddress},
+    ]);
+
+    text = '@a@google.com @b@google.com @c@google.com';
+    assert.deepEqual(extractMentionedUsers(text), [
+      {email: 'a@google.com' as EmailAddress},
+      {email: 'b@google.com' as EmailAddress},
+      {email: 'c@google.com' as EmailAddress},
+    ]);
+  });
+
   test('removeServiceUsers', () => {
     assert.sameMembers(removeServiceUsers([]), []);
     assert.sameMembers(removeServiceUsers([EMPTY, ERNIE]), [EMPTY, ERNIE]);
@@ -76,6 +126,14 @@
     ]);
   });
 
+  test('isAccountEmailOnly', () => {
+    assert.isFalse(isAccountEmailOnly(createAccountWithId(1)));
+    assert.isTrue(
+      isAccountEmailOnly(createAccountWithEmailOnly('a' as EmailAddress))
+    );
+    assert.isFalse(isAccountEmailOnly(createGroupInfo()));
+  });
+
   test('replaceTemplates with display config', () => {
     assert.equal(
       replaceTemplates(
@@ -146,4 +204,81 @@
     assert.equal(getAccountTemplate({}, config), 'Unidentified User');
     assert.equal(getAccountTemplate(), 'Anonymous');
   });
+
+  test('votable labels', async () => {
+    const change = {
+      ...createChange(),
+      labels: {
+        Foo: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 2, min: 0},
+            },
+          ],
+        },
+        Bar: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createAccountDetailWithId(1),
+              permitted_voting_range: {max: 1, min: 0},
+            },
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 1, min: 0},
+            },
+          ],
+        },
+        FooBar: {
+          ...createDetailedLabelInfo(),
+          all: [{_account_id: 7 as AccountId, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+
+    assert.strictEqual(
+      computeVoteableText(change, {...createAccountDetailWithId(1)}),
+      'Bar: +1'
+    );
+    assert.strictEqual(
+      computeVoteableText(change, {...createAccountDetailWithId(7)}),
+      'Foo: +2, Bar: +1, FooBar: 0'
+    );
+    assert.strictEqual(
+      computeVoteableText(change, {...createAccountDetailWithId(2)}),
+      ''
+    );
+  });
+
+  test('isDetailedAccount', () => {
+    assert.isFalse(isDetailedAccount({_account_id: 12345 as AccountId}));
+    assert.isFalse(isDetailedAccount({email: 'abcd' as EmailAddress}));
+
+    assert.isTrue(
+      isDetailedAccount({
+        _account_id: 12345 as AccountId,
+        email: 'abcd' as EmailAddress,
+      })
+    );
+  });
+
+  test('fails gracefully when all is not included', async () => {
+    const change = {
+      ...createChange(),
+      labels: {Foo: {}},
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+      },
+    };
+    assert.strictEqual(
+      computeVoteableText(change, {...createAccountDetailWithId(1)}),
+      ''
+    );
+  });
 });
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
index 6688c19..c8fc9fb 100644
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -1,25 +1,9 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {
-  GerritNav,
-  RepoDetailView,
-  GroupDetailView,
-} from '../elements/core/gr-navigation/gr-navigation';
-import {
   RepoName,
   GroupId,
   AccountDetailInfo,
@@ -28,6 +12,9 @@
 import {hasOwnProperty} from './common-util';
 import {GerritView} from '../services/router/router-model';
 import {MenuLink} from '../api/admin';
+import {AdminChildView} from '../models/views/admin';
+import {createGroupUrl, GroupDetailView} from '../models/views/group';
+import {createRepoUrl, RepoDetailView} from '../models/views/repo';
 
 const ADMIN_LINKS: NavLink[] = [
   {
@@ -163,69 +150,69 @@
   const children: SubsectionInterface[] = [];
   const subsection: SubsectionInterface = {
     name: groupName,
-    view: GerritNav.View.GROUP,
-    url: GerritNav.getUrlForGroup(groupId),
+    view: GerritView.GROUP,
+    url: createGroupUrl({groupId}),
     children,
   };
   if (groupIsInternal) {
     children.push({
       name: 'Members',
-      detailType: GerritNav.GroupDetailView.MEMBERS,
-      view: GerritNav.View.GROUP,
-      url: GerritNav.getUrlForGroupMembers(groupId),
+      detailType: GroupDetailView.MEMBERS,
+      view: GerritView.GROUP,
+      url: createGroupUrl({groupId, detail: GroupDetailView.MEMBERS}),
     });
   }
   if (groupIsInternal && (isAdmin || groupOwner)) {
     children.push({
       name: 'Audit Log',
-      detailType: GerritNav.GroupDetailView.LOG,
-      view: GerritNav.View.GROUP,
-      url: GerritNav.getUrlForGroupLog(groupId),
+      detailType: GroupDetailView.LOG,
+      view: GerritView.GROUP,
+      url: createGroupUrl({groupId, detail: GroupDetailView.LOG}),
     });
   }
   return subsection;
 }
 
-export function getRepoSubsections(repoName: RepoName) {
+export function getRepoSubsections(repo: RepoName) {
   return {
-    name: repoName,
-    view: GerritNav.View.REPO,
+    name: repo,
+    view: GerritView.REPO,
     children: [
       {
         name: 'General',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.GENERAL,
-        url: GerritNav.getUrlForRepo(repoName),
+        view: GerritView.REPO,
+        detailType: RepoDetailView.GENERAL,
+        url: createRepoUrl({repo, detail: RepoDetailView.GENERAL}),
       },
       {
         name: 'Access',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.ACCESS,
-        url: GerritNav.getUrlForRepoAccess(repoName),
+        view: GerritView.REPO,
+        detailType: RepoDetailView.ACCESS,
+        url: createRepoUrl({repo, detail: RepoDetailView.ACCESS}),
       },
       {
         name: 'Commands',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.COMMANDS,
-        url: GerritNav.getUrlForRepoCommands(repoName),
+        view: GerritView.REPO,
+        detailType: RepoDetailView.COMMANDS,
+        url: createRepoUrl({repo, detail: RepoDetailView.COMMANDS}),
       },
       {
         name: 'Branches',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.BRANCHES,
-        url: GerritNav.getUrlForRepoBranches(repoName),
+        view: GerritView.REPO,
+        detailType: RepoDetailView.BRANCHES,
+        url: createRepoUrl({repo, detail: RepoDetailView.BRANCHES}),
       },
       {
         name: 'Tags',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.TAGS,
-        url: GerritNav.getUrlForRepoTags(repoName),
+        view: GerritView.REPO,
+        detailType: RepoDetailView.TAGS,
+        url: createRepoUrl({repo, detail: RepoDetailView.TAGS}),
       },
       {
         name: 'Dashboards',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.DASHBOARDS,
-        url: GerritNav.getUrlForRepoDashboards(repoName),
+        view: GerritView.REPO,
+        detailType: RepoDetailView.DASHBOARDS,
+        url: createRepoUrl({repo, detail: RepoDetailView.DASHBOARDS}),
       },
     ],
   };
@@ -252,7 +239,7 @@
   name: string;
   noBaseUrl: boolean;
   url: string;
-  view?: GerritView;
+  view?: GerritView | AdminChildView;
   viewableToAll?: boolean;
   section?: string;
   capability?: string;
diff --git a/polygerrit-ui/app/utils/admin-nav-util_test.ts b/polygerrit-ui/app/utils/admin-nav-util_test.ts
index e06664e..a8600c7 100644
--- a/polygerrit-ui/app/utils/admin-nav-util_test.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util_test.ts
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
+import {assert} from '@open-wc/testing';
 import {AccountDetailInfo, GroupId, RepoName, Timestamp} from '../api/rest-api';
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {AdminNavLinksOption, getAdminLinks} from './admin-nav-util';
 
 suite('gr-admin-nav-behavior tests', () => {
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 3f51532..4281f43 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -1,22 +1,11 @@
 /**
  * @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 {Observable} from 'rxjs';
 import {filter, take} from 'rxjs/operators';
+import {assertIsDefined} from './common-util';
 
 /**
  * @param fn An iteratee function to be passed each element of
@@ -114,6 +103,92 @@
   return new DelayedTask(callback, waitMs);
 }
 
+export const DELAYED_CANCELLATION = Symbol('Delayed Cancellation');
+
+export class DelayedPromise<T> extends Promise<T> {
+  private resolve: (value: PromiseLike<T> | T) => void;
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  private reject: (reason?: any) => void;
+
+  private timer: number | undefined;
+
+  constructor(private readonly callback: () => Promise<T>, waitMs = 0) {
+    let resolve: ((value: PromiseLike<T> | T) => void) | undefined;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    let reject: ((reason?: any) => void) | undefined;
+    super((res, rej) => {
+      resolve = res;
+      reject = rej;
+    });
+    assertIsDefined(resolve);
+    assertIsDefined(reject);
+    this.resolve = resolve;
+    this.reject = reject;
+    this.timer = window.setTimeout(async () => {
+      await this.flush();
+    }, waitMs);
+  }
+
+  private stop() {
+    if (this.timer === undefined) return false;
+    window.clearTimeout(this.timer);
+    this.timer = undefined;
+    return true;
+  }
+
+  async flush() {
+    if (!this.stop()) return;
+    try {
+      this.resolve(await this.callback());
+    } catch (e) {
+      this.reject(e);
+    }
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  cancel(reason?: any) {
+    if (!this.stop()) return;
+    this.reject(reason ?? DELAYED_CANCELLATION);
+  }
+
+  delegate(other: Promise<T>) {
+    if (!this.stop()) return;
+    other
+      .then((value: T) => this.resolve(value))
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      .catch((reason?: any) => this.reject(reason));
+  }
+
+  // From ECMAScript specification:
+  // https://tc39.es/ecma262/#sec-get-promise-@@species
+  //    Promise prototype methods normally use their this value's constructor to
+  //    create a derived object. However, a subclass constructor may over-ride
+  //    that default behaviour by redefining its @@species property.
+  // NOTE: This is required otherwise .then and .catch on a DelayedPromise
+  // will try to instantiate a DelayedPromise with 'resolve, reject' arguments.
+  static override get [Symbol.species]() {
+    return Promise;
+  }
+
+  override get [Symbol.toStringTag]() {
+    return 'DelayedPromise';
+  }
+}
+
+/**
+ * The usage pattern is
+ * this.aDebouncedPromise = debounceP(this.aDebouncedPromise, () => {...}, 123)
+ */
+export function debounceP<T>(
+  existingPromise: DelayedPromise<T> | undefined,
+  callback: () => Promise<T>,
+  waitMs = 0
+): DelayedPromise<T> {
+  const promise = new DelayedPromise<T>(callback, waitMs);
+  if (existingPromise) existingPromise.delegate(promise);
+  return promise;
+}
 const THROTTLE_INTERVAL_MS = 500;
 
 /**
@@ -146,3 +221,27 @@
 }
 
 export const isFalse = (b: boolean) => b === false;
+
+export type PromiseResult<T> =
+  | {status: 'fulfilled'; value: T}
+  | {status: 'rejected'; reason: string};
+export function isFulfilled<T>(
+  promiseResult?: PromiseResult<T>
+): promiseResult is PromiseResult<T> & {status: 'fulfilled'} {
+  return promiseResult?.status === 'fulfilled';
+}
+
+// An equivalent to Promise.allSettled from ES2020.
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
+// TODO: Migrate our tooling to ES2020 and remove this method.
+export function allSettled<T>(
+  promises: Promise<T>[]
+): Promise<PromiseResult<T>[]> {
+  return Promise.all(
+    promises.map(promise =>
+      promise
+        .then(value => ({status: 'fulfilled', value} as const))
+        .catch(reason => ({status: 'rejected', reason} as const))
+    )
+  );
+}
diff --git a/polygerrit-ui/app/utils/async-util_test.ts b/polygerrit-ui/app/utils/async-util_test.ts
index 5c8f610..9f029b8 100644
--- a/polygerrit-ui/app/utils/async-util_test.ts
+++ b/polygerrit-ui/app/utils/async-util_test.ts
@@ -1,46 +1,208 @@
 /**
  * @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 '../test/common-test-setup-karma';
-import {asyncForeach} from './async-util';
+import {assert} from '@open-wc/testing';
+import {SinonFakeTimers} from 'sinon';
+import '../test/common-test-setup';
+import {waitEventLoop} from '../test/test-utils';
+import {asyncForeach, debounceP} from './async-util';
 
 suite('async-util tests', () => {
-  test('loops over each item', async () => {
-    const fn = sinon.stub().resolves();
+  suite('asyncForeach', () => {
+    test('loops over each item', async () => {
+      const fn = sinon.stub().resolves();
 
-    await asyncForeach([1, 2, 3], fn);
+      await asyncForeach([1, 2, 3], fn);
 
-    assert.isTrue(fn.calledThrice);
-    assert.equal(fn.firstCall.firstArg, 1);
-    assert.equal(fn.secondCall.firstArg, 2);
-    assert.equal(fn.thirdCall.firstArg, 3);
+      assert.isTrue(fn.calledThrice);
+      assert.equal(fn.firstCall.firstArg, 1);
+      assert.equal(fn.secondCall.firstArg, 2);
+      assert.equal(fn.thirdCall.firstArg, 3);
+    });
+
+    test('halts on stop condition', async () => {
+      const stub = sinon.stub();
+      const fn = (item: number, stopCallback: () => void) => {
+        stub(item);
+        stopCallback();
+        return Promise.resolve();
+      };
+
+      await asyncForeach([1, 2, 3], fn);
+
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.firstArg, 1);
+    });
   });
 
-  test('halts on stop condition', async () => {
-    const stub = sinon.stub();
-    const fn = (item: number, stopCallback: () => void) => {
-      stub(item);
-      stopCallback();
-      return Promise.resolve();
-    };
+  suite('DelayedPromise', () => {
+    let clock: SinonFakeTimers;
+    setup(() => {
+      clock = sinon.useFakeTimers();
+    });
 
-    await asyncForeach([1, 2, 3], fn);
+    test('It resolves after timeout', async () => {
+      const promise = debounceP<number>(
+        undefined,
+        () => Promise.resolve(5),
+        100
+      );
+      let hasResolved = false;
+      promise.then((value: number) => {
+        hasResolved = true;
+        assert.equal(value, 5);
+      });
+      promise.catch((_reason?: any) => {
+        assert.fail();
+      });
+      await waitEventLoop();
+      assert.isFalse(hasResolved);
+      clock.tick(99);
+      await waitEventLoop();
+      assert.isFalse(hasResolved);
+      clock.tick(1);
+      await waitEventLoop();
+      assert.isTrue(hasResolved);
+      await promise;
+      // Shouldn't do anything.
+      promise.cancel();
+      await waitEventLoop();
+    });
 
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.firstArg, 1);
+    test('It resolves immediately on flush and finalizes', async () => {
+      const promise = debounceP<number>(
+        undefined,
+        () => Promise.resolve(5),
+        100
+      );
+      let hasResolved = false;
+      promise.then((value: number) => {
+        hasResolved = true;
+        assert.equal(value, 5);
+      });
+      promise.catch((_reason?: any) => {
+        assert.fail();
+      });
+      promise.flush();
+      await waitEventLoop();
+      assert.isTrue(hasResolved);
+      // Shouldn't do anything.
+      promise.cancel();
+      await waitEventLoop();
+    });
+
+    test('It rejects on cancel', async () => {
+      const promise = debounceP<number>(
+        undefined,
+        () => Promise.resolve(5),
+        100
+      );
+      let hasCanceled = false;
+      promise.then((_value: number) => {
+        assert.fail();
+      });
+      promise.catch((reason?: any) => {
+        hasCanceled = true;
+        assert.strictEqual(reason, 'because');
+      });
+      await waitEventLoop();
+      assert.isFalse(hasCanceled);
+      promise.cancel('because');
+      await waitEventLoop();
+      assert.isTrue(hasCanceled);
+      // Shouldn't do anything.
+      promise.flush();
+      await waitEventLoop();
+    });
+
+    test('It delegates correctly', async () => {
+      const promise1 = debounceP<number>(
+        undefined,
+        () => Promise.resolve(5),
+        100
+      );
+      let hasResolved1 = false;
+      promise1.then((value: number) => {
+        hasResolved1 = true;
+        assert.equal(value, 6);
+      });
+      promise1.catch((_reason?: any) => {
+        assert.fail();
+      });
+      await waitEventLoop();
+      assert.isFalse(hasResolved1);
+      clock.tick(99);
+      await waitEventLoop();
+      const promise2 = debounceP<number>(
+        promise1,
+        () => Promise.resolve(6),
+        100
+      );
+      let hasResolved2 = false;
+      promise2.then((value: number) => {
+        hasResolved2 = true;
+        assert.equal(value, 6);
+      });
+      promise2.catch((_reason?: any) => {
+        assert.fail();
+      });
+      clock.tick(99);
+      await waitEventLoop();
+      assert.isFalse(hasResolved1);
+      assert.isFalse(hasResolved2);
+      clock.tick(2);
+      await waitEventLoop();
+      assert.isTrue(hasResolved1);
+      assert.isTrue(hasResolved2);
+      // Shouldn't do anything.
+      promise1.cancel();
+      await waitEventLoop();
+    });
+
+    test('It does not delegate after timeout', async () => {
+      const promise1 = debounceP<number>(
+        undefined,
+        () => Promise.resolve(5),
+        100
+      );
+      let hasResolved1 = false;
+      promise1.then((value: number) => {
+        hasResolved1 = true;
+        assert.equal(value, 5);
+      });
+      promise1.catch((_reason?: any) => {
+        assert.fail();
+      });
+      await waitEventLoop();
+      assert.isFalse(hasResolved1);
+      clock.tick(100);
+      await waitEventLoop();
+      assert.isTrue(hasResolved1);
+
+      const promise2 = debounceP<number>(
+        promise1,
+        () => Promise.resolve(6),
+        100
+      );
+      let hasResolved2 = false;
+      promise2.then((value: number) => {
+        hasResolved2 = true;
+        assert.equal(value, 6);
+      });
+      promise2.catch((_reason?: any) => {
+        assert.fail();
+      });
+      clock.tick(99);
+      await waitEventLoop();
+      assert.isFalse(hasResolved2);
+      clock.tick(1);
+      await waitEventLoop();
+      assert.isTrue(hasResolved2);
+      // Shouldn't do anything.
+      promise1.cancel();
+      await waitEventLoop();
+    });
   });
 });
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index 0347581..77834bd 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -1,20 +1,8 @@
 /**
  * @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 {AccountInfo, ChangeInfo, ServerInfo} from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
 import {
@@ -23,6 +11,7 @@
   isServiceUser,
   replaceTemplates,
 } from './account-util';
+import {CommentThread, isMentionedThread, isUnresolved} from './comment-util';
 import {hasOwnProperty} from './common-util';
 
 export function canHaveAttention(account?: AccountInfo): boolean {
@@ -60,6 +49,21 @@
   );
 }
 
+export function getMentionedReason(
+  threads: CommentThread[],
+  account?: AccountInfo,
+  mentionedAccount?: AccountInfo,
+  config?: ServerInfo
+) {
+  const mentionedThreads = threads
+    .filter(isUnresolved)
+    .filter(t => isMentionedThread(t, mentionedAccount));
+  if (mentionedThreads.length > 0) {
+    return `${getAccountTemplate(account, config)} mentioned you in a comment`;
+  }
+  return getReplyByReason(account, config);
+}
+
 export function getAddedByReason(account?: AccountInfo, config?: ServerInfo) {
   return `Added by ${getAccountTemplate(
     account,
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.ts b/polygerrit-ui/app/utils/attention-set-util_test.ts
index 14832c0..8092a6e 100644
--- a/polygerrit-ui/app/utils/attention-set-util_test.ts
+++ b/polygerrit-ui/app/utils/attention-set-util_test.ts
@@ -1,22 +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
  */
-
-import '../test/common-test-setup-karma';
-import {createChange, createServerInfo} from '../test/test-data-generators';
+import '../test/common-test-setup';
+import {
+  createAccountDetailWithIdNameAndEmail,
+  createChange,
+  createComment,
+  createCommentThread,
+  createServerInfo,
+} from '../test/test-data-generators';
 import {
   AccountId,
   AccountInfo,
@@ -24,9 +18,14 @@
   EmailAddress,
   ServerInfo,
 } from '../types/common';
-import {getReason, hasAttention} from './attention-set-util';
+import {
+  getMentionedReason,
+  getReason,
+  hasAttention,
+} from './attention-set-util';
 import {DefaultDisplayNameConfig} from '../api/rest-api';
 import {AccountsVisibility} from '../constants/constants';
+import {assert} from '@open-wc/testing';
 
 const KERMIT: AccountInfo = {
   email: 'kermit@gmail.com' as EmailAddress,
@@ -42,6 +41,20 @@
   _account_id: 31415926536 as AccountId,
 };
 
+const MENTION_ACCOUNT: AccountInfo = {
+  email: 'mention@gmail.com' as EmailAddress,
+  username: 'mention',
+  name: 'Mention User',
+  _account_id: 31415926537 as AccountId,
+};
+
+const MENTION_ACCOUNT_2: AccountInfo = {
+  email: 'mention2@gmail.com' as EmailAddress,
+  username: 'mention2',
+  name: 'Mention2 User',
+  _account_id: 31415926538 as AccountId,
+};
+
 const change: ChangeInfo = {
   ...createChange(),
   attention_set: {
@@ -54,6 +67,16 @@
       reason: 'Added by <GERRIT_ACCOUNT_31415926535>',
       reason_account: KERMIT,
     },
+    '31415926537': {
+      account: MENTION_ACCOUNT,
+      reason: '<GERRIT_ACCOUNT_31415926535> replied on the change',
+      reason_account: KERMIT,
+    },
+    '31415926538': {
+      account: MENTION_ACCOUNT_2,
+      reason: 'Bot voted negatively on the change',
+      reason_account: KERMIT,
+    },
   },
 };
 
@@ -77,4 +100,54 @@
     assert.equal(getReason(config, KERMIT, change), 'a good reason');
     assert.equal(getReason(config, OTHER_ACCOUNT, change), 'Added by kermit');
   });
+
+  test('getMentionReason', () => {
+    let comment = {
+      ...createComment(),
+      message: `hey @${MENTION_ACCOUNT.email} take a look at this`,
+      unresolved: true,
+      author: {
+        ...createAccountDetailWithIdNameAndEmail(1),
+      },
+    };
+
+    assert.equal(
+      getMentionedReason(
+        [createCommentThread([comment])],
+        KERMIT,
+        KERMIT,
+        config
+      ),
+      '<GERRIT_ACCOUNT_31415926535> replied on the change'
+    );
+
+    assert.equal(
+      getMentionedReason(
+        [createCommentThread([comment])],
+        KERMIT,
+        MENTION_ACCOUNT,
+        config
+      ),
+      '<GERRIT_ACCOUNT_31415926535> mentioned you in a comment'
+    );
+
+    // resolved mention hence does not change reason
+    comment = {
+      ...createComment(),
+      message: `hey @${MENTION_ACCOUNT.email} take a look at this`,
+      unresolved: false,
+      author: {
+        ...createAccountDetailWithIdNameAndEmail(1),
+      },
+    };
+    assert.equal(
+      getMentionedReason(
+        [createCommentThread([comment])],
+        KERMIT,
+        MENTION_ACCOUNT,
+        config
+      ),
+      '<GERRIT_ACCOUNT_31415926535> replied on the change'
+    );
+  });
 });
diff --git a/polygerrit-ui/app/utils/bulk-flow-util.ts b/polygerrit-ui/app/utils/bulk-flow-util.ts
index 9a6179a..8dac305 100644
--- a/polygerrit-ui/app/utils/bulk-flow-util.ts
+++ b/polygerrit-ui/app/utils/bulk-flow-util.ts
@@ -3,7 +3,6 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import {ProgressStatus} from '../constants/constants';
 import {NumericChangeId} from '../api/rest-api';
 
diff --git a/polygerrit-ui/app/utils/change-metadata-util.ts b/polygerrit-ui/app/utils/change-metadata-util.ts
index 84b324b..9d3106d 100644
--- a/polygerrit-ui/app/utils/change-metadata-util.ts
+++ b/polygerrit-ui/app/utils/change-metadata-util.ts
@@ -1,20 +1,8 @@
 /**
  * @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 {ParsedChangeInfo} from '../types/types';
 
 export enum Metadata {
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 258f7cd..9062ac7 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {getBaseUrl} from './url-util';
 import {ChangeStatus} from '../constants/constants';
@@ -25,6 +14,7 @@
 } from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
 import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
+import {getUserId, isServiceUser} from './account-util';
 
 // This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
@@ -147,6 +137,20 @@
 ) {
   return change?.status === ChangeStatus.ABANDONED;
 }
+/**
+ * Get the change number from either a ChangeInfo (such as those included in
+ * SubmittedTogetherInfo responses) or get the change number from a
+ * RelatedChangeAndCommitInfo (such as those included in a
+ * RelatedChangesInfo response).
+ */
+export function getChangeNumber(
+  change: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
+): NumericChangeId {
+  if (isChangeInfo(change)) {
+    return change._number;
+  }
+  return change._change_number!;
+}
 
 export function changeStatuses(
   change: ChangeInfo,
@@ -154,15 +158,19 @@
 ): ChangeStates[] {
   const states = [];
   if (change.status === ChangeStatus.MERGED) {
-    states.push(ChangeStates.MERGED);
-  } else if (change.status === ChangeStatus.ABANDONED) {
-    states.push(ChangeStates.ABANDONED);
-  } else if (
+    return [ChangeStates.MERGED];
+  }
+  if (change.status === ChangeStatus.ABANDONED) {
+    return [ChangeStates.ABANDONED];
+  }
+  if (
     change.mergeable === false ||
     (opt_options && opt_options.mergeable === false)
   ) {
     // 'mergeable' prop may not always exist (@see Issue 6819)
     states.push(ChangeStates.MERGE_CONFLICT);
+  } else if (change.contains_git_conflicts) {
+    states.push(ChangeStates.GIT_CONFLICT);
   }
   if (change.work_in_progress) {
     states.push(ChangeStates.WIP);
@@ -260,11 +268,23 @@
   );
 }
 
+export function hasHumanReviewer(
+  change?: ChangeInfo | ParsedChangeInfo
+): boolean {
+  if (!change) return false;
+  const reviewers = change.reviewers.REVIEWER ?? [];
+  return reviewers
+    .filter(r => getUserId(r) !== getUserId(change.owner))
+    .some(r => !isServiceUser(r));
+}
+
 export function isRemovableReviewer(
   change?: ChangeInfo,
   reviewer?: AccountInfo
 ): boolean {
-  if (!change?.removable_reviewers || !reviewer) return false;
+  if (!reviewer || !change) return false;
+  if (isCc(change, reviewer)) return true;
+  if (!change.removable_reviewers) return false;
   return change.removable_reviewers.some(
     account =>
       account._account_id === reviewer._account_id ||
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
index ccec27e..70e6fd6 100644
--- a/polygerrit-ui/app/utils/change-util_test.ts
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -1,24 +1,18 @@
 /**
  * @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 {assert} from '@open-wc/testing';
 import {ChangeStatus} from '../constants/constants';
 import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
-import '../test/common-test-setup-karma';
-import {createChange, createRevisions} from '../test/test-data-generators';
+import '../test/common-test-setup';
+import {
+  createAccountWithId,
+  createChange,
+  createRevisions,
+  createServiceUserWithId,
+} from '../test/test-data-generators';
 import {
   AccountId,
   CommitId,
@@ -33,6 +27,9 @@
   changePath,
   changeStatuses,
   isRemovableReviewer,
+  ListChangesOption,
+  listChangesOptionsToHex,
+  hasHumanReviewer,
 } from './change-util';
 
 suite('change-util tests', () => {
@@ -125,8 +122,11 @@
       current_revision: 'rev1' as CommitId,
       status: ChangeStatus.MERGED,
     };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, [ChangeStates.MERGED]);
+    assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]);
+    change.is_private = true;
+    assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]);
+    change.work_in_progress = true;
+    assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]);
   });
 
   test('Abandoned status', () => {
@@ -137,8 +137,11 @@
       status: ChangeStatus.ABANDONED,
       mergeable: false,
     };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, [ChangeStates.ABANDONED]);
+    assert.deepEqual(changeStatuses(change), [ChangeStates.ABANDONED]);
+    change.is_private = true;
+    assert.deepEqual(changeStatuses(change), [ChangeStates.ABANDONED]);
+    change.work_in_progress = true;
+    assert.deepEqual(changeStatuses(change), [ChangeStates.ABANDONED]);
   });
 
   test('Open status with private and wip', () => {
@@ -175,6 +178,26 @@
     ]);
   });
 
+  test('hasHumanReviewer', () => {
+    const owner = createAccountWithId(1);
+    const change = {
+      ...createChange(),
+      _number: 1 as NumericChangeId,
+      subject: 'Subject 1',
+      owner,
+      reviewers: {
+        REVIEWER: [owner],
+      },
+    };
+    assert.isFalse(hasHumanReviewer(change));
+
+    change.reviewers.REVIEWER.push(createServiceUserWithId(2));
+    assert.isFalse(hasHumanReviewer(change));
+
+    change.reviewers.REVIEWER.push(createAccountWithId(3));
+    assert.isTrue(hasHumanReviewer(change));
+  });
+
   test('isRemovableReviewer', () => {
     let change = {
       ...createChange(),
@@ -237,4 +260,18 @@
     change.status = ChangeStatus.NEW;
     assert.isFalse(changeIsAbandoned(change));
   });
+
+  test('listChangesOptionsToHex', () => {
+    const changeActionsHex = listChangesOptionsToHex(
+      ListChangesOption.MESSAGES,
+      ListChangesOption.ALL_REVISIONS
+    );
+    assert.equal(changeActionsHex, '204');
+    const dashboardHex = listChangesOptionsToHex(
+      ListChangesOption.LABELS,
+      ListChangesOption.DETAILED_ACCOUNTS,
+      ListChangesOption.SUBMIT_REQUIREMENTS
+    );
+    assert.equal(dashboardHex, '1000081');
+  });
 });
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 669c491..f531698 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {
   CommentBasics,
@@ -23,7 +12,7 @@
   UrlEncodedCommentId,
   CommentRange,
   PatchRange,
-  ParentPatchSetNum,
+  PARENT,
   ContextLine,
   BasePatchSetNum,
   RevisionPatchSetNum,
@@ -31,6 +20,8 @@
   AccountDetailInfo,
   ChangeMessageInfo,
   VotingRangeInfo,
+  FixSuggestionInfo,
+  FixId,
 } from '../types/common';
 import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
@@ -39,6 +30,7 @@
 import {DiffInfo} from '../types/diff';
 import {LineNumber} from '../api/diff';
 import {FormattedReviewerUpdateInfo} from '../types/types';
+import {extractMentionedUsers} from './account-util';
 
 export interface DraftCommentProps {
   // This must be true for all drafts. Drafts received from the backend will be
@@ -61,6 +53,7 @@
 
 export type Comment = UnsavedInfo | DraftInfo | CommentInfo | RobotCommentInfo;
 
+// TODO: Replace the CommentMap type with just an array of paths.
 export type CommentMap = {[path: string]: boolean};
 
 export function isRobot<T extends CommentBasics>(
@@ -107,6 +100,8 @@
 
 export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
 
+export const NEWLINE_PATTERN = /\n/g;
+
 export const PATCH_SET_PREFIX_PATTERN =
   /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
 
@@ -221,7 +216,7 @@
      Same as `parent` in CommentInfo.
   */
   mergeParentNum?: number;
-  patchNum?: PatchSetNum;
+  patchNum?: RevisionPatchSetNum;
   /* Different from CommentInfo, which just keeps the line undefined for
      FILE comments. */
   line?: LineNumber;
@@ -294,6 +289,16 @@
   return isDraft(getLastComment(thread));
 }
 
+export function isMentionedThread(
+  thread: CommentThread,
+  account?: AccountInfo
+) {
+  if (!account?.email) return false;
+  return getMentionedUsers(thread)
+    .map(v => v.email)
+    .includes(account.email);
+}
+
 export function isRobotThread(thread: CommentThread): boolean {
   return isRobot(getFirstComment(thread));
 }
@@ -333,7 +338,7 @@
 
   // If the base of the range is the parent of the patch:
   if (
-    range.basePatchNum === ParentPatchSetNum &&
+    range.basePatchNum === PARENT &&
     comment.side === CommentSide.PARENT &&
     comment.patch_set === range.patchNum
   ) {
@@ -341,7 +346,7 @@
   }
   // If the base of the range is not the parent of the patch:
   return (
-    range.basePatchNum !== ParentPatchSetNum &&
+    range.basePatchNum !== PARENT &&
     comment.side !== CommentSide.PARENT &&
     comment.patch_set === range.basePatchNum
   );
@@ -384,16 +389,14 @@
 
   // 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('comment.patch_set cannot be PARENT');
     return {
-      patchNum: comment.patch_set as RevisionPatchSetNum,
-      basePatchNum: ParentPatchSetNum,
+      patchNum: comment.patch_set,
+      basePatchNum: PARENT,
     };
   } else if (latestPatchNum === comment.patch_set) {
     return {
       patchNum: latestPatchNum,
-      basePatchNum: ParentPatchSetNum,
+      basePatchNum: PARENT,
     };
   } else {
     return {
@@ -504,3 +507,79 @@
     unsaved: isUnsaved(comment),
   };
 }
+
+export const USER_SUGGESTION_START_PATTERN = '```suggestion\n';
+
+// This can either mean a user or a checks provided fix.
+// "Provided" means that the fix is sent along with the request
+// when previewing and applying the fix. This is in contrast to
+// robot comment fixes, which are stored in the backend, and they
+// are referenced by a unique `FixId`;
+export const PROVIDED_FIX_ID = 'provided_fix' as FixId;
+
+export function hasUserSuggestion(comment: Comment) {
+  return comment.message?.includes(USER_SUGGESTION_START_PATTERN) ?? false;
+}
+
+export function getUserSuggestion(comment: Comment) {
+  if (!comment.message) return;
+  const start =
+    comment.message.indexOf(USER_SUGGESTION_START_PATTERN) +
+    USER_SUGGESTION_START_PATTERN.length;
+  const end = comment.message.indexOf('\n```', start);
+  return comment.message.substring(start, end);
+}
+
+export function getContentInCommentRange(
+  fileContent: string,
+  comment: Comment
+) {
+  const lines = fileContent.split('\n');
+  if (comment.range) {
+    const range = comment.range;
+    return lines.slice(range.start_line - 1, range.end_line).join('\n');
+  }
+  return lines[comment.line! - 1];
+}
+
+export function createUserFixSuggestion(
+  comment: Comment,
+  line: string,
+  replacement: string
+): FixSuggestionInfo[] {
+  const lastLine = line.split('\n').pop();
+  return [
+    {
+      fix_id: PROVIDED_FIX_ID,
+      description: 'User suggestion',
+      replacements: [
+        {
+          path: comment.path!,
+          range: {
+            start_line: comment.range?.start_line ?? comment.line!,
+            start_character: 0,
+            end_line: comment.range?.end_line ?? comment.line!,
+            end_character: lastLine!.length,
+          },
+          replacement,
+        },
+      ],
+    },
+  ];
+}
+
+function getMentionedUsers(thread: CommentThread) {
+  return thread.comments.map(c => extractMentionedUsers(c.message)).flat();
+}
+
+export function getMentionedThreads(
+  threads: CommentThread[],
+  account: AccountInfo
+) {
+  if (!account.email) return [];
+  return threads.filter(t =>
+    getMentionedUsers(t)
+      .map(v => v.email)
+      .includes(account.email)
+  );
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index f5a2177..0a8aa82 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -1,36 +1,35 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {
   isUnresolved,
   getPatchRangeForCommentUrl,
   createCommentThreads,
   sortComments,
+  USER_SUGGESTION_START_PATTERN,
+  hasUserSuggestion,
+  getUserSuggestion,
+  getContentInCommentRange,
+  createUserFixSuggestion,
+  PROVIDED_FIX_ID,
+  getMentionedThreads,
 } from './comment-util';
-import {createComment, createCommentThread} from '../test/test-data-generators';
+import {
+  createAccountWithEmail,
+  createComment,
+  createCommentThread,
+} from '../test/test-data-generators';
 import {CommentSide} from '../constants/constants';
 import {
-  ParentPatchSetNum,
-  PatchSetNum,
+  PARENT,
   RevisionPatchSetNum,
   Timestamp,
   UrlEncodedCommentId,
 } from '../types/common';
+import {assert} from '@open-wc/testing';
 
 suite('comment-util', () => {
   test('isUnresolved', () => {
@@ -70,26 +69,63 @@
     );
   });
 
-  test('getPatchRangeForCommentUrl', () => {
+  suite('getPatchRangeForCommentUrl', () => {
     test('comment created with side=PARENT does not navigate to latest ps', () => {
       const comment = {
         ...createComment(),
         id: 'c4' as UrlEncodedCommentId,
         line: 10,
-        patch_set: 4 as PatchSetNum,
+        patch_set: 4 as RevisionPatchSetNum,
         side: CommentSide.PARENT,
         path: '/COMMIT_MSG',
       };
       assert.deepEqual(
         getPatchRangeForCommentUrl(comment, 11 as RevisionPatchSetNum),
         {
-          basePatchNum: ParentPatchSetNum,
-          patchNum: 4 as PatchSetNum,
+          basePatchNum: PARENT,
+          patchNum: 4 as RevisionPatchSetNum,
         }
       );
     });
   });
 
+  test('getMentionedThreads', () => {
+    const account = createAccountWithEmail('abcd@def.com');
+    const threads = [
+      createCommentThread([
+        {
+          ...createComment(),
+          message: 'random text with no emails',
+        },
+      ]),
+      createCommentThread([
+        {
+          ...createComment(),
+          message: '@abcd@def.com please take a look',
+        },
+        {
+          ...createComment(),
+          message: '@abcd@def.com please take a look again at this',
+        },
+      ]),
+      createCommentThread([
+        {
+          ...createComment(),
+          message: '@abcd@def.com this is important',
+        },
+      ]),
+    ];
+    assert.deepEqual(getMentionedThreads(threads, account), [
+      threads[1],
+      threads[2],
+    ]);
+
+    assert.deepEqual(
+      getMentionedThreads(threads, createAccountWithEmail('xyz@def.com')),
+      []
+    );
+  });
+
   test('comments sorting', () => {
     const comments = [
       {
@@ -126,7 +162,7 @@
           message: 'i like you, jack',
           updated: '2015-12-23 15:00:20.396000000' as Timestamp,
           line: 1,
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           path: 'some/path',
         },
         {
@@ -135,7 +171,7 @@
           updated: '2015-12-24 15:01:20.396000000' as Timestamp,
           line: 1,
           in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           path: 'some/path',
         },
         {
@@ -143,7 +179,7 @@
           message: 'i do not like either of you' as UrlEncodedCommentId,
           __draft: true,
           updated: '2015-12-20 15:01:20.396000000' as Timestamp,
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           path: 'some/path',
         },
       ];
@@ -155,12 +191,12 @@
       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].patchNum, 1 as RevisionPatchSetNum);
       assert.equal(actualThreads[0].line, 1);
 
       assert.equal(actualThreads[1].comments.length, 1);
       assert.deepEqual(actualThreads[1].comments[0], comments[2]);
-      assert.equal(actualThreads[1].patchNum, 1 as PatchSetNum);
+      assert.equal(actualThreads[1].patchNum, 1 as RevisionPatchSetNum);
       assert.equal(actualThreads[1].line, 'FILE');
     });
 
@@ -176,7 +212,7 @@
             end_line: 1,
             end_character: 2,
           },
-          patch_set: 5 as PatchSetNum,
+          patch_set: 5 as RevisionPatchSetNum,
           path: '/p',
           line: 1,
         },
@@ -200,11 +236,11 @@
                 end_line: 1,
                 end_character: 2,
               },
-              patch_set: 5 as PatchSetNum,
+              patch_set: 5 as RevisionPatchSetNum,
               line: 1,
             },
           ],
-          patchNum: 5 as PatchSetNum,
+          patchNum: 5 as RevisionPatchSetNum,
           range: {
             start_line: 1,
             start_character: 1,
@@ -236,4 +272,121 @@
       assert.equal(createCommentThreads(comments).length, 2);
     });
   });
+
+  test('hasUserSuggestion', () => {
+    const comment = {
+      ...createComment(),
+      message: `${USER_SUGGESTION_START_PATTERN}${'test'}${'\n```'}`,
+    };
+    assert.isTrue(hasUserSuggestion(comment));
+  });
+
+  test('getUserSuggestion', () => {
+    const suggestion = 'test';
+    const comment = {
+      ...createComment(),
+      message: `${USER_SUGGESTION_START_PATTERN}${suggestion}${'\n```'}`,
+    };
+    assert.equal(getUserSuggestion(comment), suggestion);
+  });
+
+  suite('getContentInCommentRange', () => {
+    test('one line', () => {
+      const comment = {
+        ...createComment(),
+        line: 1,
+      };
+      const content = 'line1\nline2\nline3';
+      assert.equal(getContentInCommentRange(content, comment), 'line1');
+    });
+
+    test('multi line', () => {
+      const comment = {
+        ...createComment(),
+        line: 3,
+        range: {
+          start_line: 1,
+          start_character: 5,
+          end_line: 3,
+          end_character: 39,
+        },
+      };
+      const selectedText =
+        '   * Examples:\n' +
+        '      * Acknowledge/Dismiss, Delete, Report a bug, Report as not useful,\n' +
+        '      * Make blocking, Downgrade severity.';
+      const content = `${selectedText}\n`;
+      assert.equal(getContentInCommentRange(content, comment), selectedText);
+    });
+  });
+
+  suite('createUserFixSuggestion', () => {
+    test('one line', () => {
+      const comment = {
+        ...createComment(),
+        line: 1,
+        path: 'abc.txt',
+      };
+      const line = 'lane1';
+      const replacement = 'line1';
+      assert.deepEqual(createUserFixSuggestion(comment, line, replacement), [
+        {
+          fix_id: PROVIDED_FIX_ID,
+          description: 'User suggestion',
+          replacements: [
+            {
+              path: 'abc.txt',
+              range: {
+                start_line: 1,
+                start_character: 0,
+                end_line: 1,
+                end_character: line.length,
+              },
+              replacement,
+            },
+          ],
+        },
+      ]);
+    });
+
+    test('multiline', () => {
+      const comment = {
+        ...createComment(),
+        line: 3,
+        range: {
+          start_line: 1,
+          start_character: 5,
+          end_line: 3,
+          end_character: 39,
+        },
+        path: 'abc.txt',
+      };
+      const line =
+        '   * Examples:\n' +
+        '      * Acknowledge/Dismiss, Delete, Report a bug, Report as not useful,\n' +
+        '      * Make blocking, Downgrade severity.';
+      const replacement =
+        '   - Examples:\n' +
+        '      - Acknowledge/Dismiss, Delete, Report a bug, Report as not useful,\n' +
+        '      - Make blocking, Downgrade severity.';
+      assert.deepEqual(createUserFixSuggestion(comment, line, replacement), [
+        {
+          fix_id: PROVIDED_FIX_ID,
+          description: 'User suggestion',
+          replacements: [
+            {
+              path: 'abc.txt',
+              range: {
+                start_line: 1,
+                start_character: 0,
+                end_line: 3,
+                end_character: 42,
+              },
+              replacement,
+            },
+          ],
+        },
+      ]);
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 9e3bc74..183d167 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -1,20 +1,11 @@
 /**
  * @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 {fireAlert} from './event-util';
+
 /**
  * @fileoverview Functions in this file contains some widely used
  * code patterns. If you noticed a repeated code and none of the existing util
@@ -49,7 +40,7 @@
 /**
  * Throws an error with the provided error message if the condition is false.
  */
-export function check(
+export function assert(
   condition: boolean,
   errorMessage: string
 ): asserts condition {
@@ -59,28 +50,6 @@
 /**
  * Throws an error if the property is not defined.
  */
-export function checkProperty(
-  condition: boolean,
-  propertyName: string
-): asserts condition {
-  check(condition, `missing required property '${propertyName}'`);
-}
-
-/**
- * Throws an error if the property is not defined.
- */
-export function checkRequiredProperty<T>(
-  property: T,
-  propertyName: string
-): asserts property is NonNullable<T> {
-  if (property === undefined || property === null) {
-    throw new Error(`Required property '${propertyName}' not set.`);
-  }
-}
-
-/**
- * Throws an error if the property is not defined.
- */
 export function assertIsDefined<T>(
   val: T,
   variableName = 'variable'
@@ -95,8 +64,11 @@
   selector: string
 ): NodeListOf<E> {
   if (!el) throw new Error('element not defined');
-  const root = el.shadowRoot ?? el;
-  return root.querySelectorAll<E>(selector);
+  if (el.shadowRoot) {
+    const r = el.shadowRoot.querySelectorAll<E>(selector);
+    if (r.length > 0) return r;
+  }
+  return el.querySelectorAll<E>(selector);
 }
 
 export function query<E extends Element = Element>(
@@ -145,7 +117,7 @@
 /**
  * Add value, if the set does not contain it. Otherwise remove it.
  */
-export function toggleSetMembership<T>(set: Set<T>, value: T): void {
+export function toggleSet<T>(set: Set<T>, value: T): void {
   if (set.has(value)) {
     set.delete(value);
   } else {
@@ -153,6 +125,53 @@
   }
 }
 
+export function toggle<T>(array: T[], item: T): T[] {
+  if (array.includes(item)) {
+    return array.filter(r => r !== item);
+  } else {
+    return array.concat([item]);
+  }
+}
+
 export function unique<T>(item: T, index: number, array: T[]) {
   return array.indexOf(item) === index;
 }
+
+/**
+ * Returns the elements that are present in every sub-array. If a compareBy
+ * predicate is passed in, it will be used instead of strict equality. A new
+ * array is always returned even if there is already just a single array.
+ */
+export function intersection<T>(
+  arrays: T[][],
+  compareBy: (t: T, u: T) => boolean = (t, u) => t === u
+): T[] {
+  // Array.prototype.reduce needs either an initialValue or a non-empty array.
+  // Since there is no good initialValue for intersecting (∅ ∩ X = ∅), the
+  // empty array must be checked separately.
+  if (arrays.length === 0) {
+    return [];
+  }
+  if (arrays.length === 1) {
+    return [...arrays[0]];
+  }
+  return arrays.reduce((result, array) =>
+    result.filter(t => array.find(u => compareBy(t, u)))
+  );
+}
+
+/**
+ * Returns the elements that are present in A but not present in B.
+ */
+export function difference<T>(
+  a: T[],
+  b: T[],
+  compareBy: (t: T, u: T) => boolean = (t, u) => t === u
+): T[] {
+  return a.filter(aVal => !b.some(bVal => compareBy(aVal, bVal)));
+}
+
+export async function copyToClipbard(text: string, copyTargetName?: string) {
+  await navigator.clipboard.writeText(text);
+  fireAlert(document, `${copyTargetName ?? text} was copied to clipboard`);
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.ts b/polygerrit-ui/app/utils/common-util_test.ts
index 4156729..76c8a6c 100644
--- a/polygerrit-ui/app/utils/common-util_test.ts
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -1,22 +1,18 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
-import {hasOwnProperty, areSetsEqual, containsAll} from './common-util';
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {
+  hasOwnProperty,
+  areSetsEqual,
+  containsAll,
+  intersection,
+  difference,
+  toggle,
+} from './common-util';
 
 suite('common-util tests', () => {
   suite('hasOwnProperty', () => {
@@ -68,4 +64,45 @@
     assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([5])));
     assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 5])));
   });
+
+  test('intersections', () => {
+    const arrayWithValues = [1, 2, 3];
+    assert.sameDeepMembers(intersection([]), []);
+    assert.sameDeepMembers(intersection([arrayWithValues]), arrayWithValues);
+    // a new array is returned even if a single array is provided.
+    assert.notStrictEqual(intersection([arrayWithValues]), arrayWithValues);
+    assert.sameDeepMembers(
+      intersection([
+        [1, 2, 3],
+        [2, 3, 4],
+        [5, 3, 2],
+      ]),
+      [2, 3]
+    );
+
+    const foo1 = {value: 5};
+    const foo2 = {value: 5};
+
+    // these foo's will fail strict equality with each other, but a comparator
+    // can make them intersect.
+    assert.sameDeepMembers(intersection([[foo1], [foo2]]), []);
+    assert.sameDeepMembers(
+      intersection([[foo1], [foo2]], (a, b) => a.value === b.value),
+      [foo1]
+    );
+  });
+
+  test('difference', () => {
+    assert.deepEqual(difference([1, 2, 3], []), [1, 2, 3]);
+    assert.deepEqual(difference([1, 2, 3], [2, 3, 4]), [1]);
+    assert.deepEqual(difference([1, 2, 3], [1, 2, 3]), []);
+    assert.deepEqual(difference([1, 2, 3], [4, 5, 6]), [1, 2, 3]);
+  });
+
+  test('toggle', () => {
+    assert.deepEqual(toggle([], 1), [1]);
+    assert.deepEqual(toggle([1], 1), []);
+    assert.deepEqual(toggle([1, 2, 3], 1), [2, 3]);
+    assert.deepEqual(toggle([2, 3], 1), [2, 3, 1]);
+  });
 });
diff --git a/polygerrit-ui/app/utils/dashboard-util.ts b/polygerrit-ui/app/utils/dashboard-util.ts
new file mode 100644
index 0000000..caff603
--- /dev/null
+++ b/polygerrit-ui/app/utils/dashboard-util.ts
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ChangeConfigInfo, ChangeInfo} from '../api/rest-api';
+
+export interface DashboardSection {
+  name: string;
+  query: string;
+  suffixForDashboard?: string;
+  selfOnly?: boolean;
+  hideIfEmpty?: boolean;
+  results?: ChangeInfo[];
+}
+
+const USER_PLACEHOLDER_PATTERN = /\${user}/g;
+
+export interface UserDashboardConfig {
+  change?: ChangeConfigInfo;
+}
+
+export interface UserDashboard {
+  title?: string;
+  sections: DashboardSection[];
+}
+
+// NOTE: These queries are tested in Java. Any changes made to definitions
+// here require corresponding changes to:
+// java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+const HAS_DRAFTS: DashboardSection = {
+  // Changes with unpublished draft comments. This section is omitted when
+  // viewing other users, so we don't need to filter anything out.
+  name: 'Has draft comments',
+  query: 'has:draft',
+  selfOnly: true,
+  hideIfEmpty: true,
+  suffixForDashboard: 'limit:10',
+};
+
+export const YOUR_TURN: DashboardSection = {
+  // Changes where the user is in the attention set.
+  name: 'Your Turn',
+  query: 'attention:${user}',
+  hideIfEmpty: false,
+  suffixForDashboard: 'limit:25',
+};
+
+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.
+  name: 'Work in progress',
+  query: 'is:open owner:${user} is:wip',
+  selfOnly: true,
+  hideIfEmpty: true,
+  suffixForDashboard: 'limit:25',
+};
+
+export const OUTGOING: DashboardSection = {
+  // Non-WIP open changes owned by viewed user.
+  name: 'Outgoing reviews',
+  query: 'is:open owner:${user} -is:wip',
+  suffixForDashboard: 'limit:25',
+};
+
+const INCOMING: DashboardSection = {
+  // Non-WIP open changes not owned by the viewed user, that the viewed user
+  // is associated with as a reviewer.
+  name: 'Incoming reviews',
+  query: 'is:open -owner:${user} -is:wip reviewer:${user}',
+  suffixForDashboard: 'limit:25',
+};
+
+const CCED: DashboardSection = {
+  // Open changes the viewed user is CCed on.
+  name: 'CCed on',
+  query: 'is:open -is:wip cc:${user}',
+  suffixForDashboard: 'limit:10',
+};
+
+export const CLOSED: DashboardSection = {
+  name: 'Recently closed',
+  // Closed changes where viewed user is owner or reviewer.
+  // WIP changes not owned by the viewing user (the one instance of
+  // 'owner:self' is intentional and implements this logic) are filtered out.
+  query:
+    'is:closed (-is:wip OR owner:self) ' +
+    '(owner:${user} OR reviewer:${user} OR cc:${user})',
+  suffixForDashboard: '-age:4w limit:10',
+};
+
+const DEFAULT_SECTIONS: DashboardSection[] = [
+  HAS_DRAFTS,
+  YOUR_TURN,
+  WIP,
+  OUTGOING,
+  INCOMING,
+  CCED,
+  CLOSED,
+];
+
+export function getUserDashboard(
+  user = 'self',
+  sections = DEFAULT_SECTIONS,
+  title = ''
+): UserDashboard {
+  sections = sections
+    .filter(section => user === 'self' || !section.selfOnly)
+    .map(section => {
+      return {
+        ...section,
+        name: section.name,
+        query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+      };
+    });
+  return {title, sections};
+}
diff --git a/polygerrit-ui/app/utils/dashboard-util_test.ts b/polygerrit-ui/app/utils/dashboard-util_test.ts
new file mode 100644
index 0000000..40e4ad9
--- /dev/null
+++ b/polygerrit-ui/app/utils/dashboard-util_test.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {getUserDashboard} from './dashboard-util';
+
+suite('gr-navigation tests', () => {
+  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 = 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 = 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/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index a780af5..72e6cb7 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -2,19 +2,8 @@
 
 /**
  * @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
  */
 
 const Duration = {
@@ -44,7 +33,10 @@
 export function durationString(from: Date, to: Date, noAgo = false) {
   const ago = noAgo ? '' : ' ago';
   const secondsAgo = Math.floor((to.valueOf() - from.valueOf()) / 1000);
-  if (secondsAgo <= 59) return 'just now';
+  if (secondsAgo <= 59) {
+    if (noAgo) return `${secondsAgo} seconds`;
+    return 'just now';
+  }
   if (secondsAgo <= 119) return `1 minute${ago}`;
   const minutesAgo = Math.floor(secondsAgo / 60);
   if (minutesAgo <= 59) return `${minutesAgo} minutes${ago}`;
diff --git a/polygerrit-ui/app/utils/date-util_test.ts b/polygerrit-ui/app/utils/date-util_test.ts
index f17ced3..8e802b7 100644
--- a/polygerrit-ui/app/utils/date-util_test.ts
+++ b/polygerrit-ui/app/utils/date-util_test.ts
@@ -1,21 +1,10 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {Timestamp} from '../types/common';
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {
   isValidDate,
   parseDate,
@@ -25,6 +14,7 @@
   formatDate,
   wasYesterday,
 } from './date-util';
+import {assert} from '@open-wc/testing';
 
 suite('date-util tests', () => {
   suite('parseDate', () => {
diff --git a/polygerrit-ui/app/utils/deep-util.ts b/polygerrit-ui/app/utils/deep-util.ts
index 694a8d7..7ed7dd4 100644
--- a/polygerrit-ui/app/utils/deep-util.ts
+++ b/polygerrit-ui/app/utils/deep-util.ts
@@ -1,25 +1,31 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 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)
+  if (a instanceof Date || b instanceof Date) {
+    if (!(a instanceof Date && b instanceof Date)) return false;
     return a.getTime() === b.getTime();
+  }
+
+  if (a instanceof Set || b instanceof Set) {
+    if (!(a instanceof Set && b instanceof Set)) return false;
+    if (a.size !== b.size) return false;
+    for (const ai of a) if (!b.has(ai)) return false;
+    return true;
+  }
+  if (a instanceof Map || b instanceof Map) {
+    if (!(a instanceof Map && b instanceof Map)) return false;
+    if (a.size !== b.size) return false;
+    for (const [aKey, aValue] of a.entries()) {
+      if (!b.has(aKey) || !deepEqual(aValue, b.get(aKey))) return false;
+    }
+    return true;
+  }
 
   if (typeof a === 'object') {
     if (typeof b !== 'object') return false;
diff --git a/polygerrit-ui/app/utils/deep-util_test.ts b/polygerrit-ui/app/utils/deep-util_test.ts
index 64c442b..c671c53 100644
--- a/polygerrit-ui/app/utils/deep-util_test.ts
+++ b/polygerrit-ui/app/utils/deep-util_test.ts
@@ -1,20 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
 import {deepEqual} from './deep-util';
 
 suite('compare-util tests', () => {
@@ -68,4 +58,44 @@
     assert.isFalse(deepEqual([1, 2], [1]));
     assert.isFalse(deepEqual(['a', ['b']], ['a', ['c']]));
   });
+
+  test('deepEqual sets', () => {
+    assert.isTrue(deepEqual(new Set([]), new Set([])));
+    assert.isTrue(deepEqual(new Set([1]), new Set([1])));
+    assert.isTrue(deepEqual(new Set(['a', 'b']), new Set(['a', 'b'])));
+
+    assert.isFalse(deepEqual(undefined, new Set([])));
+    assert.isFalse(deepEqual(null, new Set([])));
+    assert.isFalse(deepEqual(new Set([]), undefined));
+    assert.isFalse(deepEqual(new Set([]), null));
+    assert.isFalse(deepEqual(new Set([]), new Set([1])));
+    assert.isFalse(deepEqual(new Set([1]), new Set([2])));
+    assert.isFalse(deepEqual(new Set([1, 2]), new Set([1])));
+  });
+
+  test('deepEqual maps', () => {
+    assert.isTrue(deepEqual(new Map([]), new Map([])));
+    assert.isTrue(deepEqual(new Map([[1, 'b']]), new Map([[1, 'b']])));
+    assert.isTrue(deepEqual(new Map([['a', 'b']]), new Map([['a', 'b']])));
+
+    assert.isFalse(deepEqual(undefined, new Map([])));
+    assert.isFalse(deepEqual(null, new Map([])));
+    assert.isFalse(deepEqual(new Map([]), undefined));
+    assert.isFalse(deepEqual(new Map([]), null));
+    assert.isFalse(deepEqual(new Map([]), new Map([[1, 'b']])));
+    assert.isFalse(deepEqual(new Map([[1, 'a']]), new Map([[1, 'b']])));
+    assert.isFalse(
+      deepEqual(
+        new Map([[1, 'a']]),
+        new Map([
+          [1, 'a'],
+          [2, 'b'],
+        ])
+      )
+    );
+  });
+
+  test('deepEqual nested', () => {
+    assert.isFalse(deepEqual({foo: new Set([])}, {foo: new Map([])}));
+  });
 });
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
index 7114f98..7c39e1a 100644
--- a/polygerrit-ui/app/utils/display-name-util.ts
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {AccountInfo, GroupInfo, ServerInfo} from '../types/common';
 import {DefaultDisplayNameConfig} from '../constants/constants';
@@ -66,7 +55,7 @@
   config: ServerInfo | undefined,
   account: AccountInfo
 ) {
-  const reviewerName = getUserName(config, account);
+  const reviewerName = getDisplayName(config, account);
   const reviewerEmail = _accountEmail(account.email);
   const reviewerStatus = account.status ? '(' + account.status + ')' : '';
   return [reviewerName, reviewerEmail, reviewerStatus]
diff --git a/polygerrit-ui/app/utils/display-name-util_test.ts b/polygerrit-ui/app/utils/display-name-util_test.ts
index e6d4704..2f938a5 100644
--- a/polygerrit-ui/app/utils/display-name-util_test.ts
+++ b/polygerrit-ui/app/utils/display-name-util_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 {
   AccountInfo,
   DefaultDisplayNameConfig,
@@ -22,7 +10,7 @@
   GroupName,
   ServerInfo,
 } from '../api/rest-api';
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {
   getDisplayName,
   getUserName,
@@ -35,6 +23,7 @@
   createGroupInfo,
   createServerInfo,
 } from '../test/test-data-generators';
+import {assert} from '@open-wc/testing';
 
 suite('display-name-utils tests', () => {
   const config: ServerInfo = {
@@ -205,6 +194,18 @@
     );
   });
 
+  test('getAccountDisplayName - account with display name', () => {
+    assert.equal(
+      getAccountDisplayName(config, {
+        display_name: 'Display Name',
+        name: 'Some name',
+        email: 'my@example.com' as EmailAddress,
+        status: 'OOO',
+      }),
+      'Display Name <my@example.com> (OOO)'
+    );
+  });
+
   test('getGroupDisplayName', () => {
     assert.equal(
       getGroupDisplayName({
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 16e0586..0b53f61 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -1,26 +1,8 @@
 /**
  * @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 {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-
-/**
- * Event emitted from polymer elements.
- */
-export interface PolymerEvent extends EventApi, Event {}
-
 interface ElementWithShadowRoot extends Element {
   shadowRoot: ShadowRoot;
 }
@@ -397,43 +379,30 @@
   shouldSuppress?: boolean;
   /**
    * Do you want to take care of calling preventDefault() and
-   * stopPropagation() yourself?
+   * stopPropagation() yourself? Then set this option to `false`.
    */
-  doNotPrevent?: boolean;
-}
-
-export function addGlobalShortcut(
-  shortcut: Binding,
-  listener: (e: KeyboardEvent) => void,
-  options: ShortcutOptions = {
-    shouldSuppress: true,
-    doNotPrevent: false,
-  }
-) {
-  return addShortcut(document.body, shortcut, listener, options);
+  preventDefault?: boolean;
 }
 
 /**
- * Deprecated.
+ * @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: ShortcutOptions = {
-    shouldSuppress: false,
-    doNotPrevent: false,
-  }
+  options?: ShortcutOptions
 ) {
+  const optShouldSuppress = options?.shouldSuppress ?? false;
+  const optPreventDefault = options?.preventDefault ?? true;
   const wrappedListener = (e: KeyboardEvent) => {
     if (e.repeat && !shortcut.allowRepeat) return;
-    if (options.shouldSuppress && shouldSuppress(e)) return;
+    if (optShouldSuppress && shouldSuppress(e)) return;
     if (!eventMatchesShortcut(e, shortcut)) return;
-    if (!options.doNotPrevent) e.preventDefault();
-    if (!options.doNotPrevent) e.stopPropagation();
+    if (optPreventDefault) e.preventDefault();
+    if (optPreventDefault) e.stopPropagation();
     listener(e);
   };
   element.addEventListener('keydown', wrappedListener);
@@ -473,9 +442,7 @@
     // mark-reviewed and then press ] to go to the next file'.
     (tagName === 'INPUT' && type !== 'checkbox') ||
     tagName === 'TEXTAREA' ||
-    // Suppress shortcuts if the key is 'enter'
-    // and target is an anchor or button or paper-tab.
-    (e.keyCode === 13 &&
+    (e.key === 'Enter' &&
       (tagName === 'A' ||
         tagName === 'BUTTON' ||
         tagName === 'GR-BUTTON' ||
@@ -491,8 +458,18 @@
   return false;
 }
 
+/** Returns a promise that waits for the element's height to become > 0. */
+export function untilRendered(el: HTMLElement) {
+  return new Promise(resolve => {
+    whenRendered(el, resolve);
+  });
+}
+
 /** Executes the given callback when the element's height is > 0. */
-export function whenRendered(el: HTMLElement, callback: () => void) {
+export function whenRendered(
+  el: HTMLElement,
+  callback: (value?: unknown) => void
+) {
   if (el.clientHeight > 0) {
     callback();
     return;
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
index 41e9857..4b52548 100644
--- a/polygerrit-ui/app/utils/dom-util_test.ts
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -1,34 +1,24 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {
   descendedFromClass,
   eventMatchesShortcut,
   getComputedStyleValue,
   getEventPath,
+  Key,
   Modifier,
   querySelectorAll,
   shouldSuppress,
   strToClassName,
 } from './dom-util';
-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 {mockPromise, queryAndAssert} from '../test/test-utils';
+import {mockPromise, pressKey, queryAndAssert} from '../test/test-utils';
+import {fixture, assert} from '@open-wc/testing';
+import {LitElement, html} from 'lit';
+import {customElement} from 'lit/decorators.js';
 
 /**
  * You might think that instead of passing in the callback with assertions as a
@@ -40,7 +30,6 @@
 function keyEventOn(
   el: HTMLElement,
   callback: (e: KeyboardEvent) => void,
-  keyCode = 75,
   key = 'k'
 ): Promise<KeyboardEvent> {
   const promise = mockPromise<KeyboardEvent>();
@@ -48,16 +37,13 @@
     callback(e);
     promise.resolve(e);
   });
-  MockInteractions.keyDownOn(el, keyCode, null, key);
+  pressKey(el, key);
   return promise;
 }
 
-class TestEle extends PolymerElement {
-  static get is() {
-    return 'dom-util-test-element';
-  }
-
-  static get template() {
+@customElement('dom-util-test-element')
+export class TestElement extends LitElement {
+  override render() {
     return html`
       <div>
         <div class="a">
@@ -72,15 +58,13 @@
   }
 }
 
-customElements.define(TestEle.is, TestEle);
-
-const basicFixture = fixtureFromTemplate(html`
-  <div id="test" class="a b c">
+async function createFixture() {
+  return await fixture<HTMLElement>(html` <div id="test" class="a b c">
     <a class="testBtn" style="color:red;"></a>
     <dom-util-test-element></dom-util-test-element>
     <span class="ss"></span>
-  </div>
-`);
+  </div>`);
+}
 
 suite('dom-util tests', () => {
   suite('getEventPath', () => {
@@ -135,25 +119,21 @@
       );
     });
 
-    test('event with real click', () => {
-      const element = basicFixture.instantiate() as HTMLElement;
-      const aLink = queryAndAssert(element, 'a');
+    test('event with real click', async () => {
+      const element = await createFixture();
+      const aLink = queryAndAssert<HTMLAnchorElement>(element, 'a');
       let path;
       aLink.addEventListener('click', (e: Event) => {
         path = getEventPath(e as MouseEvent);
       });
-      MockInteractions.click(aLink);
-      assert.equal(
-        path,
-        `html.lightTheme>body>test-fixture#${basicFixture.fixtureId}>` +
-          'div#test.a.b.c>a.testBtn'
-      );
+      aLink.click();
+      assert.equal(path, 'html>body>div>div#test.a.b.c>a.testBtn');
     });
   });
 
   suite('querySelector and querySelectorAll', () => {
-    test('query cross shadow dom', () => {
-      const element = basicFixture.instantiate() as HTMLElement;
+    test('query cross shadow dom', async () => {
+      const element = await createFixture();
       const theFirstEl = queryAndAssert(element, '.ss');
       const allEls = querySelectorAll(element, '.ss');
       assert.equal(allEls.length, 3);
@@ -162,16 +142,16 @@
   });
 
   suite('getComputedStyleValue', () => {
-    test('color style', () => {
-      const element = basicFixture.instantiate() as HTMLElement;
+    test('color style', async () => {
+      const element = await createFixture();
       const testBtn = queryAndAssert(element, '.testBtn');
       assert.equal(getComputedStyleValue('color', testBtn), 'rgb(255, 0, 0)');
     });
   });
 
   suite('descendedFromClass', () => {
-    test('basic tests', () => {
-      const element = basicFixture.instantiate() as HTMLElement;
+    test('basic tests', async () => {
+      const element = await createFixture();
       const testEl = queryAndAssert(element, 'dom-util-test-element');
       // .c is a child of .a and not vice versa.
       assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.c'), 'a'));
@@ -330,8 +310,7 @@
       await keyEventOn(
         document.createElement('gr-button'),
         e => assert.isTrue(shouldSuppress(e)),
-        13,
-        'enter'
+        Key.ENTER
       );
     });
 
@@ -342,8 +321,7 @@
       await keyEventOn(
         document.createElement('a'),
         e => assert.isTrue(shouldSuppress(e)),
-        13,
-        'enter'
+        Key.ENTER
       );
     });
   });
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 418adbd..714955b 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -1,20 +1,8 @@
 /**
  * @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 {FetchRequest} from '../types/types';
 import {
   DialogChangeEventDetail,
@@ -32,7 +20,7 @@
   );
 }
 
-type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
+export type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
   HTMLElementEventMap[K] extends CustomEvent<infer DT>
     ? unknown extends DT
       ? never
@@ -105,14 +93,14 @@
   fire(target, EventType.IRON_ANNOUNCE, {text});
 }
 
-export function fireShowPrimaryTab(
+export function fireShowTab(
   target: EventTarget,
   tab: string,
   scrollIntoView?: boolean,
   tabState?: TabState
 ) {
   const detail: SwitchTabEventDetail = {tab, scrollIntoView, tabState};
-  fire(target, EventType.SHOW_PRIMARY_TAB, detail);
+  fire(target, EventType.SHOW_TAB, detail);
 }
 
 export function fireCloseFixPreview(target: EventTarget, fixApplied: boolean) {
diff --git a/polygerrit-ui/app/utils/focusable_test.ts b/polygerrit-ui/app/utils/focusable_test.ts
index cbc3185..6ae40ec 100644
--- a/polygerrit-ui/app/utils/focusable_test.ts
+++ b/polygerrit-ui/app/utils/focusable_test.ts
@@ -3,11 +3,10 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {getFocusableElements, getFocusableElementsReverse} from './focusable';
 import {html, render} from 'lit';
-import {fixture} from '@open-wc/testing-helpers';
+import {fixture, assert} from '@open-wc/testing';
 
 async function createDom() {
   const container = await fixture<HTMLDivElement>(html`<div></div>`);
@@ -56,26 +55,18 @@
   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',
-    ]);
+    assert.includeOrderedMembers(
+      results.map(e => e.id),
+      ['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',
-    ]);
+    assert.includeOrderedMembers(
+      results.map(e => e.id),
+      ['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 6183b53..ed9bfac 100644
--- a/polygerrit-ui/app/utils/inner-html-util.ts
+++ b/polygerrit-ui/app/utils/inner-html-util.ts
@@ -8,6 +8,7 @@
 // Internally at Google it has different a implementation.
 
 import {BrandType} from '../types/common';
+export {sanitizeHtml, htmlEscape, sanitizeHtmlToFragment} from 'safevalues';
 
 export type SafeStyleSheet = BrandType<string, '_safeHtml'>;
 
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index f5703f4..aaa35a4 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {
   ChangeInfo,
@@ -21,7 +10,6 @@
   SubmitRequirementStatus,
   LabelNameToValuesMap,
 } from '../api/rest-api';
-import {FlagsService, KnownExperimentId} from '../services/flags/flags';
 import {
   AccountInfo,
   ApprovalInfo,
@@ -130,7 +118,10 @@
   }
 }
 
-export function valueString(value?: number) {
+/**
+ * Returns string representation of QuickLabelInfo value or ApprovalInfo value
+ */
+export function valueString(value?: number): string {
   if (!value) return ' 0';
   let s = `${value}`;
   if (value > 0) s = `+${s}`;
@@ -250,21 +241,38 @@
   }
   return labels.filter(unique);
 }
+export interface SubmitRequirementsIcon {
+  // The material icon name.
+  icon: string;
+  // Whether the gr-icon need to be filled.
+  filled?: boolean;
+}
 
-export function iconForStatus(status: SubmitRequirementStatus) {
+export function iconForRequirement(
+  requirement: SubmitRequirementResultInfo
+): SubmitRequirementsIcon {
+  if (isBlockingCondition(requirement)) {
+    return {icon: 'cancel', filled: true};
+  }
+  return iconForStatus(requirement.status);
+}
+
+export function iconForStatus(
+  status: SubmitRequirementStatus
+): SubmitRequirementsIcon {
   switch (status) {
     case SubmitRequirementStatus.SATISFIED:
-      return 'check-circle-filled';
+      return {icon: 'check_circle', filled: true};
     case SubmitRequirementStatus.UNSATISFIED:
-      return 'block';
+      return {icon: 'block'};
     case SubmitRequirementStatus.OVERRIDDEN:
-      return 'overridden';
+      return {icon: 'published_with_changes'};
     case SubmitRequirementStatus.NOT_APPLICABLE:
-      return 'info';
+      return {icon: 'info', filled: true};
     case SubmitRequirementStatus.ERROR:
-      return 'error';
+      return {icon: 'error', filled: true};
     case SubmitRequirementStatus.FORCED:
-      return 'check-circle-filled';
+      return {icon: 'check_circle', filled: true};
     default:
       assertNever(status, `Unsupported status: ${status}`);
   }
@@ -422,15 +430,33 @@
   );
 }
 
-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;
+export function getApplicableLabels(change?: ParsedChangeInfo | ChangeInfo) {
+  const submitReqs = change?.submit_requirements ?? [];
+  const notApplicableLabels = submitReqs
+    .filter(sr => sr.status === SubmitRequirementStatus.NOT_APPLICABLE)
+    .flatMap(req => extractAssociatedLabels(req))
+    .filter(unique);
 
-  return true;
+  const applicableLabels = submitReqs
+    .filter(sr => sr.status !== SubmitRequirementStatus.NOT_APPLICABLE)
+    .flatMap(req => extractAssociatedLabels(req))
+    .filter(unique);
+
+  const onlyInNotApplicableLabels = notApplicableLabels.filter(
+    label => !applicableLabels.includes(label)
+  );
+
+  return applicableLabels.filter(
+    label => !onlyInNotApplicableLabels.includes(label)
+  );
+}
+
+export function isBlockingCondition(
+  requirement: SubmitRequirementResultInfo
+): boolean {
+  if (requirement.status !== SubmitRequirementStatus.UNSATISFIED) return false;
+
+  return !!requirement.submittability_expression_result.passing_atoms?.some(
+    atom => atom.match(/^label[0-9]*:[\w-]+=MIN$/)
+  );
 }
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
index e655789..c86bda9 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {
   extractAssociatedLabels,
   getApprovalInfo,
@@ -33,6 +21,11 @@
   mergeLabelMaps,
   computeOrderedLabelValues,
   mergeLabelInfoMaps,
+  getApplicableLabels,
+  isBlockingCondition,
+  valueString,
+  hasVotes,
+  hasVoted,
 } from './label-util';
 import {
   AccountId,
@@ -50,12 +43,16 @@
   createNonApplicableSubmitRequirementResultInfo,
   createDetailedLabelInfo,
   createAccountWithId,
+  createQuickLabelInfo,
+  createApproval,
 } from '../test/test-data-generators';
 import {
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
   LabelNameToInfoMap,
+  SubmitRequirementExpressionInfoStatus,
 } from '../api/rest-api';
+import {assert} from '@open-wc/testing';
 
 const VALUES_0 = {
   '0': 'neutral',
@@ -599,4 +596,283 @@
       assert.deepEqual(getTriggerVotes(change), []);
     });
   });
+
+  suite('getApplicableLabels()', () => {
+    test('1 not applicable', () => {
+      const notApplicableLabel = 'Not-Applicable-Label';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${notApplicableLabel}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [notApplicableLabel]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getApplicableLabels(change), []);
+    });
+    test('1 applicable, 1 not applicable', () => {
+      const applicableLabel = 'Applicable-Label';
+      const notApplicableLabel = 'Not-Applicable-Label';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${notApplicableLabel}=MAX`,
+            },
+            is_legacy: false,
+          },
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.UNSATISFIED,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${applicableLabel}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [notApplicableLabel]: createDetailedLabelInfo(),
+          [applicableLabel]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getApplicableLabels(change), [applicableLabel]);
+    });
+
+    test('same label in applicable and not applicable requirement', () => {
+      const label = 'label';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${label}=MAX`,
+            },
+            is_legacy: false,
+          },
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.UNSATISFIED,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${label}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [label]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getApplicableLabels(change), [label]);
+    });
+  });
+
+  suite('getApplicableLabels()', () => {
+    test('1 not applicable', () => {
+      const notApplicableLabel = 'Not-Applicable-Label';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${notApplicableLabel}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [notApplicableLabel]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getApplicableLabels(change), []);
+    });
+    test('1 applicable, 1 not applicable', () => {
+      const applicableLabel = 'Applicable-Label';
+      const notApplicableLabel = 'Not-Applicable-Label';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${notApplicableLabel}=MAX`,
+            },
+            is_legacy: false,
+          },
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.UNSATISFIED,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${applicableLabel}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [notApplicableLabel]: createDetailedLabelInfo(),
+          [applicableLabel]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getApplicableLabels(change), [applicableLabel]);
+    });
+
+    test('same label in applicable and not applicable requirement', () => {
+      const label = 'label';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${label}=MAX`,
+            },
+            is_legacy: false,
+          },
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.UNSATISFIED,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${label}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [label]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getApplicableLabels(change), [label]);
+    });
+  });
+
+  suite('isBlockingCondition', () => {
+    test('true', () => {
+      const requirement: SubmitRequirementResultInfo = {
+        name: 'Code-Review',
+        description:
+          "At least one maximum vote for label 'Code-Review' is required",
+        status: SubmitRequirementStatus.UNSATISFIED,
+        is_legacy: false,
+        submittability_expression_result: {
+          expression:
+            'label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN',
+          fulfilled: false,
+          status: SubmitRequirementExpressionInfoStatus.FAIL,
+          passing_atoms: ['label:Code-Review=MIN'],
+          failing_atoms: ['label:Code-Review=MAX,user=non_uploader'],
+        },
+      };
+      assert.isTrue(isBlockingCondition(requirement));
+    });
+
+    test('false', () => {
+      const requirement: SubmitRequirementResultInfo = {
+        name: 'Code-Review',
+        description:
+          "At least one maximum vote for label 'Code-Review' is required",
+        status: SubmitRequirementStatus.UNSATISFIED,
+        is_legacy: false,
+        submittability_expression_result: {
+          expression:
+            'label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN',
+          fulfilled: false,
+          status: SubmitRequirementExpressionInfoStatus.FAIL,
+          passing_atoms: [],
+          failing_atoms: [
+            'label:Code-Review=MAX,user=non_uploader',
+            'label:Code-Review=MIN',
+          ],
+        },
+      };
+      assert.isFalse(isBlockingCondition(requirement));
+    });
+  });
+
+  suite('valueString', () => {
+    const approvalInfo = createApproval();
+    test('0', () => {
+      approvalInfo.value = 0;
+      assert.equal(valueString(approvalInfo.value), ' 0');
+    });
+    test('-1', () => {
+      approvalInfo.value = -1;
+      assert.equal(valueString(approvalInfo.value), '-1');
+    });
+    test('2', () => {
+      approvalInfo.value = 2;
+      assert.equal(valueString(approvalInfo.value), '+2');
+    });
+  });
+
+  suite('hasVotes', () => {
+    const detailedLabelInfo = createDetailedLabelInfo();
+    const quickLabelInfo = createQuickLabelInfo();
+    test('detailedLabelInfo - neutral vote => false', () => {
+      const neutralApproval = createApproval();
+      neutralApproval.value = 0;
+      detailedLabelInfo.all = [neutralApproval];
+      assert.isFalse(hasVotes(detailedLabelInfo));
+    });
+    test('detailedLabelInfo - positive vote => true', () => {
+      const positiveApproval = createApproval();
+      positiveApproval.value = 2;
+      detailedLabelInfo.all = [positiveApproval];
+      assert.isTrue(hasVotes(detailedLabelInfo));
+    });
+    test('quickLabelInfo - neutral => false', () => {
+      assert.isFalse(hasVotes(quickLabelInfo));
+    });
+    test('quickLabelInfo - negative => false', () => {
+      quickLabelInfo.rejected = createAccountWithId();
+      assert.isTrue(hasVotes(quickLabelInfo));
+    });
+  });
+
+  suite('hasVoted', () => {
+    const detailedLabelInfo = createDetailedLabelInfo();
+    const quickLabelInfo = createQuickLabelInfo();
+    const account = createAccountWithId(23);
+    test('detailedLabelInfo - positive vote => true', () => {
+      const positiveApproval = createApproval(account);
+      positiveApproval.value = 2;
+      detailedLabelInfo.all = [positiveApproval];
+      assert.isTrue(hasVoted(detailedLabelInfo, account));
+    });
+    test('detailedLabelInfo - different account vote => true', () => {
+      const differentPositiveApproval = createApproval();
+      differentPositiveApproval.value = 2;
+      detailedLabelInfo.all = [differentPositiveApproval];
+      assert.isFalse(hasVoted(detailedLabelInfo, account));
+    });
+    test('quickLabelInfo - negative => false', () => {
+      quickLabelInfo.rejected = account;
+      assert.isTrue(hasVoted(quickLabelInfo, account));
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/link-util.ts b/polygerrit-ui/app/utils/link-util.ts
new file mode 100644
index 0000000..b5b9025
--- /dev/null
+++ b/polygerrit-ui/app/utils/link-util.ts
@@ -0,0 +1,216 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import 'ba-linkify/ba-linkify';
+import {CommentLinkInfo, CommentLinks} from '../types/common';
+import {getBaseUrl} from './url-util';
+
+/**
+ * Finds links within the base string and convert them to HTML. Config-based
+ * rewrites are only applied on text that is not linked by the default linking
+ * library.
+ */
+export function linkifyUrlsAndApplyRewrite(
+  base: string,
+  repoCommentLinks: CommentLinks
+): string {
+  const parts: string[] = [];
+  window.linkify(insertZeroWidthSpace(base), {
+    callback: (text, href) => {
+      if (href) {
+        parts.push(removeZeroWidthSpace(createLinkTemplate(href, text)));
+      } else {
+        const rewriteResults = getRewriteResultsFromConfig(
+          text,
+          repoCommentLinks
+        );
+        parts.push(removeZeroWidthSpace(applyRewrites(text, rewriteResults)));
+      }
+    },
+  });
+  return parts.join('');
+}
+
+/**
+ * Generates a list of rewrites that would be applied to a base string. They are
+ * not applied immediately to the base text because one rewrite may interfere or
+ * overlap with a later rewrite. Only after all rewrites are known they are
+ * carefully merged with `applyRewrites`.
+ */
+function getRewriteResultsFromConfig(
+  base: string,
+  repoCommentLinks: CommentLinks
+): RewriteResult[] {
+  const enabledRewrites = Object.values(repoCommentLinks).filter(
+    commentLinkInfo =>
+      commentLinkInfo.enabled !== false &&
+      (commentLinkInfo.link !== undefined || commentLinkInfo.html !== undefined)
+  );
+  return enabledRewrites.flatMap(rewrite => {
+    const regexp = new RegExp(rewrite.match, 'g');
+    const partialResults: RewriteResult[] = [];
+    let match: RegExpExecArray | null;
+
+    while ((match = regexp.exec(base)) !== null) {
+      const fullReplacementText = getReplacementText(match[0], rewrite);
+      // The replacement may not be changing the entire matched substring so we
+      // "trim" the replacement position and text to the part that is actually
+      // different. This makes sure that unchanged portions are still eligible
+      // for other rewrites without being rejected as overlaps during
+      // `applyRewrites`. The new `replacementText` is not eligible for other
+      // rewrites since it would introduce unexpected interactions between
+      // rewrites depending on their order of definition/execution.
+      const sharedPrefixLength = getSharedPrefixLength(
+        match[0],
+        fullReplacementText
+      );
+      const sharedSuffixLength = getSharedSuffixLength(
+        match[0],
+        fullReplacementText
+      );
+      const prefixIndex = sharedPrefixLength;
+      const matchSuffixIndex = match[0].length - sharedSuffixLength;
+      const fullReplacementSuffixIndex =
+        fullReplacementText.length - sharedSuffixLength;
+      partialResults.push({
+        replacedTextStartPosition: match.index + prefixIndex,
+        replacedTextEndPosition: match.index + matchSuffixIndex,
+        replacementText: fullReplacementText.substring(
+          prefixIndex,
+          fullReplacementSuffixIndex
+        ),
+      });
+    }
+    return partialResults;
+  });
+}
+
+/**
+ * Applies all the rewrites to the given base string. To resolve cases where
+ * multiple rewrites target overlapping pieces of the base string, the rewrite
+ * that ends latest is kept and the rest are not applied and discarded.
+ */
+function applyRewrites(base: string, rewriteResults: RewriteResult[]): string {
+  const rewritesByEndPosition = [...rewriteResults].sort((a, b) => {
+    if (b.replacedTextEndPosition !== a.replacedTextEndPosition) {
+      return b.replacedTextEndPosition - a.replacedTextEndPosition;
+    }
+    return a.replacedTextStartPosition - b.replacedTextStartPosition;
+  });
+  const filteredSortedRewrites: RewriteResult[] = [];
+  let latestReplace = base.length;
+  for (const rewrite of rewritesByEndPosition) {
+    // Only accept rewrites that do not overlap with any previously accepted
+    // rewrites.
+    if (rewrite.replacedTextEndPosition <= latestReplace) {
+      filteredSortedRewrites.push(rewrite);
+      latestReplace = rewrite.replacedTextStartPosition;
+    }
+  }
+  return filteredSortedRewrites.reduce(
+    (text, rewrite) =>
+      text
+        .substring(0, rewrite.replacedTextStartPosition)
+        .concat(rewrite.replacementText)
+        .concat(text.substring(rewrite.replacedTextEndPosition)),
+    base
+  );
+}
+
+/**
+ * For a given regexp match, apply the rewrite based on the rewrite's type and
+ * return the resulting string.
+ */
+function getReplacementText(
+  matchedText: string,
+  rewrite: CommentLinkInfo
+): string {
+  if (rewrite.link !== undefined) {
+    const replacementHref = rewrite.link.startsWith('/')
+      ? `${getBaseUrl()}${rewrite.link}`
+      : rewrite.link;
+    const regexp = new RegExp(rewrite.match, 'g');
+    return matchedText.replace(
+      regexp,
+      createLinkTemplate(
+        replacementHref,
+        rewrite.text ?? '$&',
+        rewrite.prefix,
+        rewrite.suffix
+      )
+    );
+  } else if (rewrite.html !== undefined) {
+    return matchedText.replace(new RegExp(rewrite.match, 'g'), rewrite.html);
+  } else {
+    throw new Error('commentLinkInfo is not a link or html rewrite');
+  }
+}
+
+/**
+ * Some tools are known to look for reviewers/CCs by finding lines such as
+ * "R=foo@gmail.com, bar@gmail.com". However, "=" is technically a valid email
+ * character, so ba-linkify interprets the entire string "R=foo@gmail.com" as an
+ * email address. To fix this, we insert a zero width space character \u200B
+ * before linking that prevents ba-linkify from associating the prefix with the
+ * email. After linking we remove the zero width space.
+ */
+function insertZeroWidthSpace(base: string) {
+  return base.replace(/^(R=|CC=)/g, '$&\u200B');
+}
+
+function removeZeroWidthSpace(base: string) {
+  return base.replace(/\u200B/g, '');
+}
+
+function createLinkTemplate(
+  href: string,
+  displayText: string,
+  prefix?: string,
+  suffix?: string
+) {
+  return `${
+    prefix ?? ''
+  }<a href="${href}" rel="noopener" target="_blank">${displayText}</a>${
+    suffix ?? ''
+  }`;
+}
+
+/**
+ * Returns the number of characters that are identical at the start of both
+ * strings.
+ *
+ * For example, `getSharedPrefixLength('12345678', '1234zz78')` would return 4
+ */
+function getSharedPrefixLength(a: string, b: string) {
+  let i = 0;
+  for (; i < a.length && i < b.length; ++i) {
+    if (a[i] !== b[i]) {
+      return i;
+    }
+  }
+  return i;
+}
+
+/**
+ * Returns the number of characters that are identical at the end of both
+ * strings.
+ *
+ * For example, `getSharedSuffixLength('12345678', '1234zz78')` would return 2
+ */
+function getSharedSuffixLength(a: string, b: string) {
+  let i = a.length;
+  for (let j = b.length; i !== 0 && j !== 0; --i, --j) {
+    if (a[i] !== b[j]) {
+      return a.length - 1 - i;
+    }
+  }
+  return a.length - i;
+}
+
+interface RewriteResult {
+  replacedTextStartPosition: number;
+  replacedTextEndPosition: number;
+  replacementText: string;
+}
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
new file mode 100644
index 0000000..61d6bff
--- /dev/null
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -0,0 +1,261 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {linkifyUrlsAndApplyRewrite} from './link-util';
+import {assert} from '@open-wc/testing';
+
+suite('link-util tests', () => {
+  function link(text: string, href: string) {
+    return `<a href="${href}" rel="noopener" target="_blank">${text}</a>`;
+  }
+
+  suite('link rewrites', () => {
+    test('without text', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('foo', {
+          fooLinkWithoutText: {
+            match: 'foo',
+            link: 'foo.gov',
+          },
+        }),
+        link('foo', 'foo.gov')
+      );
+    });
+
+    test('with text', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('foo', {
+          fooLinkWithText: {
+            match: 'foo',
+            link: 'foo.gov',
+            text: 'foo site',
+          },
+        }),
+        link('foo site', 'foo.gov')
+      );
+    });
+
+    test('with prefix and suffix', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('there are 12 foos here', {
+          fooLinkWithText: {
+            match: '(.*)(bug|foo)s(.*)',
+            link: '$2.gov',
+            text: '$2 list',
+            prefix: '$1on the ',
+            suffix: '$3',
+          },
+        }),
+        `there are 12 on the ${link('foo list', 'foo.gov')} here`
+      );
+    });
+
+    test('multiple matches', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('foo foo', {
+          foo: {
+            match: 'foo',
+            link: 'foo.gov',
+          },
+        }),
+        `${link('foo', 'foo.gov')} ${link('foo', 'foo.gov')}`
+      );
+    });
+
+    test('does not apply within normal links', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('google.com', {
+          ogle: {
+            match: 'ogle',
+            link: 'gerritcodereview.com',
+          },
+        }),
+        link('google.com', 'http://google.com')
+      );
+    });
+  });
+  suite('html rewrites', () => {
+    test('basic case', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('foo', {
+          foo: {
+            match: '(foo)',
+            html: '<div>$1</div>',
+          },
+        }),
+        '<div>foo</div>'
+      );
+    });
+
+    test('only inserts', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('foo', {
+          foo: {
+            match: 'foo',
+            html: 'foo bar',
+          },
+        }),
+        'foo bar'
+      );
+    });
+
+    test('only deletes', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('foo bar baz', {
+          bar: {
+            match: 'bar',
+            html: '',
+          },
+        }),
+        'foo  baz'
+      );
+    });
+
+    test('multiple matches', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('foo foo', {
+          foo: {
+            match: '(foo)',
+            html: '<div>$1</div>',
+          },
+        }),
+        '<div>foo</div> <div>foo</div>'
+      );
+    });
+
+    test('does not apply within normal links', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('google.com', {
+          ogle: {
+            match: 'ogle',
+            html: '<div>gerritcodereview.com<div>',
+          },
+        }),
+        link('google.com', 'http://google.com')
+      );
+    });
+  });
+
+  test('for overlapping rewrites prefer the latest ending', () => {
+    assert.equal(
+      linkifyUrlsAndApplyRewrite('foobarbaz', {
+        foo: {
+          match: 'foo',
+          link: 'foo.gov',
+        },
+        foobarbaz: {
+          match: 'foobarbaz',
+          html: '<div>foobarbaz.gov</div>',
+        },
+        foobar: {
+          match: 'foobar',
+          link: 'foobar.gov',
+        },
+      }),
+      '<div>foobarbaz.gov</div>'
+    );
+  });
+
+  test('overlapping rewrites with same ending prefers earliest start', () => {
+    assert.equal(
+      linkifyUrlsAndApplyRewrite('foobarbaz', {
+        foo: {
+          match: 'baz',
+          link: 'Baz.gov',
+        },
+        foobarbaz: {
+          match: 'foobarbaz',
+          html: '<div>FooBarBaz.gov</div>',
+        },
+        foobar: {
+          match: 'barbaz',
+          link: 'BarBaz.gov',
+        },
+      }),
+      '<div>FooBarBaz.gov</div>'
+    );
+  });
+
+  test('removed overlapping rewrites do not prevent other rewrites', () => {
+    assert.equal(
+      linkifyUrlsAndApplyRewrite('foobarbaz', {
+        foo: {
+          match: 'foo',
+          html: 'FOO',
+        },
+        oobarba: {
+          match: 'oobarba',
+          html: 'OOBARBA',
+        },
+        baz: {
+          match: 'baz',
+          html: 'BAZ',
+        },
+      }),
+      'FOObarBAZ'
+    );
+  });
+
+  test('rewrites do not interfere with each other matching', () => {
+    assert.equal(
+      linkifyUrlsAndApplyRewrite('bugs: 123 234 345', {
+        bug1: {
+          match: '(bugs:) (\\d+)',
+          html: '$1 <div>bug/$2</div>',
+        },
+        bug2: {
+          match: '(bugs:) (\\d+) (\\d+)',
+          html: '$1 $2 <div>bug/$3</div>',
+        },
+        bug3: {
+          match: '(bugs:) (\\d+) (\\d+) (\\d+)',
+          html: '$1 $2 $3 <div>bug/$4</div>',
+        },
+      }),
+      'bugs: <div>bug/123</div> <div>bug/234</div> <div>bug/345</div>'
+    );
+  });
+
+  suite('normal links', () => {
+    test('links urls', () => {
+      const googleLink = link('google.com', 'http://google.com');
+      const mapsLink = link('maps.google.com', 'http://maps.google.com');
+
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('google.com, maps.google.com', {}),
+        `${googleLink}, ${mapsLink}`
+      );
+    });
+
+    test('links emails without including R= prefix', () => {
+      const fooEmail = link('foo@gmail.com', 'mailto:foo@gmail.com');
+      const barEmail = link('bar@gmail.com', 'mailto:bar@gmail.com');
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('R=foo@gmail.com, bar@gmail.com', {}),
+        `R=${fooEmail}, ${barEmail}`
+      );
+    });
+
+    test('links emails without including CC= prefix', () => {
+      const fooEmail = link('foo@gmail.com', 'mailto:foo@gmail.com');
+      const barEmail = link('bar@gmail.com', 'mailto:bar@gmail.com');
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('CC=foo@gmail.com, bar@gmail.com', {}),
+        `CC=${fooEmail}, ${barEmail}`
+      );
+    });
+
+    test('links emails maintains R= and CC= within addresses', () => {
+      const fooBarBazEmail = link(
+        'fooR=barCC=baz@gmail.com',
+        'mailto:fooR=barCC=baz@gmail.com'
+      );
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('fooR=barCC=baz@gmail.com', {}),
+        fooBarBazEmail
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/lit-util.ts b/polygerrit-ui/app/utils/lit-util.ts
new file mode 100644
index 0000000..7ffab89
--- /dev/null
+++ b/polygerrit-ui/app/utils/lit-util.ts
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html} from 'lit';
+
+/**
+ * This is a patched version of html`` to work around this Chrome bug:
+ * https://bugs.chromium.org/p/v8/issues/detail?id=13190.
+ *
+ * The problem is that Chrome should guarantee that the TemplateStringsArray
+ * is always the same instance, if the strings themselves are equal, but that
+ * guarantee seems to be broken. So we are maintaining a map from
+ * "concatenated strings" to TemplateStringsArray. If "concatenated strings"
+ * are equal, then return the already known instance of TemplateStringsArray,
+ * so html`` can use its strict equality check on it.
+ */
+export class HtmlPatched {
+  constructor(private readonly reporter?: (key: string) => void) {}
+
+  /**
+   * If `strings` are in this set, then we are sure that they are also in the
+   * map, and that we will not run into the issue of "same key, but different
+   * strings array". So this set allows us to optimize performance a bit, and
+   * call the native html`` function early.
+   */
+  private readonly lookupSet = new Set<TemplateStringsArray>();
+
+  private readonly lookupMap = new Map<string, TemplateStringsArray>();
+
+  /**
+   * Proxies lit's html`` tagges template literal. See
+   * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals
+   * https://lit.dev/docs/libraries/standalone-templates/
+   *
+   * Example: If you call html`a${1}b${2}c`, then
+   * ['a', 'b', 'c'] are the "strings", and 1, 2 are the "values".
+   */
+  html(strings: TemplateStringsArray, ...values: unknown[]) {
+    if (this.lookupSet.has(strings)) {
+      return this.nativeHtml(strings, ...values);
+    }
+
+    const key = strings.join('\0');
+    const oldStrings = this.lookupMap.get(key);
+
+    if (oldStrings === undefined) {
+      this.lookupSet.add(strings);
+      this.lookupMap.set(key, strings);
+      return this.nativeHtml(strings, ...values);
+    }
+
+    if (oldStrings === strings) {
+      return this.nativeHtml(strings, ...values);
+    }
+
+    // Without using HtmlPatcher html`` would be called with `strings`,
+    // which will be considered different, although actually being equal.
+    console.warn(`HtmlPatcher was required for '${key.substring(0, 100)}'.`);
+    this.reporter?.(key);
+    return this.nativeHtml(oldStrings, ...values);
+  }
+
+  // Allows spying on calls in tests.
+  nativeHtml(strings: TemplateStringsArray, ...values: unknown[]) {
+    return html(strings, ...values);
+  }
+}
diff --git a/polygerrit-ui/app/utils/lit-util_test.ts b/polygerrit-ui/app/utils/lit-util_test.ts
new file mode 100644
index 0000000..17197f0
--- /dev/null
+++ b/polygerrit-ui/app/utils/lit-util_test.ts
@@ -0,0 +1,118 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {HtmlPatched} from './lit-util';
+
+function tsa(strings: string[]): TemplateStringsArray {
+  return strings as unknown as TemplateStringsArray;
+}
+
+suite('lit-util HtmlPatched tests', () => {
+  let patched: HtmlPatched;
+  let nativeHtmlSpy: sinon.SinonSpy;
+  let reporterSpy: sinon.SinonSpy;
+
+  setup(async () => {
+    reporterSpy = sinon.spy();
+    patched = new HtmlPatched(reporterSpy);
+    nativeHtmlSpy = sinon.spy(patched, 'nativeHtml');
+  });
+
+  test('simple call', () => {
+    const instance1 = tsa(['1']);
+    patched.html(instance1, 'a value');
+    assert.equal(nativeHtmlSpy.callCount, 1);
+    assert.equal(reporterSpy.callCount, 0);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[0].args[0], instance1);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[0].args[1], 'a value');
+  });
+
+  test('two calls, same instance', () => {
+    const instance1 = tsa(['1']);
+    patched.html(instance1, 'a value');
+    patched.html(instance1, 'a value');
+    assert.equal(nativeHtmlSpy.callCount, 2);
+    assert.equal(reporterSpy.callCount, 0);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1);
+  });
+
+  test('two calls, different strings', () => {
+    const instance1 = tsa(['1']);
+    const instance2 = tsa(['2']);
+    patched.html(instance1, 'a value');
+    patched.html(instance2, 'a value');
+    assert.equal(nativeHtmlSpy.callCount, 2);
+    assert.equal(reporterSpy.callCount, 0);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance2);
+  });
+
+  test('two calls, same strings, different instances', () => {
+    const instance1 = tsa(['1']);
+    const instance2 = tsa(['1']);
+    patched.html(instance1, 'a value');
+    patched.html(instance2, 'a value');
+    assert.equal(nativeHtmlSpy.callCount, 2);
+    assert.equal(reporterSpy.callCount, 1);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1);
+  });
+
+  test('many calls', () => {
+    const instance1a = tsa(['1']);
+    const instance1b = tsa(['1']);
+    const instance1c = tsa(['1']);
+    const instance2a = tsa(['asdf', 'qwer']);
+    const instance2b = tsa(['asdf', 'qwer']);
+    const instance2c = tsa(['asdf', 'qwer']);
+    const instance3a = tsa(['asd', 'fqwer']);
+    const instance3b = tsa(['asd', 'fqwer']);
+    const instance3c = tsa(['asd', 'fqwer']);
+
+    patched.html(instance1a, 'a value');
+    patched.html(instance1a, 'a value');
+    patched.html(instance1b, 'a value');
+    patched.html(instance1b, 'a value');
+    patched.html(instance1c, 'a value');
+    patched.html(instance1c, 'a value');
+    patched.html(instance2a, 'a value');
+    patched.html(instance2a, 'a value');
+    patched.html(instance2b, 'a value');
+    patched.html(instance2b, 'a value');
+    patched.html(instance2c, 'a value');
+    patched.html(instance2c, 'a value');
+    patched.html(instance3a, 'a value');
+    patched.html(instance3a, 'a value');
+    patched.html(instance3b, 'a value');
+    patched.html(instance3b, 'a value');
+    patched.html(instance3c, 'a value');
+    patched.html(instance3c, 'a value');
+
+    assert.equal(nativeHtmlSpy.callCount, 18);
+    assert.equal(reporterSpy.callCount, 12);
+
+    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[2].firstArg, instance1a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[3].firstArg, instance1a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[4].firstArg, instance1a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[5].firstArg, instance1a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[6].firstArg, instance2a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[7].firstArg, instance2a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[8].firstArg, instance2a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[9].firstArg, instance2a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[10].firstArg, instance2a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[11].firstArg, instance2a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[12].firstArg, instance3a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[13].firstArg, instance3a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[14].firstArg, instance3a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[15].firstArg, instance3a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[16].firstArg, instance3a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[17].firstArg, instance3a);
+  });
+});
diff --git a/polygerrit-ui/app/utils/math-util.ts b/polygerrit-ui/app/utils/math-util.ts
deleted file mode 100644
index adec7d3..0000000
--- a/polygerrit-ui/app/utils/math-util.ts
+++ /dev/null
@@ -1,24 +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.
- */
-
-/**
- * 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
deleted file mode 100644
index fca1d73..0000000
--- a/polygerrit-ui/app/utils/math-util_test.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 '../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/utils/message-util.ts b/polygerrit-ui/app/utils/message-util.ts
index 70dd286..5acdf33 100644
--- a/polygerrit-ui/app/utils/message-util.ts
+++ b/polygerrit-ui/app/utils/message-util.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {MessageTag} from '../constants/constants';
 import {ChangeId, ChangeMessageInfo} from '../types/common';
 
diff --git a/polygerrit-ui/app/utils/observable-util.ts b/polygerrit-ui/app/utils/observable-util.ts
index e39aa48..7687fd2 100644
--- a/polygerrit-ui/app/utils/observable-util.ts
+++ b/polygerrit-ui/app/utils/observable-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {Observable} from 'rxjs';
 import {distinctUntilChanged, map, shareReplay} from 'rxjs/operators';
diff --git a/polygerrit-ui/app/utils/page-wrapper-utils.ts b/polygerrit-ui/app/utils/page-wrapper-utils.ts
index 19cfe48..78e78ed 100644
--- a/polygerrit-ui/app/utils/page-wrapper-utils.ts
+++ b/polygerrit-ui/app/utils/page-wrapper-utils.ts
@@ -1,30 +1,20 @@
 /**
  * @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 'page/page';
+// @ts-ignore: Bazel is not yet configured to download the types
+import pagejs from 'page';
 
-// Reexport page.js. To make it work, karma, server.go and rollup patch
-// page.js and replace "this" to "window". Otherwise, it can't assign global
-// property. We can't import page.mjs because typescript doesn't support mjs
-// extensions
+// Reexport page.js. To make it work rollup patches page.js and replace "this"
+// to "window". Otherwise, it can't assign global property. We can't import
+// page.mjs because typescript doesn't support mjs extensions
 export interface Page {
   (pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
   (pageCallback: PageCallback): void;
   show(url: string): void;
   redirect(url: string): void;
+  replace(path: string, state: null, init: boolean, dispatch: boolean): void;
   base(url: string): void;
   start(): void;
   exit(pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
@@ -32,14 +22,10 @@
 
 // See https://visionmedia.github.io/page.js/ for details
 export interface PageContext {
-  save(): void;
-  handled: boolean;
   canonicalPath: string;
   path: string;
   querystring: string;
   pathname: string;
-  state: unknown;
-  title: string;
   hash: string;
   params: {[paramIndex: string]: string};
 }
@@ -51,4 +37,6 @@
   next: PageNextCallback
 ) => void;
 
-export const page = window['page'] as Page;
+// TODO: Convert page usages to the real types and remove this file of wrapper
+// types. Also remove workarounds in rollup config.
+export const page = pagejs as unknown as Page;
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index cccb8ec..515f2e7 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -2,30 +2,19 @@
   RevisionInfo,
   ChangeInfo,
   PatchSetNum,
-  EditPatchSetNum,
-  ParentPatchSetNum,
+  EDIT,
+  PARENT,
   PatchSetNumber,
   BasePatchSetNum,
   RevisionPatchSetNum,
 } from '../types/common';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
-import {check} from './common-util';
+import {assert} from './common-util';
 
 /**
  * @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
  */
 
 // Tags identifying ChangeMessages that move change into WIP state.
@@ -40,14 +29,14 @@
 export const CURRENT = 'current';
 
 export interface PatchSet {
-  num: PatchSetNum;
+  num: RevisionPatchSetNum;
   desc: string | undefined;
   sha: string;
   wip?: boolean;
 }
 
 interface PatchRange {
-  patchNum?: PatchSetNum;
+  patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
 }
 
@@ -64,12 +53,12 @@
  * parent.
  */
 export function isAParent(n: PatchSetNum) {
-  return n === ParentPatchSetNum || isMergeParent(n);
+  return n === PARENT || isMergeParent(n);
 }
 
 export function isPatchSetNum(patchset: string) {
   if (!isNaN(Number(patchset))) return true;
-  return patchset === EditPatchSetNum || patchset === ParentPatchSetNum;
+  return patchset === EDIT || patchset === PARENT;
 }
 
 export function convertToPatchSetNum(
@@ -123,7 +112,7 @@
 export function findEdit(
   revisions: Array<RevisionInfo | EditRevisionInfo>
 ): EditRevisionInfo | undefined {
-  const editRev = revisions.find(info => info._number === EditPatchSetNum);
+  const editRev = revisions.find(info => info._number === EDIT);
   return editRev as EditRevisionInfo | undefined;
 }
 
@@ -152,8 +141,8 @@
   revisions: Array<RevisionInfo | EditRevisionInfo>
 ) {
   const revisionInfo = findEditParentRevision(revisions);
-  // finding parent of 'edit' patchset, hence revisionInfo._number cannot be
-  // 'edit' and must be a number
+  // finding parent of EDIT patchset, hence revisionInfo._number cannot be
+  // EDIT and must be a number
   // TODO(TS): find a way to avoid 'as'
   return revisionInfo ? (revisionInfo._number as number) : -1;
 }
@@ -162,7 +151,7 @@
  * Sort given revisions array according to the patch set number, in
  * descending order.
  * The sort algorithm is change edit aware. Change edit has patch set number
- * equals 'edit', but must appear after the patch set it was based on.
+ * equals EDIT, but must appear after the patch set it was based on.
  * Example: change edit is based on patch set 2, and another patch set was
  * uploaded after change edit creation, the sorted order should be:
  * 3, edit, 2, 1.
@@ -177,9 +166,7 @@
   // Map an edit to the patchNum of parent*2... I.e. edit on 2 -> 4.
   // TODO(TS): find a way to avoid 'as'
   const num = (r: T) =>
-    r._number === EditPatchSetNum
-      ? 2 * editParent
-      : 2 * ((r._number as number) - 1) + 1;
+    r._number === EDIT ? 2 * editParent : 2 * ((r._number as number) - 1) + 1;
   return revisions.sort((a, b) => num(b) - num(a));
 }
 
@@ -271,24 +258,20 @@
     return undefined;
   }
   let latest = allPatchSets[0].num;
-  if (latest === EditPatchSetNum) {
+  if (latest === EDIT) {
     latest = allPatchSets[1].num;
   }
-  check(isNumber(latest), 'Latest patchset cannot be EDIT or PARENT.');
+  assert(isNumber(latest), 'Latest patchset cannot be EDIT or PARENT.');
   return latest;
 }
 
 export function computePredecessor(
   patchset?: PatchSetNum
 ): BasePatchSetNum | undefined {
-  if (
-    !patchset ||
-    patchset === ParentPatchSetNum ||
-    patchset === EditPatchSetNum
-  ) {
+  if (!patchset || patchset === PARENT || patchset === EDIT) {
     return undefined;
   }
-  if (patchset === 1) return ParentPatchSetNum;
+  if (patchset === 1) return PARENT;
   return (Number(patchset) - 1) as BasePatchSetNum;
 }
 
@@ -298,14 +281,11 @@
   if (!allPatchSets || allPatchSets.length < 2) {
     return false;
   }
-  return allPatchSets[0].num === EditPatchSetNum;
+  return allPatchSets[0].num === EDIT;
 }
 
 export function hasEditPatchsetLoaded(patchRange: PatchRange) {
-  return (
-    patchRange.patchNum === EditPatchSetNum ||
-    patchRange.basePatchNum === EditPatchSetNum
-  );
+  return patchRange.patchNum === EDIT;
 }
 
 /**
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.ts b/polygerrit-ui/app/utils/patch-set-util_test.ts
index a9d9549..b67db9b 100644
--- a/polygerrit-ui/app/utils/patch-set-util_test.ts
+++ b/polygerrit-ui/app/utils/patch-set-util_test.ts
@@ -1,21 +1,10 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
 import {
   createChange,
   createChangeMessageInfo,
@@ -24,9 +13,11 @@
 import {
   BasePatchSetNum,
   ChangeInfo,
-  EditPatchSetNum,
+  EDIT,
   PatchSetNum,
+  PatchSetNumber,
   ReviewInputTag,
+  PARENT,
 } from '../types/common';
 import {
   _testOnly_computeWipForPatchSets,
@@ -65,10 +56,7 @@
     // to compare against an expected value for a particular patch set.
     const compute = (
       initialWip: boolean,
-      tagsByRevision: Map<
-        number | 'edit' | 'PARENT',
-        (ReviewInputTag | undefined)[]
-      >
+      tagsByRevision: Map<PatchSetNumber, (ReviewInputTag | undefined)[]>
     ) => {
       const change: ChangeInfo = {
         ...createChange(),
@@ -80,12 +68,12 @@
           change.messages!.push({
             ...createChangeMessageInfo(),
             tag,
-            _revision_number: rev as PatchSetNum,
+            _revision_number: rev,
           });
         }
       }
       const patchSets = Array.from(tagsByRevision.keys()).map(rev => {
-        return {num: rev as PatchSetNum, desc: 'test', sha: `rev${rev}`};
+        return {num: rev, desc: 'test', sha: `rev${rev}`};
       });
       const patchNums = _testOnly_computeWipForPatchSets(change, patchSets);
       const verifier = {
@@ -110,8 +98,14 @@
 
     const upload = 'upload' as ReviewInputTag;
 
-    compute(false, new Map([[1, [upload]]])).assertWip(1, false);
-    compute(true, new Map([[1, [upload]]])).assertWip(1, true);
+    compute(false, new Map([[1 as PatchSetNumber, [upload]]])).assertWip(
+      1,
+      false
+    );
+    compute(true, new Map([[1 as PatchSetNumber, [upload]]])).assertWip(
+      1,
+      true
+    );
 
     const setWip = 'autogenerated:gerrit:setWorkInProgress' as ReviewInputTag;
     const uploadInWip = 'autogenerated:gerrit:newWipPatchSet' as ReviewInputTag;
@@ -120,10 +114,10 @@
     compute(
       false,
       new Map([
-        [1, [upload, setWip]],
-        [2, [upload]],
-        [3, [upload, clearWip]],
-        [4, [upload, setWip]],
+        [1 as PatchSetNumber, [upload, setWip]],
+        [2 as PatchSetNumber, [upload]],
+        [3 as PatchSetNumber, [upload, clearWip]],
+        [4 as PatchSetNumber, [upload, setWip]],
       ])
     )
       .assertWip(1, false) // Change was created with PS1 ready for review
@@ -134,12 +128,15 @@
     compute(
       false,
       new Map([
-        [1, [uploadInWip, undefined, 'addReviewer' as ReviewInputTag]],
-        [2, [upload]],
-        [3, [upload, clearWip, setWip]],
-        [4, [upload]],
-        [5, [upload, clearWip]],
-        [6, [uploadInWip]],
+        [
+          1 as PatchSetNumber,
+          [uploadInWip, undefined, 'addReviewer' as ReviewInputTag],
+        ],
+        [2 as PatchSetNumber, [upload]],
+        [3 as PatchSetNumber, [upload, clearWip, setWip]],
+        [4 as PatchSetNumber, [upload]],
+        [5 as PatchSetNumber, [upload, clearWip]],
+        [6 as PatchSetNumber, [uploadInWip]],
       ])
     )
       .assertWip(1, true) // Change was created in WIP
@@ -153,8 +150,8 @@
   test('isMergeParent', () => {
     assert.isFalse(isMergeParent(1 as PatchSetNum));
     assert.isFalse(isMergeParent(4321 as PatchSetNum));
-    assert.isFalse(isMergeParent('edit' as PatchSetNum));
-    assert.isFalse(isMergeParent('PARENT' as PatchSetNum));
+    assert.isFalse(isMergeParent(EDIT as PatchSetNum));
+    assert.isFalse(isMergeParent(PARENT as PatchSetNum));
     assert.isFalse(isMergeParent(0 as PatchSetNum));
 
     assert.isTrue(isMergeParent(-23 as PatchSetNum));
@@ -166,8 +163,7 @@
     assert.strictEqual(findEditParentRevision(revisions), null);
 
     revisions.push({
-      ...createRevision(),
-      _number: EditPatchSetNum,
+      ...createRevision(EDIT),
       basePatchNum: 3 as BasePatchSetNum,
     });
     assert.strictEqual(findEditParentRevision(revisions), null);
@@ -182,8 +178,7 @@
 
     revisions.push(
       {
-        ...createRevision(),
-        _number: EditPatchSetNum,
+        ...createRevision(EDIT),
         basePatchNum: 3 as BasePatchSetNum,
       },
       createRevision(3)
@@ -199,13 +194,11 @@
 
     // Edit patchset should follow directly after its basePatchNum.
     revisions.push({
-      ...createRevision(),
-      _number: EditPatchSetNum,
+      ...createRevision(EDIT),
       basePatchNum: 2 as BasePatchSetNum,
     });
     sorted.unshift({
-      ...createRevision(),
-      _number: EditPatchSetNum,
+      ...createRevision(EDIT),
       basePatchNum: 2 as BasePatchSetNum,
     });
     assert.deepEqual(sortRevisions(revisions), sorted);
@@ -224,10 +217,10 @@
 
   test('computeAllPatchSets', () => {
     const expected = [
-      {num: 4 as PatchSetNum, desc: 'test', sha: 'rev4'},
-      {num: 3 as PatchSetNum, desc: 'test', sha: 'rev3'},
-      {num: 2 as PatchSetNum, desc: 'test', sha: 'rev2'},
-      {num: 1 as PatchSetNum, desc: 'test', sha: 'rev1'},
+      {num: 4 as PatchSetNumber, desc: 'test', sha: 'rev4'},
+      {num: 3 as PatchSetNumber, desc: 'test', sha: 'rev3'},
+      {num: 2 as PatchSetNumber, desc: 'test', sha: 'rev2'},
+      {num: 1 as PatchSetNumber, desc: 'test', sha: 'rev1'},
     ];
     const patchNums = computeAllPatchSets({
       ...createChange(),
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index 605241a..1116123 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.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 {SpecialFilePath, FileInfoStatus} from '../constants/constants';
 import {FileInfo} from '../types/common';
 import {hasOwnProperty} from './common-util';
@@ -112,7 +100,7 @@
   );
 }
 
-export function computeTruncatedPath(path: string) {
+export function computeTruncatedPath(path?: string) {
   return truncatePath(computeDisplayPath(path));
 }
 
diff --git a/polygerrit-ui/app/utils/path-list-util_test.ts b/polygerrit-ui/app/utils/path-list-util_test.ts
index eb071a5..50f5c0e 100644
--- a/polygerrit-ui/app/utils/path-list-util_test.ts
+++ b/polygerrit-ui/app/utils/path-list-util_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {FileInfoStatus, SpecialFilePath} from '../constants/constants';
 import {
   addUnmodifiedFiles,
@@ -26,6 +14,7 @@
 } from './path-list-util';
 import {FileInfo} from '../api/rest-api';
 import {hasOwnProperty} from './common-util';
+import {assert} from '@open-wc/testing';
 
 suite('path-list-utl tests', () => {
   test('special sort', () => {
diff --git a/polygerrit-ui/app/utils/safari-selection-util.ts b/polygerrit-ui/app/utils/safari-selection-util.ts
index e38111f..a24ac3e 100644
--- a/polygerrit-ui/app/utils/safari-selection-util.ts
+++ b/polygerrit-ui/app/utils/safari-selection-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {isSafari, findActiveElement} from './dom-util';
 
diff --git a/polygerrit-ui/app/utils/safe-types-util.ts b/polygerrit-ui/app/utils/safe-types-util.ts
index 18641de..e5c33bb 100644
--- a/polygerrit-ui/app/utils/safe-types-util.ts
+++ b/polygerrit-ui/app/utils/safe-types-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|[^:/?#]*(?:[/?#]|$))/i;
diff --git a/polygerrit-ui/app/utils/safe-types-util_test.ts b/polygerrit-ui/app/utils/safe-types-util_test.ts
index 03253e0..dbeaa4f 100644
--- a/polygerrit-ui/app/utils/safe-types-util_test.ts
+++ b/polygerrit-ui/app/utils/safe-types-util_test.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
 import {safeTypesBridge, _testOnly_SafeUrl} from './safe-types-util';
 
 suite('safe-types-util tests', () => {
diff --git a/polygerrit-ui/app/utils/service-worker-util.ts b/polygerrit-ui/app/utils/service-worker-util.ts
new file mode 100644
index 0000000..eb547ea
--- /dev/null
+++ b/polygerrit-ui/app/utils/service-worker-util.ts
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {AccountDetailInfo} from '../api/rest-api';
+import {ParsedChangeInfo} from '../types/types';
+import {parseDate} from './date-util';
+
+/**
+ * Filter changes that had change in attention set after last round
+ * of notifications. Filter out changes we already notified about.
+ */
+export function filterAttentionChangesAfter(
+  changes: ParsedChangeInfo[],
+  account: AccountDetailInfo,
+  latestUpdateTimestampMs: number
+) {
+  return changes.filter(change => {
+    const attention_set = change.attention_set![account._account_id!];
+    if (!attention_set.last_update) return false;
+    const lastUpdateTimestampMs = parseDate(
+      attention_set.last_update
+    ).valueOf();
+    return latestUpdateTimestampMs < lastUpdateTimestampMs;
+  });
+}
diff --git a/polygerrit-ui/app/utils/service-worker-util_test.ts b/polygerrit-ui/app/utils/service-worker-util_test.ts
new file mode 100644
index 0000000..6259288
--- /dev/null
+++ b/polygerrit-ui/app/utils/service-worker-util_test.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {Timestamp} from '../api/rest-api';
+import '../test/common-test-setup';
+import {
+  createAccountDetailWithId,
+  createParsedChange,
+} from '../test/test-data-generators';
+import {ParsedChangeInfo} from '../types/types';
+import {parseDate} from './date-util';
+import {filterAttentionChangesAfter} from './service-worker-util';
+
+suite('service worker util tests', () => {
+  test('filterAttentionChangesAfter', () => {
+    const account = createAccountDetailWithId();
+    const changeBefore: ParsedChangeInfo = {
+      ...createParsedChange(),
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: '2016-01-12 20:24:49.000000000' as Timestamp,
+        },
+      },
+    };
+    const changeAfter: ParsedChangeInfo = {
+      ...createParsedChange(),
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: '2016-01-12 20:24:51.000000000' as Timestamp,
+        },
+      },
+    };
+    const changes = [changeBefore, changeAfter];
+
+    const filteredChanges = filterAttentionChangesAfter(
+      changes,
+      account,
+      parseDate('2016-01-12 20:24:50.000000000' as Timestamp).valueOf()
+    );
+
+    assert.equal(filteredChanges.length, 1);
+    assert.equal(filteredChanges[0], changeAfter);
+  });
+});
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 0a0928e..b6f1ad1 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -1,20 +1,11 @@
 /**
  * @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 {computeDisplayPath} from './path-list-util';
+
 /**
  * Returns a count plus string that is pluralized when necessary.
  */
@@ -51,3 +42,63 @@
 export function capitalizeFirstLetter(str: string) {
   return str.charAt(0).toUpperCase() + str.slice(1);
 }
+
+/**
+ * Converts the items into a sentence-friendly format. Examples:
+ * listForSentence(["Foo", "Bar", "Baz"])
+ * => 'Foo, Bar, and Baz'
+ * listForSentence(["Foo", "Bar"])
+ * => 'Foo and Bar'
+ * listForSentence(["Foo"])
+ * => 'Foo'
+ */
+export function listForSentence(items: string[]): string {
+  if (items.length < 2) return items.join('');
+  if (items.length === 2) return items.join(' and ');
+
+  const firstItems = items.slice(0, items.length - 1);
+  const lastItem = items[items.length - 1];
+  return `${firstItems.join(', ')}, and ${lastItem}`;
+}
+
+/**
+ *  Separates a path into:
+ *  - The part that matches another path,
+ *  - The part that does not match the other path,
+ *  - The file name
+ *
+ *  For example:
+ *    diffFilePaths('same/part/new/part/foo.js', 'same/part/different/foo.js');
+ *  yields: {
+ *      matchingFolders: 'same/part/',
+ *      newFolders: 'new/part/',
+ *      fileName: 'foo.js',
+ *    }
+ */
+export function diffFilePaths(filePath: string, otherFilePath?: string) {
+  // Separate each string into an array of folder names + file name.
+  const displayPath = computeDisplayPath(filePath);
+  const previousFileDisplayPath = computeDisplayPath(otherFilePath);
+  const displayPathParts = displayPath.split('/');
+  const previousFileDisplayPathParts = previousFileDisplayPath.split('/');
+
+  // Construct separate strings for matching folders, new folders, and file
+  // name.
+  const firstDifferencePartIndex = displayPathParts.findIndex(
+    (part, index) => previousFileDisplayPathParts[index] !== part
+  );
+  const matchingSection = displayPathParts
+    .slice(0, firstDifferencePartIndex)
+    .join('/');
+  const newFolderSection = displayPathParts
+    .slice(firstDifferencePartIndex, -1)
+    .join('/');
+  const fileNameSection = displayPathParts[displayPathParts.length - 1];
+
+  // Note: folder sections need '/' appended back.
+  return {
+    matchingFolders: matchingSection.length > 0 ? `${matchingSection}/` : '',
+    newFolders: newFolderSection.length > 0 ? `${newFolderSection}/` : '',
+    fileName: fileNameSection,
+  };
+}
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
index 8de6ac2..c6c65b1 100644
--- a/polygerrit-ui/app/utils/string-util_test.ts
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -1,24 +1,18 @@
 /**
  * @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 {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {
+  pluralize,
+  ordinal,
+  listForSentence,
+  diffFilePaths,
+} from './string-util';
 
-import '../test/common-test-setup-karma';
-import {pluralize, ordinal} from './string-util';
-
-suite('formatter util tests', () => {
+suite('string-util tests', () => {
   test('pluralize', () => {
     const noun = 'comment';
     assert.equal(pluralize(0, noun), '');
@@ -39,4 +33,55 @@
     assert.equal(ordinal(44413), '44413th');
     assert.equal(ordinal(44451), '44451st');
   });
+
+  test('listForSentence', () => {
+    assert.equal(listForSentence(['Foo', 'Bar', 'Baz']), 'Foo, Bar, and Baz');
+    assert.equal(listForSentence(['Foo', 'Bar']), 'Foo and Bar');
+    assert.equal(listForSentence(['Foo']), 'Foo');
+    assert.equal(listForSentence([]), '');
+  });
+
+  test('diffFilePaths', () => {
+    const path = 'some/new/path/to/foo.js';
+
+    // no other path
+    assert.deepStrictEqual(diffFilePaths(path, undefined), {
+      matchingFolders: '',
+      newFolders: 'some/new/path/to/',
+      fileName: 'foo.js',
+    });
+    // no new folders
+    assert.deepStrictEqual(diffFilePaths(path, 'some/new/path/to/bar.js'), {
+      matchingFolders: 'some/new/path/to/',
+      newFolders: '',
+      fileName: 'foo.js',
+    });
+    // folder partially matches
+    assert.deepStrictEqual(diffFilePaths(path, 'some/ne/foo.js'), {
+      matchingFolders: 'some/',
+      newFolders: 'new/path/to/',
+      fileName: 'foo.js',
+    });
+    // no matching folders
+    assert.deepStrictEqual(
+      diffFilePaths(path, 'another/path/entirely/foo.js'),
+      {
+        matchingFolders: '',
+        newFolders: 'some/new/path/to/',
+        fileName: 'foo.js',
+      }
+    );
+    // some folders match
+    assert.deepStrictEqual(diffFilePaths(path, 'some/other/path/to/bar.js'), {
+      matchingFolders: 'some/',
+      newFolders: 'new/path/to/',
+      fileName: 'foo.js',
+    });
+    // no folders
+    assert.deepStrictEqual(diffFilePaths('COMMIT_MSG', 'some/other/foo.js'), {
+      matchingFolders: '',
+      newFolders: '',
+      fileName: 'COMMIT_MSG',
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/submit-requirement-util.ts b/polygerrit-ui/app/utils/submit-requirement-util.ts
new file mode 100644
index 0000000..6672712
--- /dev/null
+++ b/polygerrit-ui/app/utils/submit-requirement-util.ts
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {SubmitRequirementExpressionInfo} from '../api/rest-api';
+import {Execution} from '../constants/reporting';
+import {getAppContext} from '../services/app-context';
+
+export enum SubmitRequirementExpressionAtomStatus {
+  UNKNOWN = 'UNKNOWN',
+  PASSING = 'PASSING',
+  FAILING = 'FAILING',
+}
+
+export interface SubmitRequirementExpressionPart {
+  value: string;
+  isAtom: boolean;
+  // Defined iff isAtom is true.
+  atomStatus?: SubmitRequirementExpressionAtomStatus;
+}
+
+interface AtomMatch {
+  start: number;
+  end: number;
+  isPassing: boolean;
+}
+
+function appendAllOccurrences(
+  text: string,
+  match: string,
+  isPassing: boolean,
+  matchedAtoms: AtomMatch[]
+) {
+  for (let searchStartIndex = 0; ; ) {
+    let index = text.indexOf(match, searchStartIndex);
+    if (index === -1) {
+      break;
+    }
+    searchStartIndex = index + match.length;
+    // Include unary minus.
+    if (index !== 0 && text[index - 1] === '-') {
+      --index;
+      isPassing = !isPassing;
+    }
+    matchedAtoms.push({start: index, end: searchStartIndex, isPassing});
+  }
+}
+
+function splitExpressionIntoParts(
+  expression: string,
+  matchedAtoms: AtomMatch[]
+): SubmitRequirementExpressionPart[] {
+  const result: SubmitRequirementExpressionPart[] = [];
+  let currentIndex = 0;
+  for (const {start, end, isPassing} of matchedAtoms) {
+    if (start < currentIndex) {
+      getAppContext().reportingService.reportExecution(
+        Execution.REACHABLE_CODE,
+        'Overlapping atom matches in submit requirement expression.'
+      );
+      continue;
+    }
+    if (start > currentIndex) {
+      result.push({
+        value: expression.slice(currentIndex, start),
+        isAtom: false,
+      });
+    }
+    result.push({
+      value: expression.slice(start, end),
+      isAtom: true,
+      atomStatus: isPassing
+        ? SubmitRequirementExpressionAtomStatus.PASSING
+        : SubmitRequirementExpressionAtomStatus.FAILING,
+    });
+    currentIndex = end;
+  }
+  if (currentIndex < expression.length) {
+    result.push({
+      value: expression.slice(currentIndex),
+      isAtom: false,
+    });
+  }
+  return result;
+}
+
+/**
+ * Returns expression string split into ExpressionPart.
+ *
+ * Concatenation result of all parts is equal to original expression string.
+ *
+ * Unary minus is included in the atom and is accounted in the status.
+ */
+export function atomizeExpression(
+  expression: SubmitRequirementExpressionInfo
+): SubmitRequirementExpressionPart[] {
+  const matchedAtoms: AtomMatch[] = [];
+  expression.passing_atoms?.forEach(atom =>
+    appendAllOccurrences(
+      expression.expression,
+      atom,
+      /* isPassing=*/ true,
+      matchedAtoms
+    )
+  );
+  expression.failing_atoms?.forEach(atom =>
+    appendAllOccurrences(
+      expression.expression,
+      atom,
+      /* isPassing=*/ false,
+      matchedAtoms
+    )
+  );
+  matchedAtoms.sort((a, b) => a.start - b.start);
+
+  return splitExpressionIntoParts(expression.expression, matchedAtoms);
+}
diff --git a/polygerrit-ui/app/utils/submit-requirement-util_test.ts b/polygerrit-ui/app/utils/submit-requirement-util_test.ts
new file mode 100644
index 0000000..a35a121
--- /dev/null
+++ b/polygerrit-ui/app/utils/submit-requirement-util_test.ts
@@ -0,0 +1,116 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {assert} from '@open-wc/testing';
+import {SubmitRequirementExpressionInfo} from '../api/rest-api';
+import '../test/common-test-setup';
+import {
+  atomizeExpression,
+  SubmitRequirementExpressionAtomStatus,
+} from './submit-requirement-util';
+
+suite('submit-requirement-util', () => {
+  test('atomizeExpression no evaluted atoms', () => {
+    const expression: SubmitRequirementExpressionInfo = {
+      expression:
+        'label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN',
+    };
+
+    assert.deepStrictEqual(atomizeExpression(expression), [
+      {
+        value:
+          'label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN',
+        isAtom: false,
+      },
+    ]);
+  });
+
+  test('atomizeExpression normal', () => {
+    const expression: SubmitRequirementExpressionInfo = {
+      expression: 'has:unresolved AND hashtag:allow-unresolved-comments',
+      passing_atoms: ['has:unresolved'],
+      failing_atoms: ['hashtag:allow-unresolved-comments'],
+    };
+
+    assert.deepStrictEqual(atomizeExpression(expression), [
+      {
+        value: 'has:unresolved',
+        isAtom: true,
+        atomStatus: SubmitRequirementExpressionAtomStatus.PASSING,
+      },
+      {
+        value: ' AND ',
+        isAtom: false,
+      },
+      {
+        value: 'hashtag:allow-unresolved-comments',
+        isAtom: true,
+        atomStatus: SubmitRequirementExpressionAtomStatus.FAILING,
+      },
+    ]);
+  });
+
+  test('atomizeExpression unary negation', () => {
+    const expression: SubmitRequirementExpressionInfo = {
+      expression: '-has:unresolved AND hashtag:allow-unresolved-comments',
+      passing_atoms: ['has:unresolved'],
+      failing_atoms: ['hashtag:allow-unresolved-comments'],
+    };
+
+    assert.deepStrictEqual(atomizeExpression(expression), [
+      {
+        value: '-has:unresolved',
+        isAtom: true,
+        atomStatus: SubmitRequirementExpressionAtomStatus.FAILING,
+      },
+      {
+        value: ' AND ',
+        isAtom: false,
+      },
+      {
+        value: 'hashtag:allow-unresolved-comments',
+        isAtom: true,
+        atomStatus: SubmitRequirementExpressionAtomStatus.FAILING,
+      },
+    ]);
+  });
+
+  test('atomizeExpression partially unmatched', () => {
+    const expression: SubmitRequirementExpressionInfo = {
+      expression:
+        'NOT (-has:unresolved AND hashtag:allow-unresolved-comments) OR tested:no',
+      passing_atoms: ['has:unresolved'],
+      failing_atoms: ['hashtag:allow-unresolved-comments'],
+    };
+
+    // All that is not part of passing or failing atoms is considered
+    // "not an atom".
+    assert.deepStrictEqual(atomizeExpression(expression), [
+      {
+        value: 'NOT (',
+        isAtom: false,
+      },
+      {
+        value: '-has:unresolved',
+        isAtom: true,
+        atomStatus: SubmitRequirementExpressionAtomStatus.FAILING,
+      },
+      {
+        value: ' AND ',
+        isAtom: false,
+      },
+      {
+        value: 'hashtag:allow-unresolved-comments',
+        isAtom: true,
+        atomStatus: SubmitRequirementExpressionAtomStatus.FAILING,
+      },
+      {
+        value: ') OR tested:no',
+        isAtom: false,
+      },
+    ]);
+  });
+});
diff --git a/polygerrit-ui/app/utils/syntax-util.ts b/polygerrit-ui/app/utils/syntax-util.ts
index 03774bd..1b6a572 100644
--- a/polygerrit-ui/app/utils/syntax-util.ts
+++ b/polygerrit-ui/app/utils/syntax-util.ts
@@ -29,7 +29,7 @@
  * Reverse what HighlightJS does in `escapeHTML()`, see:
  * https://github.com/highlightjs/highlight.js/blob/main/src/lib/utils.js
  */
-function unescapeHTML(value: string) {
+export function unescapeHTML(value: string) {
   return value
     .replace(/&#x27;/g, "'")
     .replace(/&quot;/g, '"')
diff --git a/polygerrit-ui/app/utils/syntax-util_test.ts b/polygerrit-ui/app/utils/syntax-util_test.ts
index 4d381fb..4bf5823 100644
--- a/polygerrit-ui/app/utils/syntax-util_test.ts
+++ b/polygerrit-ui/app/utils/syntax-util_test.ts
@@ -3,7 +3,8 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
 import './syntax-util';
 import {
   highlightedStringToRanges,
diff --git a/polygerrit-ui/app/utils/theme-util.ts b/polygerrit-ui/app/utils/theme-util.ts
new file mode 100644
index 0000000..d113bcf
--- /dev/null
+++ b/polygerrit-ui/app/utils/theme-util.ts
@@ -0,0 +1,23 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {AppTheme} from '../constants/constants';
+
+// https://css-tricks.com/a-complete-guide-to-dark-mode-on-the-web/#aa-javascript
+function isDarkThemeInOs() {
+  const prefersDarkScheme = prefersDarkColorScheme();
+  return prefersDarkScheme.matches;
+}
+
+export function prefersDarkColorScheme() {
+  return window.matchMedia('(prefers-color-scheme: dark)');
+}
+
+export function isDarkTheme(theme: AppTheme, autoModeEnabled: boolean) {
+  if (autoModeEnabled && theme === AppTheme.AUTO) return isDarkThemeInOs();
+
+  return theme === AppTheme.DARK;
+}
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 1578bfb..54a6838 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -1,27 +1,48 @@
-import {ServerInfo} from '../types/common';
-import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
-
 /**
  * @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 {
+  BasePatchSetNum,
+  PARENT,
+  RevisionPatchSetNum,
+  ServerInfo,
+} from '../types/common';
+import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
+
 const PROBE_PATH = '/Documentation/index.html';
 const DOCS_BASE_PATH = '/Documentation';
 
 export function getBaseUrl(): string {
-  return window.CANONICAL_PATH || '';
+  // window is not defined in service worker, therefore no CANONICAL_PATH
+  if (typeof window === 'undefined') return '';
+  return self.CANONICAL_PATH || '';
+}
+
+export interface PatchRangeParams {
+  patchNum?: RevisionPatchSetNum;
+  basePatchNum?: BasePatchSetNum;
+}
+
+export function rootUrl() {
+  return `${getBaseUrl()}/`;
+}
+
+/**
+ * Given an object of parameters, potentially including a `patchNum` or a
+ * `basePatchNum` or both, return a string representation of that range. If
+ * no range is indicated in the params, the empty string is returned.
+ */
+export function getPatchRangeExpression(params: PatchRangeParams) {
+  let range = '';
+  if (params.patchNum) {
+    range = `${params.patchNum}`;
+  }
+  if (params.basePatchNum && params.basePatchNum !== PARENT) {
+    range = `${params.basePatchNum}..${range}`;
+  }
+  return range;
 }
 
 export function prependOrigin(path: string): string {
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index 1a8b536..aa80b73 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -1,22 +1,14 @@
 /**
  * @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 {ServerInfo} from '../api/rest-api';
-import '../test/common-test-setup-karma';
+import {
+  BasePatchSetNum,
+  RevisionPatchSetNum,
+  ServerInfo,
+} from '../api/rest-api';
+import '../test/common-test-setup';
 import {createGerritInfo, createServerInfo} from '../test/test-data-generators';
 import {
   getBaseUrl,
@@ -27,9 +19,12 @@
   toPath,
   toPathname,
   toSearchParams,
+  getPatchRangeExpression,
+  PatchRangeParams,
 } from './url-util';
 import {getAppContext, AppContext} from '../services/app-context';
 import {stubRestApi} from '../test/test-utils';
+import {assert} from '@open-wc/testing';
 
 suite('url-util tests', () => {
   let appContext: AppContext;
@@ -162,4 +157,22 @@
       'asdf?qwer=zxcv'
     );
   });
+
+  test('getPatchRangeExpression', () => {
+    const params: PatchRangeParams = {};
+    let actual = getPatchRangeExpression(params);
+    assert.equal(actual, '');
+
+    params.patchNum = 4 as RevisionPatchSetNum;
+    actual = getPatchRangeExpression(params);
+    assert.equal(actual, '4');
+
+    params.basePatchNum = 2 as BasePatchSetNum;
+    actual = getPatchRangeExpression(params);
+    assert.equal(actual, '2..4');
+
+    delete params.patchNum;
+    actual = getPatchRangeExpression(params);
+    assert.equal(actual, '2..');
+  });
 });
diff --git a/polygerrit-ui/app/utils/weblink-util.ts b/polygerrit-ui/app/utils/weblink-util.ts
new file mode 100644
index 0000000..1e9315c
--- /dev/null
+++ b/polygerrit-ui/app/utils/weblink-util.ts
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {CommitId, ServerInfo} from '../api/rest-api';
+
+export interface WebLink {
+  name?: string;
+  label: string;
+  url: string;
+}
+
+export interface GeneratedWebLink {
+  name?: string;
+  label?: string;
+  url?: string;
+}
+
+export function getPatchSetWeblink(
+  commit?: CommitId,
+  weblinks?: GeneratedWebLink[],
+  config?: ServerInfo
+): GeneratedWebLink | undefined {
+  if (!commit) return undefined;
+  const name = commit.slice(0, 7);
+  const weblink = getBrowseCommitWeblink(weblinks, config);
+  if (!weblink?.url) return {name};
+  return {name, url: weblink.url};
+}
+
+// visible for testing
+export function getCodeBrowserWeblink(weblinks: GeneratedWebLink[]) {
+  // 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'];
+  for (let i = 0; i < codeBrowserLinks.length; i++) {
+    const weblink = weblinks.find(
+      weblink => weblink.name === codeBrowserLinks[i]
+    );
+    if (weblink) return weblink;
+  }
+  return undefined;
+}
+
+// visible for testing
+export function getBrowseCommitWeblink(
+  weblinks?: GeneratedWebLink[],
+  config?: ServerInfo
+): GeneratedWebLink | undefined {
+  if (!weblinks) return undefined;
+
+  // Use primary weblink if configured and exists.
+  const primaryWeblinkName = config?.gerrit?.primary_weblink_name;
+  if (primaryWeblinkName) {
+    const weblink = weblinks.find(link => link.name === primaryWeblinkName);
+    if (weblink) return weblink;
+  }
+
+  return getCodeBrowserWeblink(weblinks);
+}
+
+export function getChangeWeblinks(
+  weblinks?: GeneratedWebLink[],
+  config?: ServerInfo
+): GeneratedWebLink[] {
+  if (!weblinks?.length) return [];
+  const commitWeblink = getBrowseCommitWeblink(weblinks, config);
+  return weblinks.filter(
+    weblink => !commitWeblink?.name || weblink.name !== commitWeblink.name
+  );
+}
diff --git a/polygerrit-ui/app/utils/weblink-util_test.ts b/polygerrit-ui/app/utils/weblink-util_test.ts
new file mode 100644
index 0000000..be97cfd
--- /dev/null
+++ b/polygerrit-ui/app/utils/weblink-util_test.ts
@@ -0,0 +1,66 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {createServerInfo, createGerritInfo} from '../test/test-data-generators';
+import {
+  getCodeBrowserWeblink,
+  getBrowseCommitWeblink,
+  getChangeWeblinks,
+} from './weblink-util';
+
+suite('weblink util tests', () => {
+  test('getCodeBrowserWeblink', () => {
+    assert.deepEqual(
+      getCodeBrowserWeblink([
+        {name: 'gitweb'},
+        {name: 'gitiles'},
+        {name: 'browse'},
+        {name: 'test'},
+      ]),
+      {name: 'gitiles'}
+    );
+
+    assert.deepEqual(
+      getCodeBrowserWeblink([{name: 'gitweb'}, {name: 'test'}]),
+      {name: 'gitweb'}
+    );
+  });
+
+  test('getBrowseCommitWeblink', () => {
+    const browserLink = {name: 'browser', url: 'browser/url'};
+    const link = {name: 'gitiles', url: 'test/url'};
+    const weblinks = [browserLink, link];
+    const config = {
+      ...createServerInfo(),
+      gerrit: {...createGerritInfo(), primary_weblink_name: browserLink.name},
+    };
+
+    assert.deepEqual(getBrowseCommitWeblink(weblinks, config), browserLink);
+    assert.deepEqual(getBrowseCommitWeblink(weblinks), link);
+  });
+
+  test('getChangeWeblinks', () => {
+    const link = {name: 'test', url: 'test/url'};
+    const browserLink = {name: 'browser', url: 'browser/url'};
+
+    assert.deepEqual(getChangeWeblinks([link, browserLink])[0], {
+      name: 'test',
+      url: 'test/url',
+    });
+
+    assert.deepEqual(getChangeWeblinks([link])[0], {
+      name: 'test',
+      url: 'test/url',
+    });
+
+    link.url = `https://${link.url}`;
+    assert.deepEqual(getChangeWeblinks([link])[0], {
+      name: 'test',
+      url: 'https://test/url',
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/worker-util.ts b/polygerrit-ui/app/utils/worker-util.ts
index 4ae41ff..aeee537 100644
--- a/polygerrit-ui/app/utils/worker-util.ts
+++ b/polygerrit-ui/app/utils/worker-util.ts
@@ -4,6 +4,11 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
+// This file adds some simple checks to match internal Google rules.
+// Internally at Google it has different a implementation.
+
+import {AccountDetailInfo} from '../api/rest-api';
+
 /**
  * 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
@@ -21,6 +26,14 @@
   return new Worker(wrapUrl(workerUrl));
 }
 
+export function registerServiceWorker(workerUrl: string) {
+  return window.navigator.serviceWorker.register(workerUrl);
+}
+
+export function areNotificationsEnabled(account?: AccountDetailInfo): boolean {
+  return !!account?._account_id;
+}
+
 export function importScript(scope: WorkerGlobalScope, url: string): void {
   scope.importScripts(url);
 }
diff --git a/polygerrit-ui/app/workers/service-worker-class.ts b/polygerrit-ui/app/workers/service-worker-class.ts
new file mode 100644
index 0000000..218744d
--- /dev/null
+++ b/polygerrit-ui/app/workers/service-worker-class.ts
@@ -0,0 +1,176 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ParsedChangeInfo} from '../types/types';
+import {getReason} from '../utils/attention-set-util';
+import {readResponsePayload} from '../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {filterAttentionChangesAfter} from '../utils/service-worker-util';
+import {AccountDetailInfo} from '../api/rest-api';
+import {
+  ServiceWorkerMessageType,
+  TRIGGER_NOTIFICATION_UPDATES_MS,
+} from '../services/service-worker-installer';
+import {
+  getServiceWorkerState,
+  putServiceWorkerState,
+} from './service-worker-indexdb';
+import {createDashboardUrl} from '../models/views/dashboard';
+import {createChangeUrl} from '../models/views/change';
+
+export class ServiceWorker {
+  constructor(
+    /* private but used in test */ public ctx: ServiceWorkerGlobalScope
+  ) {}
+
+  // private but used in test
+  latestUpdateTimestampMs = Date.now();
+
+  allowBrowserNotificationsPreference = false;
+
+  /**
+   * We cannot rely on a state in a service worker, because every time
+   * service worker starts or stops, new instance is created. So every time
+   * there is new instance we load state from indexdb.
+   */
+  async init() {
+    await this.loadState();
+    this.ctx.addEventListener('message', e => this.onMessage(e));
+    this.ctx.addEventListener('notificationclick', e =>
+      this.onNotificationClick(e)
+    );
+  }
+
+  // private but used in test
+  saveState() {
+    return putServiceWorkerState({
+      latestUpdateTimestampMs: this.latestUpdateTimestampMs,
+      allowBrowserNotificationsPreference:
+        this.allowBrowserNotificationsPreference,
+    });
+  }
+
+  private async loadState() {
+    const state = await getServiceWorkerState();
+    if (state) {
+      this.latestUpdateTimestampMs = state.latestUpdateTimestampMs;
+      this.allowBrowserNotificationsPreference =
+        state.allowBrowserNotificationsPreference;
+    }
+  }
+
+  private onMessage(e: ExtendableMessageEvent) {
+    if (e.data?.type === ServiceWorkerMessageType.TRIGGER_NOTIFICATIONS) {
+      e.waitUntil(
+        this.showLatestAttentionChangeNotification(
+          e.data?.account as AccountDetailInfo | undefined
+        )
+      );
+    } else if (
+      e.data?.type === ServiceWorkerMessageType.USER_PREFERENCE_CHANGE
+    ) {
+      e.waitUntil(
+        this.allowBrowserNotificationsPreferenceChanged(
+          e.data?.allowBrowserNotificationsPreference as boolean
+        )
+      );
+    }
+  }
+
+  private onNotificationClick(e: NotificationEvent) {
+    e.notification.close();
+    e.waitUntil(this.openWindow(e.notification.data.url));
+  }
+
+  async allowBrowserNotificationsPreferenceChanged(preference: boolean) {
+    this.allowBrowserNotificationsPreference = preference;
+    await this.saveState();
+  }
+
+  // private but used in test
+  async showLatestAttentionChangeNotification(account?: AccountDetailInfo) {
+    // Message always contains account, but we do not throw error.
+    if (!account?._account_id) return;
+    if (!this.allowBrowserNotificationsPreference) return;
+    const latestAttentionChanges = await this.getChangesToNotify(account);
+    const numOfChangesToNotifyAbout = latestAttentionChanges.length;
+    if (numOfChangesToNotifyAbout === 1) {
+      this.showNotificationForChange(latestAttentionChanges[0], account);
+    } else if (numOfChangesToNotifyAbout > 1) {
+      this.showNotificationForDashboard(numOfChangesToNotifyAbout);
+    }
+  }
+
+  // Code based on code sample from
+  // https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow
+  private async openWindow(url?: string) {
+    if (!url) return;
+    const clientsArr = await this.ctx.clients.matchAll({type: 'window'});
+    try {
+      let client = clientsArr.find(c => c.url === url);
+      if (!client)
+        client = (await this.ctx.clients.openWindow(url)) ?? undefined;
+      await client?.focus();
+    } catch (e) {
+      console.error(`Cannot open window about notified change - ${e}`);
+    }
+  }
+
+  private showNotificationForChange(
+    change: ParsedChangeInfo,
+    account: AccountDetailInfo
+  ) {
+    const body = getReason(undefined, account, change);
+    const changeUrl = createChangeUrl({
+      change,
+      usp: 'service-worker-notification',
+    });
+    // We are adding origin because each notification can have different origin
+    // User can have different service workers for different origins/hosts.
+    // TODO(milutin): Check if this works properly with getBaseUrl()
+    const data = {url: `${self.location.origin}${changeUrl}`};
+
+    // TODO(milutin): Add gerrit host icon
+    this.ctx.registration.showNotification(change.subject, {body, data});
+  }
+
+  private showNotificationForDashboard(numOfChangesToNotifyAbout: number) {
+    const title = `You are in the attention set for ${numOfChangesToNotifyAbout} changes.`;
+    const dashboardUrl = createDashboardUrl({});
+    const data = {url: `${self.location.origin}${dashboardUrl}`};
+    this.ctx.registration.showNotification(title, {data});
+  }
+
+  // private but used in test
+  async getChangesToNotify(account: AccountDetailInfo) {
+    // We throttle polling, since there can be many clients triggerring
+    // always only one service worker.
+    const durationFromLatestUpdateMS =
+      Date.now() - this.latestUpdateTimestampMs;
+    if (durationFromLatestUpdateMS < TRIGGER_NOTIFICATION_UPDATES_MS) {
+      return [];
+    }
+    const prevLatestUpdateTimestampMs = this.latestUpdateTimestampMs;
+    this.latestUpdateTimestampMs = Date.now();
+    await this.saveState();
+    const changes = await this.getLatestAttentionSetChanges();
+    const latestAttentionChanges = filterAttentionChangesAfter(
+      changes,
+      account,
+      prevLatestUpdateTimestampMs
+    );
+    return latestAttentionChanges;
+  }
+
+  // private but used in test
+  async getLatestAttentionSetChanges(): Promise<ParsedChangeInfo[]> {
+    // TODO(milutin): Implement more generic query builder
+    const response = await fetch(
+      '/changes/?O=1000081&S=0&n=25&q=attention%3Aself'
+    );
+    const payload = await readResponsePayload(response);
+    const changes = payload.parsed as unknown as ParsedChangeInfo[] | undefined;
+    return changes ?? [];
+  }
+}
diff --git a/polygerrit-ui/app/workers/service-worker-class_test.ts b/polygerrit-ui/app/workers/service-worker-class_test.ts
new file mode 100644
index 0000000..4cbd7bb
--- /dev/null
+++ b/polygerrit-ui/app/workers/service-worker-class_test.ts
@@ -0,0 +1,158 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {Timestamp} from '../api/rest-api';
+import '../test/common-test-setup';
+import {
+  createAccountDetailWithId,
+  createParsedChange,
+} from '../test/test-data-generators';
+import {mockPromise} from '../test/test-utils';
+import {ParsedChangeInfo} from '../types/types';
+import {parseDate} from '../utils/date-util';
+import {ServiceWorker} from './service-worker-class';
+
+suite('service worker class tests', () => {
+  let serviceWorker: ServiceWorker;
+
+  setup(() => {
+    const moctCtx = {
+      registration: {
+        showNotification: () => {},
+      },
+    } as {} as ServiceWorkerGlobalScope;
+    serviceWorker = new ServiceWorker(moctCtx);
+    serviceWorker.allowBrowserNotificationsPreference = true;
+  });
+
+  test('notify about attention in t2 when time is t3', async () => {
+    const t1 = parseDate('2016-01-12 20:20:00' as Timestamp).getTime();
+    const t2 = '2016-01-12 20:30:00' as Timestamp;
+    const t3 = parseDate('2016-01-12 20:40:00' as Timestamp).getTime();
+    serviceWorker.latestUpdateTimestampMs = t1;
+    const account = createAccountDetailWithId();
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: t2,
+        },
+      },
+    };
+    sinon.useFakeTimers(t3);
+    sinon
+      .stub(serviceWorker, 'getLatestAttentionSetChanges')
+      .returns(Promise.resolve([change]));
+    const changes = await serviceWorker.getChangesToNotify(account);
+    assert.equal(changes[0], change);
+  });
+
+  test('check race condition', async () => {
+    const promise = mockPromise<ParsedChangeInfo[]>();
+    sinon.stub(serviceWorker, 'saveState').returns(Promise.resolve());
+    const getLatestAttentionSetChangesStub = sinon
+      .stub(serviceWorker, 'getLatestAttentionSetChanges')
+      .returns(promise);
+    const account = createAccountDetailWithId();
+    const t1 = parseDate('2016-01-12 20:20:00' as Timestamp).getTime();
+    const t2 = '2016-01-12 20:30:00' as Timestamp;
+    serviceWorker.latestUpdateTimestampMs = t1;
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: t2,
+        },
+      },
+    };
+    serviceWorker.getChangesToNotify(account);
+    serviceWorker.getChangesToNotify(account);
+    promise.resolve([change]);
+    await serviceWorker.getChangesToNotify(account);
+    assert.isTrue(getLatestAttentionSetChangesStub.calledOnce);
+  });
+
+  test('when 2 or more changes, link to dashboard', async () => {
+    const t1 = parseDate('2016-01-12 20:20:00' as Timestamp).getTime();
+    const t2 = '2016-01-12 20:30:00' as Timestamp;
+    serviceWorker.latestUpdateTimestampMs = t1;
+    const account = createAccountDetailWithId();
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: t2,
+        },
+      },
+    };
+    sinon
+      .stub(serviceWorker, 'getLatestAttentionSetChanges')
+      .returns(Promise.resolve([change, change]));
+    sinon.stub(serviceWorker, 'saveState').returns(Promise.resolve());
+    const showNotificationMock = sinon.stub(
+      serviceWorker.ctx.registration,
+      'showNotification'
+    );
+
+    await serviceWorker.showLatestAttentionChangeNotification(account);
+
+    assert.isTrue(showNotificationMock.calledOnce);
+    assert.isTrue(
+      showNotificationMock.calledWithMatch(
+        'You are in the attention set for 2 changes.'
+      )
+    );
+  });
+
+  test('show notification for 1 change', async () => {
+    const t1 = parseDate('2016-01-12 20:20:00' as Timestamp).getTime();
+    const t2 = '2016-01-12 20:30:00' as Timestamp;
+    serviceWorker.latestUpdateTimestampMs = t1;
+    const account = createAccountDetailWithId();
+    const subject = 'New change';
+    const reason = 'Reason';
+    const change = {
+      ...createParsedChange(),
+      subject,
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: t2,
+          reason,
+        },
+      },
+    };
+    const showNotificationMock = sinon.stub(
+      serviceWorker.ctx.registration,
+      'showNotification'
+    );
+    sinon
+      .stub(serviceWorker, 'getLatestAttentionSetChanges')
+      .returns(Promise.resolve([change]));
+    sinon.stub(serviceWorker, 'saveState').returns(Promise.resolve());
+
+    await serviceWorker.showLatestAttentionChangeNotification(account);
+
+    assert.isTrue(showNotificationMock.calledOnce);
+    assert.isTrue(
+      showNotificationMock.calledWithMatch(subject, {
+        body: reason,
+        data: {
+          url: 'http://localhost:9876/c/test-project/+/42?usp=service-worker-notification',
+        },
+      })
+    );
+    assert.equal(showNotificationMock.firstCall.args?.[1]?.['body'], reason);
+    assert.isTrue(
+      showNotificationMock.firstCall.args?.[1]?.['data']?.['url'].endsWith(
+        'c/test-project/+/42?usp=service-worker-notification'
+      )
+    );
+  });
+});
diff --git a/polygerrit-ui/app/workers/service-worker-indexdb.ts b/polygerrit-ui/app/workers/service-worker-indexdb.ts
new file mode 100644
index 0000000..6d5ab40
--- /dev/null
+++ b/polygerrit-ui/app/workers/service-worker-indexdb.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export interface GerritServiceWorkerState {
+  latestUpdateTimestampMs: number;
+  allowBrowserNotificationsPreference: boolean;
+}
+
+const SERVICE_WORKER_DB = 'service-worker-db-1';
+// Object store - kind of table that holds objects
+// https://developer.mozilla.org/en-US/docs/Web/API/IDBObjectStore
+const SERVICE_WORKER_STORE = 'states';
+// Service Worker State needs just 1 entry in object store which is rewritten
+// every time state is saved. This entry has SERVICE_WORKER_STATE_ID.
+const SERVICE_WORKER_STATE_ID = 1;
+
+function getServiceWorkerDB(): Promise<IDBDatabase> {
+  return new Promise((resolve, reject) => {
+    const request = indexedDB.open(SERVICE_WORKER_DB);
+    request.onsuccess = () => resolve(request.result);
+    request.onerror = reject;
+    request.onblocked = reject;
+    // Event is fired when an attempt was made to open a database with a version
+    // higher than its current version.
+    // https://developer.mozilla.org/en-US/docs/Web/API/IDBOpenDBRequest/upgradeneeded_event
+    // It's mainly used to create object stores.
+    // https://web.dev/indexeddb/#creating-object-stores
+    request.onupgradeneeded = () => {
+      const db = request.result;
+      if (db.objectStoreNames.contains(SERVICE_WORKER_STORE)) return;
+      const states = db.createObjectStore(SERVICE_WORKER_STORE, {
+        keyPath: 'id',
+      });
+      states.createIndex('states_id_unique', 'id', {unique: true});
+    };
+  });
+}
+
+export async function putServiceWorkerState(state: GerritServiceWorkerState) {
+  const db = await getServiceWorkerDB();
+  const tx = db.transaction(SERVICE_WORKER_STORE, 'readwrite');
+  const store = tx.objectStore(SERVICE_WORKER_STORE);
+  store.put({...state, id: SERVICE_WORKER_STATE_ID});
+
+  return new Promise<void>(resolve => {
+    tx.oncomplete = () => resolve();
+  });
+}
+
+export async function getServiceWorkerState(): Promise<GerritServiceWorkerState> {
+  const db = await getServiceWorkerDB();
+  const tx = db.transaction(SERVICE_WORKER_STORE, 'readonly');
+  const store = tx.objectStore(SERVICE_WORKER_STORE);
+
+  return new Promise((resolve, reject) => {
+    const request = store.get(SERVICE_WORKER_STATE_ID);
+    request.onsuccess = () => resolve(request.result);
+    request.onerror = reject;
+  });
+}
diff --git a/polygerrit-ui/app/workers/service-worker.ts b/polygerrit-ui/app/workers/service-worker.ts
new file mode 100644
index 0000000..86edecf
--- /dev/null
+++ b/polygerrit-ui/app/workers/service-worker.ts
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {ServiceWorker} from './service-worker-class';
+
+/**
+ * `self` is for a worker what `window` is for the web app. It is called
+ * the `ServiceWorkerGlobalScope`, see
+ * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope
+ */
+const ctx = self as {} as ServiceWorkerGlobalScope;
+
+/** Singleton instance */
+const serviceWorker = new ServiceWorker(ctx);
+serviceWorker.init();
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index e0b18be..2f7cf3c 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -193,6 +193,14 @@
     "@polymer/iron-meta" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
+"@polymer/marked-element@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/marked-element/-/marked-element-3.0.1.tgz#56add62080404dea142c055977807ae9ca773a89"
+  integrity sha512-WJQzQetxdStVGQbyTBUBgd+hSI0Rl39uJg7b2zL3r6EfMnibzmA/YNT06M8jVZdxPF+B4SumrFWRtasVtGQRUQ==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+    marked "~0.3.9"
+
 "@polymer/neon-animation@^3.0.0-pre.26":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@polymer/neon-animation/-/neon-animation-3.0.1.tgz#6658e4b524abc057477772a7473292493d366c24"
@@ -717,6 +725,11 @@
   dependencies:
     semver "^6.0.0"
 
+marked@~0.3.9:
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.19.tgz#5d47f709c4c9fc3c216b6d46127280f40b39d790"
+  integrity sha512-ea2eGWOqNxPcXv8dyERdSr/6FmzvWwzjMxpfGB/sbMccXoct+xY+YukPD+QTUZwyvK7BZwcr4m21WBOW41pAkg==
+
 mimic-response@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43"
@@ -874,6 +887,11 @@
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
 
+safevalues@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/safevalues/-/safevalues-0.3.1.tgz#610a910290930ac5f25ba77055cb8a819b0a15a9"
+  integrity sha512-sp++LhKx0CiDw9QGrYSavXCxQRIoZUBsupt2NbucztV5cLpO3zzAwww+LZS8L3dgGU0f5/zw3hymq3ltrVebNA==
+
 semver@^6.0.0:
   version "6.3.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
@@ -975,6 +993,11 @@
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
 
+web-vitals@^2.1.4:
+  version "2.1.4"
+  resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.4.tgz#76563175a475a5e835264d373704f9dde718290c"
+  integrity sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==
+
 webidl-conversions@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
diff --git a/polygerrit-ui/grep-patch-karma.js b/polygerrit-ui/grep-patch-karma.js
index adf5171..ae0ff7f 100644
--- a/polygerrit-ui/grep-patch-karma.js
+++ b/polygerrit-ui/grep-patch-karma.js
@@ -1,18 +1,7 @@
 /**
  * @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
  */
 
 // The IntelliJ (and probably other IDEs) passes test names as a regexp in
@@ -23,21 +12,26 @@
 function installPatch(karma) {
   const originalKarmaStart = karma.start;
 
-  karma.start = function(config, ...args) {
-    const regexpGrepPrefix = '--grep=/';
-    const regexpGrepSuffix = '/';
+  karma.start = function (config, ...args) {
+    const regexpGrepPrefix = "--grep=/";
+    const regexpGrepSuffix = "/";
     if (config && config.args) {
       for (let i = 0; i < config.args.length; i++) {
         const arg = config.args[i];
-        if (arg.startsWith(regexpGrepPrefix) && arg.endsWith(regexpGrepSuffix)) {
-          const regexpText = arg.slice(regexpGrepPrefix.length, -regexpGrepPrefix.length);
-          config.args[i] = '--grep=' + regexpText;
+        if (
+          arg.startsWith(regexpGrepPrefix) &&
+          arg.endsWith(regexpGrepSuffix)
+        ) {
+          const regexpText = arg.slice(
+            regexpGrepPrefix.length,
+            -regexpGrepPrefix.length
+          );
+          config.args[i] = "--grep=" + regexpText;
         }
       }
     }
     originalKarmaStart.apply(this, [config, ...args]);
-  }
-
+  };
 }
 
 const karma = window.__karma__;
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
index d656eb7..60fcea3 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -1,46 +1,34 @@
 /**
  * @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
  */
 
 const runUnderBazel = !!process.env["RUNFILES_DIR"];
-const path = require('path');
+const path = require("path");
 
 function getModulesDir() {
-  if(runUnderBazel) {
+  if (runUnderBazel) {
     // Run under bazel
     return [
       `external/plugins_npm/node_modules`,
       `external/ui_npm/node_modules`,
-      `external/ui_dev_npm/node_modules`
+      `external/ui_dev_npm/node_modules`,
     ];
   }
 
   // Run from intellij or npm run test:kdebug
   return [
-    path.join(__dirname, 'app/node_modules'),
-    path.join(__dirname, 'node_modules'),
+    path.join(__dirname, "app/node_modules"),
+    path.join(__dirname, "node_modules"),
   ];
 }
 
 function getUiDevNpmFilePath(importPath) {
-  if(runUnderBazel) {
+  if (runUnderBazel) {
     return `external/ui_dev_npm/node_modules/${importPath}`;
-  }
-  else {
-    return `polygerrit-ui/node_modules/${importPath}`
+  } else {
+    return `polygerrit-ui/node_modules/${importPath}`;
   }
 }
 
@@ -54,15 +42,17 @@
   // We want to increase browserNoActivityTimeout when tests run in IDE.
   // Wd don't want to increase it in other cases, oterhise hanging tests
   // can slow down CI.
-  return !runUnderBazel &&
-      process.argv.some(arg => arg.toLowerCase().contains('intellij'));
+  return (
+    !runUnderBazel &&
+    process.argv.some((arg) => arg.toLowerCase().contains("intellij"))
+  );
 }
 
-module.exports = function(config) {
+module.exports = function (config) {
   let root = config.root;
   if (!root) {
-    console.warn(`--root argument not set. Falling back to __dirname.`)
-    root = path.resolve(__dirname) + '/';
+    console.warn(`--root argument not set. Falling back to __dirname.`);
+    root = path.resolve(__dirname) + "/";
   }
   // Use --test-files to specify pattern for a test files.
   // It can be just a file name, without a path:
@@ -74,47 +64,58 @@
   // given.
   let filePattern;
   if (typeof config.testFiles === "string") {
-    if (config.testFiles.endsWith('.ts')) {
-      filePattern = config.testFiles.substr(0, config.testFiles.lastIndexOf(".")) + ".js";
-    } else if (config.testFiles.endsWith('.js')) {
+    if (config.testFiles.endsWith(".ts")) {
+      filePattern =
+        config.testFiles.substr(0, config.testFiles.lastIndexOf(".")) + ".js";
+    } else if (config.testFiles.endsWith(".js")) {
       filePattern = config.testFiles;
     } else {
-      filePattern = config.testFiles + '.js';
+      filePattern = config.testFiles + ".js";
     }
   } else {
-    filePattern = '*_test.js';
+    filePattern = "*_test.js";
   }
-  const testFilesPattern = root + '**/' + filePattern;
+  const testFilesPattern = root + "**/" + filePattern;
 
-  console.info(`Karma test file pattern: ${testFilesPattern}`)
+  console.info(`Karma test file pattern: ${testFilesPattern}`);
   // Special patch for grep parameters (see details in the grep-patch-karam.js)
-  const additionalFiles = runUnderBazel ? [] : ['polygerrit-ui/grep-patch-karma.js'];
+  const additionalFiles = runUnderBazel
+    ? []
+    : ["polygerrit-ui/grep-patch-karma.js"];
   config.set({
     browserNoActivityTimeout: runInIde ? 60 * 60 * 1000 : 30 * 1000,
     // base path that will be used to resolve all patterns (eg. files, exclude)
-    basePath: '../',
+    basePath: "../",
     plugins: [
       // Do not use karma-* to load all installed plugin
       // This can lead to unexpected behavior under bazel
       // if you forget to add a plugin in a bazel rule.
-      require.resolve('@open-wc/karma-esm'),
-      'karma-mocha',
-      'karma-chrome-launcher',
-      'karma-mocha-reporter',
+      require.resolve("@open-wc/karma-esm"),
+      "karma-mocha",
+      "karma-chrome-launcher",
+      "karma-mocha-reporter",
     ],
     // frameworks to use
     // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
-    frameworks: ['mocha', 'esm'],
+    frameworks: ["mocha", "esm"],
 
     // list of files / patterns to load in the browser
     files: [
       ...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' },
+      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" },
     ],
     esm: {
       nodeResolve: {
@@ -123,7 +124,7 @@
         // in node resolve.
         // The .ts extension is required to display source code in browser
         // (otherwise esm plugin crashes)
-        extensions: ['.js', '.ts'],
+        extensions: [".js", ".ts"],
       },
       moduleDirs: getModulesDir(),
       // Bazel and yarn uses symlinks for files.
@@ -133,7 +134,7 @@
       // In the 'auto' mode it incorrectly applies polyfills and
       // breaks tests in some browser versions
       // (for example, Chrome 69 on gerrit-ci).
-      compatibility: 'none',
+      compatibility: "none",
       plugins: [
         {
           resolveImport(importSpecifier) {
@@ -151,62 +152,69 @@
               return importSpecifier.source;
             }
             return undefined;
-          }
+          },
         },
         {
           transform(context) {
-            if (context.path.endsWith('/node_modules/page/page.js')) {
+            if (context.path.endsWith("/node_modules/page/page.js")) {
               const orignalBody = context.body;
               // Can't import page.js directly, because this is undefined.
               // Replace it with window
               // The same replace exists in server.go
               // Rollup makes this replacement automatically
               const transformedBody = orignalBody.replace(
-                  '}(this, (function () { \'use strict\';',
-                  '}(window, (function () { \'use strict\';'
+                "}(this, (function () { 'use strict';",
+                "}(window, (function () { 'use strict';"
               );
-              if(orignalBody.length === transformedBody.length) {
-                console.error('The page.js was updated. Please update transform accordingly');
+              if (orignalBody.length === transformedBody.length) {
+                console.error(
+                  "The page.js was updated. Please update transform accordingly"
+                );
                 process.exit(1);
               }
-              return {body: transformedBody};
+              return { body: transformedBody };
             }
           },
-        }
-      ]
+        },
+      ],
     },
     // test results reporter to use
     // possible values: 'dots', 'progress'
     // available reporters: https://npmjs.org/browse/keyword/karma-reporter
-    reporters: ['mocha'],
+    reporters: ["mocha"],
 
     mochaReporter: {
-      showDiff: true
+      showDiff: true,
     },
 
+    // Listen on localhost so it either listens to ipv4
+    // or ipv6. Some OS's default to ipv6 for localhost
+    // and others ipv4.
+    // Nodejs 17 changed the behaviour from prefering ipv4 to
+    // using the OS settings.
+    // The default is 127.0.0.1 thus if localhost is on ipv6 only
+    // it'll fail to connect to the karma server.
+    // See https://github.com/karma-runner/karma/blob/e17698f950af83bf2b3edc540d2a3e1fb73cba59/lib/utils/dns-utils.js#L3
+    listenAddress: "localhost",
+
     // web server port
     port: 9876,
 
-
     // enable / disable colors in the output (reporters and logs)
     colors: true,
 
-
     // level of logging
     // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
     logLevel: config.LOG_INFO,
 
-
     // enable / disable watching file and executing tests whenever any file changes
     autoWatch: false,
 
-
     // start these browsers
     // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
     browsers: ["CustomChromeHeadless"],
     browserForDebugging: "CustomChromeHeadlessWithDebugPort",
 
-
     // Continuous Integration mode
     // if true, Karma captures browsers, runs the tests and exits
     singleRun: true,
@@ -217,25 +225,25 @@
 
     client: {
       mocha: {
-        ui: 'tdd',
+        ui: "tdd",
         timeout: 5000,
-      }
+      },
     },
 
     customLaunchers: {
       // Based on https://developers.google.com/web/updates/2017/06/headless-karma-mocha-chai
-      "CustomChromeHeadless": {
-        base: 'ChromeHeadless',
-        flags: ['--disable-translate', '--disable-extensions'],
+      CustomChromeHeadless: {
+        base: "ChromeHeadless",
+        flags: ["--disable-translate", "--disable-extensions"],
       },
-      "ChromeDev": {
-        base: 'Chrome',
-        flags: ['--disable-extensions', ' --auto-open-devtools-for-tabs'],
+      ChromeDev: {
+        base: "Chrome",
+        flags: ["--disable-extensions", " --auto-open-devtools-for-tabs"],
       },
-      "CustomChromeHeadlessWithDebugPort": {
-        base: 'CustomChromeHeadless',
-        flags: ['--remote-debugging-port=9222'],
-      }
-    }
+      CustomChromeHeadlessWithDebugPort: {
+        base: "CustomChromeHeadless",
+        flags: ["--remote-debugging-port=9222"],
+      },
+    },
   });
 };
diff --git a/polygerrit-ui/karma_test.sh b/polygerrit-ui/karma_test.sh
deleted file mode 100755
index 940b969..0000000
--- a/polygerrit-ui/karma_test.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/bash
-
-set -euo pipefail
-./$1 start $2 \
-  --root 'polygerrit-ui/app/_pg_with_tests_out/**/' \
-  --test-files '*_test.js'
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index cdc03aa..1287d0c 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -3,26 +3,33 @@
   "description": "Gerrit Code Review - Polygerrit dev dependencies",
   "browser": true,
   "dependencies": {
-    "@types/chai": "^4.2.16",
-    "@types/mocha": "^8.2.2",
     "@types/sinon": "^10.0.0"
   },
   "devDependencies": {
     "@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",
+    "@open-wc/testing": "^3.1.6",
+    "@web/dev-server-esbuild": "^0.3.2",
+    "@web/test-runner": "^0.14.0",
+    "@web/test-runner-visual-regression": "^0.6.6",
     "accessibility-developer-tools": "^2.12.0",
-    "chai": "^4.3.4",
-    "karma": "^6.3.6",
-    "karma-chrome-launcher": "^3.1.0",
+    "karma": "^6.3.20",
+    "karma-chrome-launcher": "^3.1.1",
     "karma-mocha": "^2.0.1",
     "karma-mocha-reporter": "^2.2.5",
-    "mocha": "8.3.2",
-    "sinon": "^10.0.0",
+    "mocha": "9.2.2",
+    "sinon": "^13.0.0",
     "source-map-support": "^0.5.19"
   },
+  "scripts": {
+    "test": "web-test-runner",
+    "test:screenshot": "web-test-runner --run-screenshots",
+    "test:screenshot-update": "web-test-runner --update-screenshots --files",
+    "test:coverage": "web-test-runner --coverage",
+    "test:watch": "web-test-runner --watch",
+    "test:single": "web-test-runner --watch --files",
+    "test:single:coverage": "web-test-runner --watch --coverage --files"
+  },
   "license": "Apache-2.0",
   "private": true
-}
\ No newline at end of file
+}
diff --git a/polygerrit-ui/run-server.sh b/polygerrit-ui/run-server.sh
deleted file mode 100755
index 62e1453..0000000
--- a/polygerrit-ui/run-server.sh
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/usr/bin/env bash
-# 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.
-
-bazel_bin=$(which bazelisk 2>/dev/null)
-if [[ -z "$bazel_bin" ]]; then
-    echo "Warning: bazelisk is not installed; falling back to bazel."
-    bazel_bin=bazel
-fi
-
-set -eu
-SCRIPTNAME=$(mktemp)
-trap "{ rm -f $SCRIPTNAME; }" EXIT
-${bazel_bin} run --script_path="$SCRIPTNAME" //polygerrit-ui:devserver
-"$SCRIPTNAME" "$@"
diff --git a/polygerrit-ui/screenshots/Chrome/baseline/gr-file-list.png b/polygerrit-ui/screenshots/Chrome/baseline/gr-file-list.png
new file mode 100644
index 0000000..4d0cbed
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chrome/baseline/gr-file-list.png
Binary files differ
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
deleted file mode 100644
index 2a433fb..0000000
--- a/polygerrit-ui/server.go
+++ /dev/null
@@ -1,646 +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 main
-
-import (
-	"archive/zip"
-	"bufio"
-	"bytes"
-	"compress/gzip"
-	"encoding/json"
-	"errors"
-	"flag"
-	"io"
-	"io/ioutil"
-	"log"
-	"net"
-	"net/http"
-	"net/url"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"regexp"
-	"strings"
-	"sync"
-	"time"
-
-	"golang.org/x/tools/godoc/vfs/httpfs"
-	"golang.org/x/tools/godoc/vfs/zipfs"
-)
-
-var (
-	plugins    = flag.String("plugins", "", "comma seperated plugin paths to serve")
-	port       = flag.String("port", "localhost:8081", "address to serve HTTP requests on")
-	host       = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
-	scheme     = flag.String("scheme", "https", "URL scheme")
-	cdnPattern = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_ui/[0-9.]*")
-)
-
-func main() {
-	flag.Parse()
-
-	fontsArchive, err := openDataArchive("fonts.zip")
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	workspace := os.Getenv("BUILD_WORKSPACE_DIRECTORY")
-	if err := os.Chdir(filepath.Join(workspace, "polygerrit-ui")); err != nil {
-		log.Fatal(err)
-	}
-
-	compiledSrcPath := filepath.Join(workspace, "./.ts-out/server-go")
-
-	tsInstance := newTypescriptInstance(
-		filepath.Join(workspace, "./node_modules/.bin/tsc"),
-		filepath.Join(workspace, "./polygerrit-ui/app/tsconfig.json"),
-		compiledSrcPath,
-	)
-
-	if err := tsInstance.StartWatch(); err != nil {
-		log.Fatal(err)
-	}
-
-	dirListingMux := http.NewServeMux()
-	dirListingMux.Handle("/styles/", http.StripPrefix("/styles/", http.FileServer(http.Dir("app/styles"))))
-	dirListingMux.Handle("/samples/", http.StripPrefix("/samples/", http.FileServer(http.Dir("app/samples"))))
-	dirListingMux.Handle("/elements/", http.StripPrefix("/elements/", http.FileServer(http.Dir("app/elements"))))
-	dirListingMux.Handle("/behaviors/", http.StripPrefix("/behaviors/", http.FileServer(http.Dir("app/behaviors"))))
-
-	http.HandleFunc("/",
-		func(w http.ResponseWriter, req *http.Request) {
-			// If typescript compiler hasn't finished yet, wait for it
-			tsInstance.WaitForCompilationComplete()
-			handleSrcRequest(compiledSrcPath, dirListingMux, w, req)
-		})
-
-	http.Handle("/fonts/",
-		addDevHeadersMiddleware(http.FileServer(httpfs.New(zipfs.New(fontsArchive, "fonts")))))
-
-	http.HandleFunc("/index.html", handleIndex)
-	http.HandleFunc("/changes/", handleProxy)
-	http.HandleFunc("/accounts/", handleProxy)
-	http.HandleFunc("/config/", handleProxy)
-	http.HandleFunc("/projects/", handleProxy)
-	http.HandleFunc("/static/", handleProxy)
-	http.HandleFunc("/accounts/self/detail", handleAccountDetail)
-
-	if len(*plugins) > 0 {
-		http.Handle("/plugins/", http.StripPrefix("/plugins/",
-			http.FileServer(http.Dir("../plugins"))))
-		log.Println("Local plugins from", "../plugins")
-	} else {
-		http.HandleFunc("/plugins/", handleProxy)
-		// Serve local plugins from `plugins_`
-		http.Handle("/plugins_/", http.StripPrefix("/plugins_/",
-			http.FileServer(http.Dir("../plugins"))))
-	}
-	log.Println("Serving on port", *port)
-	log.Fatal(http.ListenAndServe(*port, &server{}))
-}
-
-func addDevHeadersMiddleware(h http.Handler) http.Handler {
-	return http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) {
-		addDevHeaders(writer)
-		h.ServeHTTP(writer, req)
-	})
-}
-
-func addDevHeaders(writer http.ResponseWriter) {
-	writer.Header().Set("Access-Control-Allow-Origin", "*")
-	writer.Header().Set("Access-Control-Allow-Headers", "cache-control,x-test-origin")
-	writer.Header().Set("Cache-Control", "public, max-age=10, must-revalidate")
-}
-
-func handleSrcRequest(compiledSrcPath string, dirListingMux *http.ServeMux, writer http.ResponseWriter, originalRequest *http.Request) {
-	parsedUrl, err := url.Parse(originalRequest.RequestURI)
-	if err != nil {
-		writer.WriteHeader(500)
-		return
-	}
-	if parsedUrl.Path != "/" && strings.HasSuffix(parsedUrl.Path, "/") {
-		dirListingMux.ServeHTTP(writer, originalRequest)
-		return
-	}
-
-	normalizedContentPath := parsedUrl.Path
-
-	if !strings.HasPrefix(normalizedContentPath, "/") {
-		normalizedContentPath = "/" + normalizedContentPath
-	}
-
-	isJsFile := strings.HasSuffix(normalizedContentPath, ".js") || strings.HasSuffix(normalizedContentPath, ".mjs")
-	isTsFile := strings.HasSuffix(normalizedContentPath, ".ts")
-
-	// Source map in a compiled js file point to a file inside /app/... directory
-	// Browser tries to load original file from the directory when debugger is
-	// activated. In this case we return original content without any processing
-	isOriginalFileRequest := strings.HasPrefix(normalizedContentPath, "/polygerrit-ui/app/") && (isTsFile || isJsFile)
-
-	data, err := getContent(compiledSrcPath, normalizedContentPath, isOriginalFileRequest)
-	if err != nil {
-		if !isOriginalFileRequest {
-			data, err = getContent(compiledSrcPath, normalizedContentPath+".js", false)
-		}
-		if err != nil {
-			writer.WriteHeader(404)
-			return
-		}
-		isJsFile = true
-	}
-	if isOriginalFileRequest {
-		// Explicitly set text/html Content-Type. If live code tries
-		// to import javascript from the /app/ folder accidentally, browser fails
-		// with the import error, so we can catch this problem easily.
-		writer.Header().Set("Content-Type", "text/html")
-	} else if isJsFile {
-		// import ... from '@polymer/decorators'
-		// must be transformed into
-		// import ... from '@polymer/decorators/lib/decorators.js'
-		// The correct way to do it is to use value of the "main" property
-		// from the @polymer/decorators/package.json. However, parsing package.json
-		// is overcomplicated right now, hard-code exact path here.
-		moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'@polymer/decorators';$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '@polymer/decorators/lib/decorators.js';"))
-
-		// The following code updates import statements.
-		// 1. if an in imported file has .js or .mjs extension, the code keeps
-		//	  the file extension unchanged. Otherwise, it adds .js extension
-		// 2. For module imports it adds '/node_modules/' prefix.
-		//   Examples:
-		//   '@polymer/polymer.js' -> '/node_modules/@polymer/polymer.js'
-		//   'page/page.mjs' -> '/node_modules/page.mjs'
-		//   '@polymer/iron-icon' -> '/node_modules/@polymer/iron-icon.js'
-		//   './element/file' -> './element/file.js'
-		moduleImportRegexp = regexp.MustCompile(`(import[^'";]*|export[^'";]*from ?)['"]([^;\s]*?)(\.(m?)js)?['"];`)
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1'$2.${4}js';"))
-
-		moduleImportRegexp = regexp.MustCompile(`(import[^'";]*|export[^'";]*from ?)['"]([^/.;\s][^;\s]*)['"];`)
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1'/node_modules/$2';"))
-
-		// The es module version of rxjs can be found in the _esm2015/ directory.
-		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/rxjs)(.*).js(';)$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1/_esm2015$3/index.js$4"))
-
-		// The es module version of tslib.js can be found in tslib.es6.js.
-		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)tslib.js';$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}tslib/tslib.es6.js';"))
-
-		// 'lit.js' has to be resolved as 'lit/index.js'.
-		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)lit.js';$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}lit/index.js';"))
-		// Some lit imports 'a.js' have to be resolved as 'a/a.js'.
-		moduleImportRegexp = regexp.MustCompile(`((import|export)[^'";]*'/node_modules/(@lit/)?)(lit-element|lit-html|reactive-element).js';`)
-		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}${4}/${4}.js';"))
-
-		// 'immer' imports and exports have to be resolved to 'immer/dist/immer.esm.js'.
-		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)immer.js';$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}/immer/dist/immer.esm.js';"))
-
-		if strings.HasSuffix(normalizedContentPath, "/node_modules/page/page.js") {
-			// Can't import page.js directly, because this is undefined.
-			// Replace it with window
-			// The same replace exists in karma.conf.js
-			// Rollup makes this replacement automatically
-			pageJsRegexp := regexp.MustCompile(`(?m)^}\(this, \(function \(\) { 'use strict';$`)
-			newData := pageJsRegexp.ReplaceAll(data, []byte("}(window, (function () { 'use strict';"))
-			if len(newData) == len(data) {
-				log.Fatal("The page.js was updated. Please update regexp/replace accordingly")
-			}
-			data = newData
-		}
-
-		writer.Header().Set("Content-Type", "application/javascript")
-	} else if strings.HasSuffix(normalizedContentPath, ".css") {
-		writer.Header().Set("Content-Type", "text/css")
-	} else if strings.HasSuffix(normalizedContentPath, "_test.html") {
-		moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'([^/.].*)';$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '/node_modules/$2';"))
-		writer.Header().Set("Content-Type", "text/html")
-	} else if strings.HasSuffix(normalizedContentPath, ".html") {
-		writer.Header().Set("Content-Type", "text/html")
-	}
-	writer.WriteHeader(200)
-	addDevHeaders(writer)
-	writer.Write(data)
-}
-
-func getContent(compiledSrcPath string, normalizedContentPath string, isOriginalFileRequest bool) ([]byte, error) {
-	// normalizedContentPath must always starts with '/'
-
-	if isOriginalFileRequest {
-		data, err := ioutil.ReadFile(normalizedContentPath[len("/polygerrit-ui/"):])
-		if err != nil {
-			return nil, errors.New("File not found")
-		}
-		return data, nil
-	}
-
-	// gerrit loads gr-app.js as an ordinary script, without type="module" attribute.
-	// If server.go serves this file as is, browser shows the error:
-	// Uncaught SyntaxError: Cannot use import statement outside a module
-	//
-	// To load non-bundled gr-app.js as a module, we "virtually" renames original
-	// gr-app.js to gr-app.mjs and load it with dynamic import.
-	//
-	// Another option is to patch rewriteHostPage function and add type="module" attribute
-	// to <script src=".../elements/gr-app.js"> tag, but this solution is incompatible
-	// with --dev-cdn options. If you run local gerrit instance with --dev-cdn parameter,
-	// the server.go is used as cdn and it doesn't handle host page (i.e. rewriteHostPage
-	// method is not called).
-	if normalizedContentPath == "/elements/gr-app.js" {
-		return []byte("import('./gr-app.mjs')"), nil
-	}
-
-	if normalizedContentPath == "/elements/gr-app.mjs" {
-		normalizedContentPath = "/elements/gr-app.js"
-	}
-
-	pathsToTry := []string{compiledSrcPath + normalizedContentPath, "app" + normalizedContentPath}
-	bowerComponentsSuffix := "/bower_components/"
-	nodeModulesPrefix := "/node_modules/"
-	testComponentsPrefix := "/components/"
-
-	if strings.HasPrefix(normalizedContentPath, testComponentsPrefix) {
-		pathsToTry = append(pathsToTry, "node_modules/wct-browser-legacy/node_modules/"+normalizedContentPath[len(testComponentsPrefix):])
-		pathsToTry = append(pathsToTry, "node_modules/"+normalizedContentPath[len(testComponentsPrefix):])
-	}
-
-	if strings.HasPrefix(normalizedContentPath, bowerComponentsSuffix) {
-		pathsToTry = append(pathsToTry, "node_modules/@webcomponents/"+normalizedContentPath[len(bowerComponentsSuffix):])
-	}
-
-	if strings.HasPrefix(normalizedContentPath, nodeModulesPrefix) {
-		pathsToTry = append(pathsToTry, "node_modules/"+normalizedContentPath[len(nodeModulesPrefix):])
-	}
-
-	for _, path := range pathsToTry {
-		data, err := ioutil.ReadFile(path)
-		if err == nil {
-			return data, nil
-		}
-	}
-
-	return nil, errors.New("File not found")
-}
-
-func openDataArchive(path string) (*zip.ReadCloser, error) {
-	absBinPath, err := resourceBasePath()
-	if err != nil {
-		return nil, err
-	}
-	return zip.OpenReader(absBinPath + ".runfiles/gerrit/polygerrit-ui/" + path)
-}
-
-func resourceBasePath() (string, error) {
-	return filepath.Abs(os.Args[0])
-}
-
-func handleIndex(writer http.ResponseWriter, originalRequest *http.Request) {
-	fakeRequest := &http.Request{
-		URL: &url.URL{
-			Path:     "/",
-			RawQuery: originalRequest.URL.RawQuery,
-		},
-	}
-	handleProxy(writer, fakeRequest)
-}
-
-func handleProxy(writer http.ResponseWriter, originalRequest *http.Request) {
-	patchedRequest := &http.Request{
-		Method: "GET",
-		URL: &url.URL{
-			Scheme:   *scheme,
-			Host:     *host,
-			Opaque:   originalRequest.URL.EscapedPath(),
-			RawQuery: originalRequest.URL.RawQuery,
-		},
-	}
-	response, err := http.DefaultClient.Do(patchedRequest)
-	if err != nil {
-		http.Error(writer, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	defer response.Body.Close()
-	for name, values := range response.Header {
-		for _, value := range values {
-			if name != "Content-Length" {
-				writer.Header().Add(name, value)
-			}
-		}
-	}
-	writer.WriteHeader(response.StatusCode)
-	if _, err := io.Copy(writer, patchResponse(originalRequest, response)); err != nil {
-		log.Println("Error copying response to ResponseWriter:", err)
-		return
-	}
-}
-
-func getJsonPropByPath(json map[string]interface{}, path []string) interface{} {
-	prop, path := path[0], path[1:]
-	if json[prop] == nil {
-		return nil
-	}
-	switch json[prop].(type) {
-	case map[string]interface{}: // map
-		return getJsonPropByPath(json[prop].(map[string]interface{}), path)
-	case []interface{}: // array
-		return json[prop].([]interface{})
-	default:
-		return json[prop].(interface{})
-	}
-}
-
-func setJsonPropByPath(json map[string]interface{}, path []string, value interface{}) {
-	prop, path := path[0], path[1:]
-	if json[prop] == nil {
-		return // path not found
-	}
-	if len(path) > 0 {
-		setJsonPropByPath(json[prop].(map[string]interface{}), path, value)
-	} else {
-		json[prop] = value
-	}
-}
-
-func patchResponse(req *http.Request, res *http.Response) io.Reader {
-	switch req.URL.EscapedPath() {
-	case "/":
-		return rewriteHostPage(res.Body)
-	case "/config/server/info":
-		return injectLocalPlugins(res.Body)
-	default:
-		return res.Body
-	}
-}
-
-func rewriteHostPage(reader io.Reader) io.Reader {
-	buf := new(bytes.Buffer)
-	buf.ReadFrom(reader)
-	original := buf.String()
-
-	// Simply remove all CDN references, so files are loaded from the local file system  or the proxy
-	// server instead.
-	replaced := cdnPattern.ReplaceAllString(original, "")
-
-	// Modify window.INITIAL_DATA so that it has the same effect as injectLocalPlugins. To achieve
-	// this let's add JavaScript lines at the end of the <script>...</script> snippet that also
-	// contains window.INITIAL_DATA=...
-	// Here we rely on the fact that the <script> snippet that we want to append to is the first one.
-	if len(*plugins) > 0 {
-		insertionPoint := strings.Index(replaced, "</script>")
-		builder := new(strings.Builder)
-		builder.WriteString(
-			"window.INITIAL_DATA['/config/server/info'].plugin.js_resource_paths = []; ")
-		for _, p := range strings.Split(*plugins, ",") {
-			if filepath.Ext(p) == ".js" {
-				builder.WriteString(
-					"window.INITIAL_DATA['/config/server/info'].plugin.js_resource_paths.push('" + p + "'); ")
-			}
-		}
-		replaced = replaced[:insertionPoint] + builder.String() + replaced[insertionPoint:]
-	}
-
-	return strings.NewReader(replaced)
-}
-
-func injectLocalPlugins(reader io.Reader) io.Reader {
-	if len(*plugins) == 0 {
-		return reader
-	}
-	// Skip escape prefix
-	io.CopyN(ioutil.Discard, reader, 5)
-	dec := json.NewDecoder(reader)
-
-	var response map[string]interface{}
-	err := dec.Decode(&response)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	// Configuration path in the JSON server response
-	jsPluginsPath := []string{"plugin", "js_resource_paths"}
-	jsResources := getJsonPropByPath(response, jsPluginsPath).([]interface{})
-
-	for _, p := range strings.Split(*plugins, ",") {
-		if filepath.Ext(p) == ".js" {
-			jsResources = append(jsResources, p)
-		}
-	}
-
-	setJsonPropByPath(response, jsPluginsPath, jsResources)
-
-	reader, writer := io.Pipe()
-	go func() {
-		defer writer.Close()
-		io.WriteString(writer, ")]}'") // Write escape prefix
-		err := json.NewEncoder(writer).Encode(&response)
-		if err != nil {
-			log.Fatal(err)
-		}
-	}()
-	return reader
-}
-
-func handleAccountDetail(w http.ResponseWriter, r *http.Request) {
-	http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-}
-
-type gzipResponseWriter struct {
-	io.WriteCloser
-	http.ResponseWriter
-}
-
-func newGzipResponseWriter(w http.ResponseWriter) *gzipResponseWriter {
-	gz := gzip.NewWriter(w)
-	return &gzipResponseWriter{WriteCloser: gz, ResponseWriter: w}
-}
-
-func (w gzipResponseWriter) Write(b []byte) (int, error) {
-	return w.WriteCloser.Write(b)
-}
-
-func (w gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
-	h, ok := w.ResponseWriter.(http.Hijacker)
-	if !ok {
-		return nil, nil, errors.New("gzipResponseWriter: ResponseWriter does not satisfy http.Hijacker interface")
-	}
-	return h.Hijack()
-}
-
-type server struct{}
-
-// Any path prefixes that should resolve to index.html.
-var (
-	fePaths    = []string{"/q/", "/c/", "/id/", "/p/", "/x/", "/dashboard/", "/admin/", "/settings/"}
-	issueNumRE = regexp.MustCompile(`^\/\d+\/?$`)
-)
-
-func (_ *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	log.Printf("%s %s %s %s\n", r.Proto, r.Method, r.RemoteAddr, r.URL)
-	for _, prefix := range fePaths {
-		if strings.HasPrefix(r.URL.Path, prefix) || r.URL.Path == "/" {
-			r.URL.Path = "/index.html"
-			log.Println("Redirecting to /index.html")
-			break
-		} else if match := issueNumRE.Find([]byte(r.URL.Path)); match != nil {
-			r.URL.Path = "/index.html"
-			log.Println("Redirecting to /index.html")
-			break
-		}
-	}
-	if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
-		http.DefaultServeMux.ServeHTTP(w, r)
-		return
-	}
-	w.Header().Set("Content-Encoding", "gzip")
-	addDevHeaders(w)
-	gzw := newGzipResponseWriter(w)
-	defer gzw.Close()
-	http.DefaultServeMux.ServeHTTP(gzw, r)
-}
-
-// Typescript compiler support
-// The code below runs typescript compiler in watch mode and redirect
-// all output from the compiler to the standard logger with the prefix "TSC -"
-// Additionally, the code analyzes messages produced by the typescript compiler
-// and allows to wait until compilation is finished.
-var (
-	tsStartingCompilation   = "- Starting compilation in watch mode..."
-	tsFileChangeDetectedMsg = "- File change detected. Starting incremental compilation..."
-	// If there is only one error typescript outputs:
-	// Found 1 error
-	// In all other cases it outputs
-	// Found X errors
-	tsStartWatchingMsg        = regexp.MustCompile(`^.* - Found \d+ error(s)?\. Watching for file changes\.$`)
-	waitForNextChangeInterval = 1 * time.Second
-)
-
-// typescriptLogWriter implements Writer interface and receives output
-// (stdout and stderr) from the typescript compiler. It reads incoming
-// data line-by-line, analyzes each line and updates compilationDoneWaiter
-// according to the current compiler state. Additionally, the
-// typescriptLogWriter passes all incoming lines to the underlying logger.
-type typescriptLogWriter struct {
-	// unfinishedLine stores the portion of line which was partially received
-	// (i.e. all text received after the last EOL (\n) mark.
-	unfinishedLine string
-	// logger is used to pass-through all received strings
-	logger *log.Logger
-	// when WaitGroup counter is 0 the compilation is complete
-	compilationDoneWaiter *sync.WaitGroup
-}
-
-func newTypescriptLogWriter(compilationCompleteWaiter *sync.WaitGroup) *typescriptLogWriter {
-	return &typescriptLogWriter{
-		logger:                log.New(log.Writer(), "TSC - ", log.Flags()),
-		compilationDoneWaiter: compilationCompleteWaiter,
-	}
-}
-
-func (lw typescriptLogWriter) Write(p []byte) (n int, err error) {
-	// The input p can contain several lines and/or the partial line
-	// Code splits the input by EOL marker (\n) and stores the unfinished line
-	// for the next call to Write.
-	partialText := lw.unfinishedLine + string(p)
-	lines := strings.Split(partialText, "\n")
-	fullLines := lines
-	if strings.HasSuffix(partialText, "\n") {
-		lw.unfinishedLine = ""
-	} else {
-		fullLines = lines[:len(lines)-1]
-		lw.unfinishedLine = lines[len(lines)-1]
-	}
-	for _, fullLine := range fullLines {
-		text := strings.TrimSpace(fullLine)
-		if text == "" {
-			continue
-		}
-		if strings.HasSuffix(text, tsFileChangeDetectedMsg) ||
-			strings.HasSuffix(text, tsStartingCompilation) {
-			lw.compilationDoneWaiter.Add(1)
-		}
-		if tsStartWatchingMsg.MatchString(text) {
-			// A source code can be changed while previous compiler run is in progress.
-			// In this case typescript reruns compilation again almost immediately
-			// after the previous run finishes. To detect this situation, we are
-			// waiting waitForNextChangeInterval before decreasing the counter.
-			// If another compiler run is started in this interval, we will wait
-			// again until it finishes.
-			go func() {
-				time.Sleep(waitForNextChangeInterval)
-				lw.compilationDoneWaiter.Done()
-			}()
-		}
-		lw.logger.Print(text)
-	}
-	return len(p), nil
-}
-
-type typescriptInstance struct {
-	cmd                       *exec.Cmd
-	compilationCompleteWaiter *sync.WaitGroup
-}
-
-func newTypescriptInstance(tscBinaryPath string, projectPath string, outdir string) *typescriptInstance {
-	cmd := exec.Command(tscBinaryPath,
-		"--watch",
-		"--preserveWatchOutput",
-		"--project",
-		projectPath,
-		"--outDir",
-		outdir)
-
-	compilationCompleteWaiter := &sync.WaitGroup{}
-	logWriter := newTypescriptLogWriter(compilationCompleteWaiter)
-	// Note 1: (from https://golang.org/pkg/os/exec/#Cmd)
-	// If Stdout and Stderr are the same writer, and have a type that can
-	// be compared with ==, at most one goroutine at a time will call Write.
-	//
-	// Note 2: The typescript compiler reports all compilation errors to
-	// stdout by design (see https://github.com/microsoft/TypeScript/issues/615)
-	// It writes to stderr only when something unexpected happens (like internal
-	// exceptions). To print such errors in the same way as standard typescript
-	// error, the same logWriter is used both for Stdout and Stderr.
-	//
-	// If Stderr arrives in the middle of ordinary typescript output (i.e.
-	// something unexpected happens), the server.go can stop respond to http
-	// requests. However, this is not a problem for us: typescript compiler and
-	// server.go must be restarted anyway.
-	cmd.Stdout = logWriter
-	cmd.Stderr = logWriter
-
-	return &typescriptInstance{
-		cmd:                       cmd,
-		compilationCompleteWaiter: compilationCompleteWaiter,
-	}
-}
-
-func (ts *typescriptInstance) StartWatch() error {
-	err := ts.cmd.Start()
-	if err != nil {
-		return err
-	}
-	go func() {
-		ts.cmd.Wait()
-		log.Fatal("Typescript exits unexpected")
-	}()
-
-	return nil
-}
-
-func (ts *typescriptInstance) WaitForCompilationComplete() {
-	ts.compilationCompleteWaiter.Wait()
-}
diff --git a/polygerrit-ui/web-test-runner.config.mjs b/polygerrit-ui/web-test-runner.config.mjs
new file mode 100644
index 0000000..552e609
--- /dev/null
+++ b/polygerrit-ui/web-test-runner.config.mjs
@@ -0,0 +1,61 @@
+import { esbuildPlugin } from "@web/dev-server-esbuild";
+import { defaultReporter, summaryReporter } from "@web/test-runner";
+import { visualRegressionPlugin } from "@web/test-runner-visual-regression/plugin";
+
+/** @type {import('@web/test-runner').TestRunnerConfig} */
+const config = {
+  files: [
+    "app/**/*_test.{ts,js}",
+    "!**/node_modules/**/*",
+    ...(process.argv.includes("--run-screenshots")
+      ? []
+      : ["!app/**/*_screenshot_test.{ts,js}"]),
+  ],
+  port: 9876,
+  nodeResolve: true,
+  testFramework: { config: { ui: "tdd", timeout: 5000 } },
+  plugins: [
+    esbuildPlugin({
+      ts: true,
+      target: "es2020",
+      tsconfig: "app/tsconfig.json",
+    }),
+    visualRegressionPlugin({
+      diffOptions: {
+        threshold: 0.8,
+      },
+      update: process.argv.includes("--update-screenshots"),
+    }),
+  ],
+  // serve from gerrit root directory so that we can serve fonts from
+  // /lib/fonts/, see middleware.
+  rootDir: "..",
+  reporters: [defaultReporter(), summaryReporter()],
+  middleware: [
+    // Fonts are in /lib/fonts/, but css tries to load from
+    // /polygerrit-ui/app/fonts/. In production this works because our build
+    // copies them over, see /polygerrit-ui/BUILD
+    async (context, next) => {
+      if (context.url.startsWith("/polygerrit-ui/app/fonts/")) {
+        context.url = context.url.replace("/polygerrit-ui/app/", "/lib/");
+      }
+      await next();
+    },
+  ],
+  testRunnerHtml: (testFramework) => `
+    <!DOCTYPE html>
+    <html>
+      <head>
+        <link rel="stylesheet" href="polygerrit-ui/app/styles/main.css">
+        <link rel="stylesheet" href="polygerrit-ui/app/styles/fonts.css">
+        <link
+          rel="stylesheet"
+          href="polygerrit-ui/app/styles/material-icons.css">
+      </head>
+      <body>
+        <script type="module" src="${testFramework}"></script>
+      </body>
+    </html>
+  `,
+};
+export default config;
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index 44dd946..ca6943d 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -2,6 +2,14 @@
 # yarn lockfile v1
 
 
+"@ampproject/remapping@^2.1.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d"
+  integrity sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==
+  dependencies:
+    "@jridgewell/gen-mapping" "^0.1.0"
+    "@jridgewell/trace-mapping" "^0.3.9"
+
 "@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"
@@ -9,259 +17,253 @@
   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"
-  integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==
+"@babel/code-frame@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a"
+  integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==
   dependencies:
-    "@babel/highlight" "^7.14.5"
+    "@babel/highlight" "^7.18.6"
 
-"@babel/compat-data@^7.13.11", "@babel/compat-data@^7.14.7", "@babel/compat-data@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.15.0.tgz#2dbaf8b85334796cafbb0f5793a90a2fc010b176"
-  integrity sha512-0NqAC1IJE0S0+lL1SWFMxMkz1pKCNCjI4tr2Zx4LJSXxCLAdr6KyArnY+sno5m3yH9g737ygOyPABDsnXkpxiA==
+"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.18.8", "@babel/compat-data@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.19.0.tgz#2a592fd89bacb1fcde68de31bee4f2f2dacb0e86"
+  integrity sha512-y5rqgTTPTmaF5e2nVhOxw+Ur9HDJLsWb6U/KpgUzRZEdPfE6VOubXBKLdbcUTijzRptednSBDQbYZBOSqJxpJw==
 
 "@babel/core@^7.11.1":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.15.0.tgz#749e57c68778b73ad8082775561f67f5196aafa8"
-  integrity sha512-tXtmTminrze5HEUPn/a0JtOzzfp0nk+UEXQ/tqIJo3WDGypl/2OFQEMll/zSFU8f/lfmfLXvTaORHF3cfXIQMw==
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.19.0.tgz#d2f5f4f2033c00de8096be3c9f45772563e150c3"
+  integrity sha512-reM4+U7B9ss148rh2n1Qs9ASS+w94irYXga7c2jaQv9RVzpS7Mv1a9rnYYwuDa45G+DkORt9g6An2k/V4d9LbQ==
   dependencies:
-    "@babel/code-frame" "^7.14.5"
-    "@babel/generator" "^7.15.0"
-    "@babel/helper-compilation-targets" "^7.15.0"
-    "@babel/helper-module-transforms" "^7.15.0"
-    "@babel/helpers" "^7.14.8"
-    "@babel/parser" "^7.15.0"
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
+    "@ampproject/remapping" "^2.1.0"
+    "@babel/code-frame" "^7.18.6"
+    "@babel/generator" "^7.19.0"
+    "@babel/helper-compilation-targets" "^7.19.0"
+    "@babel/helper-module-transforms" "^7.19.0"
+    "@babel/helpers" "^7.19.0"
+    "@babel/parser" "^7.19.0"
+    "@babel/template" "^7.18.10"
+    "@babel/traverse" "^7.19.0"
+    "@babel/types" "^7.19.0"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
     gensync "^1.0.0-beta.2"
-    json5 "^2.1.2"
+    json5 "^2.2.1"
     semver "^6.3.0"
-    source-map "^0.5.0"
 
-"@babel/generator@^7.15.0", "@babel/generator@^7.4.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.15.0.tgz#a7d0c172e0d814974bad5aa77ace543b97917f15"
-  integrity sha512-eKl4XdMrbpYvuB505KTta4AV9g+wWzmVBW69tX0H2NwKVKd2YJbKgyK6M8j/rgLbmHOYJn6rUklV677nOyJrEQ==
+"@babel/generator@^7.19.0", "@babel/generator@^7.4.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.19.0.tgz#785596c06425e59334df2ccee63ab166b738419a"
+  integrity sha512-S1ahxf1gZ2dpoiFgA+ohK9DIpz50bJ0CWs7Zlzb54Z4sG8qmdIrGrVqmy1sAtTVRb+9CU6U8VqT9L0Zj7hxHVg==
   dependencies:
-    "@babel/types" "^7.15.0"
+    "@babel/types" "^7.19.0"
+    "@jridgewell/gen-mapping" "^0.3.2"
     jsesc "^2.5.1"
-    source-map "^0.5.0"
 
-"@babel/helper-annotate-as-pure@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz#7bf478ec3b71726d56a8ca5775b046fc29879e61"
-  integrity sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA==
+"@babel/helper-annotate-as-pure@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb"
+  integrity sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-builder-binary-assignment-operator-visitor@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.14.5.tgz#b939b43f8c37765443a19ae74ad8b15978e0a191"
-  integrity sha512-YTA/Twn0vBXDVGJuAX6PwW7x5zQei1luDDo2Pl6q1qZ7hVNl0RZrhHCQG/ArGpR29Vl7ETiB8eJyrvpuRp300w==
+"@babel/helper-builder-binary-assignment-operator-visitor@^7.18.6":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz#acd4edfd7a566d1d51ea975dff38fd52906981bb"
+  integrity sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==
   dependencies:
-    "@babel/helper-explode-assignable-expression" "^7.14.5"
-    "@babel/types" "^7.14.5"
+    "@babel/helper-explode-assignable-expression" "^7.18.6"
+    "@babel/types" "^7.18.9"
 
-"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.14.5", "@babel/helper-compilation-targets@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.15.0.tgz#973df8cbd025515f3ff25db0c05efc704fa79818"
-  integrity sha512-h+/9t0ncd4jfZ8wsdAsoIxSa61qhBYlycXiHWqJaQBCXAhDCMbPRSMTGnZIkkmt1u4ag+UQmuqcILwqKzZ4N2A==
+"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.0.tgz#537ec8339d53e806ed422f1e06c8f17d55b96bb0"
+  integrity sha512-Ai5bNWXIvwDvWM7njqsG3feMlL9hCVQsPYXodsZyLwshYkZVJt59Gftau4VrE8S9IT9asd2uSP1hG6wCNw+sXA==
   dependencies:
-    "@babel/compat-data" "^7.15.0"
-    "@babel/helper-validator-option" "^7.14.5"
-    browserslist "^4.16.6"
+    "@babel/compat-data" "^7.19.0"
+    "@babel/helper-validator-option" "^7.18.6"
+    browserslist "^4.20.2"
     semver "^6.3.0"
 
-"@babel/helper-create-class-features-plugin@^7.14.5":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.15.0.tgz#c9a137a4d137b2d0e2c649acf536d7ba1a76c0f7"
-  integrity sha512-MdmDXgvTIi4heDVX/e9EFfeGpugqm9fobBVg/iioE8kueXrOHdRDe36FAY7SnE9xXLVeYCoJR/gdrBEIHRC83Q==
+"@babel/helper-create-class-features-plugin@^7.18.6":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz#bfd6904620df4e46470bae4850d66be1054c404b"
+  integrity sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-member-expression-to-functions" "^7.15.0"
-    "@babel/helper-optimise-call-expression" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.15.0"
-    "@babel/helper-split-export-declaration" "^7.14.5"
+    "@babel/helper-annotate-as-pure" "^7.18.6"
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-function-name" "^7.19.0"
+    "@babel/helper-member-expression-to-functions" "^7.18.9"
+    "@babel/helper-optimise-call-expression" "^7.18.6"
+    "@babel/helper-replace-supers" "^7.18.9"
+    "@babel/helper-split-export-declaration" "^7.18.6"
 
-"@babel/helper-create-regexp-features-plugin@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.14.5.tgz#c7d5ac5e9cf621c26057722fb7a8a4c5889358c4"
-  integrity sha512-TLawwqpOErY2HhWbGJ2nZT5wSkR192QpN+nBg1THfBfftrlvOh+WbhrxXCH4q4xJ9Gl16BGPR/48JA+Ryiho/A==
+"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz#7976aca61c0984202baca73d84e2337a5424a41b"
+  integrity sha512-htnV+mHX32DF81amCDrwIDr8nrp1PTm+3wfBN9/v8QJOLEioOCOG7qNyq0nHeFiWbT3Eb7gsPwEmV64UCQ1jzw==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    regexpu-core "^4.7.1"
+    "@babel/helper-annotate-as-pure" "^7.18.6"
+    regexpu-core "^5.1.0"
 
-"@babel/helper-define-polyfill-provider@^0.2.2":
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.2.3.tgz#0525edec5094653a282688d34d846e4c75e9c0b6"
-  integrity sha512-RH3QDAfRMzj7+0Nqu5oqgO5q9mFtQEVvCRsi8qCEfzLR9p2BHfn5FzhSB2oj1fF7I2+DcTORkYaQ6aTR9Cofew==
+"@babel/helper-define-polyfill-provider@^0.3.2", "@babel/helper-define-polyfill-provider@^0.3.3":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz#8612e55be5d51f0cd1f36b4a5a83924e89884b7a"
+  integrity sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==
   dependencies:
-    "@babel/helper-compilation-targets" "^7.13.0"
-    "@babel/helper-module-imports" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/traverse" "^7.13.0"
+    "@babel/helper-compilation-targets" "^7.17.7"
+    "@babel/helper-plugin-utils" "^7.16.7"
     debug "^4.1.1"
     lodash.debounce "^4.0.8"
     resolve "^1.14.2"
     semver "^6.1.2"
 
-"@babel/helper-explode-assignable-expression@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.14.5.tgz#8aa72e708205c7bb643e45c73b4386cdf2a1f645"
-  integrity sha512-Htb24gnGJdIGT4vnRKMdoXiOIlqOLmdiUYpAQ0mYfgVT/GDm8GOYhgi4GL+hMKrkiPRohO4ts34ELFsGAPQLDQ==
+"@babel/helper-environment-visitor@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be"
+  integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==
+
+"@babel/helper-explode-assignable-expression@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz#41f8228ef0a6f1a036b8dfdfec7ce94f9a6bc096"
+  integrity sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-function-name@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz#89e2c474972f15d8e233b52ee8c480e2cfcd50c4"
-  integrity sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==
+"@babel/helper-function-name@^7.18.9", "@babel/helper-function-name@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c"
+  integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==
   dependencies:
-    "@babel/helper-get-function-arity" "^7.14.5"
-    "@babel/template" "^7.14.5"
-    "@babel/types" "^7.14.5"
+    "@babel/template" "^7.18.10"
+    "@babel/types" "^7.19.0"
 
-"@babel/helper-get-function-arity@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz#25fbfa579b0937eee1f3b805ece4ce398c431815"
-  integrity sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==
+"@babel/helper-hoist-variables@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678"
+  integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-hoist-variables@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz#e0dd27c33a78e577d7c8884916a3e7ef1f7c7f8d"
-  integrity sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==
+"@babel/helper-member-expression-to-functions@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz#1531661e8375af843ad37ac692c132841e2fd815"
+  integrity sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/types" "^7.18.9"
 
-"@babel/helper-member-expression-to-functions@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.15.0.tgz#0ddaf5299c8179f27f37327936553e9bba60990b"
-  integrity sha512-Jq8H8U2kYiafuj2xMTPQwkTBnEEdGKpT35lJEQsRRjnG0LW3neucsaMWLgKcwu3OHKNeYugfw+Z20BXBSEs2Lg==
+"@babel/helper-module-imports@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e"
+  integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==
   dependencies:
-    "@babel/types" "^7.15.0"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz#6d1a44df6a38c957aa7c312da076429f11b422f3"
-  integrity sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==
+"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz#309b230f04e22c58c6a2c0c0c7e50b216d350c30"
+  integrity sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-module-imports" "^7.18.6"
+    "@babel/helper-simple-access" "^7.18.6"
+    "@babel/helper-split-export-declaration" "^7.18.6"
+    "@babel/helper-validator-identifier" "^7.18.6"
+    "@babel/template" "^7.18.10"
+    "@babel/traverse" "^7.19.0"
+    "@babel/types" "^7.19.0"
 
-"@babel/helper-module-transforms@^7.14.5", "@babel/helper-module-transforms@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.15.0.tgz#679275581ea056373eddbe360e1419ef23783b08"
-  integrity sha512-RkGiW5Rer7fpXv9m1B3iHIFDZdItnO2/BLfWVW/9q7+KqQSDY5kUfQEbzdXM1MVhJGcugKV7kRrNVzNxmk7NBg==
+"@babel/helper-optimise-call-expression@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz#9369aa943ee7da47edab2cb4e838acf09d290ffe"
+  integrity sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==
   dependencies:
-    "@babel/helper-module-imports" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.15.0"
-    "@babel/helper-simple-access" "^7.14.8"
-    "@babel/helper-split-export-declaration" "^7.14.5"
-    "@babel/helper-validator-identifier" "^7.14.9"
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-optimise-call-expression@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz#f27395a8619e0665b3f0364cddb41c25d71b499c"
-  integrity sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==
+"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz#4796bb14961521f0f8715990bee2fb6e51ce21bf"
+  integrity sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==
+
+"@babel/helper-remap-async-to-generator@^7.18.6", "@babel/helper-remap-async-to-generator@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz#997458a0e3357080e54e1d79ec347f8a8cd28519"
+  integrity sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/helper-annotate-as-pure" "^7.18.6"
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-wrap-function" "^7.18.9"
+    "@babel/types" "^7.18.9"
 
-"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9"
-  integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==
-
-"@babel/helper-remap-async-to-generator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.14.5.tgz#51439c913612958f54a987a4ffc9ee587a2045d6"
-  integrity sha512-rLQKdQU+HYlxBwQIj8dk4/0ENOUEhA/Z0l4hN8BexpvmSMN9oA9EagjnhnDpNsRdWCfjwa4mn/HyBXO9yhQP6A==
+"@babel/helper-replace-supers@^7.18.6", "@babel/helper-replace-supers@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.18.9.tgz#1092e002feca980fbbb0bd4d51b74a65c6a500e6"
+  integrity sha512-dNsWibVI4lNT6HiuOIBr1oyxo40HvIVmbwPUm3XZ7wMh4k2WxrxTqZwSqw/eEmXDS9np0ey5M2bz9tBmO9c+YQ==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    "@babel/helper-wrap-function" "^7.14.5"
-    "@babel/types" "^7.14.5"
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-member-expression-to-functions" "^7.18.9"
+    "@babel/helper-optimise-call-expression" "^7.18.6"
+    "@babel/traverse" "^7.18.9"
+    "@babel/types" "^7.18.9"
 
-"@babel/helper-replace-supers@^7.14.5", "@babel/helper-replace-supers@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.15.0.tgz#ace07708f5bf746bf2e6ba99572cce79b5d4e7f4"
-  integrity sha512-6O+eWrhx+HEra/uJnifCwhwMd6Bp5+ZfZeJwbqUTuqkhIT6YcRhiZCOOFChRypOIe0cV46kFrRBlm+t5vHCEaA==
+"@babel/helper-simple-access@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz#d6d8f51f4ac2978068df934b569f08f29788c7ea"
+  integrity sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g==
   dependencies:
-    "@babel/helper-member-expression-to-functions" "^7.15.0"
-    "@babel/helper-optimise-call-expression" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-simple-access@^7.14.8":
-  version "7.14.8"
-  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.14.8.tgz#82e1fec0644a7e775c74d305f212c39f8fe73924"
-  integrity sha512-TrFN4RHh9gnWEU+s7JloIho2T76GPwRHhdzOWLqTrMnlas8T9O7ec+oEDNsRXndOmru9ymH9DFrEOxpzPoSbdg==
+"@babel/helper-skip-transparent-expression-wrappers@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz#778d87b3a758d90b471e7b9918f34a9a02eb5818"
+  integrity sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw==
   dependencies:
-    "@babel/types" "^7.14.8"
+    "@babel/types" "^7.18.9"
 
-"@babel/helper-skip-transparent-expression-wrappers@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.14.5.tgz#96f486ac050ca9f44b009fbe5b7d394cab3a0ee4"
-  integrity sha512-dmqZB7mrb94PZSAOYtr+ZN5qt5owZIAgqtoTuqiFbHFtxgEcmQlRJVI+bO++fciBunXtB6MK7HrzrfcAzIz2NQ==
+"@babel/helper-split-export-declaration@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075"
+  integrity sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-split-export-declaration@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz#22b23a54ef51c2b7605d851930c1976dd0bc693a"
-  integrity sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-validator-identifier@^7.14.5", "@babel/helper-validator-identifier@^7.14.9":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz#6654d171b2024f6d8ee151bf2509699919131d48"
-  integrity sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==
+"@babel/helper-string-parser@^7.18.10":
+  version "7.18.10"
+  resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56"
+  integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw==
 
 "@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"
-  integrity sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==
+"@babel/helper-validator-identifier@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076"
+  integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==
 
-"@babel/helper-wrap-function@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.14.5.tgz#5919d115bf0fe328b8a5d63bcb610f51601f2bff"
-  integrity sha512-YEdjTCq+LNuNS1WfxsDCNpgXkJaIyqco6DAelTUjT4f2KIWC1nBcaCaSdHTBqQVLnTBexBcVcFhLSU1KnYuePQ==
-  dependencies:
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.14.5"
-    "@babel/types" "^7.14.5"
+"@babel/helper-validator-option@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8"
+  integrity sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==
 
-"@babel/helpers@^7.14.8":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.15.3.tgz#c96838b752b95dcd525b4e741ed40bb1dc2a1357"
-  integrity sha512-HwJiz52XaS96lX+28Tnbu31VeFSQJGOeKHJeaEPQlTl7PnlhFElWPj8tUXtqFIzeN86XxXoBr+WFAyK2PPVz6g==
+"@babel/helper-wrap-function@^7.18.9":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz#89f18335cff1152373222f76a4b37799636ae8b1"
+  integrity sha512-txX8aN8CZyYGTwcLhlk87KRqncAzhh5TpQamZUa0/u3an36NtDpUP6bQgBCBcLeBs09R/OwQu3OjK0k/HwfNDg==
   dependencies:
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
+    "@babel/helper-function-name" "^7.19.0"
+    "@babel/template" "^7.18.10"
+    "@babel/traverse" "^7.19.0"
+    "@babel/types" "^7.19.0"
 
-"@babel/highlight@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9"
-  integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==
+"@babel/helpers@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.19.0.tgz#f30534657faf246ae96551d88dd31e9d1fa1fc18"
+  integrity sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.14.5"
-    chalk "^2.0.0"
-    js-tokens "^4.0.0"
+    "@babel/template" "^7.18.10"
+    "@babel/traverse" "^7.19.0"
+    "@babel/types" "^7.19.0"
 
 "@babel/highlight@^7.16.0":
   version "7.16.0"
@@ -272,147 +274,164 @@
     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"
-  integrity sha512-O0L6v/HvqbdJawj0iBEfVQMc3/6WP+AeOsovsIgBFyJaG+W2w7eqvZB7puddATmWuARlm1SX7DwxJ/JJUnDpEA==
-
-"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.14.5.tgz#4b467302e1548ed3b1be43beae2cc9cf45e0bb7e"
-  integrity sha512-ZoJS2XCKPBfTmL122iP6NM9dOg+d4lc9fFk3zxc8iDjvt8Pk4+TlsHSKhIPf6X+L5ORCdBzqMZDjL/WHj7WknQ==
+"@babel/highlight@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf"
+  integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5"
-    "@babel/plugin-proposal-optional-chaining" "^7.14.5"
+    "@babel/helper-validator-identifier" "^7.18.6"
+    chalk "^2.0.0"
+    js-tokens "^4.0.0"
 
-"@babel/plugin-proposal-async-generator-functions@^7.14.9":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.14.9.tgz#7028dc4fa21dc199bbacf98b39bab1267d0eaf9a"
-  integrity sha512-d1lnh+ZnKrFKwtTYdw320+sQWCTwgkB9fmUhNXRADA4akR6wLjaruSGnIEUjpt9HCOwTr4ynFTKu19b7rFRpmw==
+"@babel/parser@^7.1.0", "@babel/parser@^7.18.10", "@babel/parser@^7.19.0", "@babel/parser@^7.4.3":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.19.0.tgz#497fcafb1d5b61376959c1c338745ef0577aa02c"
+  integrity sha512-74bEXKX2h+8rrfQUfsBfuZZHzsEs6Eql4pqy/T4Nn6Y9wNPggQOqD6z6pn5Bl8ZfysKouFZT/UXEH94ummEeQw==
+
+"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2"
+  integrity sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-remap-async-to-generator" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
+
+"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz#a11af19aa373d68d561f08e0a57242350ed0ec50"
+  integrity sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9"
+    "@babel/plugin-proposal-optional-chaining" "^7.18.9"
+
+"@babel/plugin-proposal-async-generator-functions@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.0.tgz#cf5740194f170467df20581712400487efc79ff1"
+  integrity sha512-nhEByMUTx3uZueJ/QkJuSlCfN4FGg+xy+vRsfGQGzSauq5ks2Deid2+05Q3KhfaUjvec1IGhw/Zm3cFm8JigTQ==
+  dependencies:
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.19.0"
+    "@babel/helper-remap-async-to-generator" "^7.18.9"
     "@babel/plugin-syntax-async-generators" "^7.8.4"
 
-"@babel/plugin-proposal-class-properties@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.14.5.tgz#40d1ee140c5b1e31a350f4f5eed945096559b42e"
-  integrity sha512-q/PLpv5Ko4dVc1LYMpCY7RVAAO4uk55qPwrIuJ5QJ8c6cVuAmhu7I/49JOppXL6gXf7ZHzpRVEUZdYoPLM04Gg==
+"@babel/plugin-proposal-class-properties@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3"
+  integrity sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-create-class-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-proposal-class-static-block@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.14.5.tgz#158e9e10d449c3849ef3ecde94a03d9f1841b681"
-  integrity sha512-KBAH5ksEnYHCegqseI5N9skTdxgJdmDoAOc0uXa+4QMYKeZD0w5IARh4FMlTNtaHhbB8v+KzMdTgxMMzsIy6Yg==
+"@babel/plugin-proposal-class-static-block@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz#8aa81d403ab72d3962fc06c26e222dacfc9b9020"
+  integrity sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-create-class-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-class-static-block" "^7.14.5"
 
-"@babel/plugin-proposal-dynamic-import@^7.10.4", "@babel/plugin-proposal-dynamic-import@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.14.5.tgz#0c6617df461c0c1f8fff3b47cd59772360101d2c"
-  integrity sha512-ExjiNYc3HDN5PXJx+bwC50GIx/KKanX2HiggnIUAYedbARdImiCU4RhhHfdf0Kd7JNXGpsBBBCOm+bBVy3Gb0g==
+"@babel/plugin-proposal-dynamic-import@^7.10.4", "@babel/plugin-proposal-dynamic-import@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz#72bcf8d408799f547d759298c3c27c7e7faa4d94"
+  integrity sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
 
-"@babel/plugin-proposal-export-namespace-from@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.14.5.tgz#dbad244310ce6ccd083072167d8cea83a52faf76"
-  integrity sha512-g5POA32bXPMmSBu5Dx/iZGLGnKmKPc5AiY7qfZgurzrCYgIztDlHFbznSNCoQuv57YQLnQfaDi7dxCtLDIdXdA==
+"@babel/plugin-proposal-export-namespace-from@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz#5f7313ab348cdb19d590145f9247540e94761203"
+  integrity sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
     "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
 
-"@babel/plugin-proposal-json-strings@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.14.5.tgz#38de60db362e83a3d8c944ac858ddf9f0c2239eb"
-  integrity sha512-NSq2fczJYKVRIsUJyNxrVUMhB27zb7N7pOFGQOhBKJrChbGcgEAqyZrmZswkPk18VMurEeJAaICbfm57vUeTbQ==
+"@babel/plugin-proposal-json-strings@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz#7e8788c1811c393aff762817e7dbf1ebd0c05f0b"
+  integrity sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-json-strings" "^7.8.3"
 
-"@babel/plugin-proposal-logical-assignment-operators@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.14.5.tgz#6e6229c2a99b02ab2915f82571e0cc646a40c738"
-  integrity sha512-YGn2AvZAo9TwyhlLvCCWxD90Xq8xJ4aSgaX3G5D/8DW94L8aaT+dS5cSP+Z06+rCJERGSr9GxMBZ601xoc2taw==
+"@babel/plugin-proposal-logical-assignment-operators@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz#8148cbb350483bf6220af06fa6db3690e14b2e23"
+  integrity sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
     "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"
 
-"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4", "@babel/plugin-proposal-nullish-coalescing-operator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.14.5.tgz#ee38589ce00e2cc59b299ec3ea406fcd3a0fdaf6"
-  integrity sha512-gun/SOnMqjSb98Nkaq2rTKMwervfdAoz6NphdY0vTfuzMfryj+tDGb2n6UkDKwez+Y8PZDhE3D143v6Gepp4Hg==
+"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4", "@babel/plugin-proposal-nullish-coalescing-operator@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz#fdd940a99a740e577d6c753ab6fbb43fdb9467e1"
+  integrity sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
 
-"@babel/plugin-proposal-numeric-separator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.14.5.tgz#83631bf33d9a51df184c2102a069ac0c58c05f18"
-  integrity sha512-yiclALKe0vyZRZE0pS6RXgjUOt87GWv6FYa5zqj15PvhOGFO69R5DusPlgK/1K5dVnCtegTiWu9UaBSrLLJJBg==
+"@babel/plugin-proposal-numeric-separator@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz#899b14fbafe87f053d2c5ff05b36029c62e13c75"
+  integrity sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-numeric-separator" "^7.10.4"
 
-"@babel/plugin-proposal-object-rest-spread@^7.14.7":
-  version "7.14.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.14.7.tgz#5920a2b3df7f7901df0205974c0641b13fd9d363"
-  integrity sha512-082hsZz+sVabfmDWo1Oct1u1AgbKbUAyVgmX4otIc7bdsRgHBXwTwb3DpDmD4Eyyx6DNiuz5UAATT655k+kL5g==
+"@babel/plugin-proposal-object-rest-spread@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.18.9.tgz#f9434f6beb2c8cae9dfcf97d2a5941bbbf9ad4e7"
+  integrity sha512-kDDHQ5rflIeY5xl69CEqGEZ0KY369ehsCIEbTGb4siHG5BE9sga/T0r0OUwyZNLMmZE79E1kbsqAjwFCW4ds6Q==
   dependencies:
-    "@babel/compat-data" "^7.14.7"
-    "@babel/helper-compilation-targets" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/compat-data" "^7.18.8"
+    "@babel/helper-compilation-targets" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.18.9"
     "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
-    "@babel/plugin-transform-parameters" "^7.14.5"
+    "@babel/plugin-transform-parameters" "^7.18.8"
 
-"@babel/plugin-proposal-optional-catch-binding@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.14.5.tgz#939dd6eddeff3a67fdf7b3f044b5347262598c3c"
-  integrity sha512-3Oyiixm0ur7bzO5ybNcZFlmVsygSIQgdOa7cTfOYCMY+wEPAYhZAJxi3mixKFCTCKUhQXuCTtQ1MzrpL3WT8ZQ==
+"@babel/plugin-proposal-optional-catch-binding@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz#f9400d0e6a3ea93ba9ef70b09e72dd6da638a2cb"
+  integrity sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-optional-catch-binding" "^7.8.3"
 
-"@babel/plugin-proposal-optional-chaining@^7.11.0", "@babel/plugin-proposal-optional-chaining@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.14.5.tgz#fa83651e60a360e3f13797eef00b8d519695b603"
-  integrity sha512-ycz+VOzo2UbWNI1rQXxIuMOzrDdHGrI23fRiz/Si2R4kv2XZQ1BK8ccdHwehMKBlcH/joGW/tzrUmo67gbJHlQ==
+"@babel/plugin-proposal-optional-chaining@^7.11.0", "@babel/plugin-proposal-optional-chaining@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz#e8e8fe0723f2563960e4bf5e9690933691915993"
+  integrity sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9"
     "@babel/plugin-syntax-optional-chaining" "^7.8.3"
 
-"@babel/plugin-proposal-private-methods@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.14.5.tgz#37446495996b2945f30f5be5b60d5e2aa4f5792d"
-  integrity sha512-838DkdUA1u+QTCplatfq4B7+1lnDa/+QMI89x5WZHBcnNv+47N8QEj2k9I2MUU9xIv8XJ4XvPCviM/Dj7Uwt9g==
+"@babel/plugin-proposal-private-methods@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz#5209de7d213457548a98436fa2882f52f4be6bea"
+  integrity sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-create-class-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-proposal-private-property-in-object@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.14.5.tgz#9f65a4d0493a940b4c01f8aa9d3f1894a587f636"
-  integrity sha512-62EyfyA3WA0mZiF2e2IV9mc9Ghwxcg8YTu8BS4Wss4Y3PY725OmS9M0qLORbJwLqFtGh+jiE4wAmocK2CTUK2Q==
+"@babel/plugin-proposal-private-property-in-object@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz#a64137b232f0aca3733a67eb1a144c192389c503"
+  integrity sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    "@babel/helper-create-class-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-annotate-as-pure" "^7.18.6"
+    "@babel/helper-create-class-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-private-property-in-object" "^7.14.5"
 
-"@babel/plugin-proposal-unicode-property-regex@^7.14.5", "@babel/plugin-proposal-unicode-property-regex@^7.4.4":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.14.5.tgz#0f95ee0e757a5d647f378daa0eca7e93faa8bbe8"
-  integrity sha512-6axIeOU5LnY471KenAB9vI8I5j7NQ2d652hIYwVyRfgaZT5UpiqFKCuVXCDMSrU+3VFafnu2c5m3lrWIlr6A5Q==
+"@babel/plugin-proposal-unicode-property-regex@^7.18.6", "@babel/plugin-proposal-unicode-property-regex@^7.4.4":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz#af613d2cd5e643643b65cded64207b15c85cb78e"
+  integrity sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-create-regexp-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
 "@babel/plugin-syntax-async-generators@^7.8.4":
   version "7.8.4"
@@ -449,6 +468,13 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.8.3"
 
+"@babel/plugin-syntax-import-assertions@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz#cd6190500a4fa2fe31990a963ffab4b63e4505e4"
+  integrity sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.18.6"
+
 "@babel/plugin-syntax-import-meta@^7.10.4":
   version "7.10.4"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51"
@@ -519,284 +545,291 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-arrow-functions@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.14.5.tgz#f7187d9588a768dd080bf4c9ffe117ea62f7862a"
-  integrity sha512-KOnO0l4+tD5IfOdi4x8C1XmEIRWUjNRV8wc6K2vz/3e8yAOoZZvsRXRRIF/yo/MAOFb4QjtAw9xSxMXbSMRy8A==
+"@babel/plugin-transform-arrow-functions@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz#19063fcf8771ec7b31d742339dac62433d0611fe"
+  integrity sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-async-to-generator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.14.5.tgz#72c789084d8f2094acb945633943ef8443d39e67"
-  integrity sha512-szkbzQ0mNk0rpu76fzDdqSyPu0MuvpXgC+6rz5rpMb5OIRxdmHfQxrktL8CYolL2d8luMCZTR0DpIMIdL27IjA==
+"@babel/plugin-transform-async-to-generator@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz#ccda3d1ab9d5ced5265fdb13f1882d5476c71615"
+  integrity sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag==
   dependencies:
-    "@babel/helper-module-imports" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-remap-async-to-generator" "^7.14.5"
+    "@babel/helper-module-imports" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-remap-async-to-generator" "^7.18.6"
 
-"@babel/plugin-transform-block-scoped-functions@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.14.5.tgz#e48641d999d4bc157a67ef336aeb54bc44fd3ad4"
-  integrity sha512-dtqWqdWZ5NqBX3KzsVCWfQI3A53Ft5pWFCT2eCVUftWZgjc5DpDponbIF1+c+7cSGk2wN0YK7HGL/ezfRbpKBQ==
+"@babel/plugin-transform-block-scoped-functions@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz#9187bf4ba302635b9d70d986ad70f038726216a8"
+  integrity sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-block-scoping@^7.14.5":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.15.3.tgz#94c81a6e2fc230bcce6ef537ac96a1e4d2b3afaf"
-  integrity sha512-nBAzfZwZb4DkaGtOes1Up1nOAp9TDRRFw4XBzBBSG9QK7KVFmYzgj9o9sbPv7TX5ofL4Auq4wZnxCoPnI/lz2Q==
+"@babel/plugin-transform-block-scoping@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.9.tgz#f9b7e018ac3f373c81452d6ada8bd5a18928926d"
+  integrity sha512-5sDIJRV1KtQVEbt/EIBwGy4T01uYIo4KRB3VUqzkhrAIOGx7AoctL9+Ux88btY0zXdDyPJ9mW+bg+v+XEkGmtw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-classes@^7.14.9":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.14.9.tgz#2a391ffb1e5292710b00f2e2c210e1435e7d449f"
-  integrity sha512-NfZpTcxU3foGWbl4wxmZ35mTsYJy8oQocbeIMoDAGGFarAmSQlL+LWMkDx/tj6pNotpbX3rltIA4dprgAPOq5A==
+"@babel/plugin-transform-classes@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz#0e61ec257fba409c41372175e7c1e606dc79bb20"
+  integrity sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-optimise-call-expression" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.14.5"
-    "@babel/helper-split-export-declaration" "^7.14.5"
+    "@babel/helper-annotate-as-pure" "^7.18.6"
+    "@babel/helper-compilation-targets" "^7.19.0"
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-function-name" "^7.19.0"
+    "@babel/helper-optimise-call-expression" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.19.0"
+    "@babel/helper-replace-supers" "^7.18.9"
+    "@babel/helper-split-export-declaration" "^7.18.6"
     globals "^11.1.0"
 
-"@babel/plugin-transform-computed-properties@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.14.5.tgz#1b9d78987420d11223d41195461cc43b974b204f"
-  integrity sha512-pWM+E4283UxaVzLb8UBXv4EIxMovU4zxT1OPnpHJcmnvyY9QbPPTKZfEj31EUvG3/EQRbYAGaYEUZ4yWOBC2xg==
+"@babel/plugin-transform-computed-properties@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz#2357a8224d402dad623caf6259b611e56aec746e"
+  integrity sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-destructuring@^7.14.7":
-  version "7.14.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.14.7.tgz#0ad58ed37e23e22084d109f185260835e5557576"
-  integrity sha512-0mDE99nK+kVh3xlc5vKwB6wnP9ecuSj+zQCa/n0voENtP/zymdT4HH6QEb65wjjcbqr1Jb/7z9Qp7TF5FtwYGw==
+"@babel/plugin-transform-destructuring@^7.18.13":
+  version "7.18.13"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.13.tgz#9e03bc4a94475d62b7f4114938e6c5c33372cbf5"
+  integrity sha512-TodpQ29XekIsex2A+YJPj5ax2plkGa8YYY6mFjCohk/IG9IY42Rtuj1FuDeemfg2ipxIFLzPeA83SIBnlhSIow==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-dotall-regex@^7.14.5", "@babel/plugin-transform-dotall-regex@^7.4.4":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.14.5.tgz#2f6bf76e46bdf8043b4e7e16cf24532629ba0c7a"
-  integrity sha512-loGlnBdj02MDsFaHhAIJzh7euK89lBrGIdM9EAtHFo6xKygCUGuuWe07o1oZVk287amtW1n0808sQM99aZt3gw==
+"@babel/plugin-transform-dotall-regex@^7.18.6", "@babel/plugin-transform-dotall-regex@^7.4.4":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz#b286b3e7aae6c7b861e45bed0a2fafd6b1a4fef8"
+  integrity sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-create-regexp-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-duplicate-keys@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.14.5.tgz#365a4844881bdf1501e3a9f0270e7f0f91177954"
-  integrity sha512-iJjbI53huKbPDAsJ8EmVmvCKeeq21bAze4fu9GBQtSLqfvzj2oRuHVx4ZkDwEhg1htQ+5OBZh/Ab0XDf5iBZ7A==
+"@babel/plugin-transform-duplicate-keys@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz#687f15ee3cdad6d85191eb2a372c4528eaa0ae0e"
+  integrity sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-exponentiation-operator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.14.5.tgz#5154b8dd6a3dfe6d90923d61724bd3deeb90b493"
-  integrity sha512-jFazJhMBc9D27o9jDnIE5ZErI0R0m7PbKXVq77FFvqFbzvTMuv8jaAwLZ5PviOLSFttqKIW0/wxNSDbjLk0tYA==
+"@babel/plugin-transform-exponentiation-operator@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz#421c705f4521888c65e91fdd1af951bfefd4dacd"
+  integrity sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==
   dependencies:
-    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-for-of@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.14.5.tgz#dae384613de8f77c196a8869cbf602a44f7fc0eb"
-  integrity sha512-CfmqxSUZzBl0rSjpoQSFoR9UEj3HzbGuGNL21/iFTmjb5gFggJp3ph0xR1YBhexmLoKRHzgxuFvty2xdSt6gTA==
+"@babel/plugin-transform-for-of@^7.18.8":
+  version "7.18.8"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz#6ef8a50b244eb6a0bdbad0c7c61877e4e30097c1"
+  integrity sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-function-name@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.14.5.tgz#e81c65ecb900746d7f31802f6bed1f52d915d6f2"
-  integrity sha512-vbO6kv0fIzZ1GpmGQuvbwwm+O4Cbm2NrPzwlup9+/3fdkuzo1YqOZcXw26+YUJB84Ja7j9yURWposEHLYwxUfQ==
+"@babel/plugin-transform-function-name@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz#cc354f8234e62968946c61a46d6365440fc764e0"
+  integrity sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==
   dependencies:
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-compilation-targets" "^7.18.9"
+    "@babel/helper-function-name" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-literals@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.14.5.tgz#41d06c7ff5d4d09e3cf4587bd3ecf3930c730f78"
-  integrity sha512-ql33+epql2F49bi8aHXxvLURHkxJbSmMKl9J5yHqg4PLtdE6Uc48CH1GS6TQvZ86eoB/ApZXwm7jlA+B3kra7A==
+"@babel/plugin-transform-literals@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz#72796fdbef80e56fba3c6a699d54f0de557444bc"
+  integrity sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-member-expression-literals@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.14.5.tgz#b39cd5212a2bf235a617d320ec2b48bcc091b8a7"
-  integrity sha512-WkNXxH1VXVTKarWFqmso83xl+2V3Eo28YY5utIkbsmXoItO8Q3aZxN4BTS2k0hz9dGUloHK26mJMyQEYfkn/+Q==
+"@babel/plugin-transform-member-expression-literals@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz#ac9fdc1a118620ac49b7e7a5d2dc177a1bfee88e"
+  integrity sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-modules-amd@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.14.5.tgz#4fd9ce7e3411cb8b83848480b7041d83004858f7"
-  integrity sha512-3lpOU8Vxmp3roC4vzFpSdEpGUWSMsHFreTWOMMLzel2gNGfHE5UWIh/LN6ghHs2xurUp4jRFYMUIZhuFbody1g==
+"@babel/plugin-transform-modules-amd@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz#8c91f8c5115d2202f277549848874027d7172d21"
+  integrity sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg==
   dependencies:
-    "@babel/helper-module-transforms" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-module-transforms" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
     babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-modules-commonjs@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.15.0.tgz#3305896e5835f953b5cdb363acd9e8c2219a5281"
-  integrity sha512-3H/R9s8cXcOGE8kgMlmjYYC9nqr5ELiPkJn4q0mypBrjhYQoc+5/Maq69vV4xRPWnkzZuwJPf5rArxpB/35Cig==
+"@babel/plugin-transform-modules-commonjs@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz#afd243afba166cca69892e24a8fd8c9f2ca87883"
+  integrity sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q==
   dependencies:
-    "@babel/helper-module-transforms" "^7.15.0"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-simple-access" "^7.14.8"
+    "@babel/helper-module-transforms" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-simple-access" "^7.18.6"
     babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-modules-systemjs@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.14.5.tgz#c75342ef8b30dcde4295d3401aae24e65638ed29"
-  integrity sha512-mNMQdvBEE5DcMQaL5LbzXFMANrQjd2W7FPzg34Y4yEz7dBgdaC+9B84dSO+/1Wba98zoDbInctCDo4JGxz1VYA==
+"@babel/plugin-transform-modules-systemjs@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz#5f20b471284430f02d9c5059d9b9a16d4b085a1f"
+  integrity sha512-x9aiR0WXAWmOWsqcsnrzGR+ieaTMVyGyffPVA7F8cXAGt/UxefYv6uSHZLkAFChN5M5Iy1+wjE+xJuPt22H39A==
   dependencies:
-    "@babel/helper-hoist-variables" "^7.14.5"
-    "@babel/helper-module-transforms" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-validator-identifier" "^7.14.5"
+    "@babel/helper-hoist-variables" "^7.18.6"
+    "@babel/helper-module-transforms" "^7.19.0"
+    "@babel/helper-plugin-utils" "^7.19.0"
+    "@babel/helper-validator-identifier" "^7.18.6"
     babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-modules-umd@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.14.5.tgz#fb662dfee697cce274a7cda525190a79096aa6e0"
-  integrity sha512-RfPGoagSngC06LsGUYyM9QWSXZ8MysEjDJTAea1lqRjNECE3y0qIJF/qbvJxc4oA4s99HumIMdXOrd+TdKaAAA==
+"@babel/plugin-transform-modules-umd@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz#81d3832d6034b75b54e62821ba58f28ed0aab4b9"
+  integrity sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==
   dependencies:
-    "@babel/helper-module-transforms" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-module-transforms" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-named-capturing-groups-regex@^7.14.9":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.14.9.tgz#c68f5c5d12d2ebaba3762e57c2c4f6347a46e7b2"
-  integrity sha512-l666wCVYO75mlAtGFfyFwnWmIXQm3kSH0C3IRnJqWcZbWkoihyAdDhFm2ZWaxWTqvBvhVFfJjMRQ0ez4oN1yYA==
+"@babel/plugin-transform-named-capturing-groups-regex@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.0.tgz#58c52422e4f91a381727faed7d513c89d7f41ada"
+  integrity sha512-HDSuqOQzkU//kfGdiHBt71/hkDTApw4U/cMVgKgX7PqfB3LOaK+2GtCEsBu1dL9CkswDm0Gwehht1dCr421ULQ==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
+    "@babel/helper-create-regexp-features-plugin" "^7.19.0"
+    "@babel/helper-plugin-utils" "^7.19.0"
 
-"@babel/plugin-transform-new-target@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.14.5.tgz#31bdae8b925dc84076ebfcd2a9940143aed7dbf8"
-  integrity sha512-Nx054zovz6IIRWEB49RDRuXGI4Gy0GMgqG0cII9L3MxqgXz/+rgII+RU58qpo4g7tNEx1jG7rRVH4ihZoP4esQ==
+"@babel/plugin-transform-new-target@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz#d128f376ae200477f37c4ddfcc722a8a1b3246a8"
+  integrity sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-object-super@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.14.5.tgz#d0b5faeac9e98597a161a9cf78c527ed934cdc45"
-  integrity sha512-MKfOBWzK0pZIrav9z/hkRqIk/2bTv9qvxHzPQc12RcVkMOzpIKnFCNYJip00ssKWYkd8Sf5g0Wr7pqJ+cmtuFg==
+"@babel/plugin-transform-object-super@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz#fb3c6ccdd15939b6ff7939944b51971ddc35912c"
+  integrity sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-replace-supers" "^7.18.6"
 
-"@babel/plugin-transform-parameters@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.14.5.tgz#49662e86a1f3ddccac6363a7dfb1ff0a158afeb3"
-  integrity sha512-Tl7LWdr6HUxTmzQtzuU14SqbgrSKmaR77M0OKyq4njZLQTPfOvzblNKyNkGwOfEFCEx7KeYHQHDI0P3F02IVkA==
+"@babel/plugin-transform-parameters@^7.18.8":
+  version "7.18.8"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz#ee9f1a0ce6d78af58d0956a9378ea3427cccb48a"
+  integrity sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-property-literals@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.14.5.tgz#0ddbaa1f83db3606f1cdf4846fa1dfb473458b34"
-  integrity sha512-r1uilDthkgXW8Z1vJz2dKYLV1tuw2xsbrp3MrZmD99Wh9vsfKoob+JTgri5VUb/JqyKRXotlOtwgu4stIYCmnw==
+"@babel/plugin-transform-property-literals@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz#e22498903a483448e94e032e9bbb9c5ccbfc93a3"
+  integrity sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-regenerator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.14.5.tgz#9676fd5707ed28f522727c5b3c0aa8544440b04f"
-  integrity sha512-NVIY1W3ITDP5xQl50NgTKlZ0GrotKtLna08/uGY6ErQt6VEQZXla86x/CTddm5gZdcr+5GSsvMeTmWA5Ii6pkg==
+"@babel/plugin-transform-regenerator@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz#585c66cb84d4b4bf72519a34cfce761b8676ca73"
+  integrity sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ==
   dependencies:
-    regenerator-transform "^0.14.2"
+    "@babel/helper-plugin-utils" "^7.18.6"
+    regenerator-transform "^0.15.0"
 
-"@babel/plugin-transform-reserved-words@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.14.5.tgz#c44589b661cfdbef8d4300dcc7469dffa92f8304"
-  integrity sha512-cv4F2rv1nD4qdexOGsRQXJrOcyb5CrgjUH9PKrrtyhSDBNWGxd0UIitjyJiWagS+EbUGjG++22mGH1Pub8D6Vg==
+"@babel/plugin-transform-reserved-words@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz#b1abd8ebf8edaa5f7fe6bbb8d2133d23b6a6f76a"
+  integrity sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-shorthand-properties@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.14.5.tgz#97f13855f1409338d8cadcbaca670ad79e091a58"
-  integrity sha512-xLucks6T1VmGsTB+GWK5Pl9Jl5+nRXD1uoFdA5TSO6xtiNjtXTjKkmPdFXVLGlK5A2/or/wQMKfmQ2Y0XJfn5g==
+"@babel/plugin-transform-shorthand-properties@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz#6d6df7983d67b195289be24909e3f12a8f664dc9"
+  integrity sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-spread@^7.14.6":
-  version "7.14.6"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.14.6.tgz#6bd40e57fe7de94aa904851963b5616652f73144"
-  integrity sha512-Zr0x0YroFJku7n7+/HH3A2eIrGMjbmAIbJSVv0IZ+t3U2WUQUA64S/oeied2e+MaGSjmt4alzBCsK9E8gh+fag==
+"@babel/plugin-transform-spread@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz#dd60b4620c2fec806d60cfaae364ec2188d593b6"
+  integrity sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.19.0"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9"
 
-"@babel/plugin-transform-sticky-regex@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.14.5.tgz#5b617542675e8b7761294381f3c28c633f40aeb9"
-  integrity sha512-Z7F7GyvEMzIIbwnziAZmnSNpdijdr4dWt+FJNBnBLz5mwDFkqIXU9wmBcWWad3QeJF5hMTkRe4dAq2sUZiG+8A==
+"@babel/plugin-transform-sticky-regex@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz#c6706eb2b1524028e317720339583ad0f444adcc"
+  integrity sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-template-literals@^7.14.5", "@babel/plugin-transform-template-literals@^7.8.3":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.14.5.tgz#a5f2bc233937d8453885dc736bdd8d9ffabf3d93"
-  integrity sha512-22btZeURqiepOfuy/VkFr+zStqlujWaarpMErvay7goJS6BWwdd6BY9zQyDLDa4x2S3VugxFb162IZ4m/S/+Gg==
+"@babel/plugin-transform-template-literals@^7.18.9", "@babel/plugin-transform-template-literals@^7.8.3":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz#04ec6f10acdaa81846689d63fae117dd9c243a5e"
+  integrity sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-typeof-symbol@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.14.5.tgz#39af2739e989a2bd291bf6b53f16981423d457d4"
-  integrity sha512-lXzLD30ffCWseTbMQzrvDWqljvZlHkXU+CnseMhkMNqU1sASnCsz3tSzAaH3vCUXb9PHeUb90ZT1BdFTm1xxJw==
+"@babel/plugin-transform-typeof-symbol@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz#c8cea68263e45addcd6afc9091429f80925762c0"
+  integrity sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-unicode-escapes@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.14.5.tgz#9d4bd2a681e3c5d7acf4f57fa9e51175d91d0c6b"
-  integrity sha512-crTo4jATEOjxj7bt9lbYXcBAM3LZaUrbP2uUdxb6WIorLmjNKSpHfIybgY4B8SRpbf8tEVIWH3Vtm7ayCrKocA==
+"@babel/plugin-transform-unicode-escapes@^7.18.10":
+  version "7.18.10"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz#1ecfb0eda83d09bbcb77c09970c2dd55832aa246"
+  integrity sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-unicode-regex@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.14.5.tgz#4cd09b6c8425dd81255c7ceb3fb1836e7414382e"
-  integrity sha512-UygduJpC5kHeCiRw/xDVzC+wj8VaYSoKl5JNVmbP7MadpNinAm3SvZCxZ42H37KZBKztz46YC73i9yV34d0Tzw==
+"@babel/plugin-transform-unicode-regex@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz#194317225d8c201bbae103364ffe9e2cea36cdca"
+  integrity sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-create-regexp-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
 "@babel/preset-env@^7.9.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.15.0.tgz#e2165bf16594c9c05e52517a194bf6187d6fe464"
-  integrity sha512-FhEpCNFCcWW3iZLg0L2NPE9UerdtsCR6ZcsGHUX6Om6kbCQeL5QZDqFDmeNHC6/fy6UH3jEge7K4qG5uC9In0Q==
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.19.0.tgz#fd18caf499a67d6411b9ded68dc70d01ed1e5da7"
+  integrity sha512-1YUju1TAFuzjIQqNM9WsF4U6VbD/8t3wEAlw3LFYuuEr+ywqLRcSXxFKz4DCEj+sN94l/XTDiUXYRrsvMpz9WQ==
   dependencies:
-    "@babel/compat-data" "^7.15.0"
-    "@babel/helper-compilation-targets" "^7.15.0"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-validator-option" "^7.14.5"
-    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.14.5"
-    "@babel/plugin-proposal-async-generator-functions" "^7.14.9"
-    "@babel/plugin-proposal-class-properties" "^7.14.5"
-    "@babel/plugin-proposal-class-static-block" "^7.14.5"
-    "@babel/plugin-proposal-dynamic-import" "^7.14.5"
-    "@babel/plugin-proposal-export-namespace-from" "^7.14.5"
-    "@babel/plugin-proposal-json-strings" "^7.14.5"
-    "@babel/plugin-proposal-logical-assignment-operators" "^7.14.5"
-    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.14.5"
-    "@babel/plugin-proposal-numeric-separator" "^7.14.5"
-    "@babel/plugin-proposal-object-rest-spread" "^7.14.7"
-    "@babel/plugin-proposal-optional-catch-binding" "^7.14.5"
-    "@babel/plugin-proposal-optional-chaining" "^7.14.5"
-    "@babel/plugin-proposal-private-methods" "^7.14.5"
-    "@babel/plugin-proposal-private-property-in-object" "^7.14.5"
-    "@babel/plugin-proposal-unicode-property-regex" "^7.14.5"
+    "@babel/compat-data" "^7.19.0"
+    "@babel/helper-compilation-targets" "^7.19.0"
+    "@babel/helper-plugin-utils" "^7.19.0"
+    "@babel/helper-validator-option" "^7.18.6"
+    "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.18.6"
+    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.18.9"
+    "@babel/plugin-proposal-async-generator-functions" "^7.19.0"
+    "@babel/plugin-proposal-class-properties" "^7.18.6"
+    "@babel/plugin-proposal-class-static-block" "^7.18.6"
+    "@babel/plugin-proposal-dynamic-import" "^7.18.6"
+    "@babel/plugin-proposal-export-namespace-from" "^7.18.9"
+    "@babel/plugin-proposal-json-strings" "^7.18.6"
+    "@babel/plugin-proposal-logical-assignment-operators" "^7.18.9"
+    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.18.6"
+    "@babel/plugin-proposal-numeric-separator" "^7.18.6"
+    "@babel/plugin-proposal-object-rest-spread" "^7.18.9"
+    "@babel/plugin-proposal-optional-catch-binding" "^7.18.6"
+    "@babel/plugin-proposal-optional-chaining" "^7.18.9"
+    "@babel/plugin-proposal-private-methods" "^7.18.6"
+    "@babel/plugin-proposal-private-property-in-object" "^7.18.6"
+    "@babel/plugin-proposal-unicode-property-regex" "^7.18.6"
     "@babel/plugin-syntax-async-generators" "^7.8.4"
     "@babel/plugin-syntax-class-properties" "^7.12.13"
     "@babel/plugin-syntax-class-static-block" "^7.14.5"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
     "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
+    "@babel/plugin-syntax-import-assertions" "^7.18.6"
     "@babel/plugin-syntax-json-strings" "^7.8.3"
     "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"
     "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
@@ -806,50 +839,50 @@
     "@babel/plugin-syntax-optional-chaining" "^7.8.3"
     "@babel/plugin-syntax-private-property-in-object" "^7.14.5"
     "@babel/plugin-syntax-top-level-await" "^7.14.5"
-    "@babel/plugin-transform-arrow-functions" "^7.14.5"
-    "@babel/plugin-transform-async-to-generator" "^7.14.5"
-    "@babel/plugin-transform-block-scoped-functions" "^7.14.5"
-    "@babel/plugin-transform-block-scoping" "^7.14.5"
-    "@babel/plugin-transform-classes" "^7.14.9"
-    "@babel/plugin-transform-computed-properties" "^7.14.5"
-    "@babel/plugin-transform-destructuring" "^7.14.7"
-    "@babel/plugin-transform-dotall-regex" "^7.14.5"
-    "@babel/plugin-transform-duplicate-keys" "^7.14.5"
-    "@babel/plugin-transform-exponentiation-operator" "^7.14.5"
-    "@babel/plugin-transform-for-of" "^7.14.5"
-    "@babel/plugin-transform-function-name" "^7.14.5"
-    "@babel/plugin-transform-literals" "^7.14.5"
-    "@babel/plugin-transform-member-expression-literals" "^7.14.5"
-    "@babel/plugin-transform-modules-amd" "^7.14.5"
-    "@babel/plugin-transform-modules-commonjs" "^7.15.0"
-    "@babel/plugin-transform-modules-systemjs" "^7.14.5"
-    "@babel/plugin-transform-modules-umd" "^7.14.5"
-    "@babel/plugin-transform-named-capturing-groups-regex" "^7.14.9"
-    "@babel/plugin-transform-new-target" "^7.14.5"
-    "@babel/plugin-transform-object-super" "^7.14.5"
-    "@babel/plugin-transform-parameters" "^7.14.5"
-    "@babel/plugin-transform-property-literals" "^7.14.5"
-    "@babel/plugin-transform-regenerator" "^7.14.5"
-    "@babel/plugin-transform-reserved-words" "^7.14.5"
-    "@babel/plugin-transform-shorthand-properties" "^7.14.5"
-    "@babel/plugin-transform-spread" "^7.14.6"
-    "@babel/plugin-transform-sticky-regex" "^7.14.5"
-    "@babel/plugin-transform-template-literals" "^7.14.5"
-    "@babel/plugin-transform-typeof-symbol" "^7.14.5"
-    "@babel/plugin-transform-unicode-escapes" "^7.14.5"
-    "@babel/plugin-transform-unicode-regex" "^7.14.5"
-    "@babel/preset-modules" "^0.1.4"
-    "@babel/types" "^7.15.0"
-    babel-plugin-polyfill-corejs2 "^0.2.2"
-    babel-plugin-polyfill-corejs3 "^0.2.2"
-    babel-plugin-polyfill-regenerator "^0.2.2"
-    core-js-compat "^3.16.0"
+    "@babel/plugin-transform-arrow-functions" "^7.18.6"
+    "@babel/plugin-transform-async-to-generator" "^7.18.6"
+    "@babel/plugin-transform-block-scoped-functions" "^7.18.6"
+    "@babel/plugin-transform-block-scoping" "^7.18.9"
+    "@babel/plugin-transform-classes" "^7.19.0"
+    "@babel/plugin-transform-computed-properties" "^7.18.9"
+    "@babel/plugin-transform-destructuring" "^7.18.13"
+    "@babel/plugin-transform-dotall-regex" "^7.18.6"
+    "@babel/plugin-transform-duplicate-keys" "^7.18.9"
+    "@babel/plugin-transform-exponentiation-operator" "^7.18.6"
+    "@babel/plugin-transform-for-of" "^7.18.8"
+    "@babel/plugin-transform-function-name" "^7.18.9"
+    "@babel/plugin-transform-literals" "^7.18.9"
+    "@babel/plugin-transform-member-expression-literals" "^7.18.6"
+    "@babel/plugin-transform-modules-amd" "^7.18.6"
+    "@babel/plugin-transform-modules-commonjs" "^7.18.6"
+    "@babel/plugin-transform-modules-systemjs" "^7.19.0"
+    "@babel/plugin-transform-modules-umd" "^7.18.6"
+    "@babel/plugin-transform-named-capturing-groups-regex" "^7.19.0"
+    "@babel/plugin-transform-new-target" "^7.18.6"
+    "@babel/plugin-transform-object-super" "^7.18.6"
+    "@babel/plugin-transform-parameters" "^7.18.8"
+    "@babel/plugin-transform-property-literals" "^7.18.6"
+    "@babel/plugin-transform-regenerator" "^7.18.6"
+    "@babel/plugin-transform-reserved-words" "^7.18.6"
+    "@babel/plugin-transform-shorthand-properties" "^7.18.6"
+    "@babel/plugin-transform-spread" "^7.19.0"
+    "@babel/plugin-transform-sticky-regex" "^7.18.6"
+    "@babel/plugin-transform-template-literals" "^7.18.9"
+    "@babel/plugin-transform-typeof-symbol" "^7.18.9"
+    "@babel/plugin-transform-unicode-escapes" "^7.18.10"
+    "@babel/plugin-transform-unicode-regex" "^7.18.6"
+    "@babel/preset-modules" "^0.1.5"
+    "@babel/types" "^7.19.0"
+    babel-plugin-polyfill-corejs2 "^0.3.2"
+    babel-plugin-polyfill-corejs3 "^0.5.3"
+    babel-plugin-polyfill-regenerator "^0.4.0"
+    core-js-compat "^3.22.1"
     semver "^6.3.0"
 
-"@babel/preset-modules@^0.1.4":
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.4.tgz#362f2b68c662842970fdb5e254ffc8fc1c2e415e"
-  integrity sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg==
+"@babel/preset-modules@^0.1.5":
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.5.tgz#ef939d6e7f268827e1841638dc6ff95515e115d9"
+  integrity sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.0.0"
     "@babel/plugin-proposal-unicode-property-regex" "^7.4.4"
@@ -858,48 +891,107 @@
     esutils "^2.0.2"
 
 "@babel/runtime@^7.8.4":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.3.tgz#2e1c2880ca118e5b2f9988322bd8a7656a32502b"
-  integrity sha512-OvwMLqNXkCXSz1kSm58sEsNuhqOx/fKpnUnKnFB5v8uDda5bLNEHNgKPvhDN6IU0LDcnHQ90LlJ0Q6jnyBSIBA==
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259"
+  integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@babel/template@^7.14.5", "@babel/template@^7.4.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4"
-  integrity sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==
+"@babel/template@^7.18.10", "@babel/template@^7.4.0":
+  version "7.18.10"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71"
+  integrity sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==
   dependencies:
-    "@babel/code-frame" "^7.14.5"
-    "@babel/parser" "^7.14.5"
-    "@babel/types" "^7.14.5"
+    "@babel/code-frame" "^7.18.6"
+    "@babel/parser" "^7.18.10"
+    "@babel/types" "^7.18.10"
 
-"@babel/traverse@^7.13.0", "@babel/traverse@^7.14.5", "@babel/traverse@^7.15.0", "@babel/traverse@^7.4.3":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.15.0.tgz#4cca838fd1b2a03283c1f38e141f639d60b3fc98"
-  integrity sha512-392d8BN0C9eVxVWd8H6x9WfipgVH5IaIoLp23334Sc1vbKKWINnvwRpb4us0xtPaCumlwbTtIYNA0Dv/32sVFw==
+"@babel/traverse@^7.18.9", "@babel/traverse@^7.19.0", "@babel/traverse@^7.4.3":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.19.0.tgz#eb9c561c7360005c592cc645abafe0c3c4548eed"
+  integrity sha512-4pKpFRDh+utd2mbRC8JLnlsMUii3PMHjpL6a0SZ4NMZy7YFP9aXORxEhdMVOc9CpWtDF09IkciQLEhK7Ml7gRA==
   dependencies:
-    "@babel/code-frame" "^7.14.5"
-    "@babel/generator" "^7.15.0"
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-hoist-variables" "^7.14.5"
-    "@babel/helper-split-export-declaration" "^7.14.5"
-    "@babel/parser" "^7.15.0"
-    "@babel/types" "^7.15.0"
+    "@babel/code-frame" "^7.18.6"
+    "@babel/generator" "^7.19.0"
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-function-name" "^7.19.0"
+    "@babel/helper-hoist-variables" "^7.18.6"
+    "@babel/helper-split-export-declaration" "^7.18.6"
+    "@babel/parser" "^7.19.0"
+    "@babel/types" "^7.19.0"
     debug "^4.1.0"
     globals "^11.1.0"
 
-"@babel/types@^7.0.0", "@babel/types@^7.14.5", "@babel/types@^7.14.8", "@babel/types@^7.15.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.0.tgz#61af11f2286c4e9c69ca8deb5f4375a73c72dcbd"
-  integrity sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ==
+"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.0.tgz#75f21d73d73dc0351f3368d28db73465f4814600"
+  integrity sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.14.9"
+    "@babel/helper-string-parser" "^7.18.10"
+    "@babel/helper-validator-identifier" "^7.18.6"
     to-fast-properties "^2.0.0"
 
-"@koa/cors@^3.1.0":
+"@colors/colors@1.5.0":
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
+  integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==
+
+"@esbuild/linux-loong64@0.14.54":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
+  integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
+
+"@esm-bundle/chai@^4.3.4-fix.0":
+  version "4.3.4-fix.0"
+  resolved "https://registry.yarnpkg.com/@esm-bundle/chai/-/chai-4.3.4-fix.0.tgz#3084cff7eb46d741749f47f3a48dbbdcbaf30a92"
+  integrity sha512-26SKdM4uvDWlY8/OOOxSB1AqQWeBosCX3wRYUZO7enTAj03CtVxIiCimYVG2WpULcyV51qapK4qTovwkUr5Mlw==
+  dependencies:
+    "@types/chai" "^4.2.12"
+
+"@jridgewell/gen-mapping@^0.1.0":
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996"
+  integrity sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==
+  dependencies:
+    "@jridgewell/set-array" "^1.0.0"
+    "@jridgewell/sourcemap-codec" "^1.4.10"
+
+"@jridgewell/gen-mapping@^0.3.2":
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9"
+  integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==
+  dependencies:
+    "@jridgewell/set-array" "^1.0.1"
+    "@jridgewell/sourcemap-codec" "^1.4.10"
+    "@jridgewell/trace-mapping" "^0.3.9"
+
+"@jridgewell/resolve-uri@^3.0.3":
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.1.0.tgz#618bb073438cfdbd3ebd0e648a76e33b84f3a3b2"
-  integrity sha512-7ulRC1da/rBa6kj6P4g2aJfnET3z8Uf3SWu60cjbtxTA5g8lxRdX/Bd2P92EagGwwAhANeNw8T8if99rJliR6Q==
+  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
+  integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
+
+"@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
+  integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==
+
+"@jridgewell/sourcemap-codec@^1.4.10":
+  version "1.4.14"
+  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
+  integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
+
+"@jridgewell/trace-mapping@^0.3.9":
+  version "0.3.15"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz#aba35c48a38d3fd84b37e66c9c0423f9744f9774"
+  integrity sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==
+  dependencies:
+    "@jridgewell/resolve-uri" "^3.0.3"
+    "@jridgewell/sourcemap-codec" "^1.4.10"
+
+"@koa/cors@^3.1.0":
+  version "3.4.1"
+  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.4.1.tgz#ddd5c6ff07a1e60831e1281411a3b9fdb95a5b26"
+  integrity sha512-/sG9NlpGZ/aBpnRamIlGs+wX+C/IJ5DodNK7iPQIVCG4eUQdGeshGhWQ6JCi7tpnD9sCtFXcS04iTimuaJfh4Q==
   dependencies:
     vary "^1.1.2"
 
@@ -908,6 +1000,11 @@
   resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.2.tgz#daa7a7c7a6c63d735f0c9634de6b7dbd70a702ab"
   integrity sha512-oz3d3MKjQ2tXynQgyaQaMpGTDNyNDeBdo6dXf1AbjTwhA1IRINHmA7kSaVYv9ttKweNkEoNqp9DqteDdgWzPEg==
 
+"@mdn/browser-compat-data@^4.0.0":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz#1fead437f3957ceebe2e8c3f46beccdb9bc575b8"
+  integrity sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==
+
 "@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"
@@ -930,16 +1027,16 @@
     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==
+  version "2.18.5"
+  resolved "https://registry.yarnpkg.com/@open-wc/building-utils/-/building-utils-2.18.5.tgz#2e491d48f5be8f64f4d7968fa5eb0780c9d2f574"
+  integrity sha512-hNUQcowXGc6pxUDec57ZBl712XhYh09xuCkaac4jfDbLm1tc4o9DuLxsmS+MkVQbdfsWB/t+rUXJof1i1jO6kQ==
   dependencies:
     "@babel/core" "^7.11.1"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
     "@webcomponents/shadycss" "^1.10.2"
     "@webcomponents/webcomponentsjs" "^2.5.0"
     arrify "^2.0.1"
-    browserslist "^4.16.0"
+    browserslist "^4.16.5"
     chokidar "^3.4.3"
     clean-css "^4.2.3"
     clone "^2.1.2"
@@ -961,6 +1058,14 @@
     whatwg-fetch "^3.5.0"
     whatwg-url "^7.1.0"
 
+"@open-wc/chai-dom-equals@^0.12.36":
+  version "0.12.36"
+  resolved "https://registry.yarnpkg.com/@open-wc/chai-dom-equals/-/chai-dom-equals-0.12.36.tgz#ed0eb56b9e98c4d7f7280facce6215654aae9f4c"
+  integrity sha512-Gt1fa37h4rtWPQGETSU4n1L678NmMi9KwHM1sH+JCGcz45rs8DBPx7MUVeGZ+HxRlbEI5t9LU2RGGv6xT2OlyA==
+  dependencies:
+    "@open-wc/semantic-dom-diff" "^0.13.16"
+    "@types/chai" "^4.1.7"
+
 "@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"
@@ -982,14 +1087,18 @@
     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==
+"@open-wc/scoped-elements@^2.1.3":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.1.3.tgz#c4f06fa16091c6ebf2a69b3f40afc03821f42535"
+  integrity sha512-WoQD5T8Me9obek+iyjgrAMw9wxZZg4ytIteIN1i9LXW2KohezUp0LTOlWgBajWJo0/bpjUKiODX73cMYL2i3hw==
   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.13.16":
+  version "0.13.21"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.13.21.tgz#718b9ec5f9a98935fc775e577ad094ae8d8b7dea"
+  integrity sha512-BONpjHcGX2zFa9mfnwBCLEmlDsOHzT+j6Qt1yfK3MzFXFtAykfzFjAgaxPetu0YbBlCfXuMlfxI4vlRGCGMvFg==
 
 "@open-wc/semantic-dom-diff@^0.19.5":
   version "0.19.5"
@@ -999,32 +1108,48 @@
     "@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==
+"@open-wc/semantic-dom-diff@^0.19.7":
+  version "0.19.7"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.19.7.tgz#92361f0d2dcb54a8d5cf11d5ea40b8e7ffa58eb4"
+  integrity sha512-ahwHb7arQXXnkIGCrOsM895FJQrU47VWZryCsSSzl5nB3tJKcJ8yjzQ3D/yqZn6v8atqOz61vaY05aNsqoz3oA==
   dependencies:
-    "@open-wc/scoped-elements" "^2.0.1"
+    "@types/chai" "^4.3.1"
+    "@web/test-runner-commands" "^0.6.1"
+
+"@open-wc/testing-helpers@^2.1.2":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.1.3.tgz#85a133ac8637ed1d880d523b07650788eab4a128"
+  integrity sha512-hQujGaWncmWLx/974jq5yf2jydBNNTwnkISw2wLGiYgX34+3R6/ns301Oi9S3Il96Kzd8B7avdExp/gDgqcF5w==
+  dependencies:
+    "@open-wc/scoped-elements" "^2.1.3"
     lit "^2.0.0"
+    lit-html "^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"
-  integrity sha512-2R7dnGcW2eg95i7LhYWWUO4AlAk6qXsPnKoyeN2R1t0km0ECMx0jjwqeLwCo8/7LwuVPZSiarI4DK7jyU7fJLQ==
+"@open-wc/testing@^3.1.6":
+  version "3.1.6"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.6.tgz#89f71710e5530d74f0c478b0a9239d68dcdb9f5e"
+  integrity sha512-MIf9cBtac4/UBE5a+R5cXiRhOGfzetsV+ZPFc188AfkPDPbmffHqjrRoCyk4B/qS6fLEulSBMLSaQ+6ze971gQ==
   dependencies:
-    "@polymer/polymer" "^3.0.0"
+    "@esm-bundle/chai" "^4.3.4-fix.0"
+    "@open-wc/chai-dom-equals" "^0.12.36"
+    "@open-wc/semantic-dom-diff" "^0.19.7"
+    "@open-wc/testing-helpers" "^2.1.2"
+    "@types/chai" "^4.2.11"
+    "@types/chai-dom" "^0.0.12"
+    "@types/sinon-chai" "^3.2.3"
+    chai-a11y-axe "^1.3.2"
 
-"@polymer/polymer@^3.0.0":
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.4.1.tgz#333bef25711f8411bb5624fb3eba8212ef8bee96"
-  integrity sha512-KPWnhDZibtqKrUz7enIPOiO4ZQoJNOuLwqrhV2MXzIt3VVnUVJVG5ORz4Z2sgO+UZ+/UZnPD0jqY+jmw/+a9mQ==
+"@rollup/plugin-node-resolve@^13.0.4":
+  version "13.3.0"
+  resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.3.0.tgz#da1c5c5ce8316cef96a2f823d111c1e4e498801c"
+  integrity sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw==
   dependencies:
-    "@webcomponents/shadycss" "^1.9.1"
-
-"@polymer/test-fixture@^4.0.2":
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-4.0.2.tgz#2f4777ecdcfb22ee000db35a05e0edf27c722c19"
-  integrity sha512-tLX8tFE4mkc4p84YG5239G0hbgTVv2irZYrSyO0OblUqIRbRoCPmbydm3HRFQkJeAB3rPCtyeZ2roJULsmTG3A==
+    "@rollup/pluginutils" "^3.1.0"
+    "@types/resolve" "1.17.1"
+    deepmerge "^4.2.2"
+    is-builtin-module "^3.1.0"
+    is-module "^1.0.0"
+    resolve "^1.19.0"
 
 "@rollup/plugin-node-resolve@^7.1.1":
   version "7.1.3"
@@ -1037,7 +1162,7 @@
     is-module "^1.0.0"
     resolve "^1.14.2"
 
-"@rollup/pluginutils@^3.0.0", "@rollup/pluginutils@^3.0.8":
+"@rollup/pluginutils@^3.0.0", "@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0":
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
   integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
@@ -1046,24 +1171,31 @@
     estree-walker "^1.0.1"
     picomatch "^2.2.2"
 
-"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.1":
+"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3":
   version "1.8.3"
   resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
   integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==
   dependencies:
     type-detect "4.0.8"
 
-"@sinonjs/fake-timers@^7.0.4", "@sinonjs/fake-timers@^7.1.0":
+"@sinonjs/fake-timers@>=5", "@sinonjs/fake-timers@^9.1.2":
+  version "9.1.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c"
+  integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==
+  dependencies:
+    "@sinonjs/commons" "^1.7.0"
+
+"@sinonjs/fake-timers@^7.1.0":
   version "7.1.2"
   resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz#2524eae70c4910edccf99b2f4e6efc5894aff7b5"
   integrity sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==
   dependencies:
     "@sinonjs/commons" "^1.7.0"
 
-"@sinonjs/samsam@^6.0.1":
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.0.2.tgz#a0117d823260f282c04bff5f8704bdc2ac6910bb"
-  integrity sha512-jxPRPp9n93ci7b8hMfJOFDPRLFYadN6FSpeROFTR4UNF4i5b+EK6m4QXPO46BDhFgRy1JuS87zAnFOzCUwMJcQ==
+"@sinonjs/samsam@^6.1.1":
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.1.tgz#627f7f4cbdb56e6419fa2c1a3e4751ce4f6a00b1"
+  integrity sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA==
   dependencies:
     "@sinonjs/commons" "^1.6.0"
     lodash.get "^4.4.2"
@@ -1074,6 +1206,11 @@
   resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5"
   integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==
 
+"@socket.io/component-emitter@~3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553"
+  integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
+
 "@types/accepts@*":
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
@@ -1087,9 +1224,9 @@
   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"
-  integrity sha512-bxlMKPDbY8x5h6HBwVzEOk2C8fb6SLfYQ5Jw3uBYuYF1lfWk/kbLd81la82vrIkBb0l+JdmrZaDikPrNxpS/Ew==
+  version "7.1.19"
+  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460"
+  integrity sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw==
   dependencies:
     "@babel/parser" "^7.1.0"
     "@babel/types" "^7.0.0"
@@ -1098,9 +1235,9 @@
     "@types/babel__traverse" "*"
 
 "@types/babel__generator@*":
-  version "7.6.3"
-  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.3.tgz#f456b4b2ce79137f768aa130d2423d2f0ccfaba5"
-  integrity sha512-/GWCmzJWqV7diQW54smJZzWbSFf4QYtF71WCKhcx6Ru/tFyQIY2eiiITcCAeuPbNSvT9YCGkVMqqvSk2Z0mXiA==
+  version "7.6.4"
+  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.4.tgz#1f20ce4c5b1990b37900b63f050182d28c2439b7"
+  integrity sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==
   dependencies:
     "@babel/types" "^7.0.0"
 
@@ -1113,9 +1250,9 @@
     "@babel/types" "^7.0.0"
 
 "@types/babel__traverse@*":
-  version "7.14.2"
-  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.14.2.tgz#ffcd470bbb3f8bf30481678fb5502278ca833a43"
-  integrity sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA==
+  version "7.18.1"
+  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.18.1.tgz#ce5e2c8c272b99b7a9fd69fa39f0b4cd85028bd9"
+  integrity sha512-FSdLaZh2UxaMuLp9lixWaHq/golWTRWOnRsAXzDTDSDOQLuZb1nsdCt6pJSPWSEQt2eFZ2YVk3oYhn+1kLMeMA==
   dependencies:
     "@babel/types" "^7.3.0"
 
@@ -1144,15 +1281,27 @@
   resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.2.tgz#684ba0c284b2a58346abf0000bd0a735ad072d75"
   integrity sha512-YfCDMn7R59n7GFFfwjPAM0zLJQy4UvveC32rOJBmTqJJY8uSRqM4Dc7IJj8V9unA48Qy4nj5Bj3jD6Q8VZ1Seg==
 
+"@types/chai-dom@^0.0.12":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-0.0.12.tgz#fdd7a52bed4dd235ed1c94d3d2d31d4e7db1d03a"
+  integrity sha512-4rE7sDw713cV61TYzQbMrPjC4DjNk3x4vk9nAVRNXcSD4p0/5lEEfm0OgoCz5eNuWUXNKA0YiKiH/JDTuKivkA==
+  dependencies:
+    "@types/chai" "*"
+
+"@types/chai@*", "@types/chai@^4.1.7", "@types/chai@^4.2.12":
+  version "4.3.3"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07"
+  integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==
+
 "@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/chai@^4.3.1":
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.1.tgz#e2c6e73e0bdeb2521d00756d099218e9f5d90a04"
+  integrity sha512-/zPMqDkzSZ8t3VtxOa4KPq7uzzW978M9Tvh+j7GHKuo6k6GTLxPJ4J5gE5cjfJ26pnXst0N5Hax8Sr0T2Mi9zQ==
 
 "@types/co-body@^6.1.0":
   version "6.1.0"
@@ -1172,11 +1321,6 @@
   resolved "https://registry.yarnpkg.com/@types/command-line-usage/-/command-line-usage-5.0.2.tgz#ba5e3f6ae5a2009d466679cc431b50635bf1a064"
   integrity sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==
 
-"@types/component-emitter@^1.2.10":
-  version "1.2.10"
-  resolved "https://registry.yarnpkg.com/@types/component-emitter/-/component-emitter-1.2.10.tgz#ef5b1589b9f16544642e473db5ea5639107ef3ea"
-  integrity sha512-bsjleuRKWmGqajMerkzox19aGbscQX5rmmvvXl3wlIp5gMG1HgkiwPxsN5p070fBDKTNSPgojVbuY1+HWMbFhg==
-
 "@types/connect@*":
   version "3.4.35"
   resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
@@ -1265,6 +1409,11 @@
   resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762"
   integrity sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw==
 
+"@types/istanbul-lib-coverage@^2.0.1":
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
+  integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==
+
 "@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"
@@ -1322,7 +1471,7 @@
     "@types/koa" "*"
     "@types/koa-send" "*"
 
-"@types/koa@*", "@types/koa@^2.0.48", "@types/koa@^2.11.6":
+"@types/koa@*", "@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==
@@ -1336,10 +1485,24 @@
     "@types/koa-compose" "*"
     "@types/node" "*"
 
+"@types/koa@^2.0.48":
+  version "2.13.5"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.5.tgz#64b3ca4d54e08c0062e89ec666c9f45443b21a61"
+  integrity sha512-HSUOdzKz3by4fnqagwthW/1w/yJspTgppyyalPVbgZf8jQWvdIXcVW5h2DGtw4zYntOaeRGx49r1hxoPWrD4aA==
+  dependencies:
+    "@types/accepts" "*"
+    "@types/content-disposition" "*"
+    "@types/cookies" "*"
+    "@types/http-assert" "*"
+    "@types/http-errors" "*"
+    "@types/keygrip" "*"
+    "@types/koa-compose" "*"
+    "@types/node" "*"
+
 "@types/koa__cors@^3.0.1":
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.0.3.tgz#49d75813b443ba3d4da28ea6cf6244b7e99a3b23"
-  integrity sha512-74Xb4hJOPGKlrQ4PRBk1A/p0gfLpgbnpT0o67OMVbwyeMXvlBN+ZCRztAAmkKZs+8hKbgMutUlZVbA52Hr/0IA==
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.3.0.tgz#2986b320d3d7ddf05c4e2e472b25a321cb16bd3b"
+  integrity sha512-FUN8YxcBakIs+walVe3+HcNP+Bxd0SB8BJHBWkglZ5C1XQWljlKcEFDG/dPiCIqwVCUbc5X0nYDlH62uEhdHMA==
   dependencies:
     "@types/koa" "*"
 
@@ -1349,9 +1512,9 @@
   integrity sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==
 
 "@types/mime-types@^2.1.0":
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.0.tgz#9ca52cda363f699c69466c2a6ccdaad913ea7a73"
-  integrity sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM=
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.1.tgz#d9ba43490fa3a3df958759adf69396c3532cf2c1"
+  integrity sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==
 
 "@types/mime@^1":
   version "1.3.2"
@@ -1363,16 +1526,28 @@
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
   integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
 
-"@types/mocha@^8.2.2":
+"@types/mkdirp@^1.0.1":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@types/mkdirp/-/mkdirp-1.0.2.tgz#8d0bad7aa793abe551860be1f7ae7f3198c16666"
+  integrity sha512-o0K1tSO0Dx5X6xlU5F1D6625FawhC3dU3iqr25lluNv/+/QIVH8RLNEiVokgIZo+mz+87w/3Mkg/VvQS+J51fQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/mocha@^8.2.0":
   version "8.2.3"
   resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323"
   integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==
 
-"@types/node@*", "@types/node@>=10.0.0":
+"@types/node@*":
   version "16.6.1"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-16.6.1.tgz#aee62c7b966f55fc66c7b6dfa1d58db2a616da61"
   integrity sha512-Sr7BhXEAer9xyGuCN3Ek9eg9xPviCF2gfu9kTfuU2HkTVAMYSDeX40fvpmo72n5nansg3nsBjuQBrsS28r+NUw==
 
+"@types/node@>=10.0.0":
+  version "18.7.18"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.18.tgz#633184f55c322e4fb08612307c274ee6d5ed3154"
+  integrity sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==
+
 "@types/parse5@^6.0.1":
   version "6.0.2"
   resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.2.tgz#99f6b72d82e34cea03a4d8f2ed72114d909c1c61"
@@ -1383,6 +1558,20 @@
   resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
   integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
 
+"@types/pixelmatch@^5.2.2":
+  version "5.2.4"
+  resolved "https://registry.yarnpkg.com/@types/pixelmatch/-/pixelmatch-5.2.4.tgz#ca145cc5ede1388c71c68edf2d1f5190e5ddd0f6"
+  integrity sha512-HDaSHIAv9kwpMN7zlmwfTv6gax0PiporJOipcrGsVNF3Ba+kryOZc0Pio5pn6NhisgWr7TaajlPEKTbTAypIBQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/pngjs@^6.0.0":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-6.0.1.tgz#c711ec3fbbf077fed274ecccaf85dd4673130072"
+  integrity sha512-J39njbdW1U/6YyVXvC9+1iflZghP8jgRf2ndYghdJb5xL49LYDB+1EuAxfbuJ2IBbWIL3AjHPQhgaTxT3YaYeg==
+  dependencies:
+    "@types/node" "*"
+
 "@types/qs@*":
   version "6.9.7"
   resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
@@ -1400,6 +1589,13 @@
   dependencies:
     "@types/node" "*"
 
+"@types/resolve@1.17.1":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
+  integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==
+  dependencies:
+    "@types/node" "*"
+
 "@types/serve-static@*":
   version "1.13.10"
   resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9"
@@ -1408,6 +1604,21 @@
     "@types/mime" "^1"
     "@types/node" "*"
 
+"@types/sinon-chai@^3.2.3":
+  version "3.2.8"
+  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc"
+  integrity sha512-d4ImIQbT/rKMG8+AXpmcan5T2/PNeSjrYhvkwet6z0p8kzYtfgA32xzOBlbU0yqJfq+/0Ml805iFoODO0LP5/g==
+  dependencies:
+    "@types/chai" "*"
+    "@types/sinon" "*"
+
+"@types/sinon@*":
+  version "10.0.13"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.13.tgz#60a7a87a70d9372d0b7b38cc03e825f46981fb83"
+  integrity sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==
+  dependencies:
+    "@types/sinonjs__fake-timers" "*"
+
 "@types/sinon@^10.0.0":
   version "10.0.2"
   resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.2.tgz#f360d2f189c0fd433d14aeb97b9d705d7e4cc0e4"
@@ -1415,6 +1626,11 @@
   dependencies:
     "@sinonjs/fake-timers" "^7.1.0"
 
+"@types/sinonjs__fake-timers@*":
+  version "8.1.2"
+  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e"
+  integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==
+
 "@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"
@@ -1434,18 +1650,32 @@
   dependencies:
     "@types/node" "*"
 
+"@types/yauzl@^2.9.1":
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599"
+  integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==
+  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":
+"@web/browser-logs@^0.2.1", "@web/browser-logs@^0.2.2":
   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/config-loader@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@web/config-loader/-/config-loader-0.1.3.tgz#8325ea54f75ef2ee7166783e64e66936db25bff7"
+  integrity sha512-XVKH79pk4d3EHRhofete8eAnqto1e8mCRAqPV00KLNFzCWSe8sWmLnqKCqkPNARC6nksMaGrATnA5sPDRllMpQ==
+  dependencies:
+    semver "^7.3.4"
+
 "@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"
@@ -1470,6 +1700,73 @@
     picomatch "^2.2.2"
     ws "^7.4.2"
 
+"@web/dev-server-core@^0.3.18", "@web/dev-server-core@^0.3.19":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.3.19.tgz#b61f9a0b92351371347a758b30ba19e683c72e94"
+  integrity sha512-Q/Xt4RMVebLWvALofz1C0KvP8qHbzU1EmdIA2Y1WMPJwiFJFhPxdr75p9YxK32P2t0hGs6aqqS5zE0HW9wYzYA==
+  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 "^1.0.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/dev-server-esbuild@^0.3.2":
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-esbuild/-/dev-server-esbuild-0.3.2.tgz#d4f43c1677123021f6c5805beaac902318f7e083"
+  integrity sha512-Jn9b+Rs1ck4QN+ksue6qFdvUc2r/+NHpMW0R86W4Kqw5WjE7dT44pCGkKNfB8Fph4dNi0MgDaMhIkW2fcSpogA==
+  dependencies:
+    "@mdn/browser-compat-data" "^4.0.0"
+    "@web/dev-server-core" "^0.3.19"
+    esbuild "^0.12 || ^0.13 || ^0.14"
+    parse5 "^6.0.1"
+    ua-parser-js "^1.0.2"
+
+"@web/dev-server-rollup@^0.3.19":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-rollup/-/dev-server-rollup-0.3.19.tgz#188f3a37bcc38f4dc1b208663b14ab2d17321a57"
+  integrity sha512-IwiwI+fyX0YuvAOldStlYJ+Zm/JfSCk9OSGIs7+fWbOYysEHwkEVvBwoPowaclSZA44Tobvqt+6ej9udbbZ/WQ==
+  dependencies:
+    "@rollup/plugin-node-resolve" "^13.0.4"
+    "@web/dev-server-core" "^0.3.19"
+    nanocolors "^0.2.1"
+    parse5 "^6.0.1"
+    rollup "^2.67.0"
+    whatwg-url "^11.0.0"
+
+"@web/dev-server@^0.1.33":
+  version "0.1.34"
+  resolved "https://registry.yarnpkg.com/@web/dev-server/-/dev-server-0.1.34.tgz#4a94ea6dcf1c8081b97f5dd6d9790dc7e5c5039d"
+  integrity sha512-+te6iwxAQign1KyhHpkR/a3+5qw/Obg/XWCES2So6G5LcZ86zIKXbUpWAJuNOqiBV6eGwqEB1AozKr2Jj7gj/Q==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/command-line-args" "^5.0.0"
+    "@web/config-loader" "^0.1.3"
+    "@web/dev-server-core" "^0.3.19"
+    "@web/dev-server-rollup" "^0.3.19"
+    camelcase "^6.2.0"
+    command-line-args "^5.1.1"
+    command-line-usage "^6.1.1"
+    debounce "^1.2.0"
+    deepmerge "^4.2.2"
+    ip "^1.1.5"
+    nanocolors "^0.2.1"
+    open "^8.0.2"
+    portfinder "^1.0.28"
+
 "@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"
@@ -1478,6 +1775,16 @@
     "@types/parse5" "^6.0.1"
     parse5 "^6.0.1"
 
+"@web/test-runner-chrome@^0.10.7":
+  version "0.10.7"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-chrome/-/test-runner-chrome-0.10.7.tgz#2dc35da47aa8b98c59f9e229a70ea3f443303e0c"
+  integrity sha512-DKJVHhHh3e/b6/erfKOy0a4kGfZ47qMoQRgROxi9T4F9lavEY3E5/MQ7hapHFM2lBF4vDrm+EWjtBdOL8o42tw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    "@web/test-runner-coverage-v8" "^0.4.8"
+    chrome-launcher "^0.15.0"
+    puppeteer-core "^13.1.3"
+
 "@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"
@@ -1486,6 +1793,22 @@
     "@web/test-runner-core" "^0.10.20"
     mkdirp "^1.0.4"
 
+"@web/test-runner-commands@^0.6.1", "@web/test-runner-commands@^0.6.3":
+  version "0.6.4"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.6.4.tgz#61c8e1d71d30567b8e2845274426d209dbe77c7e"
+  integrity sha512-opSfIVHj4PsIA/Ah582DKgnmdfY+Xn35FnnYeJ+aBYrM+setOP63McvrY4PuwasictwswHVSzq86qZzmxvXkHw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.27"
+    mkdirp "^1.0.4"
+
+"@web/test-runner-commands@^0.6.4":
+  version "0.6.5"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.6.5.tgz#69a2a06b52fd9d329f9cf1e172cd8fb1d5ffc521"
+  integrity sha512-W+wLg10jEAJY9N6tNWqG1daKmAzxGmTbO/H9fFfcgOgdxdn+hHiR4r2/x1iylKbFLujHUQlnjNQeu2d6eDPFqg==
+  dependencies:
+    "@web/test-runner-core" "^0.10.27"
+    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"
@@ -1518,12 +1841,93 @@
     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==
+"@web/test-runner-core@^0.10.27":
+  version "0.10.27"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.27.tgz#8d1430f2364fb36b3ac15b9b43034fae9d94e177"
+  integrity sha512-ClV/hSxs4wDm/ANFfQOdRRFb/c0sYywC1QfUXG/nS4vTp3nnt7x7mjydtMGGLmvK9f6Zkubkc1aa+7ryfmVwNA==
+  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.18"
+    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/shadycss@^1.10.2", "@webcomponents/shadycss@^1.9.1":
+"@web/test-runner-coverage-v8@^0.4.8":
+  version "0.4.9"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.4.9.tgz#334d80cd19fc68c08ec3339b1b1d2725078b51a2"
+  integrity sha512-y9LWL4uY25+fKQTljwr0XTYjeWIwU4h8eYidVuLoW3n1CdFkaddv+smrGzzF5j8XY+Mp6TmV9NdxjvMWqVkDdw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    istanbul-lib-coverage "^3.0.0"
+    picomatch "^2.2.2"
+    v8-to-istanbul "^8.0.0"
+
+"@web/test-runner-mocha@^0.7.5":
+  version "0.7.5"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-mocha/-/test-runner-mocha-0.7.5.tgz#696f8cb7f5118a72bd7ac5778367ae3bd3fb92cd"
+  integrity sha512-12/OBq6efPCAvJpcz3XJs2OO5nHe7GtBibZ8Il1a0QtsGpRmuJ4/m1EF0Fj9f6KHg7JdpGo18A37oE+5hXjHwg==
+  dependencies:
+    "@types/mocha" "^8.2.0"
+    "@web/test-runner-core" "^0.10.20"
+
+"@web/test-runner-visual-regression@^0.6.6":
+  version "0.6.6"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-visual-regression/-/test-runner-visual-regression-0.6.6.tgz#4a4dc734f360cba66a005e07b4a1c0a9ef956444"
+  integrity sha512-010J3zE6z2v7eLLey/l5cYa9/WhPsgzZb3Z6K5nn4Mn5W5LGPs/f+XG3N6+Tx8Q1/RvDqLdFvRs/T6c4ul4dgQ==
+  dependencies:
+    "@types/mkdirp" "^1.0.1"
+    "@types/pixelmatch" "^5.2.2"
+    "@types/pngjs" "^6.0.0"
+    "@web/test-runner-commands" "^0.6.4"
+    "@web/test-runner-core" "^0.10.20"
+    mkdirp "^1.0.4"
+    pixelmatch "^5.2.1"
+    pngjs "^6.0.0"
+
+"@web/test-runner@^0.14.0":
+  version "0.14.0"
+  resolved "https://registry.yarnpkg.com/@web/test-runner/-/test-runner-0.14.0.tgz#fc7b206f3fdc5a1d774cfc8f60159a574d30b185"
+  integrity sha512-9xVKnsviCqXL/xi48l0GpDDfvdczZsKHfEhmZglGMTL+I5viDMAj0GGe7fD9ygJ6UT2+056a3RzyIW5x9lZTDQ==
+  dependencies:
+    "@web/browser-logs" "^0.2.2"
+    "@web/config-loader" "^0.1.3"
+    "@web/dev-server" "^0.1.33"
+    "@web/test-runner-chrome" "^0.10.7"
+    "@web/test-runner-commands" "^0.6.3"
+    "@web/test-runner-core" "^0.10.27"
+    "@web/test-runner-mocha" "^0.7.5"
+    camelcase "^6.2.0"
+    command-line-args "^5.1.1"
+    command-line-usage "^6.1.1"
+    convert-source-map "^1.7.0"
+    diff "^5.0.0"
+    globby "^11.0.1"
+    nanocolors "^0.2.1"
+    portfinder "^1.0.28"
+    source-map "^0.7.3"
+
+"@webcomponents/shadycss@^1.10.2":
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
   integrity sha512-L5O/+UPum8erOleNjKq6k58GVl3fNsEQdSOyh0EUhNmi7tHUyRuCJy1uqJiWydWcLARE5IPsMoPYMZmUGrz1JA==
@@ -1538,7 +1942,7 @@
   resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.3.tgz#1b5b487bd6436b5b764fd52a612509702c3144b5"
   integrity sha512-zetDJxd89y3X99Kvo4qFx8GKlt6GsvN3UcRZHwU6iFA/0KiOmhkTVhe8oRoTBiTVPZu09x3vCra47+w8Yz1+2Q==
 
-accepts@^1.3.5, accepts@~1.3.4:
+accepts@^1.3.5:
   version "1.3.7"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
   integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
@@ -1546,11 +1950,26 @@
     mime-types "~2.1.24"
     negotiator "0.6.2"
 
+accepts@~1.3.4:
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
+  integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
+  dependencies:
+    mime-types "~2.1.34"
+    negotiator "0.6.3"
+
 accessibility-developer-tools@^2.12.0:
   version "2.12.0"
   resolved "https://registry.yarnpkg.com/accessibility-developer-tools/-/accessibility-developer-tools-2.12.0.tgz#3da0cce9d6ec6373964b84f35db7cfc3df7ab514"
   integrity sha1-PaDM6dbsY3OWS4TzXbfPw996tRQ=
 
+agent-base@6:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
+  integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
+  dependencies:
+    debug "4"
+
 ajv@^6.12.3:
   version "6.12.6"
   resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
@@ -1574,20 +1993,25 @@
     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"
-  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.1.tgz#123d6479e92ad45ad897d4054e3c7ca7db4944e1"
+  integrity sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==
 
 ansi-regex@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
-  integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed"
+  integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==
 
 ansi-regex@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
   integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
 
+ansi-regex@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+  integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
 ansi-styles@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@@ -1602,12 +2026,12 @@
   dependencies:
     color-convert "^2.0.1"
 
-any-promise@^1.0.0, any-promise@^1.1.0:
+any-promise@^1.0.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
-  integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
+  integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==
 
-anymatch@~3.1.1, anymatch@~3.1.2:
+anymatch@~3.1.2:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
   integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
@@ -1625,7 +2049,7 @@
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
   integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
 
-array-back@^4.0.1:
+array-back@^4.0.1, array-back@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e"
   integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==
@@ -1641,49 +2065,49 @@
   integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
 
 asn1@~0.2.3:
-  version "0.2.4"
-  resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
-  integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
+  version "0.2.6"
+  resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d"
+  integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==
   dependencies:
     safer-buffer "~2.1.0"
 
 assert-plus@1.0.0, assert-plus@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
-  integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
-
-assertion-error@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
-  integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
+  integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==
 
 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"
-  integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
+async@^2.6.4:
+  version "2.6.4"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
+  integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
   dependencies:
     lodash "^4.17.14"
 
 asynckit@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
-  integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
+  integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
 
 aws-sign2@~0.7.0:
   version "0.7.0"
   resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
-  integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
+  integrity sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==
 
 aws4@^1.8.0:
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
   integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
 
+axe-core@^4.3.3:
+  version "4.4.3"
+  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.3.tgz#11c74d23d5013c0fa5d183796729bc3482bd2f6f"
+  integrity sha512-32+ub6kkdhhWick/UjvEwRchgoetXqTK14INLqbGm5U2TzBkBNF3nQtLYm8ovxSkQWArjEQvftCKryjZaATu3w==
+
 babel-plugin-dynamic-import-node@^2.3.3:
   version "2.3.3"
   resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3"
@@ -1701,39 +2125,39 @@
     istanbul-lib-instrument "^3.3.0"
     test-exclude "^5.2.3"
 
-babel-plugin-polyfill-corejs2@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.2.2.tgz#e9124785e6fd94f94b618a7954e5693053bf5327"
-  integrity sha512-kISrENsJ0z5dNPq5eRvcctITNHYXWOA4DUZRFYCz3jYCcvTb/A546LIddmoGNMVYg2U38OyFeNosQwI9ENTqIQ==
+babel-plugin-polyfill-corejs2@^0.3.2:
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz#5d1bd3836d0a19e1b84bbf2d9640ccb6f951c122"
+  integrity sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==
   dependencies:
-    "@babel/compat-data" "^7.13.11"
-    "@babel/helper-define-polyfill-provider" "^0.2.2"
+    "@babel/compat-data" "^7.17.7"
+    "@babel/helper-define-polyfill-provider" "^0.3.3"
     semver "^6.1.1"
 
-babel-plugin-polyfill-corejs3@^0.2.2:
-  version "0.2.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.2.4.tgz#68cb81316b0e8d9d721a92e0009ec6ecd4cd2ca9"
-  integrity sha512-z3HnJE5TY/j4EFEa/qpQMSbcUJZ5JQi+3UFjXzn6pQCmIKc5Ug5j98SuYyH+m4xQnvKlMDIW4plLfgyVnd0IcQ==
+babel-plugin-polyfill-corejs3@^0.5.3:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.3.tgz#d7e09c9a899079d71a8b670c6181af56ec19c5c7"
+  integrity sha512-zKsXDh0XjnrUEW0mxIHLfjBfnXSMr5Q/goMe/fxpQnLm07mcOZiIZHBNWCMx60HmdvjxfXcalac0tfFg0wqxyw==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.2.2"
-    core-js-compat "^3.14.0"
+    "@babel/helper-define-polyfill-provider" "^0.3.2"
+    core-js-compat "^3.21.0"
 
-babel-plugin-polyfill-regenerator@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.2.2.tgz#b310c8d642acada348c1fa3b3e6ce0e851bee077"
-  integrity sha512-Goy5ghsc21HgPDFtzRkSirpZVW35meGoTmTOb2bxqdl60ghub4xOidgNTHaZfQ2FaxQsKmwvXtOAkcIS4SMBWg==
+babel-plugin-polyfill-regenerator@^0.4.0:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz#390f91c38d90473592ed43351e801a9d3e0fd747"
+  integrity sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.2.2"
+    "@babel/helper-define-polyfill-provider" "^0.3.3"
 
 balanced-match@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
   integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
-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==
+base64-js@^1.3.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
+  integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
 
 base64id@2.0.0, base64id@~2.0.0:
   version "2.0.0"
@@ -1743,7 +2167,7 @@
 bcrypt-pbkdf@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
-  integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
+  integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==
   dependencies:
     tweetnacl "^0.14.3"
 
@@ -1752,21 +2176,32 @@
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
   integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
 
-body-parser@^1.19.0:
-  version "1.19.0"
-  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
-  integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
+bl@^4.0.3:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
+  integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
   dependencies:
-    bytes "3.1.0"
+    buffer "^5.5.0"
+    inherits "^2.0.4"
+    readable-stream "^3.4.0"
+
+body-parser@^1.19.0:
+  version "1.20.0"
+  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5"
+  integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==
+  dependencies:
+    bytes "3.1.2"
     content-type "~1.0.4"
     debug "2.6.9"
-    depd "~1.1.2"
-    http-errors "1.7.2"
+    depd "2.0.0"
+    destroy "1.2.0"
+    http-errors "2.0.0"
     iconv-lite "0.4.24"
-    on-finished "~2.3.0"
-    qs "6.7.0"
-    raw-body "2.4.0"
-    type-is "~1.6.17"
+    on-finished "2.4.1"
+    qs "6.10.3"
+    raw-body "2.5.1"
+    type-is "~1.6.18"
+    unpipe "1.0.0"
 
 brace-expansion@^1.1.7:
   version "1.1.11"
@@ -1789,40 +2224,59 @@
   integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==
 
 browserslist-useragent@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/browserslist-useragent/-/browserslist-useragent-3.0.3.tgz#d06c062a4e444ad5e1a80323131d4508450c9af5"
-  integrity sha512-8KKO6kOXu/93IkMi8zVqzU72BgpoxcITIHtkM1qmlnxJtIMF9Y+2uWL9JS2uUbzj/PaS3kaA6LcICBThMojGjA==
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/browserslist-useragent/-/browserslist-useragent-3.1.4.tgz#b2cb15a3a46d5c535f96335c4b8d97f94c3657a9"
+  integrity sha512-o9V55790uae98Kwn+vwyO+ww07OreiH1BUc9bjjlUbIL3Fh43fyoasZxZ2EiI4ErfEIKwbycQ1pvwOBlySJ7ow==
   dependencies:
-    browserslist "^4.12.0"
-    semver "^7.3.2"
+    browserslist "^4.19.1"
+    electron-to-chromium "^1.4.67"
+    semver "^7.3.5"
     useragent "^2.3.0"
+    yamlparser "^0.0.2"
 
-browserslist@*, browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.16.0, browserslist@^4.16.6, browserslist@^4.16.7, browserslist@^4.9.1:
-  version "4.16.7"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.7.tgz#108b0d1ef33c4af1b587c54f390e7041178e4335"
-  integrity sha512-7I4qVwqZltJ7j37wObBe3SoTz+nS8APaNcrBOlgoirb6/HbEU2XxW/LpUDTCngM6iauwFqmRTuOMfyKnFGY5JA==
+browserslist@*, browserslist@^4.0.0, browserslist@^4.16.5, browserslist@^4.19.1, browserslist@^4.20.2, browserslist@^4.21.3, browserslist@^4.9.1:
+  version "4.21.3"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a"
+  integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ==
   dependencies:
-    caniuse-lite "^1.0.30001248"
-    colorette "^1.2.2"
-    electron-to-chromium "^1.3.793"
-    escalade "^3.1.1"
-    node-releases "^1.1.73"
+    caniuse-lite "^1.0.30001370"
+    electron-to-chromium "^1.4.202"
+    node-releases "^2.0.6"
+    update-browserslist-db "^1.0.5"
+
+buffer-crc32@~0.2.3:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+  integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
 
 buffer-from@^1.0.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
   integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
 
-builtin-modules@^3.1.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887"
-  integrity sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==
+buffer@^5.2.1, buffer@^5.5.0:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
+  integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
+  dependencies:
+    base64-js "^1.3.1"
+    ieee754 "^1.1.13"
 
-bytes@3.1.0, bytes@^3.0.0:
+builtin-modules@^3.1.0, builtin-modules@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
+  integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
+
+bytes@3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
   integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
 
+bytes@3.1.2, bytes@^3.0.0:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
+  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
+
 cache-content-type@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c"
@@ -1831,7 +2285,7 @@
     mime-types "^2.1.18"
     ylru "^1.2.0"
 
-call-bind@^1.0.0:
+call-bind@^1.0.0, call-bind@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
   integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==
@@ -1852,10 +2306,10 @@
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
   integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
 
-camelcase@^6.0.0:
-  version "6.2.0"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809"
-  integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
+camelcase@^6.0.0, camelcase@^6.2.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
+  integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
 
 caniuse-api@^3.0.0:
   version "3.0.0"
@@ -1867,27 +2321,22 @@
     lodash.memoize "^4.1.2"
     lodash.uniq "^4.5.0"
 
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001033, caniuse-lite@^1.0.30001248:
-  version "1.0.30001251"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001251.tgz#6853a606ec50893115db660f82c094d18f096d85"
-  integrity sha512-HOe1r+9VkU4TFmnU70z+r7OLmtR+/chB1rdcJUeQlAinjEeb0cKL20tlAtOagNZhbrtLnCvV19B4FmF1rgzl6A==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001033, caniuse-lite@^1.0.30001370:
+  version "1.0.30001399"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001399.tgz#1bf994ca375d7f33f8d01ce03b7d5139e8587873"
+  integrity sha512-4vQ90tMKS+FkvuVWS5/QY1+d805ODxZiKFzsU8o/RsVJz49ZSRR8EjykLJbqhzdPgadbX6wB538wOzle3JniRA==
 
 caseless@~0.12.0:
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
-  integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
+  integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
 
-chai@^4.3.4:
-  version "4.3.4"
-  resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.4.tgz#b55e655b31e1eac7099be4c08c21964fce2e6c49"
-  integrity sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==
+chai-a11y-axe@^1.3.2:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.4.0.tgz#e584af967727a8656e27c32e845f5db21f2bf2e0"
+  integrity sha512-m7J6DVAl1ePL2ifPKHmwQyHXdCZ+Qfv+qduh6ScqcDfBnJEzpV1K49TblujM45j1XciZOFeFNqMb2sShXMg/mw==
   dependencies:
-    assertion-error "^1.1.0"
-    check-error "^1.0.2"
-    deep-eql "^3.0.1"
-    get-func-name "^2.0.0"
-    pathval "^1.1.1"
-    type-detect "^4.0.5"
+    axe-core "^4.3.3"
 
 chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.2:
   version "2.4.2"
@@ -1898,7 +2347,7 @@
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
-chalk@^4.0.0:
+chalk@^4.1.0:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
   integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@@ -1906,27 +2355,22 @@
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
 
-check-error@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
-  integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=
-
-chokidar@3.5.1:
-  version "3.5.1"
-  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a"
-  integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==
+chokidar@3.5.3, chokidar@^3.0.0, chokidar@^3.5.1:
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
+  integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
   dependencies:
-    anymatch "~3.1.1"
+    anymatch "~3.1.2"
     braces "~3.0.2"
-    glob-parent "~5.1.0"
+    glob-parent "~5.1.2"
     is-binary-path "~2.1.0"
     is-glob "~4.0.1"
     normalize-path "~3.0.0"
-    readdirp "~3.5.0"
+    readdirp "~3.6.0"
   optionalDependencies:
-    fsevents "~2.3.1"
+    fsevents "~2.3.2"
 
-chokidar@^3.0.0, chokidar@^3.4.3, chokidar@^3.5.1:
+chokidar@^3.4.3:
   version "3.5.2"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75"
   integrity sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==
@@ -1941,10 +2385,25 @@
   optionalDependencies:
     fsevents "~2.3.2"
 
+chownr@^1.1.1:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
+  integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
+
+chrome-launcher@^0.15.0:
+  version "0.15.1"
+  resolved "https://registry.yarnpkg.com/chrome-launcher/-/chrome-launcher-0.15.1.tgz#0a0208037063641e2b3613b7e42b0fcb3fa2d399"
+  integrity sha512-UugC8u59/w2AyX5sHLZUHoxBAiSiunUhZa3zZwMH6zPVis0C3dDKiRWyUGIo14tTbZHGVviWxv3PQWZ7taZ4fg==
+  dependencies:
+    "@types/node" "*"
+    escape-string-regexp "^4.0.0"
+    is-wsl "^2.2.0"
+    lighthouse-logger "^1.0.0"
+
 clean-css@^4.2.3:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
-  integrity sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA==
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178"
+  integrity sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==
   dependencies:
     source-map "~0.6.0"
 
@@ -2008,16 +2467,6 @@
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
-colorette@^1.2.2:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.3.0.tgz#ff45d2f0edb244069d3b772adeb04fed38d0a0af"
-  integrity sha512-ecORCqbSFP7Wm8Y6lyqMJjexBQqXSF7SSeaTyGGphogUjBlFP9m9o08wy86HL2uB7fMTxtOUzLMk7ogKcxMg1w==
-
-colors@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
-  integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
-
 combined-stream@^1.0.6, combined-stream@~1.0.6:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
@@ -2025,24 +2474,24 @@
   dependencies:
     delayed-stream "~1.0.0"
 
-command-line-args@^5.0.2:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.0.tgz#087b02748272169741f1fd7c785b295df079b9be"
-  integrity sha512-4zqtU1hYsSJzcJBOcNZIbW5Fbk9BkjCp1pZVhQKoRaWL5J7N4XphDLwo8aWwdQpTugxwu+jf9u2ZhkXiqp5Z6A==
+command-line-args@^5.0.2, command-line-args@^5.1.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
+  integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
   dependencies:
     array-back "^3.1.0"
     find-replace "^3.0.0"
     lodash.camelcase "^4.3.0"
     typical "^4.0.0"
 
-command-line-usage@^6.1.0:
-  version "6.1.1"
-  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.1.tgz#c908e28686108917758a49f45efb4f02f76bc03f"
-  integrity sha512-F59pEuAR9o1SF/bD0dQBDluhpT4jJQNWUHEuVBqpDmCUo6gPjCi+m9fCWnWZVR/oG6cMTUms4h+3NPl74wGXvA==
+command-line-usage@^6.1.0, command-line-usage@^6.1.1:
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.3.tgz#428fa5acde6a838779dfa30e44686f4b6761d957"
+  integrity sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==
   dependencies:
-    array-back "^4.0.1"
+    array-back "^4.0.2"
     chalk "^2.4.2"
-    table-layout "^1.0.1"
+    table-layout "^1.0.2"
     typical "^5.2.0"
 
 commander@^2.20.0:
@@ -2055,11 +2504,6 @@
   resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
   integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
 
-component-emitter@~1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
-  integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
-
 compressible@^2.0.0:
   version "2.0.18"
   resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
@@ -2094,7 +2538,7 @@
   resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
   integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
 
-convert-source-map@^1.7.0:
+convert-source-map@^1.6.0, convert-source-map@^1.7.0:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
   integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
@@ -2102,9 +2546,9 @@
     safe-buffer "~5.1.1"
 
 cookie@~0.4.1:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
-  integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
+  integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
 
 cookies@~0.8.0:
   version "0.8.0"
@@ -2115,22 +2559,21 @@
     keygrip "~1.1.0"
 
 core-js-bundle@^3.6.0, core-js-bundle@^3.8.1:
-  version "3.16.1"
-  resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.16.1.tgz#410c73317f7154dc4aac0674556b7003a7f4c47f"
-  integrity sha512-pPavAOLKXD2YXNBhS3jq4WMGJPeqgo4W9WZ7GebxXTZY/jvnD5ID+J3nUOCS7UXwCNsQKbbUg1+hp/4rmvzNeg==
+  version "3.25.1"
+  resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.25.1.tgz#72e3b3a11d2d9f671e5ff740135223394c5fcb85"
+  integrity sha512-f1FcTJFuKTJNSfpKAOJY/ehGLIPhQMUlQwJHGmIdEHROgcntQqRU6LGpyuIfJVjHlypY9A2DhG9qkT+uRwvwGQ==
 
-core-js-compat@^3.14.0, core-js-compat@^3.16.0:
-  version "3.16.1"
-  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.16.1.tgz#c44b7caa2dcb94b673a98f27eee1c8312f55bc2d"
-  integrity sha512-NHXQXvRbd4nxp9TEmooTJLUf94ySUG6+DSsscBpTftN1lQLQ4LjnWvc7AoIo4UjDsFF3hB8Uh5LLCRRdaiT5MQ==
+core-js-compat@^3.21.0, core-js-compat@^3.22.1:
+  version "3.25.1"
+  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.25.1.tgz#6f13a90de52f89bbe6267e5620a412c7f7ff7e42"
+  integrity sha512-pOHS7O0i8Qt4zlPW/eIFjwp+NrTPx+wTL0ctgI2fHn31sZOq89rDsmtc/A2vAX7r6shl+bmVI+678He46jgBlw==
   dependencies:
-    browserslist "^4.16.7"
-    semver "7.0.0"
+    browserslist "^4.21.3"
 
 core-util-is@1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
-  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
+  integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==
 
 cors@~2.8.5:
   version "2.8.5"
@@ -2140,80 +2583,75 @@
     object-assign "^4"
     vary "^1"
 
+cross-fetch@3.1.5:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"
+  integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==
+  dependencies:
+    node-fetch "2.6.7"
+
 custom-event@~1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
-  integrity sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU=
+  integrity sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==
 
 dashdash@^1.12.0:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
-  integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
+  integrity sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==
   dependencies:
     assert-plus "^1.0.0"
 
-date-format@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/date-format/-/date-format-2.1.0.tgz#31d5b5ea211cf5fd764cd38baf9d033df7e125cf"
-  integrity sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==
-
-date-format@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/date-format/-/date-format-3.0.0.tgz#eb8780365c7d2b1511078fb491e6479780f3ad95"
-  integrity sha512-eyTcpKOcamdhWJXj56DpQMo1ylSQpcGtGKXcU0Tb97+K56/CF5amAqqqNj0+KvA0iw2ynxtHWFsPDSClCxe48w==
+date-format@^4.0.13:
+  version "4.0.13"
+  resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.13.tgz#87c3aab3a4f6f37582c5f5f63692d2956fa67890"
+  integrity sha512-bnYCwf8Emc3pTD8pXnre+wfnjGtfi5ncMDKy7+cWZXbmRAsdWkOQHrfC1yz/KiwP5thDp2kCHWYWKBX4HP1hoQ==
 
 debounce@^1.2.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
   integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
 
-debug@2.6.9:
+debug@2.6.9, debug@^2.6.9:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
   dependencies:
     ms "2.0.0"
 
-debug@4.3.1:
-  version "4.3.1"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
-  integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
+debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
+  integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
   dependencies:
     ms "2.1.2"
 
-debug@^3.1.0, debug@^3.1.1:
+debug@4.3.3:
+  version "4.3.3"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664"
+  integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==
+  dependencies:
+    ms "2.1.2"
+
+debug@^3.1.0, debug@^3.2.7:
   version "3.2.7"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
   integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
   dependencies:
     ms "^2.1.1"
 
-debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@~4.3.1, debug@~4.3.2:
+debug@^4.1.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==
   dependencies:
     ms "2.1.2"
 
-debug@~3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
-  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
-  dependencies:
-    ms "2.0.0"
-
 decamelize@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837"
   integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==
 
-deep-eql@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
-  integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==
-  dependencies:
-    type-detect "^4.0.0"
-
 deep-equal@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
@@ -2234,24 +2672,25 @@
   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"
-  integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
+define-properties@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1"
+  integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==
   dependencies:
-    object-keys "^1.0.12"
+    has-property-descriptors "^1.0.0"
+    object-keys "^1.1.1"
 
 delayed-stream@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
-  integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
+  integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
 
 delegates@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
   integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
 
-depd@^2.0.0, depd@~2.0.0:
+depd@2.0.0, depd@^2.0.0, depd@~2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
   integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
@@ -2266,25 +2705,35 @@
   resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27"
   integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==
 
+destroy@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
+  integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+
 destroy@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
   integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
 
+devtools-protocol@0.0.981744:
+  version "0.0.981744"
+  resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf"
+  integrity sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg==
+
 di@^0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c"
-  integrity sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw=
+  integrity sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==
 
 diff@5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
   integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
 
-diff@^4.0.2:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
-  integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
+diff@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
+  integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
 
 dir-glob@^3.0.1:
   version "3.0.1"
@@ -2296,7 +2745,7 @@
 dom-serialize@^2.2.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b"
-  integrity sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs=
+  integrity sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==
   dependencies:
     custom-event "~1.0.0"
     ent "~2.2.0"
@@ -2319,7 +2768,7 @@
 ecc-jsbn@~0.1.1:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
-  integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
+  integrity sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==
   dependencies:
     jsbn "~0.1.0"
     safer-buffer "^2.1.0"
@@ -2329,10 +2778,10 @@
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
-electron-to-chromium@^1.3.793:
-  version "1.3.806"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.806.tgz#21502100f11aead6c501d1cd7f2504f16c936642"
-  integrity sha512-AH/otJLAAecgyrYp0XK1DPiGVWcOgwPeJBOLeuFQ5l//vhQhwC9u6d+GijClqJAmsHG4XDue81ndSQPohUu0xA==
+electron-to-chromium@^1.4.202, electron-to-chromium@^1.4.67:
+  version "1.4.249"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.249.tgz#49c34336c742ee65453dbddf4c84355e59b96e2c"
+  integrity sha512-GMCxR3p2HQvIw47A599crTKYZprqihoBL4lDSAUmr7IYekXFK5t/WgEBrGJDCa2HWIZFQEkGuMqPCi05ceYqPQ==
 
 emoji-regex@^8.0.0:
   version "8.0.0"
@@ -2344,24 +2793,22 @@
   resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
   integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
 
-end-of-stream@^1.1.0:
+end-of-stream@^1.1.0, end-of-stream@^1.4.1:
   version "1.4.4"
   resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
   integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
   dependencies:
     once "^1.4.0"
 
-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 "~1.0.1"
+engine.io-parser@~5.0.3:
+  version "5.0.4"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.4.tgz#0b13f704fa9271b3ec4f33112410d8f3f41d0fc0"
+  integrity sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==
 
-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==
+engine.io@~6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.2.0.tgz#003bec48f6815926f2b1b17873e576acd54f41d0"
+  integrity sha512-4KzwW3F3bk+KlzSOY57fj/Jx6LyRQ1nbcyIadehl+AnXjKT7gDO0ORdRi/84ixvMKTym6ZKuxvbzN62HDDU1Lg==
   dependencies:
     "@types/cookie" "^0.4.1"
     "@types/cors" "^2.8.12"
@@ -2371,13 +2818,13 @@
     cookie "~0.4.1"
     cors "~2.8.5"
     debug "~4.3.1"
-    engine.io-parser "~5.0.0"
+    engine.io-parser "~5.0.3"
     ws "~8.2.3"
 
 ent@~2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
-  integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0=
+  integrity sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==
 
 error-ex@^1.3.1:
   version "1.3.2"
@@ -2472,11 +2919,143 @@
   resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19"
   integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==
 
+es-module-lexer@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.0.3.tgz#f0d8d35b36d13024110000d5e6fadc8eeaeb66b8"
+  integrity sha512-iC67eXHToclrlVhQfpRawDiF8D8sQxNxmbqw5oebegOaJkyx/w9C/k57/5e6yJR2zIByRt9OXdqX50DV2t6ZKw==
+
 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"
   integrity sha512-0LTiSQoPWwdcaTVIQXhGlaDwTneD0g9/tnH1PNs3zHFFH+xoCeJclDM3rQeqF9nurXPfMKm3l9+kfPRa5VpbKg==
 
+esbuild-android-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
+  integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
+
+esbuild-android-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
+  integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
+
+esbuild-darwin-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
+  integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
+
+esbuild-darwin-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
+  integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
+
+esbuild-freebsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
+  integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
+
+esbuild-freebsd-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
+  integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
+
+esbuild-linux-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
+  integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
+
+esbuild-linux-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652"
+  integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
+
+esbuild-linux-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
+  integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
+
+esbuild-linux-arm@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
+  integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
+
+esbuild-linux-mips64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
+  integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
+
+esbuild-linux-ppc64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
+  integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
+
+esbuild-linux-riscv64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
+  integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
+
+esbuild-linux-s390x@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
+  integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
+
+esbuild-netbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
+  integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
+
+esbuild-openbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
+  integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
+
+esbuild-sunos-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
+  integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
+
+esbuild-windows-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
+  integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
+
+esbuild-windows-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
+  integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
+
+esbuild-windows-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
+  integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
+
+"esbuild@^0.12 || ^0.13 || ^0.14":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2"
+  integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==
+  optionalDependencies:
+    "@esbuild/linux-loong64" "0.14.54"
+    esbuild-android-64 "0.14.54"
+    esbuild-android-arm64 "0.14.54"
+    esbuild-darwin-64 "0.14.54"
+    esbuild-darwin-arm64 "0.14.54"
+    esbuild-freebsd-64 "0.14.54"
+    esbuild-freebsd-arm64 "0.14.54"
+    esbuild-linux-32 "0.14.54"
+    esbuild-linux-64 "0.14.54"
+    esbuild-linux-arm "0.14.54"
+    esbuild-linux-arm64 "0.14.54"
+    esbuild-linux-mips64le "0.14.54"
+    esbuild-linux-ppc64le "0.14.54"
+    esbuild-linux-riscv64 "0.14.54"
+    esbuild-linux-s390x "0.14.54"
+    esbuild-netbsd-64 "0.14.54"
+    esbuild-openbsd-64 "0.14.54"
+    esbuild-sunos-64 "0.14.54"
+    esbuild-windows-32 "0.14.54"
+    esbuild-windows-64 "0.14.54"
+    esbuild-windows-arm64 "0.14.54"
+
 escalade@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
@@ -2487,7 +3066,7 @@
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
   integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
 
-escape-string-regexp@4.0.0:
+escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
   integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
@@ -2510,7 +3089,7 @@
 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=
+  integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
 
 eventemitter3@^4.0.0:
   version "4.0.7"
@@ -2522,15 +3101,26 @@
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
   integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
 
+extract-zip@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
+  integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==
+  dependencies:
+    debug "^4.1.1"
+    get-stream "^5.1.0"
+    yauzl "^2.10.0"
+  optionalDependencies:
+    "@types/yauzl" "^2.9.1"
+
 extsprintf@1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
-  integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
+  integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==
 
 extsprintf@^1.2.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
-  integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07"
+  integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==
 
 fast-deep-equal@^3.1.1:
   version "3.1.3"
@@ -2560,6 +3150,13 @@
   dependencies:
     reusify "^1.0.4"
 
+fd-slicer@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
+  integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==
+  dependencies:
+    pend "~1.2.0"
+
 fill-range@^7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
@@ -2602,25 +3199,33 @@
   dependencies:
     locate-path "^3.0.0"
 
+find-up@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+  dependencies:
+    locate-path "^5.0.0"
+    path-exists "^4.0.0"
+
 flat@^5.0.2:
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241"
   integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==
 
-flatted@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138"
-  integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==
+flatted@^3.2.6:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787"
+  integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==
 
 follow-redirects@^1.0.0:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43"
-  integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==
+  version "1.15.2"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
+  integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
 
 forever-agent@~0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
-  integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
+  integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==
 
 form-data@~2.3.2:
   version "2.3.3"
@@ -2636,6 +3241,11 @@
   resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
   integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
 
+fs-constants@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
+  integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+
 fs-extra@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
@@ -2650,7 +3260,7 @@
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
-fsevents@~2.3.1, fsevents@~2.3.2:
+fsevents@~2.3.2:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
   integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
@@ -2670,11 +3280,6 @@
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
-get-func-name@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
-  integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=
-
 get-intrinsic@^1.0.2:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6"
@@ -2684,6 +3289,15 @@
     has "^1.0.3"
     has-symbols "^1.0.1"
 
+get-intrinsic@^1.1.1:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.3.tgz#063c84329ad93e83893c7f4f243ef63ffa351385"
+  integrity sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==
+  dependencies:
+    function-bind "^1.1.1"
+    has "^1.0.3"
+    has-symbols "^1.0.3"
+
 get-stream@^5.1.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
@@ -2699,21 +3313,21 @@
 getpass@^0.1.1:
   version "0.1.7"
   resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
-  integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
+  integrity sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==
   dependencies:
     assert-plus "^1.0.0"
 
-glob-parent@^5.1.2, glob-parent@~5.1.0, glob-parent@~5.1.2:
+glob-parent@^5.1.2, 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==
   dependencies:
     is-glob "^4.0.1"
 
-glob@7.1.6:
-  version "7.1.6"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
-  integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
+glob@7.2.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
+  integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
   dependencies:
     fs.realpath "^1.0.0"
     inflight "^1.0.4"
@@ -2722,7 +3336,7 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-glob@^7.1.3, glob@^7.1.7:
+glob@^7.1.3:
   version "7.1.7"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
   integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
@@ -2734,6 +3348,18 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+glob@^7.1.7:
+  version "7.2.3"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
+  integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.1.1"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
 globals@^11.1.0:
   version "11.12.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
@@ -2752,9 +3378,9 @@
     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"
-  integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
+  version "4.2.10"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
+  integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
 
 growl@1.10.5:
   version "1.10.5"
@@ -2764,7 +3390,7 @@
 har-schema@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
-  integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
+  integrity sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==
 
 har-validator@~5.1.3:
   version "5.1.5"
@@ -2784,11 +3410,23 @@
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
+has-property-descriptors@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861"
+  integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==
+  dependencies:
+    get-intrinsic "^1.1.1"
+
 has-symbols@^1.0.1, has-symbols@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
   integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
 
+has-symbols@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
+  integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
+
 has-tostringtag@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
@@ -2839,17 +3477,6 @@
     deep-equal "~1.0.1"
     http-errors "~1.7.2"
 
-http-errors@1.7.2:
-  version "1.7.2"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
-  integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.3"
-    setprototypeof "1.1.1"
-    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"
@@ -2861,6 +3488,17 @@
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
+http-errors@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
+  integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
+  dependencies:
+    depd "2.0.0"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses "2.0.1"
+    toidentifier "1.0.1"
+
 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"
@@ -2894,12 +3532,20 @@
 http-signature@~1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
-  integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
+  integrity sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==
   dependencies:
     assert-plus "^1.0.0"
     jsprim "^1.2.2"
     sshpk "^1.7.0"
 
+https-proxy-agent@5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
+  integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
+  dependencies:
+    agent-base "6"
+    debug "4"
+
 iconv-lite@0.4.24:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -2907,6 +3553,11 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
+ieee754@^1.1.13:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
+  integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
+
 ignore@^5.1.4:
   version "5.1.9"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.9.tgz#9ec1a5cbe8e1446ec60d4420060d43aa6e7382fb"
@@ -2925,7 +3576,7 @@
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.4:
+inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -2948,7 +3599,7 @@
 is-arrayish@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
-  integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
+  integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==
 
 is-binary-path@~2.1.0:
   version "2.1.0"
@@ -2957,6 +3608,13 @@
   dependencies:
     binary-extensions "^2.0.0"
 
+is-builtin-module@^3.1.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.0.tgz#bb0310dfe881f144ca83f30100ceb10cf58835e0"
+  integrity sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw==
+  dependencies:
+    builtin-modules "^3.3.0"
+
 is-core-module@^2.2.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.5.0.tgz#f754843617c70bfd29b7bd87327400cda5c18491"
@@ -2964,6 +3622,13 @@
   dependencies:
     has "^1.0.3"
 
+is-core-module@^2.9.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
+  integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==
+  dependencies:
+    has "^1.0.3"
+
 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"
@@ -2974,11 +3639,6 @@
   resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
   integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
 
-is-fullwidth-code-point@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
-  integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
-
 is-fullwidth-code-point@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
@@ -3021,7 +3681,12 @@
 is-typedarray@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
-  integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
+  integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==
+
+is-unicode-supported@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7"
+  integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==
 
 is-wsl@^2.1.1, is-wsl@^2.2.0:
   version "2.2.0"
@@ -3035,7 +3700,12 @@
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
   integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
 
-isbinaryfile@^4.0.2, isbinaryfile@^4.0.6, isbinaryfile@^4.0.8:
+isbinaryfile@^4.0.2, isbinaryfile@^4.0.8:
+  version "4.0.10"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3"
+  integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==
+
+isbinaryfile@^4.0.6:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.8.tgz#5d34b94865bd4946633ecc78a026fc76c5b11fcf"
   integrity sha512-53h6XFniq77YdW+spoRrebh0mnmTxRPTlcuIArO57lmMdq4uBKFKaeTjnb92oYWrSn/LVL+LT+Hap2tFQj8V+w==
@@ -3043,12 +3713,12 @@
 isexe@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
-  integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
+  integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
 
 isstream@~0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
-  integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
+  integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==
 
 istanbul-lib-coverage@^2.0.5:
   version "2.0.5"
@@ -3095,17 +3765,17 @@
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
   integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
 
-js-yaml@4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f"
-  integrity sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==
+js-yaml@4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
+  integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
   dependencies:
     argparse "^2.0.1"
 
 jsbn@~0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
-  integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
+  integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==
 
 jsesc@^2.5.1:
   version "2.5.2"
@@ -3115,7 +3785,7 @@
 jsesc@~0.5.0:
   version "0.5.0"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
-  integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
+  integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==
 
 json-parse-better-errors@^1.0.1:
   version "1.0.2"
@@ -3127,38 +3797,36 @@
   resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
   integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
 
-json-schema@0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
-  integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
+json-schema@0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
+  integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
 
 json-stringify-safe@~5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
-  integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
+  integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==
 
-json5@^2.1.2:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"
-  integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==
-  dependencies:
-    minimist "^1.2.5"
+json5@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c"
+  integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==
 
 jsonfile@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
-  integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=
+  integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==
   optionalDependencies:
     graceful-fs "^4.1.6"
 
 jsprim@^1.2.2:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
-  integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb"
+  integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==
   dependencies:
     assert-plus "1.0.0"
     extsprintf "1.3.0"
-    json-schema "0.2.3"
+    json-schema "0.4.0"
     verror "1.10.0"
 
 just-extend@^4.0.2:
@@ -3166,17 +3834,17 @@
   resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744"
   integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==
 
-karma-chrome-launcher@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.1.0.tgz#805a586799a4d05f4e54f72a204979f3f3066738"
-  integrity sha512-3dPs/n7vgz1rxxtynpzZTvb9y/GIaW8xjAwcIGttLbycqoFtI7yo1NGnQi6oFTherRE+GIhCAHZC4vEqWGhNvg==
+karma-chrome-launcher@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.1.1.tgz#baca9cc071b1562a1db241827257bfe5cab597ea"
+  integrity sha512-hsIglcq1vtboGPAN+DGCISCFOxW+ZVnIqhDQcCMqqCp+4dmJ0Qpq5QAjkbA0X2L9Mi6OBkHi2Srrbmm7pUKkzQ==
   dependencies:
     which "^1.2.1"
 
 karma-mocha-reporter@^2.2.5:
   version "2.2.5"
   resolved "https://registry.yarnpkg.com/karma-mocha-reporter/-/karma-mocha-reporter-2.2.5.tgz#15120095e8ed819186e47a0b012f3cd741895560"
-  integrity sha1-FRIAlejtgZGG5HoLAS8810GJVWA=
+  integrity sha512-Hr6nhkIp0GIJJrvzY8JFeHpQZNseuIakGac4bpw8K1+5F0tLb6l7uvXRa8mt2Z+NVwYgCct4QAfp2R2QP6o00w==
   dependencies:
     chalk "^2.1.0"
     log-symbols "^2.1.0"
@@ -3189,15 +3857,15 @@
   dependencies:
     minimist "^1.2.3"
 
-karma@^6.3.6:
-  version "6.3.6"
-  resolved "https://registry.yarnpkg.com/karma/-/karma-6.3.6.tgz#6f64cdd558c7d0c9da6fcdece156089582694611"
-  integrity sha512-xsiu3D6AjCv6Uq0YKXJgC6TvXX2WloQ5+XtHXmC1lwiLVG617DDV3W2DdM4BxCMKHlmz6l3qESZHFQGHAKvrew==
+karma@^6.3.20:
+  version "6.4.0"
+  resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.0.tgz#82652dfecdd853ec227b74ed718a997028a99508"
+  integrity sha512-s8m7z0IF5g/bS5ONT7wsOavhW4i4aFkzD4u4wgzAQWT4HGUeWI3i21cK2Yz6jndMAeHETp5XuNsRoyGJZXVd4w==
   dependencies:
+    "@colors/colors" "1.5.0"
     body-parser "^1.19.0"
     braces "^3.0.2"
     chokidar "^3.5.1"
-    colors "^1.4.0"
     connect "^3.7.0"
     di "^0.0.1"
     dom-serialize "^2.2.1"
@@ -3206,13 +3874,14 @@
     http-proxy "^1.18.1"
     isbinaryfile "^4.0.8"
     lodash "^4.17.21"
-    log4js "^6.3.0"
+    log4js "^6.4.1"
     mime "^2.5.2"
     minimatch "^3.0.4"
+    mkdirp "^0.5.5"
     qjobs "^1.2.0"
     range-parser "^1.2.1"
     rimraf "^3.0.2"
-    socket.io "^4.2.0"
+    socket.io "^4.4.1"
     source-map "^0.6.1"
     tmp "^0.2.1"
     ua-parser-js "^0.7.30"
@@ -3225,13 +3894,6 @@
   dependencies:
     tsscmp "1.0.6"
 
-koa-compose@^3.0.0:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-3.2.1.tgz#a85ccb40b7d986d8e5a345b3a1ace8eabcf54de7"
-  integrity sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=
-  dependencies:
-    any-promise "^1.1.0"
-
 koa-compose@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877"
@@ -3247,14 +3909,6 @@
     koa-is-json "^1.0.0"
     statuses "^1.0.0"
 
-koa-convert@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-1.2.0.tgz#da40875df49de0539098d1700b50820cebcd21d0"
-  integrity sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=
-  dependencies:
-    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"
@@ -3266,7 +3920,7 @@
 koa-etag@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/koa-etag/-/koa-etag-3.0.0.tgz#9ef7382ddd5a82ab0deb153415c915836f771d3f"
-  integrity sha1-nvc4Ld1agqsN6xU0FckVg293HT8=
+  integrity sha512-HYU1zIsH4S9xOlUZGuZIP1PIiJ0EkBXgwL8PjFECb/pUYmAee8gfcvIovregBMYxECDhLulEWT2+ZRsA/lczCQ==
   dependencies:
     etag "^1.3.0"
     mz "^2.1.0"
@@ -3281,7 +3935,7 @@
 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=
+  integrity sha512-+97CtHAlWDx0ndt0J8y3P12EWLwTLMXIfMnYDev3wOTwH/RpBGMlfn4bDXlMEg1u73K6XRE9BbUp+5ZAYoRYWw==
 
 koa-send@^5.0.0, koa-send@^5.0.1:
   version "5.0.1"
@@ -3300,7 +3954,7 @@
     debug "^3.1.0"
     koa-send "^5.0.0"
 
-koa@^2.13.0:
+koa@^2.13.0, koa@^2.7.0:
   version "2.13.4"
   resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.4.tgz#ee5b0cb39e0b8069c38d115139c774833d32462e"
   integrity sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==
@@ -3329,34 +3983,13 @@
     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"
-  integrity sha512-Lb2Dloc72auj5vK4X4qqL7B5jyDPQaZucc9sR/71byg7ryoD1NCaCm63CShk9ID9quQvDEi1bGR/iGjCG7As3w==
+lighthouse-logger@^1.0.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.3.0.tgz#ba6303e739307c4eee18f08249524e7dafd510db"
+  integrity sha512-BbqAKApLb9ywUli+0a+PcV04SyJ/N1q/8qgCNe6U97KbPCS1BTksEuHFLYdvc8DltuhfxIUBqDZsC0bBGtl3lA==
   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 "~3.1.0"
-    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 "^1.2.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"
+    debug "^2.6.9"
+    marky "^1.2.2"
 
 lit-element@^3.0.0:
   version "3.0.2"
@@ -3385,7 +4018,7 @@
 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"
-  integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs=
+  integrity sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==
   dependencies:
     graceful-fs "^4.1.2"
     parse-json "^4.0.0"
@@ -3400,6 +4033,13 @@
     p-locate "^3.0.0"
     path-exists "^3.0.0"
 
+locate-path@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
+  integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
+  dependencies:
+    p-locate "^4.1.0"
+
 locate-path@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
@@ -3415,7 +4055,7 @@
 lodash.debounce@^4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
-  integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
+  integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
 
 lodash.get@^4.4.2:
   version "4.4.2"
@@ -3425,29 +4065,30 @@
 lodash.memoize@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
-  integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
+  integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==
 
 lodash.sortby@^4.7.0:
   version "4.7.0"
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
-  integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
+  integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
 
 lodash.uniq@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
-  integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
+  integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
 
 lodash@^4.17.14, lodash@^4.17.21:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
 
-log-symbols@4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920"
-  integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==
+log-symbols@4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503"
+  integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==
   dependencies:
-    chalk "^4.0.0"
+    chalk "^4.1.0"
+    is-unicode-supported "^0.1.0"
 
 log-symbols@^2.1.0:
   version "2.2.0"
@@ -3466,16 +4107,16 @@
     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"
-  integrity sha512-Mc8jNuSFImQUIateBFwdOQcmC6Q5maU0VVvdC2R6XMb66/VnT+7WS4D/0EeNMZu1YODmJe5NIn2XftCzEocUgw==
+log4js@^6.4.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.6.1.tgz#48f23de8a87d2f5ffd3d913f24ca9ce77895272f"
+  integrity sha512-J8VYFH2UQq/xucdNu71io4Fo+purYYudyErgBbswWKO0MC6QVOERRomt5su/z6d3RJSmLyTGmXl3Q/XjKCf+/A==
   dependencies:
-    date-format "^3.0.0"
-    debug "^4.1.1"
-    flatted "^2.0.1"
-    rfdc "^1.1.4"
-    streamroller "^2.2.4"
+    date-format "^4.0.13"
+    debug "^4.3.4"
+    flatted "^3.2.6"
+    rfdc "^1.3.0"
+    streamroller "^3.1.2"
 
 lower-case@^2.0.2:
   version "2.0.2"
@@ -3513,6 +4154,11 @@
   dependencies:
     semver "^6.0.0"
 
+marky@^1.2.2:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0"
+  integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==
+
 media-typer@0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -3531,12 +4177,24 @@
     braces "^3.0.1"
     picomatch "^2.2.3"
 
-mime-db@1.49.0, "mime-db@>= 1.43.0 < 2":
+mime-db@1.49.0:
   version "1.49.0"
   resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.49.0.tgz#f3dfde60c99e9cf3bc9701d687778f537001cbed"
   integrity sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==
 
-mime-types@^2.1.12, mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.19, mime-types@~2.1.24:
+mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
+  version "1.52.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.34:
+  version "2.1.35"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+  dependencies:
+    mime-db "1.52.0"
+
+mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.24:
   version "2.1.32"
   resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5"
   integrity sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A==
@@ -3544,66 +4202,84 @@
     mime-db "1.49.0"
 
 mime@^2.5.2:
-  version "2.5.2"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe"
-  integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
+  integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
 
 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:
+minimatch@4.2.1:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-4.2.1.tgz#40d9d511a46bdc4e563c22c3080cde9c0d8299b4"
+  integrity sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+minimatch@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
   dependencies:
     brace-expansion "^1.1.7"
 
-minimist@^1.2.3, minimist@^1.2.5:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
-  integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
-
-mkdirp@^0.5.5:
-  version "0.5.5"
-  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
-  integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
+minimatch@^3.1.1:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+  integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
   dependencies:
-    minimist "^1.2.5"
+    brace-expansion "^1.1.7"
+
+minimist@^1.2.3, minimist@^1.2.6:
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
+  integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
+
+mkdirp-classic@^0.5.2:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
+  integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
+
+mkdirp@^0.5.5, mkdirp@^0.5.6:
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+  integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
+  dependencies:
+    minimist "^1.2.6"
 
 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"
-  integrity sha512-UdmISwr/5w+uXLPKspgoV7/RXZwKRTiTjJ2/AC5ZiEztIoOYdfKb19+9jNmEInzx5pBsCyJQzarAxqIGBNYJhg==
+mocha@9.2.2:
+  version "9.2.2"
+  resolved "https://registry.yarnpkg.com/mocha/-/mocha-9.2.2.tgz#d70db46bdb93ca57402c809333e5a84977a88fb9"
+  integrity sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g==
   dependencies:
     "@ungap/promise-all-settled" "1.1.2"
     ansi-colors "4.1.1"
     browser-stdout "1.3.1"
-    chokidar "3.5.1"
-    debug "4.3.1"
+    chokidar "3.5.3"
+    debug "4.3.3"
     diff "5.0.0"
     escape-string-regexp "4.0.0"
     find-up "5.0.0"
-    glob "7.1.6"
+    glob "7.2.0"
     growl "1.10.5"
     he "1.2.0"
-    js-yaml "4.0.0"
-    log-symbols "4.0.0"
-    minimatch "3.0.4"
+    js-yaml "4.1.0"
+    log-symbols "4.1.0"
+    minimatch "4.2.1"
     ms "2.1.3"
-    nanoid "3.1.20"
-    serialize-javascript "5.0.1"
+    nanoid "3.3.1"
+    serialize-javascript "6.0.0"
     strip-json-comments "3.1.1"
     supports-color "8.1.1"
     which "2.0.2"
-    wide-align "1.1.3"
-    workerpool "6.1.0"
+    workerpool "6.2.0"
     yargs "16.2.0"
     yargs-parser "20.2.4"
     yargs-unparser "2.0.0"
@@ -3637,10 +4313,10 @@
   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.3.1:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35"
+  integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==
 
 nanoid@^3.1.25:
   version "3.1.30"
@@ -3652,13 +4328,18 @@
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
   integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
 
-nise@^5.0.1:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.0.tgz#713ef3ed138252daef20ec035ab62b7a28be645c"
-  integrity sha512-W5WlHu+wvo3PaKLsJJkgPup2LrsXCcm7AWwyNZkUnn5rwPkuPBi3Iwk5SQtN0mv+K65k7nKKjwNQ30wg3wLAQQ==
+negotiator@0.6.3:
+  version "0.6.3"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
+  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
+
+nise@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.1.tgz#ac4237e0d785ecfcb83e20f389185975da5c31f3"
+  integrity sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==
   dependencies:
-    "@sinonjs/commons" "^1.7.0"
-    "@sinonjs/fake-timers" "^7.0.4"
+    "@sinonjs/commons" "^1.8.3"
+    "@sinonjs/fake-timers" ">=5"
     "@sinonjs/text-encoding" "^0.7.1"
     just-extend "^4.0.2"
     path-to-regexp "^1.7.0"
@@ -3671,15 +4352,17 @@
     lower-case "^2.0.2"
     tslib "^2.0.3"
 
-node-fetch@^2.6.0:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
-  integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
+node-fetch@2.6.7, node-fetch@^2.6.0:
+  version "2.6.7"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
+  integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
+  dependencies:
+    whatwg-url "^5.0.0"
 
-node-releases@^1.1.73:
-  version "1.1.74"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.74.tgz#e5866488080ebaa70a93b91144ccde06f3c3463e"
-  integrity sha512-caJBVempXZPepZoZAPCWRTNxYQ+xtG/KAi4ozTA5A+nJ7IU+kLQCbqaUjb5Rwy14M9upBWiQ4NutcmW04LJSRw==
+node-releases@^2.0.6:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503"
+  integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==
 
 normalize-package-data@^2.3.2:
   version "2.5.0"
@@ -3704,28 +4387,35 @@
 object-assign@^4, object-assign@^4.0.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
-  integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
+  integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
 
 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:
+object-keys@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
   integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
 
 object.assign@^4.1.0:
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940"
-  integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==
+  version "4.1.4"
+  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f"
+  integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==
   dependencies:
-    call-bind "^1.0.0"
-    define-properties "^1.1.3"
-    has-symbols "^1.0.1"
+    call-bind "^1.0.2"
+    define-properties "^1.1.4"
+    has-symbols "^1.0.3"
     object-keys "^1.1.1"
 
+on-finished@2.4.1:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
+  integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
+  dependencies:
+    ee-first "1.1.1"
+
 on-finished@^2.3.0, on-finished@~2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -3772,9 +4462,9 @@
 os-tmpdir@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
-  integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
+  integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
 
-p-limit@^2.0.0:
+p-limit@^2.0.0, p-limit@^2.2.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
   integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
@@ -3795,6 +4485,13 @@
   dependencies:
     p-limit "^2.0.0"
 
+p-locate@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
+  integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
+  dependencies:
+    p-limit "^2.2.0"
+
 p-locate@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
@@ -3818,7 +4515,7 @@
 parse-json@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
-  integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=
+  integrity sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==
   dependencies:
     error-ex "^1.3.1"
     json-parse-better-errors "^1.0.1"
@@ -3849,7 +4546,7 @@
 path-exists@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
-  integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
+  integrity sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==
 
 path-exists@^4.0.0:
   version "4.0.0"
@@ -3864,9 +4561,9 @@
 path-is-inside@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
-  integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=
+  integrity sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==
 
-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==
@@ -3890,15 +4587,20 @@
   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"
-  integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==
+pend@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+  integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
 
 performance-now@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
-  integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
+  integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==
+
+picocolors@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
+  integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
 
 picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3:
   version "2.3.0"
@@ -3908,7 +4610,26 @@
 pify@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
-  integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
+  integrity sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==
+
+pixelmatch@^5.2.1:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-5.3.0.tgz#5e5321a7abedfb7962d60dbf345deda87cb9560a"
+  integrity sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==
+  dependencies:
+    pngjs "^6.0.0"
+
+pkg-dir@4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
+  integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==
+  dependencies:
+    find-up "^4.0.0"
+
+pngjs@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821"
+  integrity sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==
 
 polyfills-loader@^1.7.4:
   version "1.7.6"
@@ -3931,24 +4652,34 @@
     terser "^4.6.7"
     whatwg-fetch "^3.0.0"
 
-portfinder@^1.0.21:
-  version "1.0.28"
-  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778"
-  integrity sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA==
+portfinder@^1.0.21, portfinder@^1.0.28:
+  version "1.0.32"
+  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.32.tgz#2fe1b9e58389712429dc2bea5beb2146146c7f81"
+  integrity sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==
   dependencies:
-    async "^2.6.2"
-    debug "^3.1.1"
-    mkdirp "^0.5.5"
+    async "^2.6.4"
+    debug "^3.2.7"
+    mkdirp "^0.5.6"
+
+progress@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
+  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+
+proxy-from-env@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
+  integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
 
 pseudomap@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
-  integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
+  integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==
 
 psl@^1.1.28:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
-  integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
+  integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==
 
 pump@^3.0.0:
   version "3.0.0"
@@ -3963,15 +4694,35 @@
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
+puppeteer-core@^13.1.3:
+  version "13.7.0"
+  resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-13.7.0.tgz#3344bee3994163f49120a55ddcd144a40575ba5b"
+  integrity sha512-rXja4vcnAzFAP1OVLq/5dWNfwBGuzcOARJ6qGV7oAZhnLmVRU8G5MsdeQEAOy332ZhkIOnn9jp15R89LKHyp2Q==
+  dependencies:
+    cross-fetch "3.1.5"
+    debug "4.3.4"
+    devtools-protocol "0.0.981744"
+    extract-zip "2.0.1"
+    https-proxy-agent "5.0.1"
+    pkg-dir "4.2.0"
+    progress "2.0.3"
+    proxy-from-env "1.1.0"
+    rimraf "3.0.2"
+    tar-fs "2.1.1"
+    unbzip2-stream "1.4.3"
+    ws "8.5.0"
+
 qjobs@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071"
   integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==
 
-qs@6.7.0:
-  version "6.7.0"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
-  integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
+qs@6.10.3:
+  version "6.10.3"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e"
+  integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==
+  dependencies:
+    side-channel "^1.0.4"
 
 qs@^6.5.2:
   version "6.10.1"
@@ -3981,9 +4732,9 @@
     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==
+  version "6.5.3"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad"
+  integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==
 
 queue-microtask@^1.2.2:
   version "1.2.3"
@@ -4002,13 +4753,13 @@
   resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
   integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
 
-raw-body@2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
-  integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
+raw-body@2.5.1:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
+  integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
   dependencies:
-    bytes "3.1.0"
-    http-errors "1.7.2"
+    bytes "3.1.2"
+    http-errors "2.0.0"
     iconv-lite "0.4.24"
     unpipe "1.0.0"
 
@@ -4033,18 +4784,20 @@
 read-pkg@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389"
-  integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=
+  integrity sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==
   dependencies:
     load-json-file "^4.0.0"
     normalize-package-data "^2.3.2"
     path-type "^3.0.0"
 
-readdirp@~3.5.0:
-  version "3.5.0"
-  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e"
-  integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==
+readable-stream@^3.1.1, readable-stream@^3.4.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
+  integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
   dependencies:
-    picomatch "^2.2.1"
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
 
 readdirp@~3.6.0:
   version "3.6.0"
@@ -4058,14 +4811,14 @@
   resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
   integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==
 
-regenerate-unicode-properties@^8.2.0:
-  version "8.2.0"
-  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
-  integrity sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==
+regenerate-unicode-properties@^10.0.1:
+  version "10.0.1"
+  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz#7f442732aa7934a3740c779bb9b3340dccc1fb56"
+  integrity sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw==
   dependencies:
-    regenerate "^1.4.0"
+    regenerate "^1.4.2"
 
-regenerate@^1.4.0:
+regenerate@^1.4.2:
   version "1.4.2"
   resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a"
   integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==
@@ -4075,41 +4828,41 @@
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
   integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
 
-regenerator-transform@^0.14.2:
-  version "0.14.5"
-  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4"
-  integrity sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==
+regenerator-transform@^0.15.0:
+  version "0.15.0"
+  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537"
+  integrity sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==
   dependencies:
     "@babel/runtime" "^7.8.4"
 
-regexpu-core@^4.7.1:
-  version "4.7.1"
-  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6"
-  integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ==
+regexpu-core@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.1.0.tgz#2f8504c3fd0ebe11215783a41541e21c79942c6d"
+  integrity sha512-bb6hk+xWd2PEOkj5It46A16zFMs2mv86Iwpdu94la4S3sJ7C973h2dHpYKwIBGaWSO7cIRJ+UX0IeMaWcO4qwA==
   dependencies:
-    regenerate "^1.4.0"
-    regenerate-unicode-properties "^8.2.0"
-    regjsgen "^0.5.1"
-    regjsparser "^0.6.4"
-    unicode-match-property-ecmascript "^1.0.4"
-    unicode-match-property-value-ecmascript "^1.2.0"
+    regenerate "^1.4.2"
+    regenerate-unicode-properties "^10.0.1"
+    regjsgen "^0.6.0"
+    regjsparser "^0.8.2"
+    unicode-match-property-ecmascript "^2.0.0"
+    unicode-match-property-value-ecmascript "^2.0.0"
 
-regjsgen@^0.5.1:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733"
-  integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==
+regjsgen@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.6.0.tgz#83414c5354afd7d6627b16af5f10f41c4e71808d"
+  integrity sha512-ozE883Uigtqj3bx7OhL1KNbCzGyW2NQZPl6Hs09WTvCuZD5sTI4JY58bkbQWa/Y9hxIsvJ3M8Nbf7j54IqeZbA==
 
-regjsparser@^0.6.4:
-  version "0.6.9"
-  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.9.tgz#b489eef7c9a2ce43727627011429cf833a7183e6"
-  integrity sha512-ZqbNRz1SNjLAiYuwY0zoXW8Ne675IX5q+YHioAGbCw4X96Mjl2+dcX9B2ciaeyYjViDAfvIjFpQjJgLttTEERQ==
+regjsparser@^0.8.2:
+  version "0.8.4"
+  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.8.4.tgz#8a14285ffcc5de78c5b95d62bbf413b6bc132d5f"
+  integrity sha512-J3LABycON/VNEu3abOviqGHuB/LOtOQj8SKmfP9anY5GfAVw/SPjwzSjxGjbZXIxbGfqTHtJw58C2Li/WkStmA==
   dependencies:
     jsesc "~0.5.0"
 
 relateurl@^0.2.7:
   version "0.2.7"
   resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
-  integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
+  integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==
 
 request@^2.88.0:
   version "2.88.2"
@@ -4140,7 +4893,7 @@
 require-directory@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
-  integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I=
+  integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
 
 require-main-filename@^2.0.0:
   version "2.0.0"
@@ -4150,7 +4903,7 @@
 requires-port@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
-  integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
+  integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==
 
 resize-observer-polyfill@^1.5.1:
   version "1.5.1"
@@ -4165,7 +4918,16 @@
     http-errors "~1.6.2"
     path-is-absolute "1.0.1"
 
-resolve@^1.10.0, resolve@^1.14.2, resolve@^1.19.0:
+resolve@^1.10.0, resolve@^1.14.2:
+  version "1.22.1"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
+  integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
+  dependencies:
+    is-core-module "^2.9.0"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
+resolve@^1.19.0:
   version "1.20.0"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
   integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
@@ -4186,22 +4948,22 @@
   resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
   integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
 
-rfdc@^1.1.4:
+rfdc@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b"
   integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==
 
-rimraf@^3.0.0, rimraf@^3.0.2:
+rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
   integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
   dependencies:
     glob "^7.1.3"
 
-rollup@^2.7.2:
-  version "2.56.2"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.56.2.tgz#a045ff3f6af53ee009b5f5016ca3da0329e5470f"
-  integrity sha512-s8H00ZsRi29M2/lGdm1u8DJpJ9ML8SUOpVVBd33XNeEeL3NVaTiUcSBHzBdF3eAyR0l7VSpsuoVUGrRHq7aPwQ==
+rollup@^2.67.0, rollup@^2.7.2:
+  version "2.79.0"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.0.tgz#9177992c9f09eb58c5e56cbfa641607a12b57ce2"
+  integrity sha512-x4KsrCgwQ7ZJPcFA/SUu6QVcYlO7uRLfLAy0DSA4NS2eG8japdbpM50ToH7z4iObodRYOJ0soneF0iaQRJ6zhA==
   optionalDependencies:
     fsevents "~2.3.2"
 
@@ -4217,7 +4979,7 @@
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
 
-safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2:
+safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -4232,27 +4994,22 @@
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
   integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
 
-semver@7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
-  integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
-
 semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
   version "6.3.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
-semver@^7.3.2:
-  version "7.3.5"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
-  integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
+semver@^7.3.4, semver@^7.3.5:
+  version "7.3.7"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
+  integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
   dependencies:
     lru-cache "^6.0.0"
 
-serialize-javascript@5.0.1:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-5.0.1.tgz#7886ec848049a462467a97d3d918ebb2aaf934f4"
-  integrity sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==
+serialize-javascript@6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8"
+  integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==
   dependencies:
     randombytes "^2.1.0"
 
@@ -4290,17 +5047,17 @@
   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"
-  integrity sha512-1rf86mvW4Mt7JitEIgmNaLXaWnrWd/UrVKZZlL+kbeOujXVf9fmC4kQEQ/YeHoiIA23PLNngYWK+dngIx/AumA==
+sinon@^13.0.0:
+  version "13.0.2"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-13.0.2.tgz#c6a8ddd655dc1415bbdc5ebf0e5b287806850c3a"
+  integrity sha512-KvOrztAVqzSJWMDoxM4vM+GPys1df2VBoXm+YciyB/OLMamfS3VXh3oGh5WtrAGSzrgczNWFFY22oKb7Fi5eeA==
   dependencies:
-    "@sinonjs/commons" "^1.8.1"
-    "@sinonjs/fake-timers" "^7.0.4"
-    "@sinonjs/samsam" "^6.0.1"
-    diff "^4.0.2"
-    nise "^5.0.1"
-    supports-color "^7.1.0"
+    "@sinonjs/commons" "^1.8.3"
+    "@sinonjs/fake-timers" "^9.1.2"
+    "@sinonjs/samsam" "^6.1.1"
+    diff "^5.0.0"
+    nise "^5.1.1"
+    supports-color "^7.2.0"
 
 slash@^3.0.0:
   version "3.0.0"
@@ -4316,45 +5073,39 @@
     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-adapter@~2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.4.0.tgz#b50a4a9ecdd00c34d4c8c808224daa1a786152a6"
+  integrity sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg==
 
-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==
+socket.io-parser@~4.2.0:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.1.tgz#01c96efa11ded938dcb21cbe590c26af5eff65e5"
+  integrity sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==
   dependencies:
-    "@types/component-emitter" "^1.2.10"
-    component-emitter "~1.3.0"
+    "@socket.io/component-emitter" "~3.1.0"
     debug "~4.3.1"
 
-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==
+socket.io@^4.4.1:
+  version "4.5.2"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.5.2.tgz#1eb25fd380ab3d63470aa8279f8e48d922d443ac"
+  integrity sha512-6fCnk4ARMPZN448+SQcnn1u8OHUC72puJcNtSgg2xS34Cu7br1gQ09YKkO1PFfDn/wyUE9ZgMAwosJed003+NQ==
   dependencies:
     accepts "~1.3.4"
     base64id "~2.0.0"
     debug "~4.3.2"
-    engine.io "~6.0.0"
-    socket.io-adapter "~2.3.2"
-    socket.io-parser "~4.0.4"
+    engine.io "~6.2.0"
+    socket.io-adapter "~2.4.0"
+    socket.io-parser "~4.2.0"
 
 source-map-support@^0.5.19, source-map-support@~0.5.12:
-  version "0.5.19"
-  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
-  integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
+  version "0.5.21"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
+  integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
   dependencies:
     buffer-from "^1.0.0"
     source-map "^0.6.0"
 
-source-map@^0.5.0:
-  version "0.5.7"
-  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
-  integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
-
 source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
@@ -4387,14 +5138,14 @@
     spdx-license-ids "^3.0.0"
 
 spdx-license-ids@^3.0.0:
-  version "3.0.10"
-  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz#0d9becccde7003d6c658d487dd48a32f0bf3014b"
-  integrity sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==
+  version "3.0.12"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz#69077835abe2710b65f03969898b6637b505a779"
+  integrity sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==
 
 sshpk@^1.7.0:
-  version "1.16.1"
-  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
-  integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
+  version "1.17.0"
+  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5"
+  integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==
   dependencies:
     asn1 "~0.2.3"
     assert-plus "^1.0.0"
@@ -4406,29 +5157,26 @@
     safer-buffer "^2.0.2"
     tweetnacl "~0.14.0"
 
+statuses@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
+  integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+
 "statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.0.0, statuses@^1.5.0, statuses@~1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
   integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
 
-streamroller@^2.2.4:
-  version "2.2.4"
-  resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-2.2.4.tgz#c198ced42db94086a6193608187ce80a5f2b0e53"
-  integrity sha512-OG79qm3AujAM9ImoqgWEY1xG4HX+Lw+yY6qZj9R1K2mhF5bEmQ849wvrb+4vt4jLMLzwXttJlQbOdPOQVRv7DQ==
+streamroller@^3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.2.tgz#abd444560768b340f696307cf84d3f46e86c0e63"
+  integrity sha512-wZswqzbgGGsXYIrBYhOE0yP+nQ6XRk7xDcYwuQAGTYXdyAUmvgVFE0YU1g5pvQT0m7GBaQfYcSnlHbapuK0H0A==
   dependencies:
-    date-format "^2.1.0"
-    debug "^4.1.1"
+    date-format "^4.0.13"
+    debug "^4.3.4"
     fs-extra "^8.1.0"
 
-"string-width@^1.0.2 || 2":
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
-  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
-  dependencies:
-    is-fullwidth-code-point "^2.0.0"
-    strip-ansi "^4.0.0"
-
-string-width@^4.1.0, string-width@^4.2.0:
+string-width@^4.1.0:
   version "4.2.2"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"
   integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==
@@ -4437,10 +5185,26 @@
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
 
+string-width@^4.2.0:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
+string_decoder@^1.1.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
+  integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+  dependencies:
+    safe-buffer "~5.2.0"
+
 strip-ansi@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
-  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
+  integrity sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==
   dependencies:
     ansi-regex "^3.0.0"
 
@@ -4458,10 +5222,17 @@
   dependencies:
     ansi-regex "^5.0.0"
 
+strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
 strip-bom@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
-  integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=
+  integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==
 
 strip-json-comments@3.1.1:
   version "3.1.1"
@@ -4482,19 +5253,24 @@
   dependencies:
     has-flag "^3.0.0"
 
-supports-color@^7.1.0:
+supports-color@^7.1.0, supports-color@^7.2.0:
   version "7.2.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
   integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
   dependencies:
     has-flag "^4.0.0"
 
-systemjs@^6.3.1, systemjs@^6.8.3:
-  version "6.10.2"
-  resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.10.2.tgz#c9870217bddf9cfd25d12d4fcd1989541ef1207c"
-  integrity sha512-PwaC0Z6Y1E6gFekY2u38EC5+5w2M65jYVrD1aAcOptpHVhCwPIwPFJvYJyryQKUyeuQ5bKKI3PBHWNjdE9aizg==
+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==
 
-table-layout@^1.0.1:
+systemjs@^6.3.1, systemjs@^6.8.3:
+  version "6.12.6"
+  resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.12.6.tgz#147a2a9137b8f3fddaafac1d5060adf3d11212a6"
+  integrity sha512-SawLiWya8/uNR4p12OggSYZ35tP4U4QTpfV57DdZEOPr6+J6zlLSeeEpMmzYTEoBAsMhctdEE+SWJUDYX4EaKw==
+
+table-layout@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.2.tgz#c4038a1853b0136d63365a734b6931cf4fad4a04"
   integrity sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==
@@ -4504,10 +5280,31 @@
     typical "^5.2.0"
     wordwrapjs "^4.0.0"
 
+tar-fs@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784"
+  integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==
+  dependencies:
+    chownr "^1.1.1"
+    mkdirp-classic "^0.5.2"
+    pump "^3.0.0"
+    tar-stream "^2.1.4"
+
+tar-stream@^2.1.4:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
+  integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
+  dependencies:
+    bl "^4.0.3"
+    end-of-stream "^1.4.1"
+    fs-constants "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^3.1.1"
+
 terser@^4.6.3, terser@^4.6.7:
-  version "4.8.0"
-  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
-  integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
+  version "4.8.1"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.1.tgz#a00e5634562de2239fd404c649051bf6fc21144f"
+  integrity sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==
   dependencies:
     commander "^2.20.0"
     source-map "~0.6.1"
@@ -4526,7 +5323,7 @@
 thenify-all@^1.0.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
-  integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=
+  integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==
   dependencies:
     thenify ">= 3.1.0 < 4"
 
@@ -4537,6 +5334,11 @@
   dependencies:
     any-promise "^1.0.0"
 
+through@^2.3.8:
+  version "2.3.8"
+  resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+  integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
+
 tmp@0.0.x:
   version "0.0.33"
   resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
@@ -4554,7 +5356,7 @@
 to-fast-properties@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
-  integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
+  integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==
 
 to-regex-range@^5.0.1:
   version "5.0.1"
@@ -4568,6 +5370,11 @@
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
   integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
 
+toidentifier@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
 tough-cookie@~2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
@@ -4579,19 +5386,31 @@
 tr46@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
-  integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=
+  integrity sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==
   dependencies:
     punycode "^2.1.0"
 
+tr46@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9"
+  integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==
+  dependencies:
+    punycode "^2.1.1"
+
+tr46@~0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
+  integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
+
 tslib@^1.11.1:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
 tslib@^2.0.3:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
-  integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
+  integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
 
 tsscmp@1.0.6:
   version "1.0.6"
@@ -4601,16 +5420,16 @@
 tunnel-agent@^0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
-  integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
+  integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==
   dependencies:
     safe-buffer "^5.0.1"
 
 tweetnacl@^0.14.3, tweetnacl@~0.14.0:
   version "0.14.5"
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
-  integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
+  integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==
 
-type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8:
+type-detect@4.0.8, type-detect@^4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
   integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
@@ -4620,7 +5439,7 @@
   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:
+type-is@^1.6.16, type-is@~1.6.18:
   version "1.6.18"
   resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
   integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
@@ -4639,32 +5458,45 @@
   integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
 
 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==
+  version "0.7.31"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.31.tgz#649a656b191dffab4f21d5e053e27ca17cbff5c6"
+  integrity sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==
 
-unicode-canonical-property-names-ecmascript@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
-  integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==
+ua-parser-js@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.2.tgz#e2976c34dbfb30b15d2c300b2a53eac87c57a775"
+  integrity sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg==
 
-unicode-match-property-ecmascript@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c"
-  integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==
+unbzip2-stream@1.4.3:
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7"
+  integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==
   dependencies:
-    unicode-canonical-property-names-ecmascript "^1.0.4"
-    unicode-property-aliases-ecmascript "^1.0.4"
+    buffer "^5.2.1"
+    through "^2.3.8"
 
-unicode-match-property-value-ecmascript@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz#0d91f600eeeb3096aa962b1d6fc88876e64ea531"
-  integrity sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==
+unicode-canonical-property-names-ecmascript@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc"
+  integrity sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==
 
-unicode-property-aliases-ecmascript@^1.0.4:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4"
-  integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==
+unicode-match-property-ecmascript@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz#54fd16e0ecb167cf04cf1f756bdcc92eba7976c3"
+  integrity sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==
+  dependencies:
+    unicode-canonical-property-names-ecmascript "^2.0.0"
+    unicode-property-aliases-ecmascript "^2.0.0"
+
+unicode-match-property-value-ecmascript@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz#1a01aa57247c14c568b89775a54938788189a714"
+  integrity sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==
+
+unicode-property-aliases-ecmascript@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz#0a36cb9a585c4f6abd51ad1deddb285c165297c8"
+  integrity sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ==
 
 universalify@^0.1.0:
   version "0.1.2"
@@ -4676,6 +5508,14 @@
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
   integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
 
+update-browserslist-db@^1.0.5:
+  version "1.0.9"
+  resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz#2924d3927367a38d5c555413a7ce138fc95fcb18"
+  integrity sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg==
+  dependencies:
+    escalade "^3.1.1"
+    picocolors "^1.0.0"
+
 uri-js@^4.2.2:
   version "4.4.1"
   resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
@@ -4691,20 +5531,34 @@
     lru-cache "4.1.x"
     tmp "0.0.x"
 
+util-deprecate@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+
 utils-merge@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
-  integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
+  integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
 
 uuid@^3.3.2:
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
 
+v8-to-istanbul@^8.0.0:
+  version "8.1.1"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz#77b752fd3975e31bbcef938f85e9bd1c7a8d60ed"
+  integrity sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==
+  dependencies:
+    "@types/istanbul-lib-coverage" "^2.0.1"
+    convert-source-map "^1.6.0"
+    source-map "^0.7.3"
+
 valid-url@^1.0.9:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200"
-  integrity sha1-HBRHm0DxOXp1eC8RXkCGRHQzogA=
+  integrity sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==
 
 validate-npm-package-license@^3.0.1:
   version "3.0.4"
@@ -4722,7 +5576,7 @@
 verror@1.10.0:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
-  integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
+  integrity sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==
   dependencies:
     assert-plus "^1.0.0"
     core-util-is "1.0.2"
@@ -4731,18 +5585,44 @@
 void-elements@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
-  integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=
+  integrity sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==
+
+webidl-conversions@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
+  integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
 
 webidl-conversions@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
   integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
 
+webidl-conversions@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
+  integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
+
 whatwg-fetch@^3.0.0, whatwg-fetch@^3.5.0:
   version "3.6.2"
   resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c"
   integrity sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==
 
+whatwg-url@^11.0.0:
+  version "11.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018"
+  integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==
+  dependencies:
+    tr46 "^3.0.0"
+    webidl-conversions "^7.0.0"
+
+whatwg-url@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
+  integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
+  dependencies:
+    tr46 "~0.0.3"
+    webidl-conversions "^3.0.0"
+
 whatwg-url@^7.0.0, whatwg-url@^7.1.0:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06"
@@ -4766,13 +5646,6 @@
   dependencies:
     isexe "^2.0.0"
 
-wide-align@1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
-  integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
-  dependencies:
-    string-width "^1.0.2 || 2"
-
 wordwrapjs@^4.0.0:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.1.tgz#d9790bccfb110a0fc7836b5ebce0937b37a8b98f"
@@ -4781,10 +5654,10 @@
     reduce-flatten "^2.0.0"
     typical "^5.2.0"
 
-workerpool@6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.1.0.tgz#a8e038b4c94569596852de7a8ea4228eefdeb37b"
-  integrity sha512-toV7q9rWNYha963Pl/qyeZ6wG+3nnsyvolaNUS8+R5Wtw6qJPTxIlOP1ZSvcGhEJw+l3HMMmtiNo9Gl61G4GVg==
+workerpool@6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.0.tgz#827d93c9ba23ee2019c3ffaff5c27fccea289e8b"
+  integrity sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==
 
 wrap-ansi@^6.2.0:
   version "6.2.0"
@@ -4809,6 +5682,11 @@
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
+ws@8.5.0:
+  version "8.5.0"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f"
+  integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==
+
 ws@^7.4.2:
   version "7.5.5"
   resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881"
@@ -4827,7 +5705,7 @@
 yallist@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
-  integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
+  integrity sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==
 
 yallist@^3.0.2:
   version "3.1.1"
@@ -4839,6 +5717,11 @@
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
   integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
 
+yamlparser@^0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/yamlparser/-/yamlparser-0.0.2.tgz#32393e6afc70c8ca066b6650ac6738b481678ebc"
+  integrity sha512-Cou9FCGblEENtn1/8La5wkDM/ISMh2bzu5Wh7dYzCzA0o9jD4YGyLkUJxe84oPBGoB92f+Oy4ZjVhA8S0C2wlQ==
+
 yargs-parser@20.2.4:
   version "20.2.4"
   resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54"
@@ -4872,6 +5755,14 @@
     y18n "^5.0.5"
     yargs-parser "^20.2.2"
 
+yauzl@^2.10.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
+  integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==
+  dependencies:
+    buffer-crc32 "~0.2.3"
+    fd-slicer "~1.1.0"
+
 ylru@^1.2.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"
diff --git a/proto/cache.proto b/proto/cache.proto
index 950e63e..83c2ce2 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -452,14 +452,14 @@
 message LabelTypeProto {
   string name = 1;
   string function = 2; // ENUM as String
-  bool copy_any_score = 3;
-  bool copy_min_score = 4;
-  bool copy_max_score = 5;
-  bool copy_all_scores_on_merge_first_parent_update = 6;
-  bool copy_all_scores_on_trivial_rebase = 7;
-  bool copy_all_scores_if_no_code_change = 8;
-  bool copy_all_scores_if_no_change = 9;
-  repeated int32 copy_values = 10;
+  reserved 3; // copy_any_score
+  reserved 4; // copy_min_score
+  reserved 5; // copy_max_score
+  reserved 6; // copy_all_scores_on_merge_first_parent_update
+  reserved 7; // copy_all_scores_on_trivial_rebase
+  reserved 8; // copy_all_scores_if_no_code_change
+  reserved 9; // copy_all_scores_if_no_change
+  reserved 10; // copy_values
   bool allow_post_submit = 11;
   bool ignore_self_approval = 12;
   int32 default_value = 13;
@@ -468,7 +468,7 @@
   int32 max_positive = 16;
   bool can_override = 17;
   repeated string ref_patterns = 18;
-  bool copy_all_scores_if_list_of_files_did_not_change = 19;
+  reserved 19; // copy_all_scores_if_list_of_files_did_not_change
   string copy_condition = 20;
   string description = 21;
 }
@@ -485,7 +485,7 @@
 }
 
 // Serialized form of com.google.gerrit.entities.SubmitRequirementResult.
-// Next ID: 8
+// Next ID: 9
 message SubmitRequirementResultProto {
   SubmitRequirementProto submit_requirement = 1;
   SubmitRequirementExpressionResultProto applicability_expression_result = 2;
@@ -501,6 +501,9 @@
   // Whether the submit requirement was bypassed during submission (i.e. by
   // performing a push with the %submit option).
   bool forced = 7;
+  // Whether this submit requirement result should be filtered out when returned
+  // from REST API.
+  bool hidden = 8;
 }
 
 // Serialized form of com.google.gerrit.entities.SubmitRequirementExpressionResult.
@@ -530,14 +533,17 @@
 }
 
 // Serialized form of com.google.gerrit.entities.StoredCommentLinkInfo.
-// Next ID: 7
+// Next ID: 10
 message StoredCommentLinkInfoProto {
   string name = 1;
   string match = 2;
-  string link = 3;
   string html = 4;
   bool enabled = 5;
   bool override_only = 6;
+  string link = 3;
+  string prefix = 7;
+  string suffix = 8;
+  string text = 9;
 }
 
 // Serialized form of com.google.gerrit.entities.CachedProjectConfigProto.
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 8c97a49..dbfef44 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -27,6 +27,7 @@
   {@param? versionInfo: ?}
   {@param? polyfillCE: ?}
   {@param? useGoogleFonts: ?}
+  {@param? changeNum: ?}
   {@param? changeRequestsPath: ?}
   {@param? defaultChangeDetailHex: ?}
   {@param? defaultDashboardHex: ?}
@@ -97,6 +98,7 @@
     {/if}
     <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}
+    <link rel="preload" href="{$canonicalPath}/changes/?q=change:{$changeNum}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     {if $userIsAuthenticated}
       <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts?enable-context=true&context-padding=3" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     {/if}
@@ -107,6 +109,7 @@
 
   {if $useGoogleFonts}
     <link rel="preload" as="style" href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700|Open+Sans:400,600,700&display=swap">
+    <link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0" />{\n}
   {else}
     // $useGoogleFonts only exists so that hosts can opt-out of loading fonts from fonts.googleapis.com.
     // fonts.css and the woff2 files in the fonts/ directory are only relevant, if $useGoogleFonts is false.
@@ -130,8 +133,9 @@
     <link rel="preload" href="{$staticResourcePath}/fonts/roboto-mono-latin-ext-400.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
     <link rel="preload" href="{$staticResourcePath}/fonts/roboto-mono-latin-ext-500.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
     <link rel="preload" href="{$staticResourcePath}/fonts/roboto-mono-latin-ext-700.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
-
+    <link rel="preload" href="{$staticResourcePath}/fonts/material-icons.woff2"            as="font" type="font/woff2" crossorigin="anonymous">{\n}
     <link rel="preload" as="style" href="{$staticResourcePath}/styles/fonts.css">{\n}
+    <link rel="preload" as="style" href="{$staticResourcePath}/styles/material-icons.css">{\n}
   {/if}
   <link rel="preload" as="style" href="{$staticResourcePath}/styles/main.css">{\n}
 
@@ -150,8 +154,10 @@
   // Now use preloaded resources
   {if $useGoogleFonts}
     <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700|Open+Sans:400,600,700&display=swap">{\n}
+    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0" />{\n}
   {else}
     <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
+    <link rel="stylesheet" href="{$staticResourcePath}/styles/material-icons.css">{\n}
   {/if}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
 
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index e7fda5a..1399b15 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -51,7 +51,7 @@
 
 usage() {
     me=`basename "$0"`
-    echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site]"
+    echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site] [--debug [--debug_port|--debug_address ...] [--suspend]]"
     exit 1
 }
 
@@ -148,6 +148,27 @@
     GERRIT_SITE=${1##--site-path=}
     shift
     ;;
+  --debug)
+    JVM_DEBUG=true
+    shift
+    ;;
+  --suspend)
+    JVM_DEBUG_SUSPEND=true
+    shift
+    ;;
+  --debug-port=*)
+    DEBUG_ADDRESS=${1##--debug-port=}
+    shift
+    ;;
+  --debug-address=*)
+    DEBUG_ADDRESS=${1##--debug-address=}
+    shift
+    ;;
+  --debug-port|--debug-address)
+    shift
+    DEBUG_ADDRESS=$1
+    shift
+    ;;
 
   *)
     usage
@@ -317,6 +338,20 @@
   JAVA_OPTIONS="$JAVA_OPTIONS -Xmx$GERRIT_MEMORY"
 fi
 
+if test -n "$JVM_DEBUG" ; then
+  if test -z "$DEBUG_ADDRESS" ; then
+    DEBUG_ADDRESS=8000
+  fi
+  echo "Put JVM in debug mode, debugger listens to: $DEBUG_ADDRESS"
+  if test -n "$JVM_DEBUG_SUSPEND" ; then
+    SUSPEND=y
+    echo "JVM will await for a debugger to attach"
+  else
+    SUSPEND=n
+  fi
+  JAVA_OPTIONS="$JAVA_OPTIONS -agentlib:jdwp=transport=dt_socket,server=y,suspend=$SUSPEND,address=$DEBUG_ADDRESS"
+fi
+
 GERRIT_FDS=`get_config --int core.packedGitOpenFiles`
 test -z "$GERRIT_FDS" && GERRIT_FDS=128
 FDS_MULTIPLIER=2
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index 4f1a3f7..2c256ff 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -85,11 +85,11 @@
 
   ${hook} input || fail "failed hook execution"
 
-  found=$(grep -c '^Change-Id' input)
+  found=$(grep -c '^Change-Id' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Change-Ids, want 1"
   fi
-  found=$(grep -c '^Change-Id: I123' input)
+  found=$(grep -c '^Change-Id: I123' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Change-Id: I123, want 1"
   fi
@@ -104,6 +104,18 @@
   git config gerrit.createChangeId false
   ${hook} input || fail "failed hook execution"
   git config --unset gerrit.createChangeId
+  found=$(grep -c '^Change-Id' input) || :
+  if [[ "${found}" != "0" ]]; then
+    fail "got ${found} Change-Ids, want 0"
+  fi
+}
+
+function test_suppress_squash {
+  cat << EOF > input
+squash! bla bla
+EOF
+
+  ${hook} input || fail "failed hook execution"
   found=$(grep -c '^Change-Id' input || true)
   if [[ "${found}" != "0" ]]; then
     fail "got ${found} Change-Ids, want 0"
@@ -119,11 +131,11 @@
   git config gerrit.reviewUrl https://myhost/
   ${hook} input || fail "failed hook execution"
   git config --unset gerrit.reviewUrl
-  found=$(grep -c '^Change-Id' input || true)
+  found=$(grep -c '^Change-Id' input) || :
   if [[ "${found}" != "0" ]]; then
     fail "got ${found} Change-Ids, want 0"
   fi
-  found=$(grep -c '^Link: https://myhost/id/I' input || true)
+  found=$(grep -c '^Link: https://myhost/id/I' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Link footers, want 1"
   fi
@@ -138,7 +150,7 @@
 EOF
 
   ${hook} input || fail "failed hook execution"
-  result=$(tail -1 input | grep ^Change-Id)
+  result=$(tail -1 input | grep ^Change-Id) || :
   if [[ -z "${result}" ]] ; then
     echo "after: "
     cat input
@@ -147,6 +159,25 @@
   fi
 }
 
+# Change-Id goes before Signed-off-by trailers.
+function test_before_signed_off_by {
+  cat << EOF > input
+bla bla
+
+Bug: #123
+Signed-off-by: Joe User
+EOF
+
+  ${hook} input || fail "failed hook execution"
+  result=$(tail -2 input | head -1 | grep ^Change-Id) || :
+  if [[ -z "${result}" ]] ; then
+    echo "after: "
+    cat input
+
+    fail "did not find Change-Id before Signed-off-by"
+  fi
+}
+
 function test_dash_at_end {
   if [[ ! -x /bin/dash ]] ; then
     echo "/bin/dash not installed; skipping dash test."
@@ -161,7 +192,7 @@
 
   /bin/dash ${hook} input || fail "failed hook execution"
 
-  result=$(tail -1 input | grep ^Change-Id)
+  result=$(tail -1 input | grep ^Change-Id) || :
   if [[ -z "${result}" ]] ; then
     echo "after: "
     cat input
@@ -184,11 +215,11 @@
 
   /bin/dash ${hook} input || fail "failed hook execution"
 
-  found=$(grep -c '^Change-Id' input)
+  found=$(grep -c '^Change-Id' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Change-Ids, want 1"
   fi
-  found=$(grep -c '^Change-Id: I123' input)
+  found=$(grep -c '^Change-Id: I123' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Change-Id: I123, want 1"
   fi
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 4b66401..98ab4b2 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -26,8 +26,34 @@
   {@param email: ?}
   {@param fromName: ?}
   {@param commentFiles: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   {$fromName} has posted comments on this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {if $unsatisfiedSubmitRequirements}
+    {\n}
+    The change is no longer submittable:{sp}
+    {if length($unsatisfiedSubmitRequirements) > 0}
+      {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+        {if $index > 0}
+          {if $index == length($unsatisfiedSubmitRequirements) - 1}
+            {sp}and{sp}
+          {else}
+            ,{sp}
+          {/if}
+        {/if}
+        {$unsatisfiedSubmitRequirement}
+      {/for}
+      {sp}
+      {if length($unsatisfiedSubmitRequirements) == 1}
+        is
+      {else}
+        are
+      {/if}
+      {sp}unsatisfied now.{\n}
+    {/if}
+  {/if}
   {\n}
   Change subject: {$change.subject}{\n}
   ......................................................................{\n}
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy
index a120cea..320122e 100644
--- a/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -25,6 +25,9 @@
   {@param labels: ?}
   {@param patchSet: ?}
   {@param patchSetCommentBlocks: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   {let $commentHeaderStyle kind="css"}
     margin-bottom: 4px;
   {/let}
@@ -99,6 +102,31 @@
     </p>
   {/if}
 
+  {if $unsatisfiedSubmitRequirements}
+    <p>
+      The change is no longer submittable:{sp}
+      {if length($unsatisfiedSubmitRequirements) > 0}
+        {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+          {if $index > 0}
+            {if $index == length($unsatisfiedSubmitRequirements) - 1}
+              {sp}and{sp}
+            {else}
+              ,{sp}
+            {/if}
+          {/if}
+          {$unsatisfiedSubmitRequirement}
+        {/for}
+        {sp}
+        {if length($unsatisfiedSubmitRequirements) == 1}
+          is
+        {else}
+          are
+        {/if}
+        {sp}unsatisfied now.
+      {/if}
+    </p>
+  {/if}
+
   {if $email.changeUrl}
     <p>
       {call mailTemplate.ViewChangeButton data="all" /}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
index 2647572..6ae8625 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -27,6 +27,9 @@
   {@param fromName: ?}
   {@param patchSet: ?}
   {@param projectName: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   {if $email.reviewerNames and $fromEmail == $change.ownerEmail}
     Hello{sp}
     {for $reviewerName in $email.reviewerNames}
@@ -50,6 +53,40 @@
     {/if}.
     {if $email.changeUrl} ( {$email.changeUrl} ){/if}
   {/if}{\n}
+  {if $email.outdatedApprovals and length($email.outdatedApprovals) > 0}
+    {\n}
+    The following approvals got outdated and were removed:{\n}
+    {for $outdatedApproval, $index in $email.outdatedApprovals}
+      {if $index > 0}
+        ,{sp}
+      {/if}
+      {$outdatedApproval}
+    {/for}{\n}
+  {/if}
+  {if $unsatisfiedSubmitRequirements}
+    {\n}
+    The change is no longer submittable:{sp}
+    {if length($unsatisfiedSubmitRequirements) > 0}
+      {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+        {if $index > 0}
+          {if $index == length($unsatisfiedSubmitRequirements) - 1}
+            {sp}and{sp}
+          {else}
+            ,{sp}
+          {/if}
+        {/if}
+        {$unsatisfiedSubmitRequirement}
+      {/for}
+      {sp}
+      {if length($unsatisfiedSubmitRequirements) == 1}
+        is
+      {else}
+        are
+      {/if}
+      {sp}unsatisfied now.{\n}
+    {/if}
+  {/if}
+  {\n}
   {\n}
   Change subject: {$change.subject}{\n}
   ......................................................................{\n}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
index 4916a4a..1d99591 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -25,6 +25,9 @@
   {@param fromEmail: ?}
   {@param patchSet: ?}
   {@param projectName: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   <p>
     {$fromName} <strong>uploaded patch set #{$patchSet.patchSetId}</strong>{sp}
     to{sp}
@@ -41,6 +44,43 @@
     </p>
   {/if}
 
+  {if $email.outdatedApprovals and length($email.outdatedApprovals) > 0}
+    <p>
+      The following approvals got outdated and were removed:{\n}
+      {for $outdatedApproval, $index in $email.outdatedApprovals}
+        {if $index > 0}
+          ,{sp}
+        {/if}
+        {$outdatedApproval}
+      {/for}
+    </p>
+  {/if}
+
+  {if $unsatisfiedSubmitRequirements}
+    <p>
+      The change is no longer submittable:{sp}
+      {if length($unsatisfiedSubmitRequirements) > 0}
+        {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+          {if $index > 0}
+            {if $index == length($unsatisfiedSubmitRequirements) - 1}
+              {sp}and{sp}
+            {else}
+              ,{sp}
+            {/if}
+          {/if}
+          {$unsatisfiedSubmitRequirement}
+        {/for}
+        {sp}
+        {if length($unsatisfiedSubmitRequirements) == 1}
+          is
+        {else}
+          are
+        {/if}
+        {sp}unsatisfied now.
+      {/if}
+    </p>
+  {/if}
+
   {call mailTemplate.Pre}
     {param content: $email.changeDetail /}
   {/call}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 5a08e66..2f9561b 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -231,6 +231,9 @@
 tex = text/x-latex
 text = text/plain
 textile = text/x-textile
+textpb = text/x-text-proto
+textproto = text/x-text-proto
+text_proto = text/x-text-proto
 tiddly = text/x-tiddlywiki
 tiddlywiki = text/x-tiddlywiki
 tiki = text/tiki
@@ -243,6 +246,7 @@
 ttcn3 = text/x-ttcn
 ttl = text/turtle
 txt = text/plain
+txtpb = text/x-text-proto
 twig = text/x-twig
 v = text/x-verilog
 vb = text/x-vb
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index e1d6f22..d9fd1f1 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -34,6 +34,11 @@
   exit 0
 fi
 
+# Do not create a change id for squash commits.
+if head -n1 "$1" | grep -q '^squash! '; then
+  exit 0
+fi
+
 if git rev-parse --verify HEAD >/dev/null 2>&1; then
   refhash="$(git rev-parse HEAD)"
 else
@@ -43,7 +48,7 @@
 random=$({ git var GIT_COMMITTER_IDENT ; echo "$refhash" ; cat "$1"; } | git hash-object --stdin)
 dest="$1.tmp.${random}"
 
-trap 'rm -f "${dest}"' EXIT
+trap 'rm -f "$dest" "$dest-2"' EXIT
 
 if ! git stripspace --strip-comments < "$1" > "${dest}" ; then
    echo "cannot strip comments from $1"
@@ -57,21 +62,39 @@
 
 reviewurl="$(git config --get gerrit.reviewUrl)"
 if test -n "${reviewurl}" ; then
-  if ! git interpret-trailers --parse < "$1" | grep -q '^Link:.*/id/I[0-9a-f]\{40\}$' ; then
-    if ! git interpret-trailers \
-          --trailer "Link: ${reviewurl%/}/id/I${random}" < "$1" > "${dest}" ; then
-      echo "cannot insert link footer in $1"
-      exit 1
-    fi
-  fi
+  token="Link"
+  value="${reviewurl%/}/id/I$random"
+  pattern=".*/id/I[0-9a-f]\{40\}$"
 else
-  # Avoid the --in-place option which only appeared in Git 2.8
-  # Avoid the --if-exists option which only appeared in Git 2.15
-  if ! git -c trailer.ifexists=doNothing interpret-trailers \
-        --trailer "Change-Id: I${random}" < "$1" > "${dest}" ; then
-    echo "cannot insert change-id line in $1"
-    exit 1
-  fi
+  token="Change-Id"
+  value="I$random"
+  pattern=".*"
+fi
+
+if git interpret-trailers --parse < "$1" | grep -q "^$token: $pattern$" ; then
+  exit 0
+fi
+
+# There must be a Signed-off-by trailer for the code below to work. Insert a
+# sentinel at the end to make sure there is one.
+# Avoid the --in-place option which only appeared in Git 2.8
+if ! git interpret-trailers \
+         --trailer "Signed-off-by: SENTINEL" < "$1" > "$dest-2" ; then
+  echo "cannot insert Signed-off-by sentinel line in $1"
+  exit 1
+fi
+
+# Make sure the trailer appears before any Signed-off-by trailers by inserting
+# it as if it was a Signed-off-by trailer and then use sed to remove the
+# Signed-off-by prefix and the Signed-off-by sentinel line.
+# Avoid the --in-place option which only appeared in Git 2.8
+# Avoid the --where option which only appeared in Git 2.15
+if ! git -c trailer.where=before interpret-trailers \
+         --trailer "Signed-off-by: $token: $value" < "$dest-2" |
+     sed -e "s/^Signed-off-by: \($token: \)/\1/" \
+         -e "/^Signed-off-by: SENTINEL/d" > "$dest" ; then
+  echo "cannot insert $token line in $1"
+  exit 1
 fi
 
 if ! mv "${dest}" "$1" ; then
diff --git a/tools/deps.bzl b/tools/deps.bzl
index c4cbc40..3138d15 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -9,6 +9,8 @@
 MAIL_VERS = "1.6.0"
 MIME4J_VERS = "0.8.1"
 OW2_VERS = "9.2"
+AUTO_COMMON_VERSION = "1.2.1"
+AUTO_FACTORY_VERSION = "1.0.1"
 AUTO_VALUE_VERSION = "1.7.4"
 AUTO_VALUE_GSON_VERSION = "1.3.1"
 PROLOG_VERS = "1.4.4"
@@ -411,6 +413,24 @@
     )
 
     maven_jar(
+        name = "auto-common",
+        artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION,
+        sha1 = "f6da26895f759010f5f170c8044e84c1b17ef83e",
+    )
+
+    maven_jar(
+        name = "auto-factory",
+        artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION,
+        sha1 = "f81ece06b6525085da217cd900116f44caafe877",
+    )
+
+    maven_jar(
+        name = "auto-service-annotations",
+        artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION,
+        sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432",
+    )
+
+    maven_jar(
         name = "auto-value",
         artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
         sha1 = "6b126cb218af768339e4d6e95a9b0ae41f74e73d",
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index cd9f132..c1d8095 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -44,12 +44,17 @@
     name = "autovalue_classpath_collect",
     deps = [
         "//lib/auto:auto-value",
+        "@auto-common//jar",
+        "@auto-factory//jar",
+        "@auto-service-annotations//jar",
         "@auto-value-annotations//jar",
         "@auto-value-gson-extension//jar",
         "@auto-value-gson-factory//jar",
         "@auto-value-gson-runtime//jar",
         "@autotransient//jar",
         "@gson//jar",
+        "@guava//jar",
         "@javapoet//jar",
+        "@javax_inject//jar",
     ],
 )
diff --git a/tools/js/eslint.bzl b/tools/js/eslint.bzl
index 3f48cf6..320f8da 100644
--- a/tools/js/eslint.bzl
+++ b/tools/js/eslint.bzl
@@ -47,7 +47,7 @@
         ],
     )
 
-def eslint(name, plugins, srcs, config, ignore, extensions = [".js"], data = []):
+def eslint(name, plugins, srcs, config, ignore, size = "large", extensions = [".js"], data = []):
     """ Macro to define eslint rules for files.
 
     Args:
@@ -56,6 +56,8 @@
         srcs: list of files to be checked (ignored in {name}_bin rule)
         config: eslint config file
         ignore: eslint ignore file
+        size: eslint test size, supported values are: small, medium, large and enormous,
+            with implied timeout labels: short, moderate, long, and eternal
         extensions: list of file extensions to be checked. This is an additional filter for
             srcs list. Each extension must start with '.' character.
             Default: [".js"].
@@ -125,6 +127,7 @@
             "local",
             "manual",
         ],
+        size = size,
     )
 
     nodejs_binary(
diff --git a/tools/js/template_checker.bzl b/tools/js/template_checker.bzl
deleted file mode 100644
index da77234..0000000
--- a/tools/js/template_checker.bzl
+++ /dev/null
@@ -1,136 +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.
-
-"""This file contains macro to run polymer templates check."""
-
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test", "npm_package_bin", "params_file")
-load("@rules_pkg//:pkg.bzl", "pkg_tar")
-
-def _get_generated_files(outdir, srcs):
-    result = []
-    for f in srcs:
-        result.append(outdir + "/" + f)
-    return result
-
-def _generate_transformed_templates(name, srcs, tsconfig, deps, out_tsconfig, outdir, dev_run):
-    """Generates typescript code from polymer templates. It uses twinkie package
-    for generation.
-
-    Args:
-      name: rule name
-      srcs: all files in a project project
-      tsconfig: the original typescript project file
-      deps: dependencies
-      out_tsconfig: where to store the generated TS project.
-      outdir: where to store generated .ts files
-      dev_run: if True, the generator uses different file paths in generated
-        import statements. Later, generated files can be copied into workspace
-        for future debugging\\investigation templates issues.
-
-    Returns:
-      The list of generated files
-    """
-    generated_files = _get_generated_files(outdir, srcs)
-
-    # There is a limitation on the command-line length. Put all source files
-    # into a .params file (this is a text file, where each argument is placed
-    # on a new line)
-    params_file(
-        name = name + "_params",
-        out = name + ".params",
-        args = ["$(execpath {})".format(src) for src in srcs],
-        data = srcs,
-    )
-
-    # Arguments for twinkie
-    args = [
-        "$(location //tools/node_tools:twinkie-bin)",
-        "--tsconfig $(location {})".format(tsconfig),
-        "--out-dir $(RULEDIR)/{} ".format(outdir),
-        "--files $(location {})".format(name + ".params"),
-    ]
-    if dev_run:
-        args.append("--dev-run")
-    if out_tsconfig:
-        args.append("--out-ts-config $(location {})".format(out_tsconfig))
-
-    # Execute twinkie.
-    native.genrule(
-        name = name + "_npm_bin",
-        srcs = srcs + deps + [name + ".params"],
-        outs = generated_files + ([out_tsconfig] if out_tsconfig else []),
-        cmd = " ".join(args),
-        tools = ["//tools/node_tools:twinkie-bin"],
-        # Should not run sandboxed.
-        tags = [
-            "local",
-            "manual",
-        ],
-    )
-    return generated_files
-
-def transform_polymer_templates(name, srcs, tsconfig, deps, out_tsconfig):
-    """Transforms polymer templates into typescript code.
-    Additionally, the macro defines name+"_tar" package that contains
-    generated code with slightly different import paths.
-    Note, that polygerrit template tests don't depend on the tar package, so
-    bazel doesn't generate the tar package with the bazel test command.
-    The tar package must be build explicitly with the bazel build command.
-
-    Args:
-      name: rule name
-      srcs: all files in a project project
-      tsconfig: the original typescript project file
-      deps: dependencies
-      out_tsconfig: where to store the generated TS project.
-
-    Returns:
-      list of generated files
-    """
-
-    # Transformed templates for tests
-    generated_files = _generate_transformed_templates(
-        name = name,
-        srcs = srcs,
-        tsconfig = tsconfig,
-        deps = deps,
-        out_tsconfig = out_tsconfig,
-        dev_run = False,
-        outdir = name + "_out",
-    )
-
-    # Transformed templates for developers. Only the tar package depends
-    # on it and it never runs during tests.
-    generated_dev_files = _generate_transformed_templates(
-        name = name + "_dev",
-        srcs = srcs,
-        tsconfig = tsconfig,
-        deps = deps,
-        dev_run = True,
-        outdir = name + "_dev_out",
-        out_tsconfig = None,
-    )
-
-    # Pack all transformed files. Later files can be materialized in the
-    # WORKSPACE/polygerrit-ui/app/tmpl_out dir. The following command do it
-    # automatically
-    # npm run polytest:dev
-    pkg_tar(
-        name = name + "_tar",
-        srcs = generated_dev_files,
-        # Set strip_prefix to keep directory hierarchy in the .tar
-        # https://github.com/bazelbuild/rules_pkg/issues/82
-        strip_prefix = name + "_dev_out",
-    )
-    return generated_files
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index cbef8a9..448cf82 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.6.3-SNAPSHOT</version>
+  <version>3.7.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index af19102..dc9a55a 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.6.3-SNAPSHOT</version>
+  <version>3.7.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index d238c98..b9f8e52 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.6.3-SNAPSHOT</version>
+  <version>3.7.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index cc3feb2..d4ffb24 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.6.3-SNAPSHOT</version>
+  <version>3.7.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/node_tools/BUILD b/tools/node_tools/BUILD
index bb1a9fc..b14c0c6 100644
--- a/tools/node_tools/BUILD
+++ b/tools/node_tools/BUILD
@@ -50,16 +50,3 @@
     data = ["@tools_npm//typescript"],
     entry_point = "@tools_npm//:node_modules/typescript/lib/tsc.js",
 )
-
-# Wrap twinkie into a twinkie-bin binary.
-nodejs_binary(
-    name = "twinkie-bin",
-    # Point bazel to your node_modules to find the entry point
-    data = ["@npm//:node_modules"],
-    entry_point = "@npm//:node_modules/twinkie/src/app/index.js",
-    # Should not run sandboxed.
-    tags = [
-        "local",
-        "manual",
-    ],
-)
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index bda73f3..3765575 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/rollup": "^5.1.0",
-    "@bazel/typescript": "^5.1.0",
-    "@bazel/concatjs": "^5.1.0",
+    "@bazel/rollup": "^5.5.0",
+    "@bazel/typescript": "^5.5.0",
+    "@bazel/concatjs": "^5.5.0",
     "@types/node": "^10.17.12",
     "@types/parse5": "^4.0.0",
     "@types/parse5-html-rewriting-stream": "^5.1.2",
@@ -13,12 +13,12 @@
     "dom5": "^3.0.1",
     "parse5-html-rewriting-stream": "^5.1.1",
     "polymer-bundler": "^4.0.10",
-    "polymer-cli": "^1.9.11",
     "rollup": "^2.3.4",
     "rollup-plugin-commonjs": "^10.1.0",
+    "rollup-plugin-define": "^1.0.1",
     "rollup-plugin-node-resolve": "^5.2.0",
     "rollup-plugin-terser": "^5.1.3",
-    "typescript": "4.3.2"
+    "typescript": "^4.7.2"
   },
   "devDependencies": {},
   "license": "Apache-2.0",
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 3f9d3a4..c6b3eab 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -2,686 +2,171 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.14.5", "@babel/code-frame@^7.5.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
-  integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==
+"@babel/code-frame@^7.16.7", "@babel/code-frame@^7.5.5":
+  version "7.16.7"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.7.tgz#44416b6bd7624b998f5b1af5d470856c40138789"
+  integrity sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==
   dependencies:
-    "@babel/highlight" "^7.14.5"
+    "@babel/highlight" "^7.16.7"
 
-"@babel/compat-data@^7.14.7", "@babel/compat-data@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.15.0.tgz#2dbaf8b85334796cafbb0f5793a90a2fc010b176"
-  integrity sha512-0NqAC1IJE0S0+lL1SWFMxMkz1pKCNCjI4tr2Zx4LJSXxCLAdr6KyArnY+sno5m3yH9g737ygOyPABDsnXkpxiA==
-
-"@babel/core@^7.0.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.15.0.tgz#749e57c68778b73ad8082775561f67f5196aafa8"
-  integrity sha512-tXtmTminrze5HEUPn/a0JtOzzfp0nk+UEXQ/tqIJo3WDGypl/2OFQEMll/zSFU8f/lfmfLXvTaORHF3cfXIQMw==
+"@babel/generator@^7.0.0-beta.42", "@babel/generator@^7.18.2":
+  version "7.18.2"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.2.tgz#33873d6f89b21efe2da63fe554460f3df1c5880d"
+  integrity sha512-W1lG5vUwFvfMd8HVXqdfbuG7RuaSrTCCD8cl8fP8wOivdbtbIg2Db3IWUcgvfxKbbn6ZBGYRW/Zk1MIwK49mgw==
   dependencies:
-    "@babel/code-frame" "^7.14.5"
-    "@babel/generator" "^7.15.0"
-    "@babel/helper-compilation-targets" "^7.15.0"
-    "@babel/helper-module-transforms" "^7.15.0"
-    "@babel/helpers" "^7.14.8"
-    "@babel/parser" "^7.15.0"
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
-    convert-source-map "^1.7.0"
-    debug "^4.1.0"
-    gensync "^1.0.0-beta.2"
-    json5 "^2.1.2"
-    semver "^6.3.0"
-    source-map "^0.5.0"
-
-"@babel/generator@^7.0.0-beta.42", "@babel/generator@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.15.0.tgz#a7d0c172e0d814974bad5aa77ace543b97917f15"
-  integrity sha512-eKl4XdMrbpYvuB505KTta4AV9g+wWzmVBW69tX0H2NwKVKd2YJbKgyK6M8j/rgLbmHOYJn6rUklV677nOyJrEQ==
-  dependencies:
-    "@babel/types" "^7.15.0"
+    "@babel/types" "^7.18.2"
+    "@jridgewell/gen-mapping" "^0.3.0"
     jsesc "^2.5.1"
-    source-map "^0.5.0"
 
-"@babel/helper-annotate-as-pure@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz#7bf478ec3b71726d56a8ca5775b046fc29879e61"
-  integrity sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA==
+"@babel/helper-environment-visitor@^7.18.2":
+  version "7.18.2"
+  resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.2.tgz#8a6d2dedb53f6bf248e31b4baf38739ee4a637bd"
+  integrity sha512-14GQKWkX9oJzPiQQ7/J36FTXcD4kSp8egKjO9nINlSKiHITRA9q/R74qu8S9xlc/b/yjsJItQUeeh3xnGN0voQ==
+
+"@babel/helper-function-name@^7.17.9":
+  version "7.17.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.17.9.tgz#136fcd54bc1da82fcb47565cf16fd8e444b1ff12"
+  integrity sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/template" "^7.16.7"
+    "@babel/types" "^7.17.0"
 
-"@babel/helper-builder-binary-assignment-operator-visitor@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.14.5.tgz#b939b43f8c37765443a19ae74ad8b15978e0a191"
-  integrity sha512-YTA/Twn0vBXDVGJuAX6PwW7x5zQei1luDDo2Pl6q1qZ7hVNl0RZrhHCQG/ArGpR29Vl7ETiB8eJyrvpuRp300w==
+"@babel/helper-hoist-variables@^7.16.7":
+  version "7.16.7"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz#86bcb19a77a509c7b77d0e22323ef588fa58c246"
+  integrity sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==
   dependencies:
-    "@babel/helper-explode-assignable-expression" "^7.14.5"
-    "@babel/types" "^7.14.5"
+    "@babel/types" "^7.16.7"
 
-"@babel/helper-compilation-targets@^7.14.5", "@babel/helper-compilation-targets@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.15.0.tgz#973df8cbd025515f3ff25db0c05efc704fa79818"
-  integrity sha512-h+/9t0ncd4jfZ8wsdAsoIxSa61qhBYlycXiHWqJaQBCXAhDCMbPRSMTGnZIkkmt1u4ag+UQmuqcILwqKzZ4N2A==
+"@babel/helper-split-export-declaration@^7.16.7":
+  version "7.16.7"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz#0b648c0c42da9d3920d85ad585f2778620b8726b"
+  integrity sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==
   dependencies:
-    "@babel/compat-data" "^7.15.0"
-    "@babel/helper-validator-option" "^7.14.5"
-    browserslist "^4.16.6"
-    semver "^6.3.0"
+    "@babel/types" "^7.16.7"
 
-"@babel/helper-create-regexp-features-plugin@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.14.5.tgz#c7d5ac5e9cf621c26057722fb7a8a4c5889358c4"
-  integrity sha512-TLawwqpOErY2HhWbGJ2nZT5wSkR192QpN+nBg1THfBfftrlvOh+WbhrxXCH4q4xJ9Gl16BGPR/48JA+Ryiho/A==
+"@babel/helper-validator-identifier@^7.16.7":
+  version "7.16.7"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad"
+  integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==
+
+"@babel/highlight@^7.16.7":
+  version "7.17.12"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.17.12.tgz#257de56ee5afbd20451ac0a75686b6b404257351"
+  integrity sha512-7yykMVF3hfZY2jsHZEEgLc+3x4o1O+fYyULu11GynEUQNwB6lua+IIQn1FiJxNucd5UlyJryrwsOh8PL9Sn8Qg==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    regexpu-core "^4.7.1"
-
-"@babel/helper-explode-assignable-expression@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.14.5.tgz#8aa72e708205c7bb643e45c73b4386cdf2a1f645"
-  integrity sha512-Htb24gnGJdIGT4vnRKMdoXiOIlqOLmdiUYpAQ0mYfgVT/GDm8GOYhgi4GL+hMKrkiPRohO4ts34ELFsGAPQLDQ==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-function-name@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz#89e2c474972f15d8e233b52ee8c480e2cfcd50c4"
-  integrity sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==
-  dependencies:
-    "@babel/helper-get-function-arity" "^7.14.5"
-    "@babel/template" "^7.14.5"
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-get-function-arity@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz#25fbfa579b0937eee1f3b805ece4ce398c431815"
-  integrity sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-hoist-variables@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz#e0dd27c33a78e577d7c8884916a3e7ef1f7c7f8d"
-  integrity sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-member-expression-to-functions@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.15.0.tgz#0ddaf5299c8179f27f37327936553e9bba60990b"
-  integrity sha512-Jq8H8U2kYiafuj2xMTPQwkTBnEEdGKpT35lJEQsRRjnG0LW3neucsaMWLgKcwu3OHKNeYugfw+Z20BXBSEs2Lg==
-  dependencies:
-    "@babel/types" "^7.15.0"
-
-"@babel/helper-module-imports@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz#6d1a44df6a38c957aa7c312da076429f11b422f3"
-  integrity sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-module-transforms@^7.14.5", "@babel/helper-module-transforms@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.15.0.tgz#679275581ea056373eddbe360e1419ef23783b08"
-  integrity sha512-RkGiW5Rer7fpXv9m1B3iHIFDZdItnO2/BLfWVW/9q7+KqQSDY5kUfQEbzdXM1MVhJGcugKV7kRrNVzNxmk7NBg==
-  dependencies:
-    "@babel/helper-module-imports" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.15.0"
-    "@babel/helper-simple-access" "^7.14.8"
-    "@babel/helper-split-export-declaration" "^7.14.5"
-    "@babel/helper-validator-identifier" "^7.14.9"
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
-
-"@babel/helper-optimise-call-expression@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz#f27395a8619e0665b3f0364cddb41c25d71b499c"
-  integrity sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.8.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9"
-  integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==
-
-"@babel/helper-remap-async-to-generator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.14.5.tgz#51439c913612958f54a987a4ffc9ee587a2045d6"
-  integrity sha512-rLQKdQU+HYlxBwQIj8dk4/0ENOUEhA/Z0l4hN8BexpvmSMN9oA9EagjnhnDpNsRdWCfjwa4mn/HyBXO9yhQP6A==
-  dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    "@babel/helper-wrap-function" "^7.14.5"
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-replace-supers@^7.14.5", "@babel/helper-replace-supers@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.15.0.tgz#ace07708f5bf746bf2e6ba99572cce79b5d4e7f4"
-  integrity sha512-6O+eWrhx+HEra/uJnifCwhwMd6Bp5+ZfZeJwbqUTuqkhIT6YcRhiZCOOFChRypOIe0cV46kFrRBlm+t5vHCEaA==
-  dependencies:
-    "@babel/helper-member-expression-to-functions" "^7.15.0"
-    "@babel/helper-optimise-call-expression" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
-
-"@babel/helper-simple-access@^7.14.8":
-  version "7.14.8"
-  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.14.8.tgz#82e1fec0644a7e775c74d305f212c39f8fe73924"
-  integrity sha512-TrFN4RHh9gnWEU+s7JloIho2T76GPwRHhdzOWLqTrMnlas8T9O7ec+oEDNsRXndOmru9ymH9DFrEOxpzPoSbdg==
-  dependencies:
-    "@babel/types" "^7.14.8"
-
-"@babel/helper-skip-transparent-expression-wrappers@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.14.5.tgz#96f486ac050ca9f44b009fbe5b7d394cab3a0ee4"
-  integrity sha512-dmqZB7mrb94PZSAOYtr+ZN5qt5owZIAgqtoTuqiFbHFtxgEcmQlRJVI+bO++fciBunXtB6MK7HrzrfcAzIz2NQ==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-split-export-declaration@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz#22b23a54ef51c2b7605d851930c1976dd0bc693a"
-  integrity sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-validator-identifier@^7.14.5", "@babel/helper-validator-identifier@^7.14.9":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz#6654d171b2024f6d8ee151bf2509699919131d48"
-  integrity sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==
-
-"@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"
-  integrity sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==
-
-"@babel/helper-wrap-function@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.14.5.tgz#5919d115bf0fe328b8a5d63bcb610f51601f2bff"
-  integrity sha512-YEdjTCq+LNuNS1WfxsDCNpgXkJaIyqco6DAelTUjT4f2KIWC1nBcaCaSdHTBqQVLnTBexBcVcFhLSU1KnYuePQ==
-  dependencies:
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.14.5"
-    "@babel/types" "^7.14.5"
-
-"@babel/helpers@^7.14.8":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.15.3.tgz#c96838b752b95dcd525b4e741ed40bb1dc2a1357"
-  integrity sha512-HwJiz52XaS96lX+28Tnbu31VeFSQJGOeKHJeaEPQlTl7PnlhFElWPj8tUXtqFIzeN86XxXoBr+WFAyK2PPVz6g==
-  dependencies:
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
-
-"@babel/highlight@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9"
-  integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==
-  dependencies:
-    "@babel/helper-validator-identifier" "^7.14.5"
+    "@babel/helper-validator-identifier" "^7.16.7"
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.14.5", "@babel/parser@^7.15.0":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.15.3.tgz#3416d9bea748052cfcb63dbcc27368105b1ed862"
-  integrity sha512-O0L6v/HvqbdJawj0iBEfVQMc3/6WP+AeOsovsIgBFyJaG+W2w7eqvZB7puddATmWuARlm1SX7DwxJ/JJUnDpEA==
+"@babel/parser@^7.16.7", "@babel/parser@^7.18.0":
+  version "7.18.4"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.4.tgz#6774231779dd700e0af29f6ad8d479582d7ce5ef"
+  integrity sha512-FDge0dFazETFcxGw/EXzOkN8uJp0PC7Qbm+Pe9T+av2zlBpOgunFHkQPPn+eRuClU73JF+98D531UgayY89tow==
 
-"@babel/plugin-external-helpers@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-external-helpers/-/plugin-external-helpers-7.14.5.tgz#920baa1569a8df5d5710abc342c7b1ac8968ed76"
-  integrity sha512-q/B/hLX+nDGk73Xn529d7Ar4ih17J8pNBbsXafq8oXij0XfFEA/bks+u+6q5q04zO5o/qivjzui6BqzPfYShEg==
+"@babel/template@^7.16.7":
+  version "7.16.7"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155"
+  integrity sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/code-frame" "^7.16.7"
+    "@babel/parser" "^7.16.7"
+    "@babel/types" "^7.16.7"
 
-"@babel/plugin-proposal-async-generator-functions@^7.0.0":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.14.9.tgz#7028dc4fa21dc199bbacf98b39bab1267d0eaf9a"
-  integrity sha512-d1lnh+ZnKrFKwtTYdw320+sQWCTwgkB9fmUhNXRADA4akR6wLjaruSGnIEUjpt9HCOwTr4ynFTKu19b7rFRpmw==
+"@babel/traverse@^7.0.0-beta.42":
+  version "7.18.2"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.2.tgz#b77a52604b5cc836a9e1e08dca01cba67a12d2e8"
+  integrity sha512-9eNwoeovJ6KH9zcCNnENY7DMFwTU9JdGCFtqNLfUAqtUHRCOsTOqWoffosP8vKmNYeSBUv3yVJXjfd8ucwOjUA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-remap-async-to-generator" "^7.14.5"
-    "@babel/plugin-syntax-async-generators" "^7.8.4"
-
-"@babel/plugin-proposal-object-rest-spread@^7.0.0":
-  version "7.14.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.14.7.tgz#5920a2b3df7f7901df0205974c0641b13fd9d363"
-  integrity sha512-082hsZz+sVabfmDWo1Oct1u1AgbKbUAyVgmX4otIc7bdsRgHBXwTwb3DpDmD4Eyyx6DNiuz5UAATT655k+kL5g==
-  dependencies:
-    "@babel/compat-data" "^7.14.7"
-    "@babel/helper-compilation-targets" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
-    "@babel/plugin-transform-parameters" "^7.14.5"
-
-"@babel/plugin-syntax-async-generators@^7.0.0", "@babel/plugin-syntax-async-generators@^7.8.4":
-  version "7.8.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d"
-  integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.8.0"
-
-"@babel/plugin-syntax-dynamic-import@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3"
-  integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.8.0"
-
-"@babel/plugin-syntax-import-meta@^7.0.0":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51"
-  integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-
-"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871"
-  integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.8.0"
-
-"@babel/plugin-transform-arrow-functions@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.14.5.tgz#f7187d9588a768dd080bf4c9ffe117ea62f7862a"
-  integrity sha512-KOnO0l4+tD5IfOdi4x8C1XmEIRWUjNRV8wc6K2vz/3e8yAOoZZvsRXRRIF/yo/MAOFb4QjtAw9xSxMXbSMRy8A==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-async-to-generator@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.14.5.tgz#72c789084d8f2094acb945633943ef8443d39e67"
-  integrity sha512-szkbzQ0mNk0rpu76fzDdqSyPu0MuvpXgC+6rz5rpMb5OIRxdmHfQxrktL8CYolL2d8luMCZTR0DpIMIdL27IjA==
-  dependencies:
-    "@babel/helper-module-imports" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-remap-async-to-generator" "^7.14.5"
-
-"@babel/plugin-transform-block-scoped-functions@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.14.5.tgz#e48641d999d4bc157a67ef336aeb54bc44fd3ad4"
-  integrity sha512-dtqWqdWZ5NqBX3KzsVCWfQI3A53Ft5pWFCT2eCVUftWZgjc5DpDponbIF1+c+7cSGk2wN0YK7HGL/ezfRbpKBQ==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-block-scoping@^7.0.0":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.15.3.tgz#94c81a6e2fc230bcce6ef537ac96a1e4d2b3afaf"
-  integrity sha512-nBAzfZwZb4DkaGtOes1Up1nOAp9TDRRFw4XBzBBSG9QK7KVFmYzgj9o9sbPv7TX5ofL4Auq4wZnxCoPnI/lz2Q==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-classes@^7.0.0":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.14.9.tgz#2a391ffb1e5292710b00f2e2c210e1435e7d449f"
-  integrity sha512-NfZpTcxU3foGWbl4wxmZ35mTsYJy8oQocbeIMoDAGGFarAmSQlL+LWMkDx/tj6pNotpbX3rltIA4dprgAPOq5A==
-  dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-optimise-call-expression" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.14.5"
-    "@babel/helper-split-export-declaration" "^7.14.5"
-    globals "^11.1.0"
-
-"@babel/plugin-transform-computed-properties@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.14.5.tgz#1b9d78987420d11223d41195461cc43b974b204f"
-  integrity sha512-pWM+E4283UxaVzLb8UBXv4EIxMovU4zxT1OPnpHJcmnvyY9QbPPTKZfEj31EUvG3/EQRbYAGaYEUZ4yWOBC2xg==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-destructuring@^7.0.0":
-  version "7.14.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.14.7.tgz#0ad58ed37e23e22084d109f185260835e5557576"
-  integrity sha512-0mDE99nK+kVh3xlc5vKwB6wnP9ecuSj+zQCa/n0voENtP/zymdT4HH6QEb65wjjcbqr1Jb/7z9Qp7TF5FtwYGw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-duplicate-keys@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.14.5.tgz#365a4844881bdf1501e3a9f0270e7f0f91177954"
-  integrity sha512-iJjbI53huKbPDAsJ8EmVmvCKeeq21bAze4fu9GBQtSLqfvzj2oRuHVx4ZkDwEhg1htQ+5OBZh/Ab0XDf5iBZ7A==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-exponentiation-operator@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.14.5.tgz#5154b8dd6a3dfe6d90923d61724bd3deeb90b493"
-  integrity sha512-jFazJhMBc9D27o9jDnIE5ZErI0R0m7PbKXVq77FFvqFbzvTMuv8jaAwLZ5PviOLSFttqKIW0/wxNSDbjLk0tYA==
-  dependencies:
-    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-for-of@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.14.5.tgz#dae384613de8f77c196a8869cbf602a44f7fc0eb"
-  integrity sha512-CfmqxSUZzBl0rSjpoQSFoR9UEj3HzbGuGNL21/iFTmjb5gFggJp3ph0xR1YBhexmLoKRHzgxuFvty2xdSt6gTA==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-function-name@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.14.5.tgz#e81c65ecb900746d7f31802f6bed1f52d915d6f2"
-  integrity sha512-vbO6kv0fIzZ1GpmGQuvbwwm+O4Cbm2NrPzwlup9+/3fdkuzo1YqOZcXw26+YUJB84Ja7j9yURWposEHLYwxUfQ==
-  dependencies:
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-instanceof@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-instanceof/-/plugin-transform-instanceof-7.14.5.tgz#8568277fbcfd7a3e4f3e6c8b7aa8ce4f60cba6e7"
-  integrity sha512-3CIpRzBLk5tEwIzjjD86KR8oMYrp1fl9q7kbdJa6O6Lcmkcee9DXfeO6zRXis//5gWRf63o5oDlNBh0VAlmtgw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-literals@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.14.5.tgz#41d06c7ff5d4d09e3cf4587bd3ecf3930c730f78"
-  integrity sha512-ql33+epql2F49bi8aHXxvLURHkxJbSmMKl9J5yHqg4PLtdE6Uc48CH1GS6TQvZ86eoB/ApZXwm7jlA+B3kra7A==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-modules-amd@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.14.5.tgz#4fd9ce7e3411cb8b83848480b7041d83004858f7"
-  integrity sha512-3lpOU8Vxmp3roC4vzFpSdEpGUWSMsHFreTWOMMLzel2gNGfHE5UWIh/LN6ghHs2xurUp4jRFYMUIZhuFbody1g==
-  dependencies:
-    "@babel/helper-module-transforms" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    babel-plugin-dynamic-import-node "^2.3.3"
-
-"@babel/plugin-transform-object-super@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.14.5.tgz#d0b5faeac9e98597a161a9cf78c527ed934cdc45"
-  integrity sha512-MKfOBWzK0pZIrav9z/hkRqIk/2bTv9qvxHzPQc12RcVkMOzpIKnFCNYJip00ssKWYkd8Sf5g0Wr7pqJ+cmtuFg==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.14.5"
-
-"@babel/plugin-transform-parameters@^7.0.0", "@babel/plugin-transform-parameters@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.14.5.tgz#49662e86a1f3ddccac6363a7dfb1ff0a158afeb3"
-  integrity sha512-Tl7LWdr6HUxTmzQtzuU14SqbgrSKmaR77M0OKyq4njZLQTPfOvzblNKyNkGwOfEFCEx7KeYHQHDI0P3F02IVkA==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-regenerator@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.14.5.tgz#9676fd5707ed28f522727c5b3c0aa8544440b04f"
-  integrity sha512-NVIY1W3ITDP5xQl50NgTKlZ0GrotKtLna08/uGY6ErQt6VEQZXla86x/CTddm5gZdcr+5GSsvMeTmWA5Ii6pkg==
-  dependencies:
-    regenerator-transform "^0.14.2"
-
-"@babel/plugin-transform-shorthand-properties@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.14.5.tgz#97f13855f1409338d8cadcbaca670ad79e091a58"
-  integrity sha512-xLucks6T1VmGsTB+GWK5Pl9Jl5+nRXD1uoFdA5TSO6xtiNjtXTjKkmPdFXVLGlK5A2/or/wQMKfmQ2Y0XJfn5g==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-spread@^7.0.0":
-  version "7.14.6"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.14.6.tgz#6bd40e57fe7de94aa904851963b5616652f73144"
-  integrity sha512-Zr0x0YroFJku7n7+/HH3A2eIrGMjbmAIbJSVv0IZ+t3U2WUQUA64S/oeied2e+MaGSjmt4alzBCsK9E8gh+fag==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5"
-
-"@babel/plugin-transform-sticky-regex@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.14.5.tgz#5b617542675e8b7761294381f3c28c633f40aeb9"
-  integrity sha512-Z7F7GyvEMzIIbwnziAZmnSNpdijdr4dWt+FJNBnBLz5mwDFkqIXU9wmBcWWad3QeJF5hMTkRe4dAq2sUZiG+8A==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-template-literals@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.14.5.tgz#a5f2bc233937d8453885dc736bdd8d9ffabf3d93"
-  integrity sha512-22btZeURqiepOfuy/VkFr+zStqlujWaarpMErvay7goJS6BWwdd6BY9zQyDLDa4x2S3VugxFb162IZ4m/S/+Gg==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-typeof-symbol@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.14.5.tgz#39af2739e989a2bd291bf6b53f16981423d457d4"
-  integrity sha512-lXzLD30ffCWseTbMQzrvDWqljvZlHkXU+CnseMhkMNqU1sASnCsz3tSzAaH3vCUXb9PHeUb90ZT1BdFTm1xxJw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-unicode-regex@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.14.5.tgz#4cd09b6c8425dd81255c7ceb3fb1836e7414382e"
-  integrity sha512-UygduJpC5kHeCiRw/xDVzC+wj8VaYSoKl5JNVmbP7MadpNinAm3SvZCxZ42H37KZBKztz46YC73i9yV34d0Tzw==
-  dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/runtime@^7.8.4":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.3.tgz#2e1c2880ca118e5b2f9988322bd8a7656a32502b"
-  integrity sha512-OvwMLqNXkCXSz1kSm58sEsNuhqOx/fKpnUnKnFB5v8uDda5bLNEHNgKPvhDN6IU0LDcnHQ90LlJ0Q6jnyBSIBA==
-  dependencies:
-    regenerator-runtime "^0.13.4"
-
-"@babel/template@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4"
-  integrity sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==
-  dependencies:
-    "@babel/code-frame" "^7.14.5"
-    "@babel/parser" "^7.14.5"
-    "@babel/types" "^7.14.5"
-
-"@babel/traverse@^7.0.0", "@babel/traverse@^7.0.0-beta.42", "@babel/traverse@^7.14.5", "@babel/traverse@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.15.0.tgz#4cca838fd1b2a03283c1f38e141f639d60b3fc98"
-  integrity sha512-392d8BN0C9eVxVWd8H6x9WfipgVH5IaIoLp23334Sc1vbKKWINnvwRpb4us0xtPaCumlwbTtIYNA0Dv/32sVFw==
-  dependencies:
-    "@babel/code-frame" "^7.14.5"
-    "@babel/generator" "^7.15.0"
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-hoist-variables" "^7.14.5"
-    "@babel/helper-split-export-declaration" "^7.14.5"
-    "@babel/parser" "^7.15.0"
-    "@babel/types" "^7.15.0"
+    "@babel/code-frame" "^7.16.7"
+    "@babel/generator" "^7.18.2"
+    "@babel/helper-environment-visitor" "^7.18.2"
+    "@babel/helper-function-name" "^7.17.9"
+    "@babel/helper-hoist-variables" "^7.16.7"
+    "@babel/helper-split-export-declaration" "^7.16.7"
+    "@babel/parser" "^7.18.0"
+    "@babel/types" "^7.18.2"
     debug "^4.1.0"
     globals "^11.1.0"
 
-"@babel/types@^7.0.0-beta.42", "@babel/types@^7.14.5", "@babel/types@^7.14.8", "@babel/types@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.0.tgz#61af11f2286c4e9c69ca8deb5f4375a73c72dcbd"
-  integrity sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ==
+"@babel/types@^7.0.0-beta.42", "@babel/types@^7.16.7", "@babel/types@^7.17.0", "@babel/types@^7.18.2":
+  version "7.18.4"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.4.tgz#27eae9b9fd18e9dccc3f9d6ad051336f307be354"
+  integrity sha512-ThN1mBcMq5pG/Vm2IcBmPPfyPXbd8S02rS+OBIDENdufvqC7Z/jHPCv9IcP01277aKtDI8g/2XysBN4hA8niiw==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.14.9"
+    "@babel/helper-validator-identifier" "^7.16.7"
     to-fast-properties "^2.0.0"
 
-"@bazel/concatjs@^5.1.0":
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.1.0.tgz#f4321dec4a225c3ceac41b2dc7ec7c3dd3dd5e21"
-  integrity sha512-sj+vxHVB/swh7awOfQ37h3p/gxSPgLSnUkDt6POrj26qkfi7HrLB1ZkWAPFIIxjEhsBp1LchoHiezjw2GylZQg==
+"@bazel/concatjs@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.5.0.tgz#e6104ed70595cae59463ae6b0b5389252566221e"
+  integrity sha512-hwG+ahivR20Z3iTOlkUz3OdwnW/PUaZyyz8BIX+GNUTg6U3rPHK51CavUirMupOU/LRJ5GyCwBNAAtjCyquo2g==
   dependencies:
     protobufjs "6.8.8"
     source-map-support "0.5.9"
     tsutils "3.21.0"
 
-"@bazel/rollup@^5.1.0":
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-5.1.0.tgz#dc858ddc93c9fdb9cc2e7982e632c939c646ebdc"
-  integrity sha512-wEiWdSyVbsycSirSYjR6FGfPGbRNI7sGNAYmrV0hIzYIi+KqXeTNcwKIRSE9PESP3mb0VWbZmHvXvmrWk6daPQ==
+"@bazel/rollup@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-5.5.0.tgz#1e152d6147ef5583ec9fd872756c9d0635db73c7"
+  integrity sha512-8SRbgVfaYdNb6PyIypj8jzzJHhlIRyMH3s5KpXODsjD+mXECH4jQxJ8VcRkt0f0exsgB12gK5dmoUK/F2PDKCw==
   dependencies:
-    "@bazel/worker" "5.1.0"
+    "@bazel/worker" "5.5.0"
 
-"@bazel/typescript@^5.1.0":
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-5.1.0.tgz#348552355cc92a43f22e637fabce76ed64505548"
-  integrity sha512-E7wYv1tBFtcsFp0YN7Cf9Lv184xOzvT5WJKwZxt+43oq8R5tGmTSuqQwm4c9JmEq6s0eZmwUaRv+WXp9hxsE4A==
+"@bazel/typescript@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-5.5.0.tgz#053c255acb1b3cac23d24984cd8d5d5542fe1f7c"
+  integrity sha512-Ord0+nCj+B1M4NDbe0uqZf2FyOCzaDAlc4DAsr5UKJrArCipIbMTEAxlsEk+WAYBNAFGO/FS9/zlDtLceqpHqw==
   dependencies:
-    "@bazel/worker" "5.1.0"
+    "@bazel/worker" "5.5.0"
     protobufjs "6.8.8"
     semver "5.6.0"
     source-map-support "0.5.9"
     tsutils "3.21.0"
 
-"@bazel/worker@5.1.0":
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/worker/-/worker-5.1.0.tgz#6f1e0f3ef628e3449d424cacd341c9abd09a3735"
-  integrity sha512-u3aU93UtHz3vL6ozezq0jnw83s1cNT4dAnW+vvB7M++YKFlB3CWzZFb0JRJbCp1b6DDe30ML0WOdd3nVYuylpw==
+"@bazel/worker@5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/worker/-/worker-5.5.0.tgz#d30b75e46f2052d33bcda251b328d36655a5636f"
+  integrity sha512-pYfjJKg4D1CQ/AJ1UGC5ySyH09gDqNiBrQJ0uMYVewIBW24uOAkKsJfTE2y4ES0UL1Ik758WO0la0mJeFOKScg==
   dependencies:
     google-protobuf "^3.6.1"
 
-"@dabh/diagnostics@^2.0.2":
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.2.tgz#290d08f7b381b8f94607dc8f471a12c675f9db31"
-  integrity sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q==
+"@jridgewell/gen-mapping@^0.3.0":
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.1.tgz#cf92a983c83466b8c0ce9124fadeaf09f7c66ea9"
+  integrity sha512-GcHwniMlA2z+WFPWuY8lp3fsza0I8xPFMWL5+n8LYyP6PSvPrXf4+n8stDHZY2DM0zy9sVkRDy1jDI4XGzYVqg==
   dependencies:
-    colorspace "1.1.x"
-    enabled "2.0.x"
-    kuler "^2.0.0"
+    "@jridgewell/set-array" "^1.0.0"
+    "@jridgewell/sourcemap-codec" "^1.4.10"
+    "@jridgewell/trace-mapping" "^0.3.9"
 
-"@mrmlnc/readdir-enhanced@^2.2.1":
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
-  integrity sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==
+"@jridgewell/resolve-uri@^3.0.3":
+  version "3.0.7"
+  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.0.7.tgz#30cd49820a962aff48c8fffc5cd760151fca61fe"
+  integrity sha512-8cXDaBBHOr2pQ7j77Y6Vp5VDT2sIqWyWQ56TjEq4ih/a4iST3dItRe8Q9fp0rrIl9DoKhWQtUQz/YpOxLkXbNA==
+
+"@jridgewell/set-array@^1.0.0":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.1.tgz#36a6acc93987adcf0ba50c66908bd0b70de8afea"
+  integrity sha512-Ct5MqZkLGEXTVmQYbGtx9SVqD2fqwvdubdps5D3djjAkgkKwT918VNOz65pEHFaYTeWcukmJmH5SwsA9Tn2ObQ==
+
+"@jridgewell/sourcemap-codec@^1.4.10":
+  version "1.4.13"
+  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz#b6461fb0c2964356c469e115f504c95ad97ab88c"
+  integrity sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w==
+
+"@jridgewell/trace-mapping@^0.3.9":
+  version "0.3.13"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.13.tgz#dcfe3e95f224c8fe97a87a5235defec999aa92ea"
+  integrity sha512-o1xbKhp9qnIAoHJSWd6KlCZfqslL4valSF81H8ImioOAxluWYWOpWkpyktY2vnt4tbrX9XYaxovq6cgowaJp2w==
   dependencies:
-    call-me-maybe "^1.0.1"
-    glob-to-regexp "^0.3.0"
-
-"@nodelib/fs.stat@^1.1.2":
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b"
-  integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==
-
-"@octokit/auth-token@^2.4.0":
-  version "2.4.5"
-  resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.4.5.tgz#568ccfb8cb46f36441fac094ce34f7a875b197f3"
-  integrity sha512-BpGYsPgJt05M7/L/5FoE1PiAbdxXFZkX/3kDYcsvd1v6UhlnE5e96dTDr0ezX/EFwciQxf3cNV0loipsURU+WA==
-  dependencies:
-    "@octokit/types" "^6.0.3"
-
-"@octokit/endpoint@^6.0.1":
-  version "6.0.12"
-  resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.12.tgz#3b4d47a4b0e79b1027fb8d75d4221928b2d05658"
-  integrity sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==
-  dependencies:
-    "@octokit/types" "^6.0.3"
-    is-plain-object "^5.0.0"
-    universal-user-agent "^6.0.0"
-
-"@octokit/openapi-types@^10.0.0":
-  version "10.0.0"
-  resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-10.0.0.tgz#db4335de99509021f501fc4e026e6ff495fe1e62"
-  integrity sha512-k1iO2zKuEjjRS1EJb4FwSLk+iF6EGp+ZV0OMRViQoWhQ1fZTk9hg1xccZII5uyYoiqcbC73MRBmT45y1vp2PPg==
-
-"@octokit/plugin-paginate-rest@^1.1.1":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-1.1.2.tgz#004170acf8c2be535aba26727867d692f7b488fc"
-  integrity sha512-jbsSoi5Q1pj63sC16XIUboklNw+8tL9VOnJsWycWYR78TKss5PVpIPb1TUUcMQ+bBh7cY579cVAWmf5qG+dw+Q==
-  dependencies:
-    "@octokit/types" "^2.0.1"
-
-"@octokit/plugin-request-log@^1.0.0":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz#5e50ed7083a613816b1e4a28aeec5fb7f1462e85"
-  integrity sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==
-
-"@octokit/plugin-rest-endpoint-methods@2.4.0":
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-2.4.0.tgz#3288ecf5481f68c494dd0602fc15407a59faf61e"
-  integrity sha512-EZi/AWhtkdfAYi01obpX0DF7U6b1VRr30QNQ5xSFPITMdLSfhcBqjamE3F+sKcxPbD7eZuMHu3Qkk2V+JGxBDQ==
-  dependencies:
-    "@octokit/types" "^2.0.1"
-    deprecation "^2.3.1"
-
-"@octokit/request-error@^1.0.2":
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-1.2.1.tgz#ede0714c773f32347576c25649dc013ae6b31801"
-  integrity sha512-+6yDyk1EES6WK+l3viRDElw96MvwfJxCt45GvmjDUKWjYIb3PJZQkq3i46TwGwoPD4h8NmTrENmtyA1FwbmhRA==
-  dependencies:
-    "@octokit/types" "^2.0.0"
-    deprecation "^2.0.0"
-    once "^1.4.0"
-
-"@octokit/request-error@^2.1.0":
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-2.1.0.tgz#9e150357831bfc788d13a4fd4b1913d60c74d677"
-  integrity sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==
-  dependencies:
-    "@octokit/types" "^6.0.3"
-    deprecation "^2.0.0"
-    once "^1.4.0"
-
-"@octokit/request@^5.2.0":
-  version "5.6.1"
-  resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.6.1.tgz#f97aff075c37ab1d427c49082fefeef0dba2d8ce"
-  integrity sha512-Ls2cfs1OfXaOKzkcxnqw5MR6drMA/zWX/LIS/p8Yjdz7QKTPQLMsB3R+OvoxE6XnXeXEE2X7xe4G4l4X0gRiKQ==
-  dependencies:
-    "@octokit/endpoint" "^6.0.1"
-    "@octokit/request-error" "^2.1.0"
-    "@octokit/types" "^6.16.1"
-    is-plain-object "^5.0.0"
-    node-fetch "^2.6.1"
-    universal-user-agent "^6.0.0"
-
-"@octokit/rest@^16.2.0":
-  version "16.43.2"
-  resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-16.43.2.tgz#c53426f1e1d1044dee967023e3279c50993dd91b"
-  integrity sha512-ngDBevLbBTFfrHZeiS7SAMAZ6ssuVmXuya+F/7RaVvlysgGa1JKJkKWY+jV6TCJYcW0OALfJ7nTIGXcBXzycfQ==
-  dependencies:
-    "@octokit/auth-token" "^2.4.0"
-    "@octokit/plugin-paginate-rest" "^1.1.1"
-    "@octokit/plugin-request-log" "^1.0.0"
-    "@octokit/plugin-rest-endpoint-methods" "2.4.0"
-    "@octokit/request" "^5.2.0"
-    "@octokit/request-error" "^1.0.2"
-    atob-lite "^2.0.0"
-    before-after-hook "^2.0.0"
-    btoa-lite "^1.0.0"
-    deprecation "^2.0.0"
-    lodash.get "^4.4.2"
-    lodash.set "^4.3.2"
-    lodash.uniq "^4.5.0"
-    octokit-pagination-methods "^1.1.0"
-    once "^1.4.0"
-    universal-user-agent "^4.0.0"
-
-"@octokit/types@^2.0.0", "@octokit/types@^2.0.1":
-  version "2.16.2"
-  resolved "https://registry.yarnpkg.com/@octokit/types/-/types-2.16.2.tgz#4c5f8da3c6fecf3da1811aef678fda03edac35d2"
-  integrity sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==
-  dependencies:
-    "@types/node" ">= 8"
-
-"@octokit/types@^6.0.3", "@octokit/types@^6.16.1":
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.26.0.tgz#b8af298485d064ad9424cb41520541c1bf820346"
-  integrity sha512-RDxZBAFMtqs1ZPnbUu1e7ohPNfoNhTiep4fErY7tZs995BeHu369Vsh5woMIaFbllRWEZBfvTCS4hvDnMPiHrA==
-  dependencies:
-    "@octokit/openapi-types" "^10.0.0"
-
-"@polymer/esm-amd-loader@^1.0.0":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@polymer/esm-amd-loader/-/esm-amd-loader-1.0.4.tgz#4e77f2f59b29b01e0ad02aa83d33716cddc5f9f9"
-  integrity sha512-h+hqYkL+tQV/y2ESD5gFXMl5z4cC+XY1jTlBeGSBaTcj3VbB5OBEScbvRXm63NcEbBneQQYbHfBAXAkF9i9wIA==
-
-"@polymer/sinonjs@^1.14.1":
-  version "1.17.1"
-  resolved "https://registry.yarnpkg.com/@polymer/sinonjs/-/sinonjs-1.17.1.tgz#e47d3785b7d0e8c29feb97f7e924b0fc597e2e9b"
-  integrity sha512-/U8F/cOTrbF2iVVYgINYmvKbtbexs+89Q3v8AaHADRYabTg7aOZGOb0RyWpOI+sUJt04kj63U4FwMhzW5r4wZA==
-
-"@polymer/test-fixture@^0.0.3":
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-0.0.3.tgz#4443752697d4d9293bbc412ea0b5e4d341f149d9"
-  integrity sha1-REN1JpfU2Sk7vEEuoLXk00HxSdk=
+    "@jridgewell/resolve-uri" "^3.0.3"
+    "@jridgewell/sourcemap-codec" "^1.4.10"
 
 "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
-  integrity sha1-m4sMxmPWaafY9vXQiToU00jzD78=
+  integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==
 
 "@protobufjs/base64@^1.1.2":
   version "1.1.2"
@@ -696,12 +181,12 @@
 "@protobufjs/eventemitter@^1.1.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70"
-  integrity sha1-NVy8mLr61ZePntCV85diHx0Ga3A=
+  integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==
 
 "@protobufjs/fetch@^1.1.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45"
-  integrity sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=
+  integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==
   dependencies:
     "@protobufjs/aspromise" "^1.1.1"
     "@protobufjs/inquire" "^1.1.0"
@@ -709,32 +194,40 @@
 "@protobufjs/float@^1.0.2":
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1"
-  integrity sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=
+  integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==
 
 "@protobufjs/inquire@^1.1.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089"
-  integrity sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=
+  integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==
 
 "@protobufjs/path@^1.1.2":
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d"
-  integrity sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=
+  integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==
 
 "@protobufjs/pool@^1.1.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54"
-  integrity sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=
+  integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==
 
 "@protobufjs/utf8@^1.1.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
-  integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
+  integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
+
+"@rollup/pluginutils@^4.0.0":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d"
+  integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==
+  dependencies:
+    estree-walker "^2.0.1"
+    picomatch "^2.2.2"
 
 "@sindresorhus/is@^4.0.0":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.0.1.tgz#d26729db850fa327b7cacc5522252194404226f5"
-  integrity sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f"
+  integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==
 
 "@szmarczak/http-timer@^4.0.5":
   version "4.0.6"
@@ -744,9 +237,9 @@
     defer-to-connect "^2.0.0"
 
 "@types/babel-generator@^6.25.1":
-  version "6.25.4"
-  resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.4.tgz#74eacdaa4822c4c6923e68c541144a04415ad8a1"
-  integrity sha512-Rnsen+ckop5mbl9d43bempS7i9wdTN1vytiTlmQla/YiNm6kH8kEVABVSXmp1UbnpkUV44nUCPeDQoa+Mu7ALA==
+  version "6.25.5"
+  resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.5.tgz#b02723fd589349b05524376e5530228d3675d878"
+  integrity sha512-lhbwMlAy5rfWG+R6l8aPtJdEFX/kcv6LMFIuvUb0i89ehqgD24je9YcB+0fRspQhgJGlEsUImxpw4pQeKS/+8Q==
   dependencies:
     "@types/babel-types" "*"
 
@@ -774,15 +267,10 @@
   dependencies:
     "@types/babel-types" "*"
 
-"@types/bluebird@*":
-  version "3.5.36"
-  resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.36.tgz#00d9301d4dc35c2f6465a8aec634bb533674c652"
-  integrity sha512-HBNx4lhkxN7bx6P0++W8E289foSu8kO8GCk2unhuVggO+cE7rh9DhZUyPhUxNRG9m+5B5BTKxZQ5ZP92x/mx9Q==
-
 "@types/body-parser@*":
-  version "1.19.1"
-  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.1.tgz#0c0174c42a7d017b818303d4b5d969cb0b75929c"
-  integrity sha512-a6bTJ21vFOGIkwM0kzh9Yr89ziVxq4vYH2fQ6N8AeipEzai/cFK6aGMArIkUeIdRIgpwQa+2bXiLuUJCpSf2Cg==
+  version "1.19.2"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
+  integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==
   dependencies:
     "@types/connect" "*"
     "@types/node" "*"
@@ -805,41 +293,19 @@
     "@types/chai" "*"
 
 "@types/chai@*":
-  version "4.2.21"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.21.tgz#9f35a5643129df132cf3b5c1ec64046ea1af0650"
-  integrity sha512-yd+9qKmJxm496BOV9CMNaey8TWsikaZOwMRwPHQIjcOJM9oV+fi9ZMNw3JsVnbEEbo2gRTDnGEBv8pjyn67hNg==
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.1.tgz#e2c6e73e0bdeb2521d00756d099218e9f5d90a04"
+  integrity sha512-/zPMqDkzSZ8t3VtxOa4KPq7uzzW978M9Tvh+j7GHKuo6k6GTLxPJ4J5gE5cjfJ26pnXst0N5Hax8Sr0T2Mi9zQ==
 
 "@types/chalk@^0.4.30":
   version "0.4.31"
   resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-0.4.31.tgz#a31d74241a6b1edbb973cf36d97a2896834a51f9"
-  integrity sha1-ox10JBprHtu5c8822XooloNKUfk=
-
-"@types/chalk@^2.2.0":
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-2.2.0.tgz#b7f6e446f4511029ee8e3f43075fb5b73fbaa0ba"
-  integrity sha512-1zzPV9FDe1I/WHhRkf9SNgqtRJWZqrBWgu7JGveuHmmyR9CnAPCie2N/x+iHrgnpYBIcCJWHBoMRv2TRWktsvw==
-  dependencies:
-    chalk "*"
-
-"@types/clean-css@*":
-  version "4.2.5"
-  resolved "https://registry.yarnpkg.com/@types/clean-css/-/clean-css-4.2.5.tgz#69ce62cc13557c90ca40460133f672dc52ceaf89"
-  integrity sha512-NEzjkGGpbs9S9fgC4abuBvTpVwE3i+Acu9BBod3PUyjDVZcNsGx61b8r2PphR61QGPnn0JHVs5ey6/I4eTrkxw==
-  dependencies:
-    "@types/node" "*"
-    source-map "^0.6.0"
+  integrity sha512-nF0fisEPYMIyfrFgabFimsz9Lnuu9MwkNrrlATm2E4E46afKDyeelT+8bXfw1VSc7sLBxMxRgT7PxTC2JcqN4Q==
 
 "@types/clone@^0.1.29", "@types/clone@^0.1.30":
   version "0.1.30"
   resolved "https://registry.yarnpkg.com/@types/clone/-/clone-0.1.30.tgz#e7365648c1b42136a59c7d5040637b3b5c83b614"
-  integrity sha1-5zZWSMG0ITalnH1QQGN7O1yDthQ=
-
-"@types/compression@^0.0.33":
-  version "0.0.33"
-  resolved "https://registry.yarnpkg.com/@types/compression/-/compression-0.0.33.tgz#95dc733a2339aa846381d7f1377792d2553dc27d"
-  integrity sha1-ldxzOiM5qoRjgdfxN3eS0lU9wn0=
-  dependencies:
-    "@types/express" "*"
+  integrity sha512-vcxBr+ybljeSiasmdke1cQ9ICxoEwaBgM1OQ/P5h4MPj/kRyLcDl5L8PrftlbyV1kBbJIs3M3x1A1+rcWd4mEA==
 
 "@types/connect@*":
   version "3.4.35"
@@ -848,58 +314,31 @@
   dependencies:
     "@types/node" "*"
 
-"@types/content-type@^1.1.0":
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/@types/content-type/-/content-type-1.1.5.tgz#aa02dca40864749a9e2bf0161a6216da57e3ede5"
-  integrity sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==
-
 "@types/cssbeautify@^0.3.1":
   version "0.3.2"
   resolved "https://registry.yarnpkg.com/@types/cssbeautify/-/cssbeautify-0.3.2.tgz#8a76207cd980d3e7b29b4b6dea1f4ed861285615"
   integrity sha512-b3PXlFAcS4gvGr2pDz0NoZEBo3MMQe8Ozy6+Mvm3XIEcHS4oQstvCnnCofBZD/0tQgxSzkYbW+cD3yD4yaKTxQ==
 
-"@types/del@^3.0.0":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@types/del/-/del-3.0.1.tgz#4712da8c119873cbbf533ad8dbf1baac5940ac5d"
-  integrity sha512-y6qRq6raBuu965clKgx6FHuiPu3oHdtmzMPXi8Uahsjdq1L6DL5fS/aY5/s71YwM7k6K1QIWvem5vNwlnNGIkQ==
-  dependencies:
-    "@types/glob" "*"
-
 "@types/doctrine@^0.0.1":
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.1.tgz#b999f2d9f7b43cabe0a1a2f39bc203bc7dcada9d"
-  integrity sha1-uZny2fe0PKvgoaLzm8IDvH3K2p0=
-
-"@types/escape-html@0.0.20":
-  version "0.0.20"
-  resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-0.0.20.tgz#cae698714dd61ebee5ab3f2aeb9a34ba1011735a"
-  integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
+  integrity sha512-iN9ewNbXmuWLOAB3wk/YpCqIBWK3wBNE1D/4u+jA/GyrqsE4r3ozbpS5F0fr0tIYmmnqhbVvT9OOXzt+vw+LDg==
 
 "@types/estree@*":
-  version "0.0.50"
-  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83"
-  integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==
-
-"@types/events@*":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
-  integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==
-
-"@types/expect@^1.20.4":
-  version "1.20.4"
-  resolved "https://registry.yarnpkg.com/@types/expect/-/expect-1.20.4.tgz#8288e51737bf7e3ab5d7c77bfa695883745264e5"
-  integrity sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==
+  version "0.0.51"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40"
+  integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==
 
 "@types/express-serve-static-core@^4.17.18":
-  version "4.17.24"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.24.tgz#ea41f93bf7e0d59cd5a76665068ed6aab6815c07"
-  integrity sha512-3UJuW+Qxhzwjq3xhwXm2onQcFHn76frIYVbTu+kn24LFxI+dEhdfISDFovPB8VpEgW8oQCTpRuCe+0zJxB7NEA==
+  version "4.17.28"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz#c47def9f34ec81dc6328d0b1b5303d1ec98d86b8"
+  integrity sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
     "@types/range-parser" "*"
 
-"@types/express@*", "@types/express@^4.0.30", "@types/express@^4.0.36":
+"@types/express@^4.0.30":
   version "4.17.13"
   resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034"
   integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==
@@ -909,100 +348,30 @@
     "@types/qs" "*"
     "@types/serve-static" "*"
 
-"@types/fast-levenshtein@0.0.1":
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/@types/fast-levenshtein/-/fast-levenshtein-0.0.1.tgz#3a3615cf173645c8fca58d051e4e32824e4bd286"
-  integrity sha1-OjYVzxc2Rcj8pY0FHk4ygk5L0oY=
-
-"@types/findup-sync@^0.3.29":
-  version "0.3.30"
-  resolved "https://registry.yarnpkg.com/@types/findup-sync/-/findup-sync-0.3.30.tgz#8ab7bdbd6ba7cbf4f33b6596fde6fff1129c738d"
-  integrity sha512-Dpt1x3rhz6t8BMTS4vziTVos8VLkF4RngIxMBCSE6w0STmnVEEaoe3w+BG5xHyZXshye9lyZE99lpBDoLGY8eA==
-  dependencies:
-    "@types/minimatch" "*"
-
-"@types/form-data@*":
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.5.0.tgz#5025f7433016f923348434c40006d9a797c1b0e8"
-  integrity sha512-23/wYiuckYYtFpL+4RPWiWmRQH2BjFuqCUi2+N3amB1a1Drv+i/byTrGvlLwRVLFNAZbwpbQ7JvTK+VCAPMbcg==
-  dependencies:
-    form-data "*"
-
 "@types/freeport@^1.0.19":
   version "1.0.22"
   resolved "https://registry.yarnpkg.com/@types/freeport/-/freeport-1.0.22.tgz#dbe627a20cb30c17c8aaaba09332e1d14cc2281f"
   integrity sha512-UGg4s5PDPXZXkkrHarU1l6WDbULxN3g7xUEtdbNf9HQhU/JnCj1G1/xZHZmQjC0uWqN1LlB0R0xOlk3k5svgTQ==
 
-"@types/glob-stream@*":
-  version "6.1.1"
-  resolved "https://registry.yarnpkg.com/@types/glob-stream/-/glob-stream-6.1.1.tgz#c792d8d1514278ff03cad5689aba4c4ab4fbc805"
-  integrity sha512-AGOUTsTdbPkRS0qDeyeS+6KypmfVpbT5j23SN8UPG63qjKXNKjXn6V9wZUr8Fin0m9l8oGYaPK8b2WUMF8xI1A==
-  dependencies:
-    "@types/glob" "*"
-    "@types/node" "*"
-
-"@types/glob@*", "@types/glob@^7.1.1":
-  version "7.1.4"
-  resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.4.tgz#ea59e21d2ee5c517914cb4bc8e4153b99e566672"
-  integrity sha512-w+LsMxKyYQm347Otw+IfBXOv9UWVjpHpCDdbBMt8Kz/xbvCYNjP+0qPh91Km3iKfSRLBB0P7fAMf0KHrPu+MyA==
-  dependencies:
-    "@types/minimatch" "*"
-    "@types/node" "*"
-
-"@types/globby@^6.1.0":
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/@types/globby/-/globby-6.1.0.tgz#7c25b975512a89effea2a656ca8cf6db7fb29d11"
-  integrity sha512-j3XSDNoK4LO5T+ZviQD6PqfEjm07QFEacOTbJR3hnLWuWX0ZMLJl9oRPgj1PyrfGbXhfHFkksC9QZ9HFltJyrw==
-  dependencies:
-    "@types/glob" "*"
-
-"@types/gulp-if@0.0.33":
-  version "0.0.33"
-  resolved "https://registry.yarnpkg.com/@types/gulp-if/-/gulp-if-0.0.33.tgz#edece22b7925d9a6db5f9c8c0d7882aa776fb678"
-  integrity sha512-J5lzff21X7r1x/4hSzn02GgIUEyjCqYIXZ9GgGBLhbsD3RiBdqwnkFWgF16/0jO5rcVZ52Zp+6MQMQdvIsWuKg==
-  dependencies:
-    "@types/node" "*"
-    "@types/vinyl" "*"
-
-"@types/html-minifier@^3.5.1":
-  version "3.5.3"
-  resolved "https://registry.yarnpkg.com/@types/html-minifier/-/html-minifier-3.5.3.tgz#5276845138db2cebc54c789e0aaf87621a21e84f"
-  integrity sha512-j1P/4PcWVVCPEy5lofcHnQ6BtXz9tHGiFPWzqm7TtGuWZEfCHEP446HlkSNc9fQgNJaJZ6ewPtp2aaFla/Uerg==
-  dependencies:
-    "@types/clean-css" "*"
-    "@types/relateurl" "*"
-    "@types/uglify-js" "*"
-
 "@types/http-cache-semantics@*":
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812"
   integrity sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==
 
-"@types/inquirer@*":
-  version "7.3.3"
-  resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-7.3.3.tgz#92e6676efb67fa6925c69a2ee638f67a822952ac"
-  integrity sha512-HhxyLejTHMfohAuhRun4csWigAMjXTmRyiJTU1Y/I1xmggikFMkOUoMQRlFm+zQcPEGHSs3io/0FAmNZf8EymQ==
-  dependencies:
-    "@types/through" "*"
-    rxjs "^6.4.0"
-
-"@types/inquirer@0.0.32":
-  version "0.0.32"
-  resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-0.0.32.tgz#a4a08e83741c500a7c3c8e7776014f7f8a65870d"
-  integrity sha1-pKCOg3QcUAp8PI53dgFPf4plhw0=
-  dependencies:
-    "@types/rx" "*"
-    "@types/through" "*"
-
 "@types/is-windows@^0.2.0":
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/@types/is-windows/-/is-windows-0.2.0.tgz#6f24ee48731d31168ea510610d6dd15e5fc9c6ff"
-  integrity sha1-byTuSHMdMRaOpRBhDW3RXl/Jxv8=
+  integrity sha512-xuK4kuYgV6/auME6nVp78i9B22jBUYZUCTl64fpJ3O7qWRxK5uRya5yrkBAlSU17k3EVf0DwT7NUjCo5wZD8OA==
+
+"@types/json-buffer@~3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/json-buffer/-/json-buffer-3.0.0.tgz#85c1ff0f0948fc159810d4b5be35bf8c20875f64"
+  integrity sha512-3YP80IxxFJB4b5tYC2SUPwkg0XQLiu0nWvhRgEatgjf+29IcWO9X1k8xRv5DGssJ/lCrjYTjQPcobJr2yWIVuQ==
 
 "@types/keyv@*":
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.2.tgz#5d97bb65526c20b6e0845f6b0d2ade4f28604ee5"
-  integrity sha512-/FvAK2p4jQOaJ6CGDHJTqZcUtbZe820qIeTg7o0Shg7drB4JHeL+V/dhSaly7NXx6u8eSee+r7coT+yuJEvDLg==
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6"
+  integrity sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==
   dependencies:
     "@types/node" "*"
 
@@ -1012,51 +381,24 @@
   integrity sha512-kQ1a7PwzJelwwOIw1SABmW5OsbCRPvdjps0J84MahGsEKzN89StrPyrWCMWfwpONR3ZqSxDeblxS+8WznIBEGw==
 
 "@types/long@^4.0.0":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
-  integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
-
-"@types/merge-stream@^1.0.28":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@types/merge-stream/-/merge-stream-1.1.2.tgz#a880ff66b1fbbb5eef4958d015c5947a9334dbb1"
-  integrity sha512-7faLmaE99g/yX0Y9pF1neh2IUqOf/fXMOWCVzsXjqI1EJ91lrgXmaBKf6bRWM164lLyiHxHt6t/ZO/cIzq61XA==
-  dependencies:
-    "@types/node" "*"
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a"
+  integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==
 
 "@types/mime@^1":
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
   integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
 
-"@types/mime@^2.0.0":
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a"
-  integrity sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==
-
-"@types/minimatch@*", "@types/minimatch@^3.0.1", "@types/minimatch@^3.0.3":
+"@types/minimatch@^3.0.1":
   version "3.0.5"
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
   integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
 
-"@types/mz@0.0.29":
-  version "0.0.29"
-  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.29.tgz#bc24728c649973f1c7851e9033f9ce525668c27b"
-  integrity sha1-vCRyjGSZc/HHhR6QM/nOUlZowns=
-  dependencies:
-    "@types/bluebird" "*"
-    "@types/node" "*"
-
-"@types/mz@0.0.31", "@types/mz@^0.0.31":
-  version "0.0.31"
-  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.31.tgz#a4d80c082fefe71e40a7c0f07d1e6555bbbc7b52"
-  integrity sha1-pNgMCC/v5x5Ap8DwfR5lVbu8e1I=
-  dependencies:
-    "@types/node" "*"
-
-"@types/node@*", "@types/node@>= 8":
-  version "16.7.10"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-16.7.10.tgz#7aa732cc47341c12a16b7d562f519c2383b6d4fc"
-  integrity sha512-S63Dlv4zIPb8x6MMTgDq5WWRJQe56iBEY0O3SOFA9JrRienkOVDXSXBjjJw6HTNQYSE2JI6GMCR6LVbIMHJVvA==
+"@types/node@*":
+  version "17.0.38"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.38.tgz#f8bb07c371ccb1903f3752872c89f44006132947"
+  integrity sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g==
 
 "@types/node@6.0.*":
   version "6.0.118"
@@ -1073,18 +415,6 @@
   resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.5.tgz#a3785db96b07a4b56466cc99fd624838746f2e25"
   integrity sha512-+8fpgbXsbATKRF2ayAlYhPl2E9MPdLjrnK/79ZEpyPJ+k7dZwJm9YM8FK+l4rqL//xHk7PgQhGwz6aA2ckxbCQ==
 
-"@types/normalize-package-data@^2.4.0":
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
-  integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
-
-"@types/opn@^3.0.28":
-  version "3.0.28"
-  resolved "https://registry.yarnpkg.com/@types/opn/-/opn-3.0.28.tgz#097d0d1c9b5749573a5d96df132387bb6d02118a"
-  integrity sha1-CX0NHJtXSVc6XZbfEyOHu20CEYo=
-  dependencies:
-    "@types/node" "*"
-
 "@types/parse5-html-rewriting-stream@^5.1.2":
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/@types/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.2.tgz#919d5bbf69ef61e11d873e7195891c3811491a03"
@@ -1101,21 +431,21 @@
     "@types/parse5" "*"
 
 "@types/parse5@*":
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.1.tgz#f8ae4fbcd2b9ba4ff934698e28778961f9cb22ca"
-  integrity sha512-ARATsLdrGPUnaBvxLhUlnltcMgn7pQG312S8ccdYlnyijabrX9RN/KN/iGj9Am96CoW8e/K9628BA7Bv4XHdrA==
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb"
+  integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
 
 "@types/parse5@^0.0.31":
   version "0.0.31"
   resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-0.0.31.tgz#e827a493a443b156e1b582a2e4c3bdc0040f2ee7"
-  integrity sha1-6Cekk6RDsVbhtYKi5MO9wAQPLuc=
+  integrity sha512-W9yKi+ZkSypS/6SXd0ebArnPxg5mwSAdmLqlJX+boeu845j3WVaYSJjqIg0i8Rh5btq7KytgIcta2KJB1aS4Mw==
   dependencies:
     "@types/node" "6.0.*"
 
 "@types/parse5@^2.2.34":
   version "2.2.34"
   resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-2.2.34.tgz#e3870a10e82735a720f62d71dcd183ba78ef3a9d"
-  integrity sha1-44cKEOgnNacg9i1x3NGDunjvOp0=
+  integrity sha512-p3qOvaRsRpFyEmaS36RtLzpdxZZnmxGuT1GMgzkTtTJVFuEw7KFjGK83MFODpJExgX1bEzy9r0NYjMC3IMfi7w==
   dependencies:
     "@types/node" "*"
 
@@ -1131,13 +461,6 @@
   resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
   integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
 
-"@types/pem@^1.8.1":
-  version "1.9.6"
-  resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.6.tgz#c3686832e935947fdd9d848dec3b8fe830068de7"
-  integrity sha512-IC67SxacM9fxEi/w7hf98dTun83OwUMeLMo1NS2gE0wdM9MHeg73iH/Pp9nB02OUCQ7Zb2UuKE/IpFCmQw9jxw==
-  dependencies:
-    "@types/node" "*"
-
 "@types/qs@*":
   version "6.9.7"
   resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
@@ -1148,26 +471,6 @@
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
   integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
 
-"@types/relateurl@*":
-  version "0.2.29"
-  resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.29.tgz#68ccecec3d4ffdafb9c577fe764f912afc050fe6"
-  integrity sha512-QSvevZ+IRww2ldtfv1QskYsqVVVwCKQf1XbwtcyyoRvLIQzfyPhj/C+3+PKzSDRdiyejaiLgnq//XTkleorpLg==
-
-"@types/request@2.0.3":
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/@types/request/-/request-2.0.3.tgz#bdf0fba9488c822f77e97de3dd8fe357b2fb8c06"
-  integrity sha512-cIvnyFRARxwE4OHpCyYue7H+SxaKFPpeleRCHJicft8QhyTNbVYsMwjvEzEPqG06D2LGHZ+sN5lXc8+bTu6D8A==
-  dependencies:
-    "@types/form-data" "*"
-    "@types/node" "*"
-
-"@types/resolve@0.0.4":
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.4.tgz#9b586d65a947dea88c4bc24da0b905fe9520a0d5"
-  integrity sha1-m1htZalH3qiMS8JNoLkF/pUgoNU=
-  dependencies:
-    "@types/node" "*"
-
 "@types/resolve@0.0.6":
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.6.tgz#0bd2f236c2e1cebb98b79885df57edd71a8d770e"
@@ -1175,13 +478,6 @@
   dependencies:
     "@types/node" "*"
 
-"@types/resolve@0.0.7":
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.7.tgz#b299c13be8d712b1b502fb14a084252acef84f4d"
-  integrity sha512-GPewdjkb0Q76o459qgp6pBLzJj/bD3oveS2kfLhIkZ9U3t3AFKtl5DlFB6lGTw0iZmcmxoGC8lpLW3NNJKrN9A==
-  dependencies:
-    "@types/node" "*"
-
 "@types/resolve@0.0.8":
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.8.tgz#f26074d238e02659e323ce1a13d041eee280e194"
@@ -1196,118 +492,7 @@
   dependencies:
     "@types/node" "*"
 
-"@types/rimraf@^0.0.28":
-  version "0.0.28"
-  resolved "https://registry.yarnpkg.com/@types/rimraf/-/rimraf-0.0.28.tgz#5562519bc7963caca8abf7f128cae3b594d41d06"
-  integrity sha1-VWJRm8eWPKyoq/fxKMrjtZTUHQY=
-
-"@types/rx-core-binding@*":
-  version "4.0.4"
-  resolved "https://registry.yarnpkg.com/@types/rx-core-binding/-/rx-core-binding-4.0.4.tgz#d969d32f15a62b89e2862c17b3ee78fe329818d3"
-  integrity sha512-5pkfxnC4w810LqBPUwP5bg7SFR/USwhMSaAeZQQbEHeBp57pjKXRlXmqpMrLJB4y1oglR/c2502853uN0I+DAQ==
-  dependencies:
-    "@types/rx-core" "*"
-
-"@types/rx-core@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-core/-/rx-core-4.0.3.tgz#0b3354b1238cedbe2b74f6326f139dbc7a591d60"
-  integrity sha1-CzNUsSOM7b4rdPYybxOdvHpZHWA=
-
-"@types/rx-lite-aggregates@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-aggregates/-/rx-lite-aggregates-4.0.3.tgz#6efb2b7f3d5f07183a1cb2bd4b1371d7073384c2"
-  integrity sha512-MAGDAHy8cRatm94FDduhJF+iNS5//jrZ/PIfm+QYw9OCeDgbymFHChM8YVIvN2zArwsRftKgE33QfRWvQk4DPg==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-async@*":
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-async/-/rx-lite-async-4.0.2.tgz#27fbf0caeff029f41e2d2aae638b05e91ceb600c"
-  integrity sha512-vTEv5o8l6702ZwfAM5aOeVDfUwBSDOs+ARoGmWAKQ6LOInQ8J4/zjM7ov12fuTpktUKdMQjkeCp07Vd73mPkxw==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-backpressure@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-backpressure/-/rx-lite-backpressure-4.0.3.tgz#05abb19bdf87cc740196c355e5d0b37bb50b5d56"
-  integrity sha512-Y6aIeQCtNban5XSAF4B8dffhIKu6aAy/TXFlScHzSxh6ivfQBQw6UjxyEJxIOt3IT49YkS+siuayM2H/Q0cmgA==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-coincidence@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-coincidence/-/rx-lite-coincidence-4.0.3.tgz#80bd69acc4054a15cdc1638e2dc8843498cd85c0"
-  integrity sha512-1VNJqzE9gALUyMGypDXZZXzR0Tt7LC9DdAZQ3Ou/Q0MubNU35agVUNXKGHKpNTba+fr8GdIdkC26bRDqtCQBeQ==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-experimental@*":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-experimental/-/rx-lite-experimental-4.0.1.tgz#c532f5cbdf3f2c15da16ded8930d1b2984023cbd"
-  integrity sha1-xTL1y98/LBXaFt7Ykw0bKYQCPL0=
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-joinpatterns@*":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-joinpatterns/-/rx-lite-joinpatterns-4.0.1.tgz#f70fe370518a8432f29158cc92ffb56b4e4afc3e"
-  integrity sha1-9w/jcFGKhDLykVjMkv+1a05K/D4=
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-testing@*":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-testing/-/rx-lite-testing-4.0.1.tgz#21b19d11f4dfd6ffef5a9d1648e9c8879bfe21e9"
-  integrity sha1-IbGdEfTf1v/vWp0WSOnIh5v+Iek=
-  dependencies:
-    "@types/rx-lite-virtualtime" "*"
-
-"@types/rx-lite-time@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-time/-/rx-lite-time-4.0.3.tgz#0eda65474570237598f3448b845d2696f2dbb1c4"
-  integrity sha512-ukO5sPKDRwCGWRZRqPlaAU0SKVxmWwSjiOrLhoQDoWxZWg6vyB9XLEZViKOzIO6LnTIQBlk4UylYV0rnhJLxQw==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-virtualtime@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-virtualtime/-/rx-lite-virtualtime-4.0.3.tgz#4b30cacd0fe2e53af29f04f7438584c7d3959537"
-  integrity sha512-3uC6sGmjpOKatZSVHI2xB1+dedgml669ZRvqxy+WqmGJDVusOdyxcKfyzjW0P3/GrCiN4nmRkLVMhPwHCc5QLg==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite@*":
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite/-/rx-lite-4.0.6.tgz#3c02921c4244074234f26b772241bcc20c18c253"
-  integrity sha512-oYiDrFIcor9zDm0VDUca1UbROiMYBxMLMaM6qzz4ADAfOmA9r1dYEcAFH+2fsPI5BCCjPvV9pWC3X3flbrvs7w==
-  dependencies:
-    "@types/rx-core" "*"
-    "@types/rx-core-binding" "*"
-
-"@types/rx@*":
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/@types/rx/-/rx-4.1.2.tgz#a4061b3d72b03cf11a38d69e2022a17334c54dc0"
-  integrity sha512-1r8ZaT26Nigq7o4UBGl+aXB2UMFUIdLPP/8bLIP0x3d0pZL46ybKKjhWKaJQWIkLl5QCLD0nK3qTOO1QkwdFaA==
-  dependencies:
-    "@types/rx-core" "*"
-    "@types/rx-core-binding" "*"
-    "@types/rx-lite" "*"
-    "@types/rx-lite-aggregates" "*"
-    "@types/rx-lite-async" "*"
-    "@types/rx-lite-backpressure" "*"
-    "@types/rx-lite-coincidence" "*"
-    "@types/rx-lite-experimental" "*"
-    "@types/rx-lite-joinpatterns" "*"
-    "@types/rx-lite-testing" "*"
-    "@types/rx-lite-time" "*"
-    "@types/rx-lite-virtualtime" "*"
-
-"@types/semver@^5.3.30":
-  version "5.5.0"
-  resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45"
-  integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==
-
-"@types/serve-static@*", "@types/serve-static@^1.7.31":
+"@types/serve-static@*":
   version "1.13.10"
   resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9"
   integrity sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==
@@ -1315,75 +500,6 @@
     "@types/mime" "^1"
     "@types/node" "*"
 
-"@types/spdy@^3.4.1":
-  version "3.4.5"
-  resolved "https://registry.yarnpkg.com/@types/spdy/-/spdy-3.4.5.tgz#194dc132312ddcd31e8053789ae83a7bb32a8aaf"
-  integrity sha512-/33fIRK/aqkKNxg9BSjpzt1ucmvPremgeDywm9z2C2mOlIh5Ljjvgc3UhQHqwXsSLDLHPT9jlsnrjKQ1XiVJzA==
-  dependencies:
-    "@types/node" "*"
-
-"@types/temp@^0.8.28":
-  version "0.8.34"
-  resolved "https://registry.yarnpkg.com/@types/temp/-/temp-0.8.34.tgz#03e4b3cb67cbb48c425bbf54b12230fef85540ac"
-  integrity sha512-oLa9c5LHXgS6UimpEVp08De7QvZ+Dfu5bMQuWyMhf92Z26Q10ubEMOWy9OEfUdzW7Y/sDWVHmUaLFtmnX/2j0w==
-  dependencies:
-    "@types/node" "*"
-
-"@types/through@*":
-  version "0.0.30"
-  resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895"
-  integrity sha512-FvnCJljyxhPM3gkRgWmxmDZyAQSiBQQWLI0A0VFL0K7W1oRUrPJSqNO0NvTnLkBcotdlp3lKvaT0JrnyRDkzOg==
-  dependencies:
-    "@types/node" "*"
-
-"@types/ua-parser-js@^0.7.31":
-  version "0.7.36"
-  resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz#9bd0b47f26b5a3151be21ba4ce9f5fa457c5f190"
-  integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==
-
-"@types/uglify-js@*":
-  version "3.13.1"
-  resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.13.1.tgz#5e889e9e81e94245c75b6450600e1c5ea2878aea"
-  integrity sha512-O3MmRAk6ZuAKa9CHgg0Pr0+lUOqoMLpc9AS4R8ano2auvsg7IE8syF3Xh/NPr26TWklxYcqoEEFdzLLs1fV9PQ==
-  dependencies:
-    source-map "^0.6.1"
-
-"@types/update-notifier@^1.0.0":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@types/update-notifier/-/update-notifier-1.0.4.tgz#ce73d597bd399d5df4544fe136a79c2b9fe41958"
-  integrity sha512-smyU9GTDitojg87woCcLNCdPnUfNx4LHRBWf+aWmHsAgE1kaCDhhcu84W+dFymAKL1yKDsq2JFWKkR2K6WjJfw==
-
-"@types/uuid@^3.4.3":
-  version "3.4.10"
-  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.10.tgz#637d3c8431f112edf6728ac9bdfadfe029540f48"
-  integrity sha512-BgeaZuElf7DEYZhWYDTc/XcLZXdVgFkVSTa13BqKvbnmUrxr3TJFKofUxCtDO9UQOdhnV+HPOESdHiHKZOJV1A==
-
-"@types/vinyl-fs@0.0.28":
-  version "0.0.28"
-  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-0.0.28.tgz#4663017bc802c6570eae4f3409fd5cabf97cbfde"
-  integrity sha1-RmMBe8gCxlcOrk80Cf1cq/l8v94=
-  dependencies:
-    "@types/glob-stream" "*"
-    "@types/node" "*"
-    "@types/vinyl" "*"
-
-"@types/vinyl-fs@^2.4.8":
-  version "2.4.12"
-  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-2.4.12.tgz#7b4673d9b4d5a874c8652d10f0f0265479014c8e"
-  integrity sha512-LgBpYIWuuGsihnlF+OOWWz4ovwCYlT03gd3DuLwex50cYZLmX3yrW+sFF9ndtmh7zcZpS6Ri47PrIu+fV+sbXw==
-  dependencies:
-    "@types/glob-stream" "*"
-    "@types/node" "*"
-    "@types/vinyl" "*"
-
-"@types/vinyl@*", "@types/vinyl@^2.0.0":
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/@types/vinyl/-/vinyl-2.0.5.tgz#52d3b850a4ed494aaad51e96708834c500c8d5cd"
-  integrity sha512-1m6uReH8R/RuLVQGvTT/4LlWq67jZEUxp+FBHt0hYv2BT7TUwFbKI0wa7JZVEU/XtlcnX1QcTuZ36es4rGj7jg==
-  dependencies:
-    "@types/expect" "^1.20.4"
-    "@types/node" "*"
-
 "@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"
@@ -1396,51 +512,17 @@
   resolved "https://registry.yarnpkg.com/@types/which/-/which-1.3.2.tgz#9c246fc0c93ded311c8512df2891fb41f6227fdf"
   integrity sha512-8oDqyLC7eD4HM307boe2QWKyuzdzWBj56xI/imSl2cpL+U3tCMaTAkMJ4ee5JBZ/FsOJlvRGeIShiZDAl1qERA==
 
-"@types/yeoman-generator@^2.0.3":
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/@types/yeoman-generator/-/yeoman-generator-2.0.3.tgz#f4b161ee354078b526e0901a5a5f87d4f8e085f6"
-  integrity sha512-vch2UFd6k7DdfWEv/alRwZIRXQoxZNUDpfLOK24+005dzE1HVnwSWfETF3WxJnWlsOcH87wU4uzldAE/7F/6Lw==
-  dependencies:
-    "@types/events" "*"
-    "@types/inquirer" "*"
-
-"@webcomponents/webcomponentsjs@^1.0.7":
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.3.3.tgz#5bb82a0d3210c836bd4623e13a4a93145cb9dc27"
-  integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
-
-JSONStream@^1.2.1, JSONStream@^1.3.5:
-  version "1.3.5"
-  resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"
-  integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==
-  dependencies:
-    jsonparse "^1.2.0"
-    through ">=2.2.7 <3"
-
-accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
-  version "1.3.7"
-  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
-  integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
-  dependencies:
-    mime-types "~2.1.24"
-    negotiator "0.6.2"
-
-accessibility-developer-tools@^2.12.0:
-  version "2.12.0"
-  resolved "https://registry.yarnpkg.com/accessibility-developer-tools/-/accessibility-developer-tools-2.12.0.tgz#3da0cce9d6ec6373964b84f35db7cfc3df7ab514"
-  integrity sha1-PaDM6dbsY3OWS4TzXbfPw996tRQ=
-
 acorn-jsx@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b"
-  integrity sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=
+  integrity sha512-AU7pnZkguthwBjKgCg6998ByQNIMjbuDQZ8bb78QAFZwPfmKia8AIzgY/gWgqCjnht8JLdXmB4YxA0KaV60ncQ==
   dependencies:
     acorn "^3.0.4"
 
 acorn@^3.0.4:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
-  integrity sha1-ReN/s56No/JbruP/U2niu18iAXo=
+  integrity sha512-OLUyIIZ7mF5oaAUT1w0TFqQS81q3saT46x8t7ukpPjMNk+nbs4ZHhs7ToV8EWnLYLepjETXd4XaCE4uxkMeqUw==
 
 acorn@^5.5.0:
   version "5.7.4"
@@ -1452,23 +534,6 @@
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
   integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
 
-adm-zip@~0.4.3:
-  version "0.4.16"
-  resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.16.tgz#cf4c508fdffab02c269cbc7f471a875f05570365"
-  integrity sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==
-
-after@0.8.2:
-  version "0.8.2"
-  resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
-  integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
-
-agent-base@6:
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
-  integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
-  dependencies:
-    debug "4"
-
 agent-base@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
@@ -1476,68 +541,22 @@
   dependencies:
     es6-promisify "^5.0.0"
 
-ajv@^6.12.3:
-  version "6.12.6"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
-  integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
-  dependencies:
-    fast-deep-equal "^3.1.1"
-    fast-json-stable-stringify "^2.0.0"
-    json-schema-traverse "^0.4.1"
-    uri-js "^4.2.2"
-
-ansi-align@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-1.1.0.tgz#2f0c1658829739add5ebb15e6b0c6e3423f016ba"
-  integrity sha1-LwwWWIKXOa3V67FeawxuNCPwFro=
-  dependencies:
-    string-width "^1.0.1"
-
-ansi-align@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f"
-  integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=
-  dependencies:
-    string-width "^2.0.0"
-
 ansi-escape-sequences@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/ansi-escape-sequences/-/ansi-escape-sequences-3.0.0.tgz#1c18394b6af9b76ff9a63509fa497669fd2ce53e"
-  integrity sha1-HBg5S2r5t2/5pjUJ+kl2af0s5T4=
+  integrity sha512-nOj2mwGB2lJzx9YDqaiI77vYh4SWcOCTday6kdtx6ojUk1s1HqSiK604UIq8jlBVC0UBsX7Bph3SfOf9QsJerA==
   dependencies:
     array-back "^1.0.3"
 
-ansi-escapes@^1.1.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
-  integrity sha1-06ioOzGapneTZisT52HHkRQiMG4=
-
-ansi-escapes@^4.2.1:
-  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@^2.0.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
-  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
-
-ansi-regex@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
-  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
-
-ansi-regex@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
-  integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
+  integrity sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==
 
 ansi-styles@^2.2.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
-  integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=
+  integrity sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==
 
 ansi-styles@^3.2.1:
   version "3.2.1"
@@ -1546,91 +565,10 @@
   dependencies:
     color-convert "^1.9.0"
 
-ansi-styles@^4.1.0:
-  version "4.3.0"
-  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
-  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
-  dependencies:
-    color-convert "^2.0.1"
-
-ansi-styles@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.0.0.tgz#cb102df1c56f5123eab8b67cd7b98027a0279178"
-  integrity sha1-yxAt8cVvUSPquLZ817mAJ6AnkXg=
-
-any-promise@^1.0.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
-  integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
-
-anymatch@^1.3.0:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a"
-  integrity sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==
-  dependencies:
-    micromatch "^2.1.5"
-    normalize-path "^2.0.0"
-
-append-field@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56"
-  integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=
-
-archiver-utils@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2"
-  integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==
-  dependencies:
-    glob "^7.1.4"
-    graceful-fs "^4.2.0"
-    lazystream "^1.0.0"
-    lodash.defaults "^4.2.0"
-    lodash.difference "^4.5.0"
-    lodash.flatten "^4.4.0"
-    lodash.isplainobject "^4.0.6"
-    lodash.union "^4.6.0"
-    normalize-path "^3.0.0"
-    readable-stream "^2.0.0"
-
-archiver@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/archiver/-/archiver-3.1.1.tgz#9db7819d4daf60aec10fe86b16cb9258ced66ea0"
-  integrity sha512-5Hxxcig7gw5Jod/8Gq0OneVgLYET+oNHcxgWItq4TbhOzRLKNAFUb9edAftiMKXvXfCB0vbGrJdZDNq0dWMsxg==
-  dependencies:
-    archiver-utils "^2.1.0"
-    async "^2.6.3"
-    buffer-crc32 "^0.2.1"
-    glob "^7.1.4"
-    readable-stream "^3.4.0"
-    tar-stream "^2.1.0"
-    zip-stream "^2.1.2"
-
-arr-diff@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf"
-  integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=
-  dependencies:
-    arr-flatten "^1.0.1"
-
-arr-diff@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
-  integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=
-
-arr-flatten@^1.0.1, arr-flatten@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
-  integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==
-
-arr-union@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
-  integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
-
 array-back@^1.0.3, array-back@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-1.0.4.tgz#644ba7f095f7ffcf7c43b5f0dc39d3c1f03c063b"
-  integrity sha1-ZEun8JX3/898Q7Xw3DnTwfA8Bjs=
+  integrity sha512-1WxbZvrmyhkNoeYcizokbmh5oiOCIfyvGtcqbK3Ls1v1fKcquzxnQSceOx6tzq7jmai2kFLWIpGND2cLhH6TPw==
   dependencies:
     typical "^2.6.0"
 
@@ -1646,148 +584,22 @@
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
   integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
 
-array-differ@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-1.0.0.tgz#eff52e3758249d33be402b8bb8e564bb2b5d4031"
-  integrity sha1-7/UuN1gknTO+QCuLuOVkuytdQDE=
-
-array-differ@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-3.0.0.tgz#3cbb3d0f316810eafcc47624734237d6aee4ae6b"
-  integrity sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==
-
-array-find-index@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
-  integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=
-
-array-flatten@1.1.1:
+ast-matcher@^1.1.1:
   version "1.1.1"
-  resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
-  integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
+  resolved "https://registry.yarnpkg.com/ast-matcher/-/ast-matcher-1.1.1.tgz#95a6dc72318319507024fff438b7839e4e280813"
+  integrity sha512-wQPAp09kPFRQsOijM2Blfg4lH6B9MIhIUrhFtDdhD/1JFhPmfg2/+WAjViVYl3N7EwleHI+q/enTHjaDrv+wEw==
 
-array-union@^1.0.1, array-union@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39"
-  integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=
-  dependencies:
-    array-uniq "^1.0.1"
-
-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==
-
-array-uniq@^1.0.1:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
-  integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=
-
-array-unique@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
-  integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=
-
-array-unique@^0.3.2:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
-  integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
-
-arraybuffer.slice@~0.0.7:
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
-  integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
-
-arrify@^1.0.0, arrify@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
-  integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=
-
-arrify@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
-  integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
-
-asn1@~0.2.3:
-  version "0.2.4"
-  resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
-  integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
-  dependencies:
-    safer-buffer "~2.1.0"
-
-assert-plus@1.0.0, assert-plus@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
-  integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
-
-assign-symbols@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
-  integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
-
-async-each@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
-  integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
-
-async@0.9.x:
-  version "0.9.2"
-  resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
-  integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=
-
-async@^2.0.0, async@^2.0.1, async@^2.1.2, async@^2.4.1, async@^2.6.0, async@^2.6.2, async@^2.6.3:
-  version "2.6.3"
-  resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
-  integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
+async@^2.0.1:
+  version "2.6.4"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
+  integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
   dependencies:
     lodash "^4.17.14"
 
-async@^3.1.0:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/async/-/async-3.2.1.tgz#d3274ec66d107a47476a4c49136aacdb00665fc8"
-  integrity sha512-XdD5lRO/87udXCMC9meWdYiR+Nq6ZjUfXidViUZGu2F1MO4T3XwZ1et0hb2++BgLfhyJwy44BGB/yx80ABx8hg==
-
-async@~0.2.9:
-  version "0.2.10"
-  resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
-  integrity sha1-trvgsGdLnXGXCMo43owjfLUmw9E=
-
-asynckit@^0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
-  integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
-
-atob-lite@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/atob-lite/-/atob-lite-2.0.0.tgz#0fef5ad46f1bd7a8502c65727f0367d5ee43d696"
-  integrity sha1-D+9a1G8b16hQLGVyfwNn1e5D1pY=
-
-atob@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
-  integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
-
-aws-sign2@~0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
-  integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
-
-aws4@^1.8.0:
-  version "1.11.0"
-  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
-  integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
-
-axios@^0.21.1:
-  version "0.21.1"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
-  integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
-  dependencies:
-    follow-redirects "^1.10.0"
-
 babel-code-frame@^6.26.0:
   version "6.26.0"
   resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
-  integrity sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=
+  integrity sha512-XqYMR2dfdGMW+hd0IUZ2PwK+fGeFkOxZJ0wY+JaQAHzt1Zx8LcvpiZD2NiGkEG8qx0CfkAOr5xt76d1e8vG90g==
   dependencies:
     chalk "^1.1.3"
     esutils "^2.0.2"
@@ -1807,223 +619,17 @@
     source-map "^0.5.7"
     trim-right "^1.0.1"
 
-babel-helper-evaluate-path@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-evaluate-path/-/babel-helper-evaluate-path-0.5.0.tgz#a62fa9c4e64ff7ea5cea9353174ef023a900a67c"
-  integrity sha512-mUh0UhS607bGh5wUMAQfOpt2JX2ThXMtppHRdRU1kL7ZLRWIXxoV2UIV1r2cAeeNeU1M5SB5/RSUgUxrK8yOkA==
-
-babel-helper-flip-expressions@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-flip-expressions/-/babel-helper-flip-expressions-0.4.3.tgz#3696736a128ac18bc25254b5f40a22ceb3c1d3fd"
-  integrity sha1-NpZzahKKwYvCUlS19AoizrPB0/0=
-
-babel-helper-is-nodes-equiv@^0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-is-nodes-equiv/-/babel-helper-is-nodes-equiv-0.0.1.tgz#34e9b300b1479ddd98ec77ea0bbe9342dfe39684"
-  integrity sha1-NOmzALFHnd2Y7HfqC76TQt/jloQ=
-
-babel-helper-is-void-0@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-is-void-0/-/babel-helper-is-void-0-0.4.3.tgz#7d9c01b4561e7b95dbda0f6eee48f5b60e67313e"
-  integrity sha1-fZwBtFYee5Xb2g9u7kj1tg5nMT4=
-
-babel-helper-mark-eval-scopes@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-mark-eval-scopes/-/babel-helper-mark-eval-scopes-0.4.3.tgz#d244a3bef9844872603ffb46e22ce8acdf551562"
-  integrity sha1-0kSjvvmESHJgP/tG4izorN9VFWI=
-
-babel-helper-remove-or-void@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-remove-or-void/-/babel-helper-remove-or-void-0.4.3.tgz#a4f03b40077a0ffe88e45d07010dee241ff5ae60"
-  integrity sha1-pPA7QAd6D/6I5F0HAQ3uJB/1rmA=
-
-babel-helper-to-multiple-sequence-expressions@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-to-multiple-sequence-expressions/-/babel-helper-to-multiple-sequence-expressions-0.5.0.tgz#a3f924e3561882d42fcf48907aa98f7979a4588d"
-  integrity sha512-m2CvfDW4+1qfDdsrtf4dwOslQC3yhbgyBFptncp4wvtdrDHqueW7slsYv4gArie056phvQFhT2nRcGS4bnm6mA==
-
 babel-messages@^6.23.0:
   version "6.23.0"
   resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e"
-  integrity sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=
+  integrity sha512-Bl3ZiA+LjqaMtNYopA9TYE9HP1tQ+E5dLxE0XrAzcIJeK2UqF0/EaqXwBn9esd4UmTfEab+P+UYQ1GnioFIb/w==
   dependencies:
     babel-runtime "^6.22.0"
 
-babel-plugin-dynamic-import-node@^2.3.3:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3"
-  integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==
-  dependencies:
-    object.assign "^4.1.0"
-
-babel-plugin-minify-builtins@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-builtins/-/babel-plugin-minify-builtins-0.5.0.tgz#31eb82ed1a0d0efdc31312f93b6e4741ce82c36b"
-  integrity sha512-wpqbN7Ov5hsNwGdzuzvFcjgRlzbIeVv1gMIlICbPj0xkexnfoIDe7q+AZHMkQmAE/F9R5jkrB6TLfTegImlXag==
-
-babel-plugin-minify-constant-folding@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-constant-folding/-/babel-plugin-minify-constant-folding-0.5.0.tgz#f84bc8dbf6a561e5e350ff95ae216b0ad5515b6e"
-  integrity sha512-Vj97CTn/lE9hR1D+jKUeHfNy+m1baNiJ1wJvoGyOBUx7F7kJqDZxr9nCHjO/Ad+irbR3HzR6jABpSSA29QsrXQ==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-
-babel-plugin-minify-dead-code-elimination@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-dead-code-elimination/-/babel-plugin-minify-dead-code-elimination-0.5.1.tgz#1a0c68e44be30de4976ca69ffc535e08be13683f"
-  integrity sha512-x8OJOZIrRmQBcSqxBcLbMIK8uPmTvNWPXH2bh5MDCW1latEqYiRMuUkPImKcfpo59pTUB2FT7HfcgtG8ZlR5Qg==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-    babel-helper-mark-eval-scopes "^0.4.3"
-    babel-helper-remove-or-void "^0.4.3"
-    lodash "^4.17.11"
-
-babel-plugin-minify-flip-comparisons@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-flip-comparisons/-/babel-plugin-minify-flip-comparisons-0.4.3.tgz#00ca870cb8f13b45c038b3c1ebc0f227293c965a"
-  integrity sha1-AMqHDLjxO0XAOLPB68DyJyk8llo=
-  dependencies:
-    babel-helper-is-void-0 "^0.4.3"
-
-babel-plugin-minify-guarded-expressions@^0.4.3, babel-plugin-minify-guarded-expressions@^0.4.4:
-  version "0.4.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-guarded-expressions/-/babel-plugin-minify-guarded-expressions-0.4.4.tgz#818960f64cc08aee9d6c75bec6da974c4d621135"
-  integrity sha512-RMv0tM72YuPPfLT9QLr3ix9nwUIq+sHT6z8Iu3sLbqldzC1Dls8DPCywzUIzkTx9Zh1hWX4q/m9BPoPed9GOfA==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-    babel-helper-flip-expressions "^0.4.3"
-
-babel-plugin-minify-infinity@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-infinity/-/babel-plugin-minify-infinity-0.4.3.tgz#dfb876a1b08a06576384ef3f92e653ba607b39ca"
-  integrity sha1-37h2obCKBldjhO8/kuZTumB7Oco=
-
-babel-plugin-minify-mangle-names@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-mangle-names/-/babel-plugin-minify-mangle-names-0.5.0.tgz#bcddb507c91d2c99e138bd6b17a19c3c271e3fd3"
-  integrity sha512-3jdNv6hCAw6fsX1p2wBGPfWuK69sfOjfd3zjUXkbq8McbohWy23tpXfy5RnToYWggvqzuMOwlId1PhyHOfgnGw==
-  dependencies:
-    babel-helper-mark-eval-scopes "^0.4.3"
-
-babel-plugin-minify-numeric-literals@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-numeric-literals/-/babel-plugin-minify-numeric-literals-0.4.3.tgz#8e4fd561c79f7801286ff60e8c5fd9deee93c0bc"
-  integrity sha1-jk/VYcefeAEob/YOjF/Z3u6TwLw=
-
-babel-plugin-minify-replace@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-replace/-/babel-plugin-minify-replace-0.5.0.tgz#d3e2c9946c9096c070efc96761ce288ec5c3f71c"
-  integrity sha512-aXZiaqWDNUbyNNNpWs/8NyST+oU7QTpK7J9zFEFSA0eOmtUNMU3fczlTTTlnCxHmq/jYNFEmkkSG3DDBtW3Y4Q==
-
-babel-plugin-minify-simplify@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-simplify/-/babel-plugin-minify-simplify-0.5.1.tgz#f21613c8b95af3450a2ca71502fdbd91793c8d6a"
-  integrity sha512-OSYDSnoCxP2cYDMk9gxNAed6uJDiDz65zgL6h8d3tm8qXIagWGMLWhqysT6DY3Vs7Fgq7YUDcjOomhVUb+xX6A==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-    babel-helper-flip-expressions "^0.4.3"
-    babel-helper-is-nodes-equiv "^0.0.1"
-    babel-helper-to-multiple-sequence-expressions "^0.5.0"
-
-babel-plugin-minify-type-constructors@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-type-constructors/-/babel-plugin-minify-type-constructors-0.4.3.tgz#1bc6f15b87f7ab1085d42b330b717657a2156500"
-  integrity sha1-G8bxW4f3qxCF1CszC3F2V6IVZQA=
-  dependencies:
-    babel-helper-is-void-0 "^0.4.3"
-
-babel-plugin-transform-inline-consecutive-adds@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-inline-consecutive-adds/-/babel-plugin-transform-inline-consecutive-adds-0.4.3.tgz#323d47a3ea63a83a7ac3c811ae8e6941faf2b0d1"
-  integrity sha1-Mj1Ho+pjqDp6w8gRro5pQfrysNE=
-
-babel-plugin-transform-member-expression-literals@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-member-expression-literals/-/babel-plugin-transform-member-expression-literals-6.9.4.tgz#37039c9a0c3313a39495faac2ff3a6b5b9d038bf"
-  integrity sha1-NwOcmgwzE6OUlfqsL/OmtbnQOL8=
-
-babel-plugin-transform-merge-sibling-variables@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-merge-sibling-variables/-/babel-plugin-transform-merge-sibling-variables-6.9.4.tgz#85b422fc3377b449c9d1cde44087203532401dae"
-  integrity sha1-hbQi/DN3tEnJ0c3kQIcgNTJAHa4=
-
-babel-plugin-transform-minify-booleans@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-minify-booleans/-/babel-plugin-transform-minify-booleans-6.9.4.tgz#acbb3e56a3555dd23928e4b582d285162dd2b198"
-  integrity sha1-rLs+VqNVXdI5KOS1gtKFFi3SsZg=
-
-babel-plugin-transform-property-literals@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-property-literals/-/babel-plugin-transform-property-literals-6.9.4.tgz#98c1d21e255736573f93ece54459f6ce24985d39"
-  integrity sha1-mMHSHiVXNlc/k+zlRFn2ziSYXTk=
-  dependencies:
-    esutils "^2.0.2"
-
-babel-plugin-transform-regexp-constructors@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-regexp-constructors/-/babel-plugin-transform-regexp-constructors-0.4.3.tgz#58b7775b63afcf33328fae9a5f88fbd4fb0b4965"
-  integrity sha1-WLd3W2OvzzMyj66aX4j71PsLSWU=
-
-babel-plugin-transform-remove-console@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz#b980360c067384e24b357a588d807d3c83527780"
-  integrity sha1-uYA2DAZzhOJLNXpYjYB9PINSd4A=
-
-babel-plugin-transform-remove-debugger@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-debugger/-/babel-plugin-transform-remove-debugger-6.9.4.tgz#42b727631c97978e1eb2d199a7aec84a18339ef2"
-  integrity sha1-QrcnYxyXl44estGZp67IShgznvI=
-
-babel-plugin-transform-remove-undefined@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-undefined/-/babel-plugin-transform-remove-undefined-0.5.0.tgz#80208b31225766c630c97fa2d288952056ea22dd"
-  integrity sha512-+M7fJYFaEE/M9CXa0/IRkDbiV3wRELzA1kKQFCJ4ifhrzLKn/9VCCgj9OFmYWwBd8IB48YdgPkHYtbYq+4vtHQ==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-
-babel-plugin-transform-simplify-comparison-operators@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-simplify-comparison-operators/-/babel-plugin-transform-simplify-comparison-operators-6.9.4.tgz#f62afe096cab0e1f68a2d753fdf283888471ceb9"
-  integrity sha1-9ir+CWyrDh9ootdT/fKDiIRxzrk=
-
-babel-plugin-transform-undefined-to-void@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-undefined-to-void/-/babel-plugin-transform-undefined-to-void-6.9.4.tgz#be241ca81404030678b748717322b89d0c8fe280"
-  integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA=
-
-babel-preset-minify@^0.5.0:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/babel-preset-minify/-/babel-preset-minify-0.5.1.tgz#25f5d0bce36ec818be80338d0e594106e21eaa9f"
-  integrity sha512-1IajDumYOAPYImkHbrKeiN5AKKP9iOmRoO2IPbIuVp0j2iuCcj0n7P260z38siKMZZ+85d3mJZdtW8IgOv+Tzg==
-  dependencies:
-    babel-plugin-minify-builtins "^0.5.0"
-    babel-plugin-minify-constant-folding "^0.5.0"
-    babel-plugin-minify-dead-code-elimination "^0.5.1"
-    babel-plugin-minify-flip-comparisons "^0.4.3"
-    babel-plugin-minify-guarded-expressions "^0.4.4"
-    babel-plugin-minify-infinity "^0.4.3"
-    babel-plugin-minify-mangle-names "^0.5.0"
-    babel-plugin-minify-numeric-literals "^0.4.3"
-    babel-plugin-minify-replace "^0.5.0"
-    babel-plugin-minify-simplify "^0.5.1"
-    babel-plugin-minify-type-constructors "^0.4.3"
-    babel-plugin-transform-inline-consecutive-adds "^0.4.3"
-    babel-plugin-transform-member-expression-literals "^6.9.4"
-    babel-plugin-transform-merge-sibling-variables "^6.9.4"
-    babel-plugin-transform-minify-booleans "^6.9.4"
-    babel-plugin-transform-property-literals "^6.9.4"
-    babel-plugin-transform-regexp-constructors "^0.4.3"
-    babel-plugin-transform-remove-console "^6.9.4"
-    babel-plugin-transform-remove-debugger "^6.9.4"
-    babel-plugin-transform-remove-undefined "^0.5.0"
-    babel-plugin-transform-simplify-comparison-operators "^6.9.4"
-    babel-plugin-transform-undefined-to-void "^6.9.4"
-    lodash "^4.17.11"
-
 babel-runtime@^6.22.0, babel-runtime@^6.26.0:
   version "6.26.0"
   resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
-  integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4=
+  integrity sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==
   dependencies:
     core-js "^2.4.0"
     regenerator-runtime "^0.11.0"
@@ -2031,7 +637,7 @@
 babel-traverse@^6.26.0:
   version "6.26.0"
   resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
-  integrity sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=
+  integrity sha512-iSxeXx7apsjCHe9c7n8VtRXGzI2Bk1rBSOJgCCjfyXb6v1aCqE1KSEpq/8SXuVN8Ka/Rh1WDTF0MDzkvTA4MIA==
   dependencies:
     babel-code-frame "^6.26.0"
     babel-messages "^6.23.0"
@@ -2046,7 +652,7 @@
 babel-types@^6.26.0:
   version "6.26.0"
   resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
-  integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=
+  integrity sha512-zhe3V/26rCWsEZK8kZN+HaQj5yQ1CilTObixFzKW1UWjqG7618Twz6YEsCnjfg5gBcJh02DrpCkS9h98ZqDY+g==
   dependencies:
     babel-runtime "^6.26.0"
     esutils "^2.0.2"
@@ -2063,86 +669,21 @@
   resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.47.tgz#6d1fa44f0abec41ab7c780481e62fd9aafbdea80"
   integrity sha512-+rq2cr4GDhtToEzKFD6KZZMDBXhjFAr9JjPw9pAppZACeEWqNM294j+NdBzkSHYXwzzBmVjZ3nEVJlOhbR2gOQ==
 
-backo2@1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
-  integrity sha1-MasayLEpNjRj41s+u2n038+6eUc=
-
 balanced-match@^1.0.0:
   version "1.0.2"
   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-js@1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1"
-  integrity sha1-o5mS1yNYSBGYK+XikLtqU9hnAPE=
+  integrity sha512-hURVuTTGLOppKhjSe9lZy4NCjnvaIAF/juwazv4WtHwsk5rxKrU1WbxN+XtwKDSvkrNbIIaTBQd9wUsSwruZUg==
 
 base64-js@^1.3.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
   integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
 
-base64id@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
-  integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
-
-base@^0.11.1:
-  version "0.11.2"
-  resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
-  integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==
-  dependencies:
-    cache-base "^1.0.1"
-    class-utils "^0.3.5"
-    component-emitter "^1.2.1"
-    define-property "^1.0.0"
-    isobject "^3.0.1"
-    mixin-deep "^1.2.0"
-    pascalcase "^0.1.1"
-
-bcrypt-pbkdf@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
-  integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
-  dependencies:
-    tweetnacl "^0.14.3"
-
-before-after-hook@^2.0.0:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.2.tgz#a6e8ca41028d90ee2c24222f201c90956091613e"
-  integrity sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==
-
-binary-extensions@^1.0.0:
-  version "1.13.1"
-  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65"
-  integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==
-
-binaryextensions@^2.1.2:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.3.0.tgz#1d269cbf7e6243ea886aa41453c3651ccbe13c22"
-  integrity sha512-nAihlQsYGyc5Bwq6+EsubvANYGExeJKHDO3RjnvwU042fawQTQfM3Kxn7IHUXQOz4bzfwsGYYHGSvXyW4zOGLg==
-
-bindings@^1.5.0:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df"
-  integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==
-  dependencies:
-    file-uri-to-path "1.0.0"
-
-bl@^1.0.0:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7"
-  integrity sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==
-  dependencies:
-    readable-stream "^2.3.5"
-    safe-buffer "^5.1.1"
-
 bl@^4.0.3:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
@@ -2152,89 +693,6 @@
     inherits "^2.0.4"
     readable-stream "^3.4.0"
 
-blob@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
-  integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
-
-body-parser@1.19.0, body-parser@^1.17.2:
-  version "1.19.0"
-  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
-  integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
-  dependencies:
-    bytes "3.1.0"
-    content-type "~1.0.4"
-    debug "2.6.9"
-    depd "~1.1.2"
-    http-errors "1.7.2"
-    iconv-lite "0.4.24"
-    on-finished "~2.3.0"
-    qs "6.7.0"
-    raw-body "2.4.0"
-    type-is "~1.6.17"
-
-bower-config@^1.4.0, bower-config@^1.4.1:
-  version "1.4.3"
-  resolved "https://registry.yarnpkg.com/bower-config/-/bower-config-1.4.3.tgz#3454fecdc5f08e7aa9cc6d556e492be0669689ae"
-  integrity sha512-MVyyUk3d1S7d2cl6YISViwJBc2VXCkxF5AUFykvN0PQj5FsUiMNSgAYTso18oRFfyZ6XEtjrgg9MAaufHbOwNw==
-  dependencies:
-    graceful-fs "^4.1.3"
-    minimist "^0.2.1"
-    mout "^1.0.0"
-    osenv "^0.1.3"
-    untildify "^2.1.0"
-    wordwrap "^0.0.3"
-
-bower-json@^0.8.1:
-  version "0.8.4"
-  resolved "https://registry.yarnpkg.com/bower-json/-/bower-json-0.8.4.tgz#9c3b375870dcd9581350c1f403f6383dbf6a18b1"
-  integrity sha512-mMKghvq9ivbuzSsY5nrOLnDtZIJMUCpysqbGaGW3mj88JAcuSi8ZAzIt34vNZjohy0aR9VXLwgPTZGnBX2Vpjg==
-  dependencies:
-    deep-extend "^0.5.1"
-    ends-with "^0.2.0"
-    ext-list "^2.0.0"
-    graceful-fs "^4.1.3"
-    intersect "^1.0.1"
-    sort-keys-length "^1.0.0"
-
-bower-logger@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/bower-logger/-/bower-logger-0.2.2.tgz#39be07e979b2fc8e03a94634205ed9422373d381"
-  integrity sha1-Ob4H6Xmy/I4DqUY0IF7ZQiNz04E=
-
-bower@^1.8.8:
-  version "1.8.12"
-  resolved "https://registry.yarnpkg.com/bower/-/bower-1.8.12.tgz#44cfca2a5e04b8d9a066621e24c8b179d8ac321e"
-  integrity sha512-u1xy9SrwwoPlgjuHNjhV+YUPVdqyBj2ALBxuzeIUKXaPI2i2xypGgxqXkuHcITGdi5yBj5JuXgyMvgiWiS1S3Q==
-
-boxen@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/boxen/-/boxen-0.6.0.tgz#8364d4248ac34ff0ef1b2f2bf49a6c60ce0d81b6"
-  integrity sha1-g2TUJIrDT/DvGy8r9JpsYM4NgbY=
-  dependencies:
-    ansi-align "^1.1.0"
-    camelcase "^2.1.0"
-    chalk "^1.1.1"
-    cli-boxes "^1.0.0"
-    filled-array "^1.0.0"
-    object-assign "^4.0.1"
-    repeating "^2.0.0"
-    string-width "^1.0.1"
-    widest-line "^1.0.0"
-
-boxen@^1.2.1:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
-  integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==
-  dependencies:
-    ansi-align "^2.0.0"
-    camelcase "^4.0.0"
-    chalk "^2.0.1"
-    cli-boxes "^1.0.0"
-    string-width "^2.0.0"
-    term-size "^1.2.0"
-    widest-line "^2.0.0"
-
 brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -2243,57 +701,6 @@
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-braces@^1.8.2:
-  version "1.8.5"
-  resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7"
-  integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=
-  dependencies:
-    expand-range "^1.8.1"
-    preserve "^0.2.0"
-    repeat-element "^1.1.2"
-
-braces@^2.3.1:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
-  integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==
-  dependencies:
-    arr-flatten "^1.1.0"
-    array-unique "^0.3.2"
-    extend-shallow "^2.0.1"
-    fill-range "^4.0.0"
-    isobject "^3.0.1"
-    repeat-element "^1.1.2"
-    snapdragon "^0.8.1"
-    snapdragon-node "^2.0.1"
-    split-string "^3.0.2"
-    to-regex "^3.0.1"
-
-browser-capabilities@^1.0.0:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/browser-capabilities/-/browser-capabilities-1.1.4.tgz#a6bd657a07a134532ad66c722b8949904478b973"
-  integrity sha512-BezMQhbQklxjRQpZZQ8tnbzEo6AldUwMh8/PeWt5/CTBSwByQRXZEAK2fbnEahQ4poeeaI0suAYRq25A1YGOmw==
-  dependencies:
-    "@types/ua-parser-js" "^0.7.31"
-    ua-parser-js "^0.7.15"
-
-browserify-zlib@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d"
-  integrity sha1-uzX4pRn2AOD6a4SFJByXnQFB+y0=
-  dependencies:
-    pako "~0.2.0"
-
-browserslist@^4.16.6:
-  version "4.16.8"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.8.tgz#cb868b0b554f137ba6e33de0ecff2eda403c4fb0"
-  integrity sha512-sc2m9ohR/49sWEbPj14ZSSZqp+kbi16aLao42Hmn3Z8FpjuMaq2xCA2l4zl9ITfyzvnvyE0hcg62YkIGKxgaNQ==
-  dependencies:
-    caniuse-lite "^1.0.30001251"
-    colorette "^1.3.0"
-    electron-to-chromium "^1.3.811"
-    escalade "^3.1.1"
-    node-releases "^1.1.75"
-
 browserstack@^1.2.0:
   version "1.6.1"
   resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.6.1.tgz#e051f9733ec3b507659f395c7a4765a1b1e358b3"
@@ -2301,40 +708,17 @@
   dependencies:
     https-proxy-agent "^2.2.1"
 
-btoa-lite@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337"
-  integrity sha1-M3dm2hWAEhD92VbCLpxokaudAzc=
-
-buffer-alloc-unsafe@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
-  integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==
-
-buffer-alloc@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec"
-  integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==
-  dependencies:
-    buffer-alloc-unsafe "^1.1.0"
-    buffer-fill "^1.0.0"
-
-buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3:
+buffer-crc32@~0.2.3:
   version "0.2.13"
   resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
-  integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
-
-buffer-fill@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
-  integrity sha1-+PeLdniYiO858gXNY39o5wISKyw=
+  integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
 
 buffer-from@^1.0.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
   integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
 
-buffer@^5.1.0, buffer@^5.5.0:
+buffer@^5.5.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
   integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
@@ -2343,42 +727,9 @@
     ieee754 "^1.1.13"
 
 builtin-modules@^3.1.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887"
-  integrity sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==
-
-busboy@^0.2.11:
-  version "0.2.14"
-  resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453"
-  integrity sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=
-  dependencies:
-    dicer "0.2.5"
-    readable-stream "1.1.x"
-
-bytes@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
-  integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
-
-bytes@3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
-  integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
-
-cache-base@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
-  integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==
-  dependencies:
-    collection-visit "^1.0.0"
-    component-emitter "^1.2.1"
-    get-value "^2.0.6"
-    has-value "^1.0.0"
-    isobject "^3.0.1"
-    set-value "^2.0.0"
-    to-object-path "^0.3.0"
-    union-value "^1.0.0"
-    unset-value "^1.0.0"
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
+  integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
 
 cacheable-lookup@^5.0.3:
   version "5.0.4"
@@ -2398,79 +749,17 @@
     normalize-url "^6.0.1"
     responselike "^2.0.0"
 
-call-bind@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
-  integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==
-  dependencies:
-    function-bind "^1.1.1"
-    get-intrinsic "^1.0.2"
-
-call-me-maybe@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b"
-  integrity sha1-JtII6onje1y95gJQoV8DHBak1ms=
-
-camel-case@3.0.x:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73"
-  integrity sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=
-  dependencies:
-    no-case "^2.2.0"
-    upper-case "^1.1.1"
-
-camelcase-keys@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7"
-  integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc=
-  dependencies:
-    camelcase "^2.0.0"
-    map-obj "^1.0.0"
-
-camelcase@^2.0.0, camelcase@^2.1.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
-  integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=
-
-camelcase@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
-  integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
-
 cancel-token@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/cancel-token/-/cancel-token-0.1.1.tgz#c18197674bb1c84c1d6933ebf15d8d5a5ce79b4f"
-  integrity sha1-wYGXZ0uxyEwdaTPr8V2NWlznm08=
+  integrity sha512-22DV8aB/ovjL6l9S+QLwFzyP5+azENgfNywoJffIE8ZNx2Nnz7UlJ0mEULTtaeuf+tmnvaUdN6WKtV1LTBlbuA==
   dependencies:
     "@types/node" "^4.0.30"
 
-caniuse-lite@^1.0.30001251:
-  version "1.0.30001252"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001252.tgz#cb16e4e3dafe948fc4a9bb3307aea054b912019a"
-  integrity sha512-I56jhWDGMtdILQORdusxBOH+Nl/KgQSdDmpJezYddnAkVOmnoU8zwjTV9xAjMIYxr0iPreEAVylCGcmHCjfaOw==
-
-capture-stack-trace@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d"
-  integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw==
-
-caseless@~0.12.0:
-  version "0.12.0"
-  resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
-  integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
-
-chalk@*, chalk@^4.1.0:
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
-  integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
-  dependencies:
-    ansi-styles "^4.1.0"
-    supports-color "^7.1.0"
-
-chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
+chalk@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
-  integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=
+  integrity sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==
   dependencies:
     ansi-styles "^2.2.1"
     escape-string-regexp "^1.0.2"
@@ -2478,7 +767,7 @@
     strip-ansi "^3.0.0"
     supports-color "^2.0.0"
 
-chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2:
+chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.1:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
@@ -2487,246 +776,44 @@
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
-chalk@~0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.4.0.tgz#5199a3ddcd0c1efe23bc08c1b027b06176e0c64f"
-  integrity sha1-UZmj3c0MHv4jvAjBsCewYXbgxk8=
-  dependencies:
-    ansi-styles "~1.0.0"
-    has-color "~0.1.0"
-    strip-ansi "~0.1.0"
-
-chardet@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
-  integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
-
-charenc@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
-  integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=
-
-chokidar@^1.7.0:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
-  integrity sha1-eY5ol3gVHIB2tLNg5e3SjNortGg=
-  dependencies:
-    anymatch "^1.3.0"
-    async-each "^1.0.0"
-    glob-parent "^2.0.0"
-    inherits "^2.0.1"
-    is-binary-path "^1.0.0"
-    is-glob "^2.0.0"
-    path-is-absolute "^1.0.0"
-    readdirp "^2.0.0"
-  optionalDependencies:
-    fsevents "^1.0.0"
-
-chownr@^1.0.1:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
-  integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
-
-ci-info@^1.5.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497"
-  integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==
-
-class-utils@^0.3.5:
-  version "0.3.6"
-  resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
-  integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==
-  dependencies:
-    arr-union "^3.1.0"
-    define-property "^0.2.5"
-    isobject "^3.0.0"
-    static-extend "^0.1.1"
-
-clean-css@4.2.x:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
-  integrity sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA==
-  dependencies:
-    source-map "~0.6.0"
-
 cleankill@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/cleankill/-/cleankill-2.0.0.tgz#59830dfc8b411d53dc72ad09d45a78ea33161a91"
-  integrity sha1-WYMN/ItBHVPccq0J1Fp46jMWGpE=
-
-cli-boxes@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
-  integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM=
-
-cli-cursor@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
-  integrity sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=
-  dependencies:
-    restore-cursor "^1.0.1"
-
-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"
-
-cli-table@^0.3.1:
-  version "0.3.6"
-  resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.6.tgz#e9d6aa859c7fe636981fd3787378c2a20bce92fc"
-  integrity sha512-ZkNZbnZjKERTY5NwC2SeMeLeifSPq/pubeRoTpdr3WchLlnZg6hEgvHkK5zL7KNFdd9PmHN8lxrENUwI3cE8vQ==
-  dependencies:
-    colors "1.0.3"
-
-cli-width@^2.0.0:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48"
-  integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==
-
-cli-width@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6"
-  integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==
-
-clone-buffer@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58"
-  integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg=
-
-clone-deep@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
-  integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==
-  dependencies:
-    is-plain-object "^2.0.4"
-    kind-of "^6.0.2"
-    shallow-clone "^3.0.0"
+  integrity sha512-qj/ZY1wjON/36bsk3cF5WtXnrxUgWqc5PCN78LsOpjIk0Dka0lPqbhu9FVk4Yy4N3VuDA8VhlcgBLWC5L+tGHg==
 
 clone-response@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
-  integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=
+  integrity sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q==
   dependencies:
     mimic-response "^1.0.0"
 
-clone-stats@^0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1"
-  integrity sha1-uI+UqCzzi4eR1YBG6kAprYjKmdE=
-
-clone-stats@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680"
-  integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA=
-
-clone@^1.0.0, clone@^1.0.2:
+clone@^1.0.2:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
-  integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
+  integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
 
-clone@^2.0.0, clone@^2.1.0, clone@^2.1.1:
+clone@^2.0.0, clone@^2.1.0:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
-  integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
+  integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
 
-cloneable-readable@^1.0.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.3.tgz#120a00cb053bfb63a222e709f9683ea2e11d8cec"
-  integrity sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==
-  dependencies:
-    inherits "^2.0.1"
-    process-nextick-args "^2.0.0"
-    readable-stream "^2.3.5"
-
-code-point-at@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
-  integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
-
-collection-visit@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
-  integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=
-  dependencies:
-    map-visit "^1.0.0"
-    object-visit "^1.0.0"
-
-color-convert@^1.9.0, color-convert@^1.9.1:
+color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
   integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
   dependencies:
     color-name "1.1.3"
 
-color-convert@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
-  integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
-  dependencies:
-    color-name "~1.1.4"
-
 color-name@1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
-  integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
-
-color-name@^1.0.0, color-name@~1.1.4:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
-  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
-
-color-string@^1.5.2:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312"
-  integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==
-  dependencies:
-    color-name "^1.0.0"
-    simple-swizzle "^0.2.2"
-
-color@3.0.x:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a"
-  integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==
-  dependencies:
-    color-convert "^1.9.1"
-    color-string "^1.5.2"
-
-colorette@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.3.0.tgz#ff45d2f0edb244069d3b772adeb04fed38d0a0af"
-  integrity sha512-ecORCqbSFP7Wm8Y6lyqMJjexBQqXSF7SSeaTyGGphogUjBlFP9m9o08wy86HL2uB7fMTxtOUzLMk7ogKcxMg1w==
-
-colors@1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b"
-  integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=
-
-colors@^1.2.1:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
-  integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
-
-colorspace@1.1.x:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5"
-  integrity sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ==
-  dependencies:
-    color "3.0.x"
-    text-hex "1.0.x"
-
-combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
-  version "1.0.8"
-  resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
-  integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
-  dependencies:
-    delayed-stream "~1.0.0"
+  integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
 
 command-line-args@^3.0.1:
   version "3.0.5"
   resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-3.0.5.tgz#5bd4ad45e7983e5c1344918e40280ee2693c5ac0"
-  integrity sha1-W9StReeYPlwTRJGOQCgO4mk8WsA=
+  integrity sha512-M29kjOI24VF4HqatnqVyDqyeq3SYYZbq6LWv/AdVZ5LvrcqVNSN2XeYPrBxcO19T8YkGmyCqTUqYR07DFjVhyg==
   dependencies:
     array-back "^1.0.4"
     feature-detect-es6 "^1.3.1"
@@ -2734,26 +821,19 @@
     typical "^2.6.0"
 
 command-line-args@^5.0.2:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.0.tgz#087b02748272169741f1fd7c785b295df079b9be"
-  integrity sha512-4zqtU1hYsSJzcJBOcNZIbW5Fbk9BkjCp1pZVhQKoRaWL5J7N4XphDLwo8aWwdQpTugxwu+jf9u2ZhkXiqp5Z6A==
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
+  integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
   dependencies:
     array-back "^3.1.0"
     find-replace "^3.0.0"
     lodash.camelcase "^4.3.0"
     typical "^4.0.0"
 
-command-line-commands@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/command-line-commands/-/command-line-commands-2.0.1.tgz#c58aa13dc78c06038ed67077e57ad09a6f858f46"
-  integrity sha512-m8c2p1DrNd2ruIAggxd/y6DgygQayf6r8RHwchhXryaLF8I6koYjoYroVP+emeROE9DXN5b9sP1Gh+WtvTTdtQ==
-  dependencies:
-    array-back "^2.0.0"
-
 command-line-usage@^3.0.8:
   version "3.0.8"
   resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-3.0.8.tgz#b6a20978c1b383477f5c11a529428b880bfe0f4d"
-  integrity sha1-tqIJeMGzg0d/XBGlKUKLiAv+D00=
+  integrity sha512-KMWPF8wNWa+wzffE9247hlDB1c9DMMxhwIFzwRn7oNv5CU7auuJ3zKWv756F/9qqlEucC5jI8/3S8qdGKdVelw==
   dependencies:
     ansi-escape-sequences "^3.0.0"
     array-back "^1.0.3"
@@ -2771,202 +851,29 @@
     table-layout "^0.4.3"
     typical "^2.6.1"
 
-commander@2.17.x:
-  version "2.17.1"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
-  integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
-
 commander@^2.20.0, commander@^2.20.3:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
   integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
 
-commander@~2.19.0:
-  version "2.19.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
-  integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==
-
-commondir@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
-  integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
-
-component-bind@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
-  integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=
-
-component-emitter@1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
-  integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
-
-component-emitter@^1.2.1, component-emitter@~1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
-  integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
-
-component-inherit@0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
-  integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=
-
-compress-commons@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-2.1.1.tgz#9410d9a534cf8435e3fbbb7c6ce48de2dc2f0610"
-  integrity sha512-eVw6n7CnEMFzc3duyFVrQEuY1BlHR3rYsSztyG32ibGMW722i3C6IizEGMFmfMU+A+fALvBIwxN3czffTcdA+Q==
+compress-brotli@^1.3.8:
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/compress-brotli/-/compress-brotli-1.3.8.tgz#0c0a60c97a989145314ec381e84e26682e7b38db"
+  integrity sha512-lVcQsjhxhIXsuupfy9fmZUFtAIdBmXA7EGY6GBdgZ++qkM9zG4YFT8iU7FoBxzryNDMOpD1HIFHUSX4D87oqhQ==
   dependencies:
-    buffer-crc32 "^0.2.13"
-    crc32-stream "^3.0.1"
-    normalize-path "^3.0.0"
-    readable-stream "^2.3.6"
-
-compressible@~2.0.16:
-  version "2.0.18"
-  resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
-  integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
-  dependencies:
-    mime-db ">= 1.43.0 < 2"
-
-compression@^1.6.2:
-  version "1.7.4"
-  resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f"
-  integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==
-  dependencies:
-    accepts "~1.3.5"
-    bytes "3.0.0"
-    compressible "~2.0.16"
-    debug "2.6.9"
-    on-headers "~1.0.2"
-    safe-buffer "5.1.2"
-    vary "~1.1.2"
+    "@types/json-buffer" "~3.0.0"
+    json-buffer "~3.0.1"
 
 concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
-  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
-
-concat-stream@^1.4.7, concat-stream@^1.5.2:
-  version "1.6.2"
-  resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
-  integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
-  dependencies:
-    buffer-from "^1.0.0"
-    inherits "^2.0.3"
-    readable-stream "^2.2.2"
-    typedarray "^0.0.6"
-
-configstore@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/configstore/-/configstore-2.1.0.tgz#737a3a7036e9886102aa6099e47bb33ab1aba1a1"
-  integrity sha1-c3o6cDbpiGECqmCZ5HuzOrGroaE=
-  dependencies:
-    dot-prop "^3.0.0"
-    graceful-fs "^4.1.2"
-    mkdirp "^0.5.0"
-    object-assign "^4.0.1"
-    os-tmpdir "^1.0.0"
-    osenv "^0.1.0"
-    uuid "^2.0.1"
-    write-file-atomic "^1.1.2"
-    xdg-basedir "^2.0.0"
-
-configstore@^3.0.0:
-  version "3.1.5"
-  resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.5.tgz#e9af331fadc14dabd544d3e7e76dc446a09a530f"
-  integrity sha512-nlOhI4+fdzoK5xmJ+NY+1gZK56bwEaWZr8fYuXohZ9Vkc1o3a4T/R3M+yE/w7x/ZVJ1zF8c+oaOvF0dztdUgmA==
-  dependencies:
-    dot-prop "^4.2.1"
-    graceful-fs "^4.1.2"
-    make-dir "^1.0.0"
-    unique-string "^1.0.0"
-    write-file-atomic "^2.0.0"
-    xdg-basedir "^3.0.0"
-
-content-disposition@0.5.3:
-  version "0.5.3"
-  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
-  integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
-  dependencies:
-    safe-buffer "5.1.2"
-
-content-type@^1.0.2, content-type@~1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
-  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
-
-convert-source-map@^1.1.1, convert-source-map@^1.7.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
-  integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
-  dependencies:
-    safe-buffer "~5.1.1"
-
-cookie-signature@1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
-  integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
-
-cookie@0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
-  integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
-
-cookie@~0.4.1:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
-  integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
-
-copy-descriptor@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
-  integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
+  integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
 
 core-js@^2.4.0, core-js@^2.4.1:
   version "2.6.12"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
   integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
 
-core-util-is@1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
-  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
-
-core-util-is@~1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
-  integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
-
-cors@^2.8.4:
-  version "2.8.5"
-  resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
-  integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
-  dependencies:
-    object-assign "^4"
-    vary "^1"
-
-crc32-stream@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-3.0.1.tgz#cae6eeed003b0e44d739d279de5ae63b171b4e85"
-  integrity sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==
-  dependencies:
-    crc "^3.4.4"
-    readable-stream "^3.4.0"
-
-crc@^3.4.4:
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6"
-  integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==
-  dependencies:
-    buffer "^5.1.0"
-
-create-error-class@^3.0.0, create-error-class@^3.0.1:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6"
-  integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=
-  dependencies:
-    capture-stack-trace "^1.0.0"
-
 crisper@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/crisper/-/crisper-2.1.1.tgz#4cc7321c3e90f3c5cbdc3503217f118fd7d5c51c"
@@ -2976,27 +883,7 @@
     command-line-usage "^3.0.8"
     dom5 "^1.0.1"
 
-cross-spawn@^5.0.1:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
-  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
-  dependencies:
-    lru-cache "^4.0.1"
-    shebang-command "^1.2.0"
-    which "^1.2.9"
-
-cross-spawn@^6.0.0, 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.0, cross-spawn@^7.0.3:
+cross-spawn@^7.0.3:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
   integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
@@ -3005,75 +892,18 @@
     shebang-command "^2.0.0"
     which "^2.0.1"
 
-crypt@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
-  integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=
-
-crypto-random-string@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
-  integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
-
-css-slam@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/css-slam/-/css-slam-2.1.2.tgz#3d35b1922cb3e0002a45c89ab189492508c493e5"
-  integrity sha512-cObrY+mhFEmepWpua6EpMrgRNTQ0eeym+kvR0lukI6hDEzK7F8himEDS4cJ9+fPHCoArTzVrrR0d+oAUbTR1NA==
-  dependencies:
-    command-line-args "^5.0.2"
-    command-line-usage "^5.0.5"
-    dom5 "^3.0.0"
-    parse5 "^4.0.0"
-    shady-css-parser "^0.1.0"
-
-css-what@^2.1.0:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
-  integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==
-
 cssbeautify@^0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/cssbeautify/-/cssbeautify-0.3.1.tgz#12dd1f734035c2e6faca67dcbdcef74e42811397"
-  integrity sha1-Et0fc0A1wub6ymfcvc73TkKBE5c=
+  integrity sha512-ljnSOCOiMbklF+dwPbpooyB78foId02vUrTDogWzu6ca2DCNB7Kc/BHEGBnYOlUYtwXvSW0mWTwaiO2pwFIoRg==
 
-currently-unhandled@^0.4.1:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
-  integrity sha1-mI3zP+qxke95mmE2nddsF635V+o=
-  dependencies:
-    array-find-index "^1.0.1"
-
-dargs@^6.0.0, dargs@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/dargs/-/dargs-6.1.0.tgz#1f3b9b56393ecf8caa7cbfd6c31496ffcfb9b272"
-  integrity sha512-5dVBvpBLBnPwSsYXqfybFyehMmC/EenKEcf23AhCTgTf48JFBbmJKqoZBsERDnjL0FyiVTYWdFsRfTLHxLyKdQ==
-
-dashdash@^1.12.0:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
-  integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
-  dependencies:
-    assert-plus "^1.0.0"
-
-dateformat@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
-  integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
-
-debug@2.6.9, debug@^2.0.0, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8:
+debug@^2.2.0, debug@^2.6.8:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
   dependencies:
     ms "2.0.0"
 
-debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
-  integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
-  dependencies:
-    ms "2.1.2"
-
 debug@^3.1.0:
   version "3.2.7"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
@@ -3081,36 +911,12 @@
   dependencies:
     ms "^2.1.1"
 
-debug@~3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
-  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
+debug@^4.1.0, debug@^4.3.1:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
+  integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
   dependencies:
-    ms "2.0.0"
-
-debug@~4.1.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
-  integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
-  dependencies:
-    ms "^2.1.1"
-
-decamelize@^1.1.2:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
-  integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
-
-decode-uri-component@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
-  integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
-
-decompress-response@^3.2.0:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
-  integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=
-  dependencies:
-    mimic-response "^1.0.0"
+    ms "2.1.2"
 
 decompress-response@^6.0.0:
   version "6.0.0"
@@ -3119,154 +925,28 @@
   dependencies:
     mimic-response "^3.1.0"
 
-deep-extend@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.5.1.tgz#b894a9dd90d3023fbf1c55a394fb858eb2066f1f"
-  integrity sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==
-
-deep-extend@^0.6.0, deep-extend@~0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
-  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
-
 deep-extend@~0.4.1:
   version "0.4.2"
   resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
-  integrity sha1-SLaZwn4zS/ifEIkr5DL25MfTSn8=
+  integrity sha512-cQ0iXSEKi3JRNhjUsLWvQ+MVPxLVqpwCd0cFsWbJxlCim2TlCo1JvN5WaPdPvSpUdEnkJ/X+mPGcq5RJ68EK8g==
+
+deep-extend@~0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
 
 defer-to-connect@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587"
   integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==
 
-define-properties@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
-  integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
-  dependencies:
-    object-keys "^1.0.12"
-
-define-property@^0.2.5:
-  version "0.2.5"
-  resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116"
-  integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=
-  dependencies:
-    is-descriptor "^0.1.0"
-
-define-property@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6"
-  integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY=
-  dependencies:
-    is-descriptor "^1.0.0"
-
-define-property@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d"
-  integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==
-  dependencies:
-    is-descriptor "^1.0.2"
-    isobject "^3.0.1"
-
-del@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5"
-  integrity sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU=
-  dependencies:
-    globby "^6.1.0"
-    is-path-cwd "^1.0.0"
-    is-path-in-cwd "^1.0.0"
-    p-map "^1.1.1"
-    pify "^3.0.0"
-    rimraf "^2.2.8"
-
-delayed-stream@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
-  integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
-
-depd@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
-  integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
-
-deprecation@^2.0.0, deprecation@^2.3.1:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
-  integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==
-
-destroy@~1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
-  integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
-
-detect-conflict@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/detect-conflict/-/detect-conflict-1.0.1.tgz#088657a66a961c05019db7c4230883b1c6b4176e"
-  integrity sha1-CIZXpmqWHAUBnbfEIwiDsca0F24=
-
-detect-file@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-0.1.0.tgz#4935dedfd9488648e006b0129566e9386711ea63"
-  integrity sha1-STXe39lIhkjgBrASlWbpOGcR6mM=
-  dependencies:
-    fs-exists-sync "^0.1.0"
-
-detect-file@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
-  integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=
-
 detect-indent@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
-  integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg=
+  integrity sha512-BDKtmHlOzwI7iRuEkhzsnPoi5ypEhWAJB5RvHWe1kMr06js3uK5B3734i3ui5Yd+wOJV1cpE4JnivPD283GU/A==
   dependencies:
     repeating "^2.0.0"
 
-detect-node@^2.0.3:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1"
-  integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
-
-dicer@0.2.5:
-  version "0.2.5"
-  resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f"
-  integrity sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=
-  dependencies:
-    readable-stream "1.1.x"
-    streamsearch "0.1.2"
-
-diff@^2.1.2:
-  version "2.2.3"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-2.2.3.tgz#60eafd0d28ee906e4e8ff0a52c1229521033bf99"
-  integrity sha1-YOr9DSjukG5Oj/ClLBIpUhAzv5k=
-
-diff@^3.1.0, diff@^3.5.0:
-  version "3.5.0"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
-  integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==
-
-diff@^4.0.1:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
-  integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
-
-dir-glob@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034"
-  integrity sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag==
-  dependencies:
-    arrify "^1.0.1"
-    path-type "^3.0.0"
-
-dir-glob@^2.2.2:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.2.2.tgz#fa09f0694153c8918b18ba0deafae94769fc50c4"
-  integrity sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==
-  dependencies:
-    path-type "^3.0.0"
-
 doctrine@^2.0.2:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
@@ -3274,17 +954,10 @@
   dependencies:
     esutils "^2.0.2"
 
-dom-urls@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/dom-urls/-/dom-urls-1.1.0.tgz#001ddf81628cd1e706125c7176f53ccec55d918e"
-  integrity sha1-AB3fgWKM0ecGElxxdvU8zsVdkY4=
-  dependencies:
-    urijs "^1.16.1"
-
 dom5@^1.0.1:
   version "1.3.6"
   resolved "https://registry.yarnpkg.com/dom5/-/dom5-1.3.6.tgz#a7088a9fc5f3b08dc9f6eda4c7abaeb241945e0d"
-  integrity sha1-pwiKn8XzsI3J9u2kx6uuskGUXg0=
+  integrity sha512-mcW8C3hP6NR7PD2mpa6cLihu0ToVrsloG69a/4vZ8lbKrAApEVJi99O2vqd5G1gfnvmLHbGSo/LdHbWBwdF4Rw==
   dependencies:
     "@types/clone" "^0.1.29"
     "@types/node" "^4.0.30"
@@ -3301,181 +974,14 @@
     clone "^2.1.0"
     parse5 "^4.0.0"
 
-dot-prop@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-3.0.0.tgz#1b708af094a49c9a0e7dbcad790aba539dac1177"
-  integrity sha1-G3CK8JSknJoOfbyteQq6U52sEXc=
-  dependencies:
-    is-obj "^1.0.0"
-
-dot-prop@^4.2.1:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.1.tgz#45884194a71fc2cda71cbb4bceb3a4dd2f433ba4"
-  integrity sha512-l0p4+mIuJIua0mhxGoh4a+iNL9bmeK5DvnSVQa6T0OhrVmaEa1XScX5Etc673FePCJOArq/4Pa2cLGODUWTPOQ==
-  dependencies:
-    is-obj "^1.0.0"
-
-download-stats@^0.3.4:
-  version "0.3.4"
-  resolved "https://registry.yarnpkg.com/download-stats/-/download-stats-0.3.4.tgz#67ea0c32f14acd9f639da704eef509684ba2dae7"
-  integrity sha512-ic2BigbyUWx7/CBbsfGjf71zUNZB4edBGC3oRliSzsoNmvyVx3Ycfp1w3vp2Y78Ee0eIIkjIEO5KzW0zThDGaA==
-  dependencies:
-    JSONStream "^1.2.1"
-    lazy-cache "^2.0.1"
-    moment "^2.15.1"
-
-duplexer2@^0.1.2, duplexer2@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
-  integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=
-  dependencies:
-    readable-stream "^2.0.2"
-
-duplexer3@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
-  integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
-
-duplexify@^3.2.0, duplexify@^3.5.0, duplexify@^3.6.0:
-  version "3.7.1"
-  resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"
-  integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==
-  dependencies:
-    end-of-stream "^1.0.0"
-    inherits "^2.0.1"
-    readable-stream "^2.0.0"
-    stream-shift "^1.0.0"
-
-ecc-jsbn@~0.1.1:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
-  integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
-  dependencies:
-    jsbn "~0.1.0"
-    safer-buffer "^2.1.0"
-
-editions@^2.2.0:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/editions/-/editions-2.3.1.tgz#3bc9962f1978e801312fbd0aebfed63b49bfe698"
-  integrity sha512-ptGvkwTvGdGfC0hfhKg0MT+TRLRKGtUiWGBInxOm5pz7ssADezahjCUaYuZ8Dr+C05FW0AECIIPt4WBxVINEhA==
-  dependencies:
-    errlop "^2.0.0"
-    semver "^6.3.0"
-
-ee-first@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
-  integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
-
-ejs@^2.5.9, ejs@^2.6.1:
-  version "2.7.4"
-  resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba"
-  integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==
-
-ejs@^3.1.5:
-  version "3.1.6"
-  resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.6.tgz#5bfd0a0689743bb5268b3550cceeebbc1702822a"
-  integrity sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==
-  dependencies:
-    jake "^10.6.1"
-
-electron-to-chromium@^1.3.811:
-  version "1.3.826"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.826.tgz#dbe356b1546b39d83bcd47e675a9c5f61dadaed2"
-  integrity sha512-bpLc4QU4B8PYmdO4MSu2ZBTMD8lAaEXRS43C09lB31BvYwuk9UxgBRXbY5OJBw7VuMGcg2MZG5FyTaP9u4PQnw==
-
-emitter-component@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6"
-  integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY=
-
-emoji-regex@^8.0.0:
-  version "8.0.0"
-  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
-  integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
-
-enabled@2.0.x:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2"
-  integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==
-
-encodeurl@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
-  integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
-
-end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1:
+end-of-stream@^1.1.0, end-of-stream@^1.4.1:
   version "1.4.4"
   resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
   integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
   dependencies:
     once "^1.4.0"
 
-ends-with@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/ends-with/-/ends-with-0.2.0.tgz#2f9da98d57a50cfda4571ce4339000500f4e6b8a"
-  integrity sha1-L52pjVelDP2kVxzkM5AAUA9Oa4o=
-
-engine.io-client@~3.5.0:
-  version "3.5.2"
-  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.5.2.tgz#0ef473621294004e9ceebe73cef0af9e36f2f5fa"
-  integrity sha512-QEqIp+gJ/kMHeUun7f5Vv3bteRHppHH/FMBQX/esFj/fuYfjyUKWGMo3VCvIP/V8bE9KcjHmRZrhIz2Z9oNsDA==
-  dependencies:
-    component-emitter "~1.3.0"
-    component-inherit "0.0.3"
-    debug "~3.1.0"
-    engine.io-parser "~2.2.0"
-    has-cors "1.1.0"
-    indexof "0.0.1"
-    parseqs "0.0.6"
-    parseuri "0.0.6"
-    ws "~7.4.2"
-    xmlhttprequest-ssl "~1.6.2"
-    yeast "0.1.2"
-
-engine.io-parser@~2.2.0:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.1.tgz#57ce5611d9370ee94f99641b589f94c97e4f5da7"
-  integrity sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==
-  dependencies:
-    after "0.8.2"
-    arraybuffer.slice "~0.0.7"
-    base64-arraybuffer "0.1.4"
-    blob "0.0.5"
-    has-binary2 "~1.0.2"
-
-engine.io@~3.5.0:
-  version "3.5.0"
-  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.5.0.tgz#9d6b985c8a39b1fe87cd91eb014de0552259821b"
-  integrity sha512-21HlvPUKaitDGE4GXNtQ7PLP0Sz4aWLddMPw2VTyFz1FVZqu/kZsJUO8WNpKuE/OCL7nkfRaOui2ZCJloGznGA==
-  dependencies:
-    accepts "~1.3.4"
-    base64id "2.0.0"
-    cookie "~0.4.1"
-    debug "~4.1.0"
-    engine.io-parser "~2.2.0"
-    ws "~7.4.2"
-
-errlop@^2.0.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/errlop/-/errlop-2.2.0.tgz#1ff383f8f917ae328bebb802d6ca69666a42d21b"
-  integrity sha512-e64Qj9+4aZzjzzFpZC7p5kmm/ccCrbLhAJplhsDXQFs87XTsXwOpH4s1Io2s90Tau/8r2j9f4l/thhDevRjzxw==
-
-error-ex@^1.2.0, error-ex@^1.3.1:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
-  integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
-  dependencies:
-    is-arrayish "^0.2.1"
-
-error@^7.0.2:
-  version "7.2.1"
-  resolved "https://registry.yarnpkg.com/error/-/error-7.2.1.tgz#eab21a4689b5f684fc83da84a0e390de82d94894"
-  integrity sha512-fo9HBvWnx3NGUKMvMwB/CBCMMrfEJgbDTVDEkPygA3Bdd3lM1OyCd+rbQ8BwnpF6GdVeOLDNmyL4N5Bg80ZvdA==
-  dependencies:
-    string-template "~0.2.1"
-
-es6-promise@^4.0.3, es6-promise@^4.0.5:
+es6-promise@^4.0.3:
   version "4.2.8"
   resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
   integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
@@ -3483,29 +989,19 @@
 es6-promisify@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
-  integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
+  integrity sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==
   dependencies:
     es6-promise "^4.0.3"
 
-es6-promisify@^6.0.0:
-  version "6.1.1"
-  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-6.1.1.tgz#46837651b7b06bf6fff893d03f29393668d01621"
-  integrity sha512-HBL8I3mIki5C1Cc9QjKUenHtnG0A5/xA8Q/AllRcfiwl2CZFXGK7ddBiCoRwAix4i2KxcQfjtIVcrVbB3vbmwg==
-
-escalade@^3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
-  integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
-
-escape-html@^1.0.3, escape-html@~1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
-  integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
-
-escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.4, escape-string-regexp@^1.0.5:
+escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
-  integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
+  integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
+
+escape-string-regexp@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
+  integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
 
 espree@^3.5.2:
   version "3.5.4"
@@ -3520,256 +1016,20 @@
   resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362"
   integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==
 
+estree-walker@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
+  integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
+
 esutils@^2.0.2:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
   integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
 
-etag@~1.8.1:
-  version "1.8.1"
-  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
-  integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
-
-eventemitter3@^4.0.0:
-  version "4.0.7"
-  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
-  integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
-
-execa@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
-  integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=
-  dependencies:
-    cross-spawn "^5.0.1"
-    get-stream "^3.0.0"
-    is-stream "^1.1.0"
-    npm-run-path "^2.0.0"
-    p-finally "^1.0.0"
-    signal-exit "^3.0.0"
-    strip-eof "^1.0.0"
-
-execa@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8"
-  integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==
-  dependencies:
-    cross-spawn "^6.0.0"
-    get-stream "^4.0.0"
-    is-stream "^1.1.0"
-    npm-run-path "^2.0.0"
-    p-finally "^1.0.0"
-    signal-exit "^3.0.0"
-    strip-eof "^1.0.0"
-
-execa@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a"
-  integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==
-  dependencies:
-    cross-spawn "^7.0.0"
-    get-stream "^5.0.0"
-    human-signals "^1.1.1"
-    is-stream "^2.0.0"
-    merge-stream "^2.0.0"
-    npm-run-path "^4.0.0"
-    onetime "^5.1.0"
-    signal-exit "^3.0.2"
-    strip-final-newline "^2.0.0"
-
-exit-hook@^1.0.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
-  integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=
-
-expand-brackets@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
-  integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=
-  dependencies:
-    is-posix-bracket "^0.1.0"
-
-expand-brackets@^2.1.4:
-  version "2.1.4"
-  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622"
-  integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI=
-  dependencies:
-    debug "^2.3.3"
-    define-property "^0.2.5"
-    extend-shallow "^2.0.1"
-    posix-character-classes "^0.1.0"
-    regex-not "^1.0.0"
-    snapdragon "^0.8.1"
-    to-regex "^3.0.1"
-
-expand-range@^1.8.1:
-  version "1.8.2"
-  resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337"
-  integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=
-  dependencies:
-    fill-range "^2.1.0"
-
-expand-tilde@^1.2.2:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-1.2.2.tgz#0b81eba897e5a3d31d1c3d102f8f01441e559449"
-  integrity sha1-C4HrqJflo9MdHD0QL48BRB5VlEk=
-  dependencies:
-    os-homedir "^1.0.1"
-
-expand-tilde@^2.0.0, expand-tilde@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
-  integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=
-  dependencies:
-    homedir-polyfill "^1.0.1"
-
-express@^4.15.3, express@^4.8.5:
-  version "4.17.1"
-  resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
-  integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
-  dependencies:
-    accepts "~1.3.7"
-    array-flatten "1.1.1"
-    body-parser "1.19.0"
-    content-disposition "0.5.3"
-    content-type "~1.0.4"
-    cookie "0.4.0"
-    cookie-signature "1.0.6"
-    debug "2.6.9"
-    depd "~1.1.2"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    finalhandler "~1.1.2"
-    fresh "0.5.2"
-    merge-descriptors "1.0.1"
-    methods "~1.1.2"
-    on-finished "~2.3.0"
-    parseurl "~1.3.3"
-    path-to-regexp "0.1.7"
-    proxy-addr "~2.0.5"
-    qs "6.7.0"
-    range-parser "~1.2.1"
-    safe-buffer "5.1.2"
-    send "0.17.1"
-    serve-static "1.14.1"
-    setprototypeof "1.1.1"
-    statuses "~1.5.0"
-    type-is "~1.6.18"
-    utils-merge "1.0.1"
-    vary "~1.1.2"
-
-ext-list@^2.0.0:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/ext-list/-/ext-list-2.2.2.tgz#0b98e64ed82f5acf0f2931babf69212ef52ddd37"
-  integrity sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==
-  dependencies:
-    mime-db "^1.28.0"
-
-extend-shallow@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
-  integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=
-  dependencies:
-    is-extendable "^0.1.0"
-
-extend-shallow@^3.0.0, extend-shallow@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8"
-  integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=
-  dependencies:
-    assign-symbols "^1.0.0"
-    is-extendable "^1.0.1"
-
-extend@^3.0.0, extend@~3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
-  integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
-
-external-editor@^1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-1.1.1.tgz#12d7b0db850f7ff7e7081baf4005700060c4600b"
-  integrity sha1-Etew24UPf/fnCBuvQAVwAGDEYAs=
-  dependencies:
-    extend "^3.0.0"
-    spawn-sync "^1.0.15"
-    tmp "^0.0.29"
-
-external-editor@^3.0.3:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495"
-  integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==
-  dependencies:
-    chardet "^0.7.0"
-    iconv-lite "^0.4.24"
-    tmp "^0.0.33"
-
-extglob@^0.3.1:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
-  integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=
-  dependencies:
-    is-extglob "^1.0.0"
-
-extglob@^2.0.4:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543"
-  integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==
-  dependencies:
-    array-unique "^0.3.2"
-    define-property "^1.0.0"
-    expand-brackets "^2.1.4"
-    extend-shallow "^2.0.1"
-    fragment-cache "^0.2.1"
-    regex-not "^1.0.0"
-    snapdragon "^0.8.1"
-    to-regex "^3.0.1"
-
-extsprintf@1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
-  integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
-
-extsprintf@^1.2.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
-  integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
-
-fast-deep-equal@^3.1.1:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
-  integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
-
-fast-glob@^2.0.2, fast-glob@^2.2.6:
-  version "2.2.7"
-  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d"
-  integrity sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==
-  dependencies:
-    "@mrmlnc/readdir-enhanced" "^2.2.1"
-    "@nodelib/fs.stat" "^1.1.2"
-    glob-parent "^3.1.0"
-    is-glob "^4.0.0"
-    merge2 "^1.2.3"
-    micromatch "^3.1.10"
-
-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==
-
-fast-levenshtein@^2.0.6:
-  version "2.0.6"
-  resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
-  integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
-
-fast-safe-stringify@^2.0.4:
-  version "2.0.8"
-  resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.8.tgz#dc2af48c46cf712b683e849b2bbd446b32de936f"
-  integrity sha512-lXatBjf3WPjmWD6DpIZxkeSsCOwqI0maYMpgDlx8g4U2qi4lbjA9oH/HD2a87G+KfsUmo5WbJFmqBZlPxtptag==
-
 fd-slicer@~1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
-  integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=
+  integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==
   dependencies:
     pend "~1.2.0"
 
@@ -3780,98 +1040,10 @@
   dependencies:
     array-back "^1.0.4"
 
-fecha@^2.3.3:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd"
-  integrity sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==
-
-fecha@^4.2.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.1.tgz#0a83ad8f86ef62a091e22bb5a039cd03d23eecce"
-  integrity sha512-MMMQ0ludy/nBs1/o0zVOiKTpG7qMbonKUzjJgQFEuvq6INZ1OraKPRAWkBq5vlKLOUMpmNYG1JoN3oDPUQ9m3Q==
-
-figures@^1.3.5:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
-  integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=
-  dependencies:
-    escape-string-regexp "^1.0.5"
-    object-assign "^4.1.0"
-
-figures@^3.0.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af"
-  integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==
-  dependencies:
-    escape-string-regexp "^1.0.5"
-
-file-uri-to-path@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
-  integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
-
-filelist@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.2.tgz#80202f21462d4d1c2e214119b1807c1bc0380e5b"
-  integrity sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==
-  dependencies:
-    minimatch "^3.0.4"
-
-filename-regex@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
-  integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=
-
-fill-range@^2.1.0:
-  version "2.2.4"
-  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565"
-  integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==
-  dependencies:
-    is-number "^2.1.0"
-    isobject "^2.0.0"
-    randomatic "^3.0.0"
-    repeat-element "^1.1.2"
-    repeat-string "^1.5.2"
-
-fill-range@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
-  integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=
-  dependencies:
-    extend-shallow "^2.0.1"
-    is-number "^3.0.0"
-    repeat-string "^1.6.1"
-    to-regex-range "^2.1.0"
-
-filled-array@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/filled-array/-/filled-array-1.1.0.tgz#c3c4f6c663b923459a9aa29912d2d031f1507f84"
-  integrity sha1-w8T2xmO5I0WamqKZEtLQMfFQf4Q=
-
-finalhandler@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
-  integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
-  dependencies:
-    debug "2.6.9"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    on-finished "~2.3.0"
-    parseurl "~1.3.3"
-    statuses "~1.5.0"
-    unpipe "~1.0.0"
-
-find-port@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/find-port/-/find-port-1.0.1.tgz#db084a6cbf99564d99869ae79fbdecf66e8a185c"
-  integrity sha1-2whKbL+ZVk2Zhprnn73s9m6KGFw=
-  dependencies:
-    async "~0.2.9"
-
 find-replace@^1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-1.0.3.tgz#b88e7364d2d9c959559f388c66670d6130441fa0"
-  integrity sha1-uI5zZNLZyVlVnziMZmcNYTBEH6A=
+  integrity sha512-KrUnjzDCD9426YnCP56zGYy/eieTnhtK6Vn++j+JJzmlsWWwEkDnsyVF575spT6HJ6Ow9tlbT3TQTDsa+O4UWA==
   dependencies:
     array-back "^1.0.4"
     test-value "^2.1.0"
@@ -3883,154 +1055,20 @@
   dependencies:
     array-back "^3.0.1"
 
-find-up@^1.0.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
-  integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=
-  dependencies:
-    path-exists "^2.0.0"
-    pinkie-promise "^2.0.0"
-
-find-up@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73"
-  integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==
-  dependencies:
-    locate-path "^3.0.0"
-
-findup-sync@^0.4.2:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.4.3.tgz#40043929e7bc60adf0b7f4827c4c6e75a0deca12"
-  integrity sha1-QAQ5Kee8YK3wt/SCfExudaDeyhI=
-  dependencies:
-    detect-file "^0.1.0"
-    is-glob "^2.0.1"
-    micromatch "^2.3.7"
-    resolve-dir "^0.1.0"
-
-findup-sync@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc"
-  integrity sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=
-  dependencies:
-    detect-file "^1.0.0"
-    is-glob "^3.1.0"
-    micromatch "^3.0.4"
-    resolve-dir "^1.0.1"
-
-first-chunk-stream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz#59bfb50cd905f60d7c394cd3d9acaab4e6ad934e"
-  integrity sha1-Wb+1DNkF9g18OUzT2ayqtOatk04=
-
-first-chunk-stream@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-2.0.0.tgz#1bdecdb8e083c0664b91945581577a43a9f31d70"
-  integrity sha1-G97NuOCDwGZLkZRVgVd6Q6nzHXA=
-  dependencies:
-    readable-stream "^2.0.2"
-
-fn.name@1.x.x:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc"
-  integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==
-
-follow-redirects@^1.0.0, follow-redirects@^1.10.0:
-  version "1.14.2"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.2.tgz#cecb825047c00f5e66b142f90fed4f515dec789b"
-  integrity sha512-yLR6WaE2lbF0x4K2qE2p9PEXKLDjUjnR/xmjS3wHAYxtlsI9MLLBJUZirAHKzUZDGLxje7w/cXR49WOUo4rbsA==
-
-for-in@^1.0.1, for-in@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
-  integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
-
-for-own@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
-  integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=
-  dependencies:
-    for-in "^1.0.1"
-
-forever-agent@~0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
-  integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
-
-fork-stream@^0.0.4:
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/fork-stream/-/fork-stream-0.0.4.tgz#db849fce77f6708a5f8f386ae533a0907b54ae70"
-  integrity sha1-24Sfznf2cIpfjzhq5TOgkHtUrnA=
-
-form-data@*:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
-  integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
-  dependencies:
-    asynckit "^0.4.0"
-    combined-stream "^1.0.8"
-    mime-types "^2.1.12"
-
-form-data@~2.3.2:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
-  integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==
-  dependencies:
-    asynckit "^0.4.0"
-    combined-stream "^1.0.6"
-    mime-types "^2.1.12"
-
-formatio@1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.2.0.tgz#f3b2167d9068c4698a8d51f4f760a39a54d818eb"
-  integrity sha1-87IWfZBoxGmKjVH092CjmlTYGOs=
-  dependencies:
-    samsam "1.x"
-
-forwarded@0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
-  integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
-
-fragment-cache@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
-  integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=
-  dependencies:
-    map-cache "^0.2.2"
-
 freeport@^1.0.4:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/freeport/-/freeport-1.0.5.tgz#255e8ab84170c33ba85d990e821ae5f4a1a9bc5d"
-  integrity sha1-JV6KuEFwwzuoXZkOghrl9KGpvF0=
-
-fresh@0.5.2:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
-  integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
+  integrity sha512-1+iRfba5tXzQAF83Tvvw5ZuhqDzyACfM+v13SZkdq8xKdaj/WR0Bke4sw9HsO1nU143+Hn0JxIleHEct+xbz9A==
 
 fs-constants@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
   integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
 
-fs-exists-sync@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add"
-  integrity sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=
-
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
-  integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
-
-fsevents@^1.0.0:
-  version "1.2.13"
-  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38"
-  integrity sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==
-  dependencies:
-    bindings "^1.5.0"
-    nan "^2.12.1"
+  integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
 
 fsevents@~2.3.2:
   version "2.3.2"
@@ -4042,207 +1080,25 @@
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
   integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
 
-gensync@^1.0.0-beta.2:
-  version "1.0.0-beta.2"
-  resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
-  integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
-
-get-intrinsic@^1.0.2:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6"
-  integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==
-  dependencies:
-    function-bind "^1.1.1"
-    has "^1.0.3"
-    has-symbols "^1.0.1"
-
-get-stdin@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
-  integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=
-
-get-stream@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
-  integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=
-
-get-stream@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
-  integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==
-  dependencies:
-    pump "^3.0.0"
-
-get-stream@^5.0.0, get-stream@^5.1.0:
+get-stream@^5.1.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
   integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==
   dependencies:
     pump "^3.0.0"
 
-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"
-  integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
-
-getpass@^0.1.1:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
-  integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
-  dependencies:
-    assert-plus "^1.0.0"
-
-gh-got@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/gh-got/-/gh-got-5.0.0.tgz#ee95be37106fd8748a96f8d1db4baea89e1bfa8a"
-  integrity sha1-7pW+NxBv2HSKlvjR20uuqJ4b+oo=
-  dependencies:
-    got "^6.2.0"
-    is-plain-obj "^1.1.0"
-
-gh-got@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/gh-got/-/gh-got-6.0.0.tgz#d74353004c6ec466647520a10bd46f7299d268d0"
-  integrity sha512-F/mS+fsWQMo1zfgG9MD8KWvTWPPzzhuVwY++fhQ5Ggd+0P+CAMHtzMZhNxG+TqGfHDChJKsbh6otfMGqO2AKBw==
-  dependencies:
-    got "^7.0.0"
-    is-plain-obj "^1.1.0"
-
-github-username@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/github-username/-/github-username-3.0.0.tgz#0a772219b3130743429f2456d0bdd3db55dce7b1"
-  integrity sha1-CnciGbMTB0NCnyRW0L3T21Xc57E=
-  dependencies:
-    gh-got "^5.0.0"
-
-github-username@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/github-username/-/github-username-4.1.0.tgz#cbe280041883206da4212ae9e4b5f169c30bf417"
-  integrity sha1-y+KABBiDIG2kISrp5LXxacML9Bc=
-  dependencies:
-    gh-got "^6.0.0"
-
-glob-base@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
-  integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=
-  dependencies:
-    glob-parent "^2.0.0"
-    is-glob "^2.0.0"
-
-glob-parent@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28"
-  integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=
-  dependencies:
-    is-glob "^2.0.0"
-
-glob-parent@^3.0.0, glob-parent@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
-  integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=
-  dependencies:
-    is-glob "^3.1.0"
-    path-dirname "^1.0.0"
-
-glob-stream@^5.3.2:
-  version "5.3.5"
-  resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-5.3.5.tgz#a55665a9a8ccdc41915a87c701e32d4e016fad22"
-  integrity sha1-pVZlqajM3EGRWofHAeMtTgFvrSI=
-  dependencies:
-    extend "^3.0.0"
-    glob "^5.0.3"
-    glob-parent "^3.0.0"
-    micromatch "^2.3.7"
-    ordered-read-streams "^0.3.0"
-    through2 "^0.6.0"
-    to-absolute-glob "^0.1.1"
-    unique-stream "^2.0.2"
-
-glob-to-regexp@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
-  integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=
-
-glob@^5.0.3:
-  version "5.0.15"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
-  integrity sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=
-  dependencies:
-    inflight "^1.0.4"
-    inherits "2"
-    minimatch "2 || 3"
-    once "^1.3.0"
-    path-is-absolute "^1.0.0"
-
-glob@^6.0.1:
-  version "6.0.4"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
-  integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=
-  dependencies:
-    inflight "^1.0.4"
-    inherits "2"
-    minimatch "2 || 3"
-    once "^1.3.0"
-    path-is-absolute "^1.0.0"
-
-glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4:
-  version "7.1.7"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
-  integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
+glob@^7.1.3:
+  version "7.2.3"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
+  integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
   dependencies:
     fs.realpath "^1.0.0"
     inflight "^1.0.4"
     inherits "2"
-    minimatch "^3.0.4"
+    minimatch "^3.1.1"
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-global-dirs@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
-  integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=
-  dependencies:
-    ini "^1.3.4"
-
-global-modules@^0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d"
-  integrity sha1-6lo77ULG1s6ZWk+KEmm12uIjgo0=
-  dependencies:
-    global-prefix "^0.1.4"
-    is-windows "^0.2.0"
-
-global-modules@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
-  integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==
-  dependencies:
-    global-prefix "^1.0.1"
-    is-windows "^1.0.1"
-    resolve-dir "^1.0.0"
-
-global-prefix@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-0.1.5.tgz#8d3bc6b8da3ca8112a160d8d496ff0462bfef78f"
-  integrity sha1-jTvGuNo8qBEqFg2NSW/wRiv+948=
-  dependencies:
-    homedir-polyfill "^1.0.0"
-    ini "^1.3.4"
-    is-windows "^0.2.0"
-    which "^1.2.12"
-
-global-prefix@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
-  integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=
-  dependencies:
-    expand-tilde "^2.0.2"
-    homedir-polyfill "^1.0.1"
-    ini "^1.3.4"
-    is-windows "^1.0.1"
-    which "^1.2.14"
-
 globals@^11.1.0:
   version "11.12.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
@@ -4253,65 +1109,15 @@
   resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
   integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==
 
-globby@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-4.1.0.tgz#080f54549ec1b82a6c60e631fc82e1211dbe95f8"
-  integrity sha1-CA9UVJ7BuCpsYOYx/ILhIR2+lfg=
-  dependencies:
-    array-union "^1.0.1"
-    arrify "^1.0.0"
-    glob "^6.0.1"
-    object-assign "^4.0.1"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-
-globby@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c"
-  integrity sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=
-  dependencies:
-    array-union "^1.0.1"
-    glob "^7.0.3"
-    object-assign "^4.0.1"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-
-globby@^8.0.1:
-  version "8.0.2"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-8.0.2.tgz#5697619ccd95c5275dbb2d6faa42087c1a941d8d"
-  integrity sha512-yTzMmKygLp8RUpG1Ymu2VXPSJQZjNAZPD4ywgYEaG7e4tBJeUQBO8OpXrf1RCNcEs5alsoJYPAMiIHP0cmeC7w==
-  dependencies:
-    array-union "^1.0.1"
-    dir-glob "2.0.0"
-    fast-glob "^2.0.2"
-    glob "^7.1.2"
-    ignore "^3.3.5"
-    pify "^3.0.0"
-    slash "^1.0.0"
-
-globby@^9.2.0:
-  version "9.2.0"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-9.2.0.tgz#fd029a706c703d29bdd170f4b6db3a3f7a7cb63d"
-  integrity sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==
-  dependencies:
-    "@types/glob" "^7.1.1"
-    array-union "^1.0.2"
-    dir-glob "^2.2.2"
-    fast-glob "^2.2.6"
-    glob "^7.1.3"
-    ignore "^4.0.3"
-    pify "^4.0.1"
-    slash "^2.0.0"
-
 google-protobuf@^3.6.1:
-  version "3.19.4"
-  resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.19.4.tgz#8d32c3e34be9250956f28c0fb90955d13f311888"
-  integrity sha512-OIPNCxsG2lkIvf+P5FNfJ/Km95CsXOBecS9ZcAU6m2Rq3svc0Apl9nB3GMDNKfQ9asNv4KjyAqGwPQFrVle3Yg==
+  version "3.20.1"
+  resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.20.1.tgz#1b255c2b59bcda7c399df46c65206aa3c7a0ce8b"
+  integrity sha512-XMf1+O32FjYIV3CYu6Tuh5PNbfNEU5Xu22X+Xkdb/DUexFlCzhvv7d5Iirm4AOwn8lv4al1YvIhzGrg2j9Zfzw==
 
 got@^11.8.2:
-  version "11.8.3"
-  resolved "https://registry.yarnpkg.com/got/-/got-11.8.3.tgz#f496c8fdda5d729a90b4905d2b07dbd148170770"
-  integrity sha512-7gtQ5KiPh1RtGS9/Jbv1ofDpBFuq42gyfEib+ejaRBJuj/3tQFeR5+gw57e4ipaU8c/rCjvX6fkQz2lyDlGAOg==
+  version "11.8.5"
+  resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046"
+  integrity sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ==
   dependencies:
     "@sindresorhus/is" "^4.0.0"
     "@szmarczak/http-timer" "^4.0.5"
@@ -4325,221 +1131,17 @@
     p-cancelable "^2.0.0"
     responselike "^2.0.0"
 
-got@^5.0.0:
-  version "5.7.1"
-  resolved "https://registry.yarnpkg.com/got/-/got-5.7.1.tgz#5f81635a61e4a6589f180569ea4e381680a51f35"
-  integrity sha1-X4FjWmHkplifGAVp6k44FoClHzU=
-  dependencies:
-    create-error-class "^3.0.1"
-    duplexer2 "^0.1.4"
-    is-redirect "^1.0.0"
-    is-retry-allowed "^1.0.0"
-    is-stream "^1.0.0"
-    lowercase-keys "^1.0.0"
-    node-status-codes "^1.0.0"
-    object-assign "^4.0.1"
-    parse-json "^2.1.0"
-    pinkie-promise "^2.0.0"
-    read-all-stream "^3.0.0"
-    readable-stream "^2.0.5"
-    timed-out "^3.0.0"
-    unzip-response "^1.0.2"
-    url-parse-lax "^1.0.0"
-
-got@^6.2.0, got@^6.7.1:
-  version "6.7.1"
-  resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
-  integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=
-  dependencies:
-    create-error-class "^3.0.0"
-    duplexer3 "^0.1.4"
-    get-stream "^3.0.0"
-    is-redirect "^1.0.0"
-    is-retry-allowed "^1.0.0"
-    is-stream "^1.0.0"
-    lowercase-keys "^1.0.0"
-    safe-buffer "^5.0.1"
-    timed-out "^4.0.0"
-    unzip-response "^2.0.1"
-    url-parse-lax "^1.0.0"
-
-got@^7.0.0:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/got/-/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a"
-  integrity sha512-Y5WMo7xKKq1muPsxD+KmrR8DH5auG7fBdDVueZwETwV6VytKyU9OX/ddpq2/1hp1vIPvVb4T81dKQz3BivkNLw==
-  dependencies:
-    decompress-response "^3.2.0"
-    duplexer3 "^0.1.4"
-    get-stream "^3.0.0"
-    is-plain-obj "^1.1.0"
-    is-retry-allowed "^1.0.0"
-    is-stream "^1.0.0"
-    isurl "^1.0.0-alpha5"
-    lowercase-keys "^1.0.0"
-    p-cancelable "^0.3.0"
-    p-timeout "^1.1.1"
-    safe-buffer "^5.0.1"
-    timed-out "^4.0.0"
-    url-parse-lax "^1.0.0"
-    url-to-options "^1.0.1"
-
-graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.2.0:
-  version "4.2.8"
-  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
-  integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
-
-grouped-queue@^0.3.0:
-  version "0.3.3"
-  resolved "https://registry.yarnpkg.com/grouped-queue/-/grouped-queue-0.3.3.tgz#c167d2a5319c5a0e0964ef6a25b7c2df8996c85c"
-  integrity sha1-wWfSpTGcWg4JZO9qJbfC34mWyFw=
-  dependencies:
-    lodash "^4.17.2"
-
-grouped-queue@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/grouped-queue/-/grouped-queue-1.1.0.tgz#63e3f9ca90af952269d1d40879e41221eacc74cb"
-  integrity sha512-rZOFKfCqLhsu5VqjBjEWiwrYqJR07KxIkH4mLZlNlGDfntbb4FbMyGFP14TlvRPrU9S3Hnn/sgxbC5ZeN0no3Q==
-  dependencies:
-    lodash "^4.17.15"
-
-gulp-if@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/gulp-if/-/gulp-if-2.0.2.tgz#a497b7e7573005041caa2bc8b7dda3c80444d629"
-  integrity sha1-pJe351cwBQQcqivIt92jyARE1ik=
-  dependencies:
-    gulp-match "^1.0.3"
-    ternary-stream "^2.0.1"
-    through2 "^2.0.1"
-
-gulp-match@^1.0.3:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/gulp-match/-/gulp-match-1.1.0.tgz#552b7080fc006ee752c90563f9fec9d61aafdf4f"
-  integrity sha512-DlyVxa1Gj24DitY2OjEsS+X6tDpretuxD6wTfhXE/Rw2hweqc1f6D/XtsJmoiCwLWfXgR87W9ozEityPCVzGtQ==
-  dependencies:
-    minimatch "^3.0.3"
-
-gulp-sourcemaps@1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/gulp-sourcemaps/-/gulp-sourcemaps-1.6.0.tgz#b86ff349d801ceb56e1d9e7dc7bbcb4b7dee600c"
-  integrity sha1-uG/zSdgBzrVuHZ59x7vLS33uYAw=
-  dependencies:
-    convert-source-map "^1.1.1"
-    graceful-fs "^4.1.2"
-    strip-bom "^2.0.0"
-    through2 "^2.0.0"
-    vinyl "^1.0.0"
-
-gunzip-maybe@^1.3.1:
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz#b913564ae3be0eda6f3de36464837a9cd94b98ac"
-  integrity sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==
-  dependencies:
-    browserify-zlib "^0.1.4"
-    is-deflate "^1.0.0"
-    is-gzip "^1.0.0"
-    peek-stream "^1.1.0"
-    pumpify "^1.3.3"
-    through2 "^2.0.3"
-
-handle-thing@^1.2.5:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4"
-  integrity sha1-/Xqtcmvxpf0W38KbL3pmAdJxOcQ=
-
-har-schema@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
-  integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
-
-har-validator@~5.1.0, har-validator@~5.1.3:
-  version "5.1.5"
-  resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd"
-  integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==
-  dependencies:
-    ajv "^6.12.3"
-    har-schema "^2.0.0"
-
 has-ansi@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
-  integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=
+  integrity sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==
   dependencies:
     ansi-regex "^2.0.0"
 
-has-binary2@~1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
-  integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
-  dependencies:
-    isarray "2.0.1"
-
-has-color@~0.1.0:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/has-color/-/has-color-0.1.7.tgz#67144a5260c34fc3cca677d041daf52fe7b78b2f"
-  integrity sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=
-
-has-cors@1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
-  integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=
-
 has-flag@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
-  integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
-
-has-flag@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
-  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
-
-has-symbol-support-x@^1.4.1:
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455"
-  integrity sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==
-
-has-symbols@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
-  integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
-
-has-to-string-tag-x@^1.2.0:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz#a045ab383d7b4b2012a00148ab0aa5f290044d4d"
-  integrity sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==
-  dependencies:
-    has-symbol-support-x "^1.4.1"
-
-has-value@^0.3.1:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
-  integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=
-  dependencies:
-    get-value "^2.0.3"
-    has-values "^0.1.4"
-    isobject "^2.0.0"
-
-has-value@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177"
-  integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=
-  dependencies:
-    get-value "^2.0.6"
-    has-values "^1.0.0"
-    isobject "^3.0.0"
-
-has-values@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771"
-  integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E=
-
-has-values@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f"
-  integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=
-  dependencies:
-    is-number "^3.0.0"
-    kind-of "^4.0.0"
+  integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==
 
 has@^1.0.3:
   version "1.0.3"
@@ -4548,116 +1150,11 @@
   dependencies:
     function-bind "^1.1.1"
 
-he@1.2.x:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
-  integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
-
-homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
-  integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==
-  dependencies:
-    parse-passwd "^1.0.0"
-
-hosted-git-info@^2.1.4:
-  version "2.8.9"
-  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
-  integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
-
-hpack.js@^2.1.6:
-  version "2.1.6"
-  resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2"
-  integrity sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=
-  dependencies:
-    inherits "^2.0.1"
-    obuf "^1.0.0"
-    readable-stream "^2.0.1"
-    wbuf "^1.1.0"
-
-html-minifier@^3.5.10:
-  version "3.5.21"
-  resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.21.tgz#d0040e054730e354db008463593194015212d20c"
-  integrity sha512-LKUKwuJDhxNa3uf/LPR/KVjm/l3rBqtYeCOAekvG8F1vItxMUpueGd94i/asDDr8/1u7InxzFA5EeGjhhG5mMA==
-  dependencies:
-    camel-case "3.0.x"
-    clean-css "4.2.x"
-    commander "2.17.x"
-    he "1.2.x"
-    param-case "2.1.x"
-    relateurl "0.2.x"
-    uglify-js "3.4.x"
-
 http-cache-semantics@^4.0.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
   integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
 
-http-deceiver@^1.2.7:
-  version "1.2.7"
-  resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
-  integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=
-
-http-errors@1.7.2:
-  version "1.7.2"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
-  integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.3"
-    setprototypeof "1.1.1"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.0"
-
-http-errors@~1.6.2:
-  version "1.6.3"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
-  integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.3"
-    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-middleware@^0.17.2:
-  version "0.17.4"
-  resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833"
-  integrity sha1-ZC6ISIUdZvCdTxJJEoRtuutBuDM=
-  dependencies:
-    http-proxy "^1.16.2"
-    is-glob "^3.1.0"
-    lodash "^4.17.2"
-    micromatch "^2.3.11"
-
-http-proxy@^1.16.2:
-  version "1.18.1"
-  resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
-  integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==
-  dependencies:
-    eventemitter3 "^4.0.0"
-    follow-redirects "^1.0.0"
-    requires-port "^1.0.0"
-
-http-signature@~1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
-  integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
-  dependencies:
-    assert-plus "^1.0.0"
-    jsprim "^1.2.2"
-    sshpk "^1.7.0"
-
 http2-wrapper@^1.0.0-beta.5.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d"
@@ -4674,140 +1171,29 @@
     agent-base "^4.3.0"
     debug "^3.1.0"
 
-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"
-  integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==
-  dependencies:
-    agent-base "6"
-    debug "4"
-
-human-signals@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
-  integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
-
-iconv-lite@0.4.24, iconv-lite@^0.4.24:
-  version "0.4.24"
-  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
-  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
-  dependencies:
-    safer-buffer ">= 2.1.2 < 3"
-
 ieee754@^1.1.13:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
-ignore@^3.3.5:
-  version "3.3.10"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043"
-  integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==
-
-ignore@^4.0.3:
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
-  integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
-
-import-lazy@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
-  integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=
-
-imurmurhash@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
-  integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
-
-indent-string@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
-  integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=
-  dependencies:
-    repeating "^2.0.0"
-
 indent@0.0.2:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/indent/-/indent-0.0.2.tgz#8c79f080190559b687034b84c7aefa97d5a911d9"
-  integrity sha1-jHnwgBkFWbaHA0uEx676l9WpEdk=
-
-indexof@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
-  integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
+  integrity sha512-/F1w9/msSQCfXDTvEU8rKBObcv4cBN6m8hujC/zwVc8vOuf4b76AwBVGChbg+3o0M3kp1XDjoMDQR5Nh6SAHfA==
 
 inflight@^1.0.4:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
-  integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+  integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
   dependencies:
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3:
+inherits@2, inherits@^2.0.3, inherits@^2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
-inherits@2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
-  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
-
-ini@^1.3.4, ini@~1.3.0:
-  version "1.3.8"
-  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
-  integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
-
-inquirer@^1.0.2:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-1.2.3.tgz#4dec6f32f37ef7bb0b2ed3f1d1a5c3f545074918"
-  integrity sha1-TexvMvN+97sLLtPx0aXD9UUHSRg=
-  dependencies:
-    ansi-escapes "^1.1.0"
-    chalk "^1.0.0"
-    cli-cursor "^1.0.1"
-    cli-width "^2.0.0"
-    external-editor "^1.1.0"
-    figures "^1.3.5"
-    lodash "^4.3.0"
-    mute-stream "0.0.6"
-    pinkie-promise "^2.0.0"
-    run-async "^2.2.0"
-    rx "^4.1.0"
-    string-width "^1.0.1"
-    strip-ansi "^3.0.0"
-    through "^2.3.6"
-
-inquirer@^7.1.0:
-  version "7.3.3"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003"
-  integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==
-  dependencies:
-    ansi-escapes "^4.2.1"
-    chalk "^4.1.0"
-    cli-cursor "^3.1.0"
-    cli-width "^3.0.0"
-    external-editor "^3.0.3"
-    figures "^3.0.0"
-    lodash "^4.17.19"
-    mute-stream "0.0.8"
-    run-async "^2.4.0"
-    rxjs "^6.6.0"
-    string-width "^4.1.0"
-    strip-ansi "^6.0.0"
-    through "^2.3.6"
-
-interpret@^1.0.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
-  integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
-
-intersect@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/intersect/-/intersect-1.0.1.tgz#332650e10854d8c0ac58c192bdc27a8bf7e7a30c"
-  integrity sha1-MyZQ4QhU2MCsWMGSvcJ6i/fnoww=
-
 invariant@^2.2.2:
   version "2.2.4"
   resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
@@ -4815,289 +1201,22 @@
   dependencies:
     loose-envify "^1.0.0"
 
-ipaddr.js@1.9.1:
-  version "1.9.1"
-  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
-  integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
-
-is-accessor-descriptor@^0.1.6:
-  version "0.1.6"
-  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
-  integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=
-  dependencies:
-    kind-of "^3.0.2"
-
-is-accessor-descriptor@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656"
-  integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==
-  dependencies:
-    kind-of "^6.0.0"
-
-is-arrayish@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
-  integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
-
-is-arrayish@^0.3.1:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
-  integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
-
-is-binary-path@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898"
-  integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=
-  dependencies:
-    binary-extensions "^1.0.0"
-
-is-buffer@^1.1.5, is-buffer@~1.1.6:
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
-  integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
-
-is-ci@^1.0.10:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c"
-  integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==
-  dependencies:
-    ci-info "^1.5.0"
-
-is-core-module@^2.2.0:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.6.0.tgz#d7553b2526fe59b92ba3e40c8df757ec8a709e19"
-  integrity sha512-wShG8vs60jKfPWpF2KZRaAtvt3a20OAn7+IJ6hLPECpSABLcKtFKTTI4ZtH5QcBruBHlq+WsdHWyz0BCZW7svQ==
-  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==
+  version "2.9.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69"
+  integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==
   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"
-  integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=
-  dependencies:
-    kind-of "^3.0.2"
-
-is-data-descriptor@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7"
-  integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==
-  dependencies:
-    kind-of "^6.0.0"
-
-is-deflate@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-deflate/-/is-deflate-1.0.0.tgz#c862901c3c161fb09dac7cdc7e784f80e98f2f14"
-  integrity sha1-yGKQHDwWH7CdrHzcfnhPgOmPLxQ=
-
-is-descriptor@^0.1.0:
-  version "0.1.6"
-  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca"
-  integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==
-  dependencies:
-    is-accessor-descriptor "^0.1.6"
-    is-data-descriptor "^0.1.4"
-    kind-of "^5.0.0"
-
-is-descriptor@^1.0.0, is-descriptor@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec"
-  integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==
-  dependencies:
-    is-accessor-descriptor "^1.0.0"
-    is-data-descriptor "^1.0.0"
-    kind-of "^6.0.2"
-
-is-dotfile@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
-  integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=
-
-is-equal-shallow@^0.1.3:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
-  integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=
-  dependencies:
-    is-primitive "^2.0.0"
-
-is-extendable@^0.1.0, is-extendable@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
-  integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=
-
-is-extendable@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4"
-  integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==
-  dependencies:
-    is-plain-object "^2.0.4"
-
-is-extglob@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
-  integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=
-
-is-extglob@^2.1.0, is-extglob@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
-  integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
-
 is-finite@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3"
   integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==
 
-is-fullwidth-code-point@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
-  integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
-  dependencies:
-    number-is-nan "^1.0.0"
-
-is-fullwidth-code-point@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
-  integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
-
-is-fullwidth-code-point@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
-  integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
-
-is-glob@^2.0.0, is-glob@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
-  integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=
-  dependencies:
-    is-extglob "^1.0.0"
-
-is-glob@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
-  integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=
-  dependencies:
-    is-extglob "^2.1.0"
-
-is-glob@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
-  integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
-  dependencies:
-    is-extglob "^2.1.1"
-
-is-gzip@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-gzip/-/is-gzip-1.0.0.tgz#6ca8b07b99c77998025900e555ced8ed80879a83"
-  integrity sha1-bKiwe5nHeZgCWQDlVc7Y7YCHmoM=
-
-is-installed-globally@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
-  integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=
-  dependencies:
-    global-dirs "^0.1.0"
-    is-path-inside "^1.0.0"
-
 is-module@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
-  integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=
-
-is-npm@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
-  integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ=
-
-is-number@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
-  integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=
-  dependencies:
-    kind-of "^3.0.2"
-
-is-number@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
-  integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=
-  dependencies:
-    kind-of "^3.0.2"
-
-is-number@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
-  integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==
-
-is-obj@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
-  integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
-
-is-object@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.2.tgz#a56552e1c665c9e950b4a025461da87e72f86fcf"
-  integrity sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==
-
-is-path-cwd@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d"
-  integrity sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=
-
-is-path-in-cwd@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52"
-  integrity sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==
-  dependencies:
-    is-path-inside "^1.0.0"
-
-is-path-inside@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036"
-  integrity sha1-jvW33lBDej/cprToZe96pVy0gDY=
-  dependencies:
-    path-is-inside "^1.0.1"
-
-is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
-  integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4=
-
-is-plain-object@^2.0.3, is-plain-object@^2.0.4:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
-  integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==
-  dependencies:
-    isobject "^3.0.1"
-
-is-plain-object@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
-  integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
-
-is-posix-bracket@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
-  integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=
-
-is-potential-custom-element-name@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
-  integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
-
-is-primitive@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
-  integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU=
-
-is-redirect@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
-  integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=
+  integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
 
 is-reference@^1.1.2:
   version "1.2.1"
@@ -5106,128 +1225,15 @@
   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"
-  integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==
-
-is-scoped@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-scoped/-/is-scoped-1.0.0.tgz#449ca98299e713038256289ecb2b540dc437cb30"
-  integrity sha1-RJypgpnnEwOCViieyytUDcQ3yzA=
-  dependencies:
-    scoped-regex "^1.0.0"
-
-is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
-  integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
-
-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-typedarray@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
-  integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
-
-is-utf8@^0.2.0, is-utf8@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
-  integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
-
-is-valid-glob@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/is-valid-glob/-/is-valid-glob-0.3.0.tgz#d4b55c69f51886f9b65c70d6c2622d37e29f48fe"
-  integrity sha1-1LVcafUYhvm2XHDWwmItN+KfSP4=
-
-is-windows@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c"
-  integrity sha1-3hqm1j6indJIc3tp8f+LgALSEIw=
-
-is-windows@^1.0.1, is-windows@^1.0.2:
+is-windows@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
   integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
 
-isarray@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
-  integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
-
-isarray@1.0.0, isarray@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
-  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
-
-isarray@2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
-  integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
-
-isbinaryfile@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.3.tgz#5d6def3edebf6e8ca8cae9c30183a804b5f8be80"
-  integrity sha512-8cJBL5tTd2OS0dM4jz07wQd5g0dCCqIhUxPIGtZfa5L6hWlvV5MHTITy/DBAsF+Oe2LS1X3krBUhNwaGUWpWxw==
-  dependencies:
-    buffer-alloc "^1.2.0"
-
-isbinaryfile@^4.0.0:
-  version "4.0.8"
-  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.8.tgz#5d34b94865bd4946633ecc78a026fc76c5b11fcf"
-  integrity sha512-53h6XFniq77YdW+spoRrebh0mnmTxRPTlcuIArO57lmMdq4uBKFKaeTjnb92oYWrSn/LVL+LT+Hap2tFQj8V+w==
-
 isexe@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
-  integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
-
-isobject@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
-  integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=
-  dependencies:
-    isarray "1.0.0"
-
-isobject@^3.0.0, isobject@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
-  integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
-
-isstream@~0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
-  integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
-
-istextorbinary@^2.2.1, istextorbinary@^2.5.1:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.6.0.tgz#60776315fb0fa3999add276c02c69557b9ca28ab"
-  integrity sha512-+XRlFseT8B3L9KyjxxLjfXSLMuErKDsd8DBNrsaxoViABMEZlOSCstwmw0qpoFX3+U6yWU1yhLudAe6/lETGGA==
-  dependencies:
-    binaryextensions "^2.1.2"
-    editions "^2.2.0"
-    textextensions "^2.5.0"
-
-isurl@^1.0.0-alpha5:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67"
-  integrity sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==
-  dependencies:
-    has-to-string-tag-x "^1.2.0"
-    is-object "^1.0.1"
-
-jake@^10.6.1:
-  version "10.8.2"
-  resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b"
-  integrity sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==
-  dependencies:
-    async "0.9.x"
-    chalk "^2.4.2"
-    filelist "^1.0.1"
-    minimatch "^3.0.4"
+  integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
 
 jest-worker@^24.9.0:
   version "24.9.0"
@@ -5245,140 +1251,36 @@
 js-tokens@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
-  integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls=
-
-jsbn@~0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
-  integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
+  integrity sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg==
 
 jsesc@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
-  integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s=
+  integrity sha512-Mke0DA0QjUWuJlhsE0ZPPhYiJkRap642SmI/4ztCFaUs6V2AiH1sfecc+57NgaryfAA2VR3v6O+CSjC1jZJKOA==
 
 jsesc@^2.5.1:
   version "2.5.2"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
   integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==
 
-jsesc@~0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
-  integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
-
-json-buffer@3.0.1:
+json-buffer@3.0.1, json-buffer@~3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
   integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
 
-json-parse-better-errors@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
-  integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
-
-json-parse-even-better-errors@^2.3.0:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
-  integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
-
-json-schema-traverse@^0.4.1:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
-  integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
-
-json-schema@0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
-  integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
-
-json-stable-stringify-without-jsonify@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
-  integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
-
-json-stringify-safe@~5.0.1:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
-  integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
-
-json5@^2.1.2:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"
-  integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==
-  dependencies:
-    minimist "^1.2.5"
-
-jsonparse@^1.2.0:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
-  integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
-
-jsonschema@^1.1.0, jsonschema@^1.1.1:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.0.tgz#1afa34c4bc22190d8e42271ec17ac8b3404f87b2"
-  integrity sha512-/YgW6pRMr6M7C+4o8kS+B/2myEpHCrxO4PEWnqJNBFMjn7EWXqlQ4tGwL6xTHeRplwuZmcAncdvfOad1nT2yMw==
-
-jsprim@^1.2.2:
+jsonschema@^1.1.0:
   version "1.4.1"
-  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
-  integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=
-  dependencies:
-    assert-plus "1.0.0"
-    extsprintf "1.3.0"
-    json-schema "0.2.3"
-    verror "1.10.0"
+  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.1.tgz#cc4c3f0077fb4542982973d8a083b6b34f482dab"
+  integrity sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==
 
 keyv@^4.0.0:
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.0.3.tgz#4f3aa98de254803cafcd2896734108daa35e4254"
-  integrity sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.3.0.tgz#b4352e0e4fe7c94111947d6738a6d3fe7903027c"
+  integrity sha512-C30Un9+63J0CsR7Wka5quXKqYZsT6dcRQ2aOwGcSc3RiQ4HGWpTAHlCA+puNfw2jA/s11EsxA1nCXgZRuRKMQQ==
   dependencies:
+    compress-brotli "^1.3.8"
     json-buffer "3.0.1"
 
-kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
-  version "3.2.2"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
-  integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=
-  dependencies:
-    is-buffer "^1.1.5"
-
-kind-of@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57"
-  integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc=
-  dependencies:
-    is-buffer "^1.1.5"
-
-kind-of@^5.0.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
-  integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==
-
-kind-of@^6.0.0, kind-of@^6.0.2:
-  version "6.0.3"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
-  integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
-
-kuler@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3"
-  integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==
-
-latest-version@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-2.0.0.tgz#56f8d6139620847b8017f8f1f4d78e211324168b"
-  integrity sha1-VvjWE5YghHuAF/jx9NeOIRMkFos=
-  dependencies:
-    package-json "^2.0.0"
-
-latest-version@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
-  integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=
-  dependencies:
-    package-json "^4.0.0"
-
 "launchpad@git+https://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be", "launchpad@git://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be":
   version "0.7.5"
   resolved "git+https://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be"
@@ -5392,103 +1294,15 @@
     rimraf "^3.0.0"
     underscore "^1.8.3"
 
-lazy-cache@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-2.0.2.tgz#b9190a4f913354694840859f8a8f7084d8822264"
-  integrity sha1-uRkKT5EzVGlIQIWfio9whNiCImQ=
-  dependencies:
-    set-getter "^0.1.0"
-
-lazy-req@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/lazy-req/-/lazy-req-1.1.0.tgz#bdaebead30f8d824039ce0ce149d4daa07ba1fac"
-  integrity sha1-va6+rTD42CQDnODOFJ1Nqge6H6w=
-
-lazystream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4"
-  integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=
-  dependencies:
-    readable-stream "^2.0.5"
-
-lines-and-columns@^1.1.6:
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
-  integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
-
-load-json-file@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
-  integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=
-  dependencies:
-    graceful-fs "^4.1.2"
-    parse-json "^2.2.0"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-    strip-bom "^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"
-  integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs=
-  dependencies:
-    graceful-fs "^4.1.2"
-    parse-json "^4.0.0"
-    pify "^3.0.0"
-    strip-bom "^3.0.0"
-
-locate-path@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
-  integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==
-  dependencies:
-    p-locate "^3.0.0"
-    path-exists "^3.0.0"
-
-lodash._reinterpolate@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
-  integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
-
 lodash.camelcase@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
-  integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
-
-lodash.defaults@^4.2.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
-  integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
-
-lodash.difference@^4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c"
-  integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=
-
-lodash.flatten@^4.4.0:
-  version "4.4.0"
-  resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
-  integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
-
-lodash.get@^4.4.2:
-  version "4.4.2"
-  resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
-  integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
-
-lodash.isequal@^4.0.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
-  integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
-
-lodash.isplainobject@^4.0.6:
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
-  integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
+  integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
 
 lodash.mapvalues@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c"
-  integrity sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw=
+  integrity sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==
 
 lodash.merge@^4.6.2:
   version "4.6.2"
@@ -5498,89 +1312,18 @@
 lodash.padend@^4.6.1:
   version "4.6.1"
   resolved "https://registry.yarnpkg.com/lodash.padend/-/lodash.padend-4.6.1.tgz#53ccba047d06e158d311f45da625f4e49e6f166e"
-  integrity sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=
-
-lodash.set@^4.3.2:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
-  integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=
+  integrity sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw==
 
 lodash.sortby@^4.7.0:
   version "4.7.0"
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
-  integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
+  integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
 
-lodash.template@^4.4.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
-  integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==
-  dependencies:
-    lodash._reinterpolate "^3.0.0"
-    lodash.templatesettings "^4.0.0"
-
-lodash.templatesettings@^4.0.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33"
-  integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==
-  dependencies:
-    lodash._reinterpolate "^3.0.0"
-
-lodash.union@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
-  integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=
-
-lodash.uniq@^4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
-  integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
-
-lodash@4.17.21, lodash@^3.0.0, lodash@^3.10.1, lodash@^4.0.0, lodash@^4.11.1, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.3.0:
+lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.4:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
 
-log-symbols@^1.0.0, log-symbols@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
-  integrity sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=
-  dependencies:
-    chalk "^1.0.0"
-
-log-symbols@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
-  integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==
-  dependencies:
-    chalk "^2.0.1"
-
-logform@^1.9.1:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/logform/-/logform-1.10.0.tgz#c9d5598714c92b546e23f4e78147c40f1e02012e"
-  integrity sha512-em5ojIhU18fIMOw/333mD+ZLE2fis0EzXl1ZwHx4iQzmpQi6odNiY/t+ITNr33JZhT9/KEaH+UPIipr6a9EjWg==
-  dependencies:
-    colors "^1.2.1"
-    fast-safe-stringify "^2.0.4"
-    fecha "^2.3.3"
-    ms "^2.1.1"
-    triple-beam "^1.2.0"
-
-logform@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/logform/-/logform-2.2.0.tgz#40f036d19161fc76b68ab50fdc7fe495544492f2"
-  integrity sha512-N0qPlqfypFx7UHNn4B3lzS/b0uLqt2hmuoa+PpuXNYgozdJYAyauF5Ky0BWVjrxDlMWiT3qN4zPq3vVAfZy7Yg==
-  dependencies:
-    colors "^1.2.1"
-    fast-safe-stringify "^2.0.4"
-    fecha "^4.2.0"
-    ms "^2.1.1"
-    triple-beam "^1.3.0"
-
-lolex@^1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.6.0.tgz#3a9a0283452a47d7439e72731b9e07d7386e49f6"
-  integrity sha1-OpoCg0UqR9dDnnJzG54H1zhuSfY=
-
 long@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
@@ -5593,49 +1336,11 @@
   dependencies:
     js-tokens "^3.0.0 || ^4.0.0"
 
-loud-rejection@^1.0.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"
-  integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=
-  dependencies:
-    currently-unhandled "^0.4.1"
-    signal-exit "^3.0.0"
-
-lower-case@^1.1.1:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac"
-  integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw=
-
-lowercase-keys@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
-  integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
-
 lowercase-keys@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479"
   integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==
 
-lru-cache@^4.0.1, lru-cache@^4.0.2:
-  version "4.1.5"
-  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
-  integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
-  dependencies:
-    pseudomap "^1.0.2"
-    yallist "^2.1.2"
-
-lru-cache@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
-  integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
-  dependencies:
-    yallist "^4.0.0"
-
-macos-release@^2.2.0:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.5.0.tgz#067c2c88b5f3fb3c56a375b2ec93826220fa1ff2"
-  integrity sha512-EIgv+QZ9r+814gjJj0Bt5vSLJLzswGmSUbUpbi9AIr/fsN2IWFBl2NucV9PAiek+U1STK468tEkxmVYUtuAN3g==
-
 magic-string@^0.22.4:
   version "0.22.5"
   resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e"
@@ -5643,243 +1348,18 @@
   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==
+magic-string@^0.25.2, magic-string@^0.25.7:
+  version "0.25.9"
+  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
+  integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==
   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"
-  integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==
-  dependencies:
-    pify "^3.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"
-
-map-cache@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
-  integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=
-
-map-obj@^1.0.0, map-obj@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
-  integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
-
-map-visit@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
-  integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=
-  dependencies:
-    object-visit "^1.0.0"
-
-matcher@^1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/matcher/-/matcher-1.1.1.tgz#51d8301e138f840982b338b116bb0c09af62c1c2"
-  integrity sha512-+BmqxWIubKTRKNWx/ahnCkk3mG8m7OturVlqq6HiojGJTd5hVYbgZm6WzcYPCoB+KBT4Vd6R7WSRG2OADNaCjg==
-  dependencies:
-    escape-string-regexp "^1.0.4"
-
-math-random@^1.0.1:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c"
-  integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
-
-md5@^2.2.1:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f"
-  integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==
-  dependencies:
-    charenc "0.0.2"
-    crypt "0.0.2"
-    is-buffer "~1.1.6"
-
-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=
-
-mem-fs-editor@^5.0.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-5.1.0.tgz#51972241640be8567680a04f7adaffe5fc603667"
-  integrity sha512-2Yt2GCYEbcotYbIJagmow4gEtHDqzpq5XN94+yAx/NT5+bGqIjkXnm3KCUQfE6kRfScGp9IZknScoGRKu8L78w==
-  dependencies:
-    commondir "^1.0.1"
-    deep-extend "^0.6.0"
-    ejs "^2.5.9"
-    glob "^7.0.3"
-    globby "^8.0.1"
-    isbinaryfile "^3.0.2"
-    mkdirp "^0.5.0"
-    multimatch "^2.0.0"
-    rimraf "^2.2.8"
-    through2 "^2.0.0"
-    vinyl "^2.0.1"
-
-mem-fs-editor@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-6.0.0.tgz#d63607cf0a52fe6963fc376c6a7aa52db3edabab"
-  integrity sha512-e0WfJAMm8Gv1mP5fEq/Blzy6Lt1VbLg7gNnZmZak7nhrBTibs+c6nQ4SKs/ZyJYHS1mFgDJeopsLAv7Ow0FMFg==
-  dependencies:
-    commondir "^1.0.1"
-    deep-extend "^0.6.0"
-    ejs "^2.6.1"
-    glob "^7.1.4"
-    globby "^9.2.0"
-    isbinaryfile "^4.0.0"
-    mkdirp "^0.5.0"
-    multimatch "^4.0.0"
-    rimraf "^2.6.3"
-    through2 "^3.0.1"
-    vinyl "^2.2.0"
-
-mem-fs-editor@^7.0.1:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-7.1.0.tgz#2a16f143228df87bf918874556723a7ee73bfe88"
-  integrity sha512-BH6QEqCXSqGeX48V7zu+e3cMwHU7x640NB8Zk8VNvVZniz+p4FK60pMx/3yfkzo6miI6G3a8pH6z7FeuIzqrzA==
-  dependencies:
-    commondir "^1.0.1"
-    deep-extend "^0.6.0"
-    ejs "^3.1.5"
-    glob "^7.1.4"
-    globby "^9.2.0"
-    isbinaryfile "^4.0.0"
-    mkdirp "^1.0.0"
-    multimatch "^4.0.0"
-    rimraf "^3.0.0"
-    through2 "^3.0.2"
-    vinyl "^2.2.1"
-
-mem-fs@^1.1.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/mem-fs/-/mem-fs-1.2.0.tgz#5f29b2d02a5875cd14cd836c388385892d556cde"
-  integrity sha512-b8g0jWKdl8pM0LqAPdK9i8ERL7nYrzmJfRhxMiWH2uYdfYnb7uXnmwVb0ZGe7xyEl4lj+nLIU3yf4zPUT+XsVQ==
-  dependencies:
-    through2 "^3.0.0"
-    vinyl "^2.0.1"
-    vinyl-file "^3.0.0"
-
-meow@^3.7.0:
-  version "3.7.0"
-  resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
-  integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=
-  dependencies:
-    camelcase-keys "^2.0.0"
-    decamelize "^1.1.2"
-    loud-rejection "^1.0.0"
-    map-obj "^1.0.1"
-    minimist "^1.1.3"
-    normalize-package-data "^2.3.4"
-    object-assign "^4.0.1"
-    read-pkg-up "^1.0.1"
-    redent "^1.0.0"
-    trim-newlines "^1.0.0"
-
-merge-descriptors@1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
-  integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
-
-merge-stream@^1.0.0, merge-stream@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1"
-  integrity sha1-QEEgLVCKNCugAXQAjfDCUbjBNeE=
-  dependencies:
-    readable-stream "^2.0.1"
+    sourcemap-codec "^1.4.8"
 
 merge-stream@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
   integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
 
-merge2@^1.2.3:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
-  integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
-
-methods@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
-  integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
-
-micromatch@^2.1.5, micromatch@^2.3.11, micromatch@^2.3.7:
-  version "2.3.11"
-  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565"
-  integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=
-  dependencies:
-    arr-diff "^2.0.0"
-    array-unique "^0.2.1"
-    braces "^1.8.2"
-    expand-brackets "^0.1.4"
-    extglob "^0.3.1"
-    filename-regex "^2.0.0"
-    is-extglob "^1.0.0"
-    is-glob "^2.0.1"
-    kind-of "^3.0.2"
-    normalize-path "^2.0.1"
-    object.omit "^2.0.0"
-    parse-glob "^3.0.4"
-    regex-cache "^0.4.2"
-
-micromatch@^3.0.4, micromatch@^3.1.10:
-  version "3.1.10"
-  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
-  integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==
-  dependencies:
-    arr-diff "^4.0.0"
-    array-unique "^0.3.2"
-    braces "^2.3.1"
-    define-property "^2.0.2"
-    extend-shallow "^3.0.2"
-    extglob "^2.0.4"
-    fragment-cache "^0.2.1"
-    kind-of "^6.0.2"
-    nanomatch "^1.2.9"
-    object.pick "^1.3.0"
-    regex-not "^1.0.0"
-    snapdragon "^0.8.1"
-    to-regex "^3.0.2"
-
-mime-db@1.49.0, "mime-db@>= 1.43.0 < 2", mime-db@^1.28.0:
-  version "1.49.0"
-  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.49.0.tgz#f3dfde60c99e9cf3bc9701d687778f537001cbed"
-  integrity sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==
-
-mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24:
-  version "2.1.32"
-  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5"
-  integrity sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A==
-  dependencies:
-    mime-db "1.49.0"
-
-mime@1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
-  integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==
-
-mime@1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
-  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
-
-mime@^2.3.1:
-  version "2.5.2"
-  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==
-
 mimic-response@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
@@ -5890,74 +1370,34 @@
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
   integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
 
-minimalistic-assert@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
-  integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
-
-minimatch-all@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/minimatch-all/-/minimatch-all-1.1.0.tgz#40c496a27a2e128d19bf758e76bb01a0c7145787"
-  integrity sha1-QMSWonouEo0Zv3WOdrsBoMcUV4c=
-  dependencies:
-    minimatch "^3.0.2"
-
-"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
-  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
+minimatch@^3.0.4, minimatch@^3.1.1:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+  integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
   dependencies:
     brace-expansion "^1.1.7"
 
-minimist@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.2.1.tgz#827ba4e7593464e7c221e8c5bed930904ee2c455"
-  integrity sha512-GY8fANSrTMfBVfInqJAY41QkOM+upUTytK1jZ0c8+3HdHrJxBJ3rF5i9moClXTE8uUSnUo8cAsCoxDXvSY4DHg==
+minimist@^1.2.5, minimist@^1.2.6:
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
+  integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
 
-minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
-  integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
-
-mixin-deep@^1.2.0:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
-  integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
+mkdirp@^0.5.1:
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+  integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
   dependencies:
-    for-in "^1.0.2"
-    is-extendable "^1.0.1"
+    minimist "^1.2.6"
 
-mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.4:
-  version "0.5.5"
-  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
-  integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
-  dependencies:
-    minimist "^1.2.5"
-
-mkdirp@^1.0.0, mkdirp@^1.0.4:
+mkdirp@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
   integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
 
-moment@^2.15.1, moment@^2.24.0:
-  version "2.29.1"
-  resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
-  integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
-
-mout@^1.0.0:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/mout/-/mout-1.2.2.tgz#c9b718a499806a0632cede178e80f436259e777d"
-  integrity sha512-w0OUxFEla6z3d7sVpMZGBCpQvYh8PHS1wZ6Wu9GNKHMpAHWJ0if0LsQZh3DlOqw55HlhJEOMLpFnwtxp99Y5GA==
-
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
-  integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
-
-ms@2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
-  integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
+  integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
 
 ms@2.1.2:
   version "2.1.2"
@@ -5969,468 +1409,23 @@
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
   integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
 
-multer@^1.3.0:
-  version "1.4.3"
-  resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.3.tgz#4db352d6992e028ac0eacf7be45c6efd0264297b"
-  integrity sha512-np0YLKncuZoTzufbkM6wEKp68EhWJXcU6fq6QqrSwkckd2LlMgd1UqhUJLj6NS/5sZ8dE8LYDWslsltJznnXlg==
-  dependencies:
-    append-field "^1.0.0"
-    busboy "^0.2.11"
-    concat-stream "^1.5.2"
-    mkdirp "^0.5.4"
-    object-assign "^4.1.1"
-    on-finished "^2.3.0"
-    type-is "^1.6.4"
-    xtend "^4.0.0"
-
-multimatch@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-2.1.0.tgz#9c7906a22fb4c02919e2f5f75161b4cdbd4b2a2b"
-  integrity sha1-nHkGoi+0wCkZ4vX3UWG0zb1LKis=
-  dependencies:
-    array-differ "^1.0.0"
-    array-union "^1.0.1"
-    arrify "^1.0.0"
-    minimatch "^3.0.0"
-
-multimatch@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-4.0.0.tgz#8c3c0f6e3e8449ada0af3dd29efb491a375191b3"
-  integrity sha512-lDmx79y1z6i7RNx0ZGCPq1bzJ6ZoDDKbvh7jxr9SJcWLkShMzXrHbYVpTdnhNM5MXpDUxCQ4DgqVttVXlBgiBQ==
-  dependencies:
-    "@types/minimatch" "^3.0.3"
-    array-differ "^3.0.0"
-    array-union "^2.1.0"
-    arrify "^2.0.1"
-    minimatch "^3.0.4"
-
-multipipe@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/multipipe/-/multipipe-1.0.2.tgz#cc13efd833c9cda99f224f868461b8e1a3fd939d"
-  integrity sha1-zBPv2DPJzamfIk+GhGG44aP9k50=
-  dependencies:
-    duplexer2 "^0.1.2"
-    object-assign "^4.1.0"
-
-mute-stream@0.0.6:
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.6.tgz#48962b19e169fd1dfc240b3f1e7317627bbc47db"
-  integrity sha1-SJYrGeFp/R38JAs/HnMXYnu8R9s=
-
-mute-stream@0.0.8:
-  version "0.0.8"
-  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
-  integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
-
-mz@^2.4.0, mz@^2.6.0:
-  version "2.7.0"
-  resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
-  integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
-  dependencies:
-    any-promise "^1.0.0"
-    object-assign "^4.0.1"
-    thenify-all "^1.0.0"
-
-nan@^2.12.1:
-  version "2.15.0"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee"
-  integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==
-
-nanomatch@^1.2.9:
-  version "1.2.13"
-  resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
-  integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==
-  dependencies:
-    arr-diff "^4.0.0"
-    array-unique "^0.3.2"
-    define-property "^2.0.2"
-    extend-shallow "^3.0.2"
-    fragment-cache "^0.2.1"
-    is-windows "^1.0.2"
-    kind-of "^6.0.2"
-    object.pick "^1.3.0"
-    regex-not "^1.0.0"
-    snapdragon "^0.8.1"
-    to-regex "^3.0.1"
-
-native-promise-only@^0.8.1:
-  version "0.8.1"
-  resolved "https://registry.yarnpkg.com/native-promise-only/-/native-promise-only-0.8.1.tgz#20a318c30cb45f71fe7adfbf7b21c99c1472ef11"
-  integrity sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE=
-
-negotiator@0.6.2:
-  version "0.6.2"
-  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
-  integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
-
-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==
-
-no-case@^2.2.0:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac"
-  integrity sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==
-  dependencies:
-    lower-case "^1.1.1"
-
-node-fetch@^2.6.0, node-fetch@^2.6.1:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
-  integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
-
-node-releases@^1.1.75:
-  version "1.1.75"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.75.tgz#6dd8c876b9897a1b8e5a02de26afa79bb54ebbfe"
-  integrity sha512-Qe5OUajvqrqDSy6wrWFmMwfJ0jVgwiw4T3KqmbTcZ62qW0gQkheXYhcFM1+lOVcGUoRxcEcfyvFMAnDgaF1VWw==
-
-node-status-codes@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/node-status-codes/-/node-status-codes-1.0.0.tgz#5ae5541d024645d32a58fcddc9ceecea7ae3ac2f"
-  integrity sha1-WuVUHQJGRdMqWPzdyc7s6nrjrC8=
-
-nomnom@^1.8.1:
-  version "1.8.1"
-  resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.8.1.tgz#2151f722472ba79e50a76fc125bb8c8f2e4dc2a7"
-  integrity sha1-IVH3Ikcrp55Qp2/BJbuMjy5Nwqc=
-  dependencies:
-    chalk "~0.4.0"
-    underscore "~1.6.0"
-
-normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, 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"
-  integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
-  dependencies:
-    hosted-git-info "^2.1.4"
-    resolve "^1.10.0"
-    semver "2 || 3 || 4 || 5"
-    validate-npm-package-license "^3.0.1"
-
-normalize-path@^2.0.0, normalize-path@^2.0.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
-  integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=
-  dependencies:
-    remove-trailing-separator "^1.0.1"
-
-normalize-path@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
-  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
-
 normalize-url@^6.0.1:
   version "6.1.0"
   resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
   integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
 
-npm-api@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/npm-api/-/npm-api-1.0.1.tgz#3def9b51afedca57db14ca0c970d92442d21c9c5"
-  integrity sha512-4sITrrzEbPcr0aNV28QyOmgn6C9yKiF8k92jn4buYAK8wmA5xo1qL3II5/gT1r7wxbXBflSduZ2K3FbtOrtGkA==
-  dependencies:
-    JSONStream "^1.3.5"
-    clone-deep "^4.0.1"
-    download-stats "^0.3.4"
-    moment "^2.24.0"
-    node-fetch "^2.6.0"
-    paged-request "^2.0.1"
-
-npm-run-path@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
-  integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=
-  dependencies:
-    path-key "^2.0.0"
-
-npm-run-path@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
-  integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==
-  dependencies:
-    path-key "^3.0.0"
-
-number-is-nan@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
-  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
-
-oauth-sign@~0.9.0:
-  version "0.9.0"
-  resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
-  integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
-
-object-assign@^4, object-assign@^4.0.0, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
-  integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
-
-object-copy@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
-  integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw=
-  dependencies:
-    copy-descriptor "^0.1.0"
-    define-property "^0.2.5"
-    kind-of "^3.0.3"
-
-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"
-  integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
-
-object-visit@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
-  integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=
-  dependencies:
-    isobject "^3.0.0"
-
-object.assign@^4.1.0:
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940"
-  integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==
-  dependencies:
-    call-bind "^1.0.0"
-    define-properties "^1.1.3"
-    has-symbols "^1.0.1"
-    object-keys "^1.1.1"
-
-object.omit@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
-  integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=
-  dependencies:
-    for-own "^0.1.4"
-    is-extendable "^0.1.1"
-
-object.pick@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747"
-  integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=
-  dependencies:
-    isobject "^3.0.1"
-
-obuf@^1.0.0, obuf@^1.1.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
-  integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
-
-octokit-pagination-methods@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4"
-  integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==
-
-on-finished@^2.3.0, on-finished@~2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
-  integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=
-  dependencies:
-    ee-first "1.1.1"
-
-on-headers@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
-  integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
-
 once@^1.3.0, once@^1.3.1, once@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
-  integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
+  integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
   dependencies:
     wrappy "1"
 
-one-time@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45"
-  integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==
-  dependencies:
-    fn.name "1.x.x"
-
-onetime@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
-  integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=
-
-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"
-
-opn@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/opn/-/opn-3.0.3.tgz#b6d99e7399f78d65c3baaffef1fb288e9b85243a"
-  integrity sha1-ttmec5n3jWXDuq/+8fsojpuFJDo=
-  dependencies:
-    object-assign "^4.0.1"
-
-ordered-read-streams@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-0.3.0.tgz#7137e69b3298bb342247a1bbee3881c80e2fd78b"
-  integrity sha1-cTfmmzKYuzQiR6G77jiByA4v14s=
-  dependencies:
-    is-stream "^1.0.1"
-    readable-stream "^2.0.1"
-
-os-homedir@^1.0.0, os-homedir@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
-  integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
-
-os-name@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/os-name/-/os-name-3.1.0.tgz#dec19d966296e1cd62d701a5a66ee1ddeae70801"
-  integrity sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==
-  dependencies:
-    macos-release "^2.2.0"
-    windows-release "^3.1.0"
-
-os-shim@^0.1.2:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/os-shim/-/os-shim-0.1.3.tgz#6b62c3791cf7909ea35ed46e17658bb417cb3917"
-  integrity sha1-a2LDeRz3kJ6jXtRuF2WLtBfLORc=
-
-os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1, os-tmpdir@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
-  integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
-
-osenv@^0.1.0, osenv@^0.1.3:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
-  integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
-  dependencies:
-    os-homedir "^1.0.0"
-    os-tmpdir "^1.0.0"
-
-p-cancelable@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa"
-  integrity sha512-RVbZPLso8+jFeq1MfNvgXtCRED2raz/dKpacfTNxsx6pLEpEomM7gah6VeHSYV3+vo0OAi4MkArtQcWWXuQoyw==
-
 p-cancelable@^2.0.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf"
   integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==
 
-p-finally@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
-  integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=
-
-p-limit@^2.0.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
-  integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
-  dependencies:
-    p-try "^2.0.0"
-
-p-locate@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
-  integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==
-  dependencies:
-    p-limit "^2.0.0"
-
-p-map@^1.1.1:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b"
-  integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==
-
-p-timeout@^1.1.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-1.2.1.tgz#5eb3b353b7fce99f101a1038880bb054ebbea386"
-  integrity sha1-XrOzU7f86Z8QGhA4iAuwVOu+o4Y=
-  dependencies:
-    p-finally "^1.0.0"
-
-p-try@^2.0.0, p-try@^2.1.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
-  integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
-
-package-json@^2.0.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/package-json/-/package-json-2.4.0.tgz#0d15bd67d1cbbddbb2ca222ff2edb86bcb31a8bb"
-  integrity sha1-DRW9Z9HLvduyyiIv8u24a8sxqLs=
-  dependencies:
-    got "^5.0.0"
-    registry-auth-token "^3.0.1"
-    registry-url "^3.0.3"
-    semver "^5.1.0"
-
-package-json@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed"
-  integrity sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=
-  dependencies:
-    got "^6.7.1"
-    registry-auth-token "^3.0.1"
-    registry-url "^3.0.3"
-    semver "^5.1.0"
-
-paged-request@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/paged-request/-/paged-request-2.0.2.tgz#4d621a08b8d6bee4440a0a92112354eeece5b5b0"
-  integrity sha512-NWrGqneZImDdcMU/7vMcAOo1bIi5h/pmpJqe7/jdsy85BA/s5MSaU/KlpxwW/IVPmIwBcq2uKPrBWWhEWhtxag==
-  dependencies:
-    axios "^0.21.1"
-
-pako@~0.2.0:
-  version "0.2.9"
-  resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
-  integrity sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU=
-
-param-case@2.1.x:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247"
-  integrity sha1-35T9jPZTHs915r75oIWPvHK+Ikc=
-  dependencies:
-    no-case "^2.2.0"
-
-parse-glob@^3.0.4:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
-  integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw=
-  dependencies:
-    glob-base "^0.3.0"
-    is-dotfile "^1.0.0"
-    is-extglob "^1.0.0"
-    is-glob "^2.0.0"
-
-parse-json@^2.1.0, parse-json@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
-  integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=
-  dependencies:
-    error-ex "^1.2.0"
-
-parse-json@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
-  integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=
-  dependencies:
-    error-ex "^1.3.1"
-    json-parse-better-errors "^1.0.1"
-
-parse-json@^5.0.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"
-  integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==
-  dependencies:
-    "@babel/code-frame" "^7.0.0"
-    error-ex "^1.3.1"
-    json-parse-even-better-errors "^2.3.0"
-    lines-and-columns "^1.1.6"
-
-parse-passwd@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
-  integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=
-
 parse5-html-rewriting-stream@^5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.1.tgz#fc18570ba0d09b5091250956d1c3f716ef0a07b7"
@@ -6449,7 +1444,7 @@
 parse5@^1.4.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94"
-  integrity sha1-m387DeMr543CQBsXVzzK8Pb1nZQ=
+  integrity sha512-w2jx/0tJzvgKwZa58sj2vAYq/S/K1QJfIB3cWYea/Iu1scFPDQQ3IQiVZTHWtRBwAjv2Yd7S/xeZf3XqLDb3bA==
 
 parse5@^4.0.0:
   version "4.0.0"
@@ -6461,171 +1456,46 @@
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
   integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
 
-parseqs@0.0.6:
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.6.tgz#8e4bb5a19d1cdc844a08ac974d34e273afa670d5"
-  integrity sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==
-
-parseuri@0.0.6:
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.6.tgz#e1496e829e3ac2ff47f39a4dd044b32823c4a25a"
-  integrity sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==
-
-parseurl@~1.3.3:
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
-  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
-
-pascalcase@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
-  integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=
-
-path-dirname@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"
-  integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=
-
-path-exists@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
-  integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=
-  dependencies:
-    pinkie-promise "^2.0.0"
-
-path-exists@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
-  integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
-
 path-is-absolute@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
-  integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
+  integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
 
-path-is-inside@^1.0.1, path-is-inside@^1.0.2:
+path-is-inside@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
-  integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=
+  integrity sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==
 
-path-key@^2.0.0, 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:
+path-key@^3.1.0:
   version "3.1.1"
   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.7:
+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==
 
-path-to-regexp@0.1.7:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
-  integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
-
-path-to-regexp@^1.0.1, path-to-regexp@^1.7.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
-  integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
-  dependencies:
-    isarray "0.0.1"
-
-path-type@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
-  integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=
-  dependencies:
-    graceful-fs "^4.1.2"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-
-path-type@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f"
-  integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==
-  dependencies:
-    pify "^3.0.0"
-
-peek-stream@^1.1.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/peek-stream/-/peek-stream-1.1.3.tgz#3b35d84b7ccbbd262fff31dc10da56856ead6d67"
-  integrity sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==
-  dependencies:
-    buffer-from "^1.0.0"
-    duplexify "^3.5.0"
-    through2 "^2.0.3"
-
-pem@^1.8.3:
-  version "1.14.4"
-  resolved "https://registry.yarnpkg.com/pem/-/pem-1.14.4.tgz#a68c70c6e751ccc5b3b5bcd7af78b0aec1177ff9"
-  integrity sha512-v8lH3NpirgiEmbOqhx0vwQTxwi0ExsiWBGYh0jYNq7K6mQuO4gI6UEFlr6fLAdv9TPXRt6GqiwE37puQdIDS8g==
-  dependencies:
-    es6-promisify "^6.0.0"
-    md5 "^2.2.1"
-    os-tmpdir "^1.0.1"
-    which "^2.0.2"
-
 pend@~1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
-  integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA=
+  integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
 
-performance-now@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
-  integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
-
-pify@^2.0.0, pify@^2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
-  integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
-
-pify@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
-  integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
-
-pify@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
-  integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
-
-pinkie-promise@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
-  integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o=
-  dependencies:
-    pinkie "^2.0.0"
-
-pinkie@^2.0.0:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
-  integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA=
+picomatch@^2.2.2:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+  integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
 
 plist@^2.0.1:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/plist/-/plist-2.1.0.tgz#57ccdb7a0821df21831217a3cad54e3e146a1025"
-  integrity sha1-V8zbeggh3yGDEhejytVOPhRqECU=
+  integrity sha512-yirJ+8SSb8o7pkfyNv+fTzUP0GbK52HMvh0MjMycCxvpL8rHiAfKhXU/3R5znSJnrGakV0WNZhr8yTR4//PjyA==
   dependencies:
     base64-js "1.2.0"
     xmlbuilder "8.2.2"
     xmldom "0.1.x"
 
-plylog@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/plylog/-/plylog-1.1.0.tgz#f6f354e2ae0b01f6db4ed111f4b3855da9c37417"
-  integrity sha512-/QnY5aSVaP54va6hruzNtAj02HpsLlAt7V5EndMrtq6ZUTZJKUja43rgiUtGXqm95yrSJjbZoPW0yQQQwLpoJA==
-  dependencies:
-    logform "^1.9.1"
-    winston "^3.0.0"
-    winston-transport "^4.2.0"
-
-polymer-analyzer@^3.0.0, polymer-analyzer@^3.1.3, polymer-analyzer@^3.2.2:
+polymer-analyzer@^3.2.2:
   version "3.2.4"
   resolved "https://registry.yarnpkg.com/polymer-analyzer/-/polymer-analyzer-3.2.4.tgz#7d76356620a2328e8bc9e30e47069f9729260ca1"
   integrity sha512-JmxUhMajTuC18tLXbTtu2+aN2x9bTX+4MvCD4IZKJ0rtAL8jWi1iRLfogpHJB4Ig9Dc8EEEuEYipLuzPFl3vqA==
@@ -6668,78 +1538,7 @@
     vscode-uri "=1.0.6"
     whatwg-url "^6.4.0"
 
-polymer-build@^3.1.0, polymer-build@^3.1.4:
-  version "3.1.4"
-  resolved "https://registry.yarnpkg.com/polymer-build/-/polymer-build-3.1.4.tgz#ab539f1a13d803518b13b73ffd09198431d98142"
-  integrity sha512-OhTOPG5Y/tK2HqGZ5XA/CVDh+TuOaDv7wTZWXDCg6hxeMgNKuljDMn2coyGU5NLM0pLbS+gwFAc2ZJ5cWHCHNg==
-  dependencies:
-    "@babel/core" "^7.0.0"
-    "@babel/plugin-external-helpers" "^7.0.0"
-    "@babel/plugin-proposal-async-generator-functions" "^7.0.0"
-    "@babel/plugin-proposal-object-rest-spread" "^7.0.0"
-    "@babel/plugin-syntax-async-generators" "^7.0.0"
-    "@babel/plugin-syntax-dynamic-import" "^7.0.0"
-    "@babel/plugin-syntax-import-meta" "^7.0.0"
-    "@babel/plugin-syntax-object-rest-spread" "^7.0.0"
-    "@babel/plugin-transform-arrow-functions" "^7.0.0"
-    "@babel/plugin-transform-async-to-generator" "^7.0.0"
-    "@babel/plugin-transform-block-scoped-functions" "^7.0.0"
-    "@babel/plugin-transform-block-scoping" "^7.0.0"
-    "@babel/plugin-transform-classes" "^7.0.0"
-    "@babel/plugin-transform-computed-properties" "^7.0.0"
-    "@babel/plugin-transform-destructuring" "^7.0.0"
-    "@babel/plugin-transform-duplicate-keys" "^7.0.0"
-    "@babel/plugin-transform-exponentiation-operator" "^7.0.0"
-    "@babel/plugin-transform-for-of" "^7.0.0"
-    "@babel/plugin-transform-function-name" "^7.0.0"
-    "@babel/plugin-transform-instanceof" "^7.0.0"
-    "@babel/plugin-transform-literals" "^7.0.0"
-    "@babel/plugin-transform-modules-amd" "^7.0.0"
-    "@babel/plugin-transform-object-super" "^7.0.0"
-    "@babel/plugin-transform-parameters" "^7.0.0"
-    "@babel/plugin-transform-regenerator" "^7.0.0"
-    "@babel/plugin-transform-shorthand-properties" "^7.0.0"
-    "@babel/plugin-transform-spread" "^7.0.0"
-    "@babel/plugin-transform-sticky-regex" "^7.0.0"
-    "@babel/plugin-transform-template-literals" "^7.0.0"
-    "@babel/plugin-transform-typeof-symbol" "^7.0.0"
-    "@babel/plugin-transform-unicode-regex" "^7.0.0"
-    "@babel/traverse" "^7.0.0"
-    "@polymer/esm-amd-loader" "^1.0.0"
-    "@types/babel-types" "^6.25.1"
-    "@types/babylon" "^6.16.2"
-    "@types/gulp-if" "0.0.33"
-    "@types/html-minifier" "^3.5.1"
-    "@types/is-windows" "^0.2.0"
-    "@types/mz" "0.0.31"
-    "@types/parse5" "^2.2.34"
-    "@types/resolve" "0.0.7"
-    "@types/uuid" "^3.4.3"
-    "@types/vinyl" "^2.0.0"
-    "@types/vinyl-fs" "^2.4.8"
-    babel-plugin-minify-guarded-expressions "^0.4.3"
-    babel-preset-minify "^0.5.0"
-    babylon "^7.0.0-beta.42"
-    css-slam "^2.1.2"
-    dom5 "^3.0.0"
-    gulp-if "^2.0.2"
-    html-minifier "^3.5.10"
-    matcher "^1.1.0"
-    multipipe "^1.0.2"
-    mz "^2.6.0"
-    parse5 "^4.0.0"
-    plylog "^1.0.0"
-    polymer-analyzer "^3.1.3"
-    polymer-bundler "^4.0.9"
-    polymer-project-config "^4.0.3"
-    regenerator-runtime "^0.11.1"
-    stream "0.0.2"
-    sw-precache "^5.1.1"
-    uuid "^3.2.1"
-    vinyl "^1.2.0"
-    vinyl-fs "^2.4.4"
-
-polymer-bundler@^4.0.10, polymer-bundler@^4.0.9:
+polymer-bundler@^4.0.10:
   version "4.0.10"
   resolved "https://registry.yarnpkg.com/polymer-bundler/-/polymer-bundler-4.0.10.tgz#abc8d33977652f031068d034c8104841e80d4cbb"
   integrity sha512-nwlN3LQlQDqbZ2sUH3394C/dHZUDHq8tpdS5HARvPDb0Q9IXWD+znOR1cr7wSjF0EZN4LiUH5hWyUoV4QSjhpQ==
@@ -6762,165 +1561,6 @@
     source-map "^0.5.6"
     vscode-uri "=1.0.6"
 
-polymer-cli@^1.9.11:
-  version "1.9.11"
-  resolved "https://registry.yarnpkg.com/polymer-cli/-/polymer-cli-1.9.11.tgz#0b5310732b787e07b811af96627ef0fd1263f5da"
-  integrity sha512-tiURjHDCOUUtDVPuVYvrfFI9PXe4OOUmBbn6Sg5GJNQ2POtP7r7hv+I5yI8P9qsxmalHTa19chVtf5/t9IBXDg==
-  dependencies:
-    "@octokit/rest" "^16.2.0"
-    "@types/chalk" "^2.2.0"
-    "@types/del" "^3.0.0"
-    "@types/findup-sync" "^0.3.29"
-    "@types/globby" "^6.1.0"
-    "@types/inquirer" "0.0.32"
-    "@types/merge-stream" "^1.0.28"
-    "@types/mz" "^0.0.31"
-    "@types/request" "2.0.3"
-    "@types/resolve" "0.0.4"
-    "@types/rimraf" "^0.0.28"
-    "@types/semver" "^5.3.30"
-    "@types/temp" "^0.8.28"
-    "@types/update-notifier" "^1.0.0"
-    "@types/vinyl" "^2.0.0"
-    "@types/vinyl-fs" "0.0.28"
-    "@types/yeoman-generator" "^2.0.3"
-    bower "^1.8.8"
-    bower-json "^0.8.1"
-    bower-logger "^0.2.2"
-    chalk "^2.4.2"
-    chokidar "^1.7.0"
-    command-line-args "^5.0.2"
-    command-line-commands "^2.0.1"
-    command-line-usage "^5.0.5"
-    del "^3.0.0"
-    findup-sync "^0.4.2"
-    globby "^8.0.1"
-    gunzip-maybe "^1.3.1"
-    inquirer "^1.0.2"
-    merge-stream "^1.0.1"
-    mz "^2.6.0"
-    plylog "^1.0.0"
-    polymer-analyzer "^3.2.2"
-    polymer-build "^3.1.4"
-    polymer-bundler "^4.0.9"
-    polymer-linter "^3.0.0"
-    polymer-project-config "^4.0.3"
-    polyserve "^0.27.15"
-    request "^2.72.0"
-    rimraf "^2.6.1"
-    semver "^5.3.0"
-    tar-fs "^1.12.0"
-    temp "^0.8.3"
-    update-notifier "^1.0.0"
-    validate-element-name "^2.1.1"
-    vinyl "^1.1.1"
-    vinyl-fs "^2.4.3"
-    web-component-tester "^6.9.0"
-    yeoman-environment "^1.5.2"
-    yeoman-generator "^3.1.1"
-
-polymer-linter@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/polymer-linter/-/polymer-linter-3.0.1.tgz#8804e1705fa2a7c263467b8a22da11bb764ee26b"
-  integrity sha512-eDh2CeswZz4Rwf8gfYXpMN66pieq4qJvP9bH3m39LLGm81hRePo4N5OHoQzR5unen1PUdmtjDv0Iicz3dTYEZQ==
-  dependencies:
-    "@types/fast-levenshtein" "0.0.1"
-    "@types/parse5" "^2.2.34"
-    babel-traverse "^6.26.0"
-    babel-types "^6.26.0"
-    cancel-token "^0.1.1"
-    css-what "^2.1.0"
-    dom5 "^3.0.0"
-    fast-levenshtein "^2.0.6"
-    parse5 "^4.0.0"
-    polymer-analyzer "^3.0.0"
-    shady-css-parser "^0.1.0"
-    stable "^0.1.6"
-    strip-indent "^2.0.0"
-    validate-element-name "^2.1.1"
-
-polymer-project-config@^4.0.0, polymer-project-config@^4.0.3:
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/polymer-project-config/-/polymer-project-config-4.0.3.tgz#ef0c1a676ce4809907986c8e910745660de8024f"
-  integrity sha512-Drr+Imq+znhBC8XSt9pMlmPixoGnIOmleV5SD6mto1zOGC5oCDbSNsQL2v89DWOk+9aSUO79vnWwOmEPDSvYfw==
-  dependencies:
-    "@types/parse5" "^2.2.34"
-    browser-capabilities "^1.0.0"
-    jsonschema "^1.1.1"
-    minimatch-all "^1.1.0"
-    plylog "^1.0.0"
-    winston "^3.0.0"
-
-polyserve@^0.27.13, polyserve@^0.27.15:
-  version "0.27.15"
-  resolved "https://registry.yarnpkg.com/polyserve/-/polyserve-0.27.15.tgz#261fa5a0873c8d95fd7068598f44c9dac20cf9c4"
-  integrity sha512-AaFgANt+tUUVgHLw+BnaVYcn649JiwL1ru0TOZUKj1gGGn/Bq2S16gxql+1muGpRaAsgFu13Zu7k5XkwatwwSg==
-  dependencies:
-    "@types/compression" "^0.0.33"
-    "@types/content-type" "^1.1.0"
-    "@types/escape-html" "0.0.20"
-    "@types/express" "^4.0.36"
-    "@types/mime" "^2.0.0"
-    "@types/mz" "0.0.29"
-    "@types/opn" "^3.0.28"
-    "@types/parse5" "^2.2.34"
-    "@types/pem" "^1.8.1"
-    "@types/resolve" "0.0.6"
-    "@types/serve-static" "^1.7.31"
-    "@types/spdy" "^3.4.1"
-    bower-config "^1.4.1"
-    browser-capabilities "^1.0.0"
-    command-line-args "^5.0.2"
-    command-line-usage "^5.0.5"
-    compression "^1.6.2"
-    content-type "^1.0.2"
-    cors "^2.8.4"
-    escape-html "^1.0.3"
-    express "^4.8.5"
-    find-port "^1.0.1"
-    http-proxy-middleware "^0.17.2"
-    lru-cache "^4.0.2"
-    mime "^2.3.1"
-    mz "^2.4.0"
-    opn "^3.0.2"
-    pem "^1.8.3"
-    polymer-build "^3.1.0"
-    polymer-project-config "^4.0.0"
-    requirejs "^2.3.4"
-    resolve "^1.5.0"
-    send "^0.16.2"
-    spdy "^3.3.3"
-
-posix-character-classes@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
-  integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
-
-prepend-http@^1.0.1:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
-  integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
-
-preserve@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
-  integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=
-
-pretty-bytes@^4.0.2:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9"
-  integrity sha1-sr+C5zUNZcbDOqlaqlpPYyf2HNk=
-
-pretty-bytes@^5.1.0, pretty-bytes@^5.2.0:
-  version "5.6.0"
-  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
-  integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
-
-process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
-  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
-
 progress@2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
@@ -6945,40 +1585,6 @@
     "@types/node" "^10.1.0"
     long "^4.0.0"
 
-proxy-addr@~2.0.5:
-  version "2.0.7"
-  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
-  integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
-  dependencies:
-    forwarded "0.2.0"
-    ipaddr.js "1.9.1"
-
-pseudomap@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
-  integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
-
-psl@^1.1.24, psl@^1.1.28:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
-  integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
-
-pump@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954"
-  integrity sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw==
-  dependencies:
-    end-of-stream "^1.1.0"
-    once "^1.3.1"
-
-pump@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909"
-  integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==
-  dependencies:
-    end-of-stream "^1.1.0"
-    once "^1.3.1"
-
 pump@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
@@ -6987,54 +1593,21 @@
     end-of-stream "^1.1.0"
     once "^1.3.1"
 
-pumpify@^1.3.3:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce"
-  integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==
-  dependencies:
-    duplexify "^3.6.0"
-    inherits "^2.0.3"
-    pump "^2.0.0"
-
-punycode@^1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
-  integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
-
-punycode@^2.1.0, punycode@^2.1.1:
+punycode@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
-q@^1.4.1, q@^1.5.1:
+q@^1.4.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
-  integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
-
-qs@6.7.0:
-  version "6.7.0"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
-  integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
-
-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==
+  integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==
 
 quick-lru@^5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
   integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
 
-randomatic@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"
-  integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==
-  dependencies:
-    is-number "^4.0.0"
-    kind-of "^6.0.0"
-    math-random "^1.0.1"
-
 randombytes@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
@@ -7042,110 +1615,7 @@
   dependencies:
     safe-buffer "^5.1.0"
 
-range-parser@~1.2.0, range-parser@~1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
-  integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
-
-raw-body@2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
-  integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
-  dependencies:
-    bytes "3.1.0"
-    http-errors "1.7.2"
-    iconv-lite "0.4.24"
-    unpipe "1.0.0"
-
-rc@^1.0.1, rc@^1.1.6:
-  version "1.2.8"
-  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
-  integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
-  dependencies:
-    deep-extend "^0.6.0"
-    ini "~1.3.0"
-    minimist "^1.2.0"
-    strip-json-comments "~2.0.1"
-
-read-all-stream@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/read-all-stream/-/read-all-stream-3.1.0.tgz#35c3e177f2078ef789ee4bfafa4373074eaef4fa"
-  integrity sha1-NcPhd/IHjveJ7kv6+kNzB06u9Po=
-  dependencies:
-    pinkie-promise "^2.0.0"
-    readable-stream "^2.0.0"
-
-read-chunk@^3.0.0, read-chunk@^3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/read-chunk/-/read-chunk-3.2.0.tgz#2984afe78ca9bfbbdb74b19387bf9e86289c16ca"
-  integrity sha512-CEjy9LCzhmD7nUpJ1oVOE6s/hBkejlcJEgLQHVnQznOSilOPb+kpKktlLfFDK3/WP43+F80xkUTM2VOkYoSYvQ==
-  dependencies:
-    pify "^4.0.1"
-    with-open-file "^0.1.6"
-
-read-pkg-up@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02"
-  integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=
-  dependencies:
-    find-up "^1.0.0"
-    read-pkg "^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"
-  integrity sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==
-  dependencies:
-    find-up "^3.0.0"
-    read-pkg "^3.0.0"
-
-read-pkg-up@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-5.0.0.tgz#b6a6741cb144ed3610554f40162aa07a6db621b8"
-  integrity sha512-XBQjqOBtTzyol2CpsQOw8LHV0XbDZVG7xMMjmXAJomlVY03WOBRmYgDJETlvcg0H63AJvPRwT7GFi5rvOzUOKg==
-  dependencies:
-    find-up "^3.0.0"
-    read-pkg "^5.0.0"
-
-read-pkg@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
-  integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=
-  dependencies:
-    load-json-file "^1.0.0"
-    normalize-package-data "^2.3.2"
-    path-type "^1.0.0"
-
-read-pkg@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389"
-  integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=
-  dependencies:
-    load-json-file "^4.0.0"
-    normalize-package-data "^2.3.2"
-    path-type "^3.0.0"
-
-read-pkg@^5.0.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc"
-  integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==
-  dependencies:
-    "@types/normalize-package-data" "^2.4.0"
-    normalize-package-data "^2.5.0"
-    parse-json "^5.0.0"
-    type-fest "^0.6.0"
-
-readable-stream@1.1.x:
-  version "1.1.14"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
-  integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "0.0.1"
-    string_decoder "~0.10.x"
-
-"readable-stream@2 || 3", readable-stream@^3.1.1, readable-stream@^3.4.0:
+readable-stream@^3.1.1, readable-stream@^3.4.0:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
   integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
@@ -7154,161 +1624,16 @@
     string_decoder "^1.1.1"
     util-deprecate "^1.0.1"
 
-"readable-stream@>=1.0.33-1 <1.1.0-0":
-  version "1.0.34"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
-  integrity sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "0.0.1"
-    string_decoder "~0.10.x"
-
-readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@^2.3.7, readable-stream@~2.3.6:
-  version "2.3.7"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
-  integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.3"
-    isarray "~1.0.0"
-    process-nextick-args "~2.0.0"
-    safe-buffer "~5.1.1"
-    string_decoder "~1.1.1"
-    util-deprecate "~1.0.1"
-
-readdirp@^2.0.0:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525"
-  integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==
-  dependencies:
-    graceful-fs "^4.1.11"
-    micromatch "^3.1.10"
-    readable-stream "^2.0.2"
-
-rechoir@^0.6.2:
-  version "0.6.2"
-  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"
-  integrity sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=
-  dependencies:
-    resolve "^1.1.6"
-
-redent@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
-  integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=
-  dependencies:
-    indent-string "^2.1.0"
-    strip-indent "^1.0.1"
-
 reduce-flatten@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327"
   integrity sha1-JYx479FT3fk8tWEjf2EYTzaW4yc=
 
-regenerate-unicode-properties@^8.2.0:
-  version "8.2.0"
-  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
-  integrity sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==
-  dependencies:
-    regenerate "^1.4.0"
-
-regenerate@^1.4.0:
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a"
-  integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==
-
-regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1:
+regenerator-runtime@^0.11.0:
   version "0.11.1"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
   integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
 
-regenerator-runtime@^0.13.4:
-  version "0.13.9"
-  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
-  integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
-
-regenerator-transform@^0.14.2:
-  version "0.14.5"
-  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4"
-  integrity sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==
-  dependencies:
-    "@babel/runtime" "^7.8.4"
-
-regex-cache@^0.4.2:
-  version "0.4.4"
-  resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"
-  integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==
-  dependencies:
-    is-equal-shallow "^0.1.3"
-
-regex-not@^1.0.0, regex-not@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
-  integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==
-  dependencies:
-    extend-shallow "^3.0.2"
-    safe-regex "^1.1.0"
-
-regexpu-core@^4.7.1:
-  version "4.7.1"
-  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6"
-  integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ==
-  dependencies:
-    regenerate "^1.4.0"
-    regenerate-unicode-properties "^8.2.0"
-    regjsgen "^0.5.1"
-    regjsparser "^0.6.4"
-    unicode-match-property-ecmascript "^1.0.4"
-    unicode-match-property-value-ecmascript "^1.2.0"
-
-registry-auth-token@^3.0.1:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.4.0.tgz#d7446815433f5d5ed6431cd5dca21048f66b397e"
-  integrity sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A==
-  dependencies:
-    rc "^1.1.6"
-    safe-buffer "^5.0.1"
-
-registry-url@^3.0.3:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
-  integrity sha1-PU74cPc93h138M+aOBQyRE4XSUI=
-  dependencies:
-    rc "^1.0.1"
-
-regjsgen@^0.5.1:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733"
-  integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==
-
-regjsparser@^0.6.4:
-  version "0.6.9"
-  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.9.tgz#b489eef7c9a2ce43727627011429cf833a7183e6"
-  integrity sha512-ZqbNRz1SNjLAiYuwY0zoXW8Ne675IX5q+YHioAGbCw4X96Mjl2+dcX9B2ciaeyYjViDAfvIjFpQjJgLttTEERQ==
-  dependencies:
-    jsesc "~0.5.0"
-
-relateurl@0.2.x:
-  version "0.2.7"
-  resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
-  integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
-
-remove-trailing-separator@^1.0.1:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
-  integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8=
-
-repeat-element@^1.1.2:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.4.tgz#be681520847ab58c7568ac75fbfad28ed42d39e9"
-  integrity sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==
-
-repeat-string@^1.5.2, repeat-string@^1.6.1:
-  version "1.6.1"
-  resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
-  integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
-
 repeating@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
@@ -7316,113 +1641,12 @@
   dependencies:
     is-finite "^1.0.0"
 
-replace-ext@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924"
-  integrity sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ=
-
-replace-ext@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a"
-  integrity sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==
-
-request@2.88.0:
-  version "2.88.0"
-  resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
-  integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
-  dependencies:
-    aws-sign2 "~0.7.0"
-    aws4 "^1.8.0"
-    caseless "~0.12.0"
-    combined-stream "~1.0.6"
-    extend "~3.0.2"
-    forever-agent "~0.6.1"
-    form-data "~2.3.2"
-    har-validator "~5.1.0"
-    http-signature "~1.2.0"
-    is-typedarray "~1.0.0"
-    isstream "~0.1.2"
-    json-stringify-safe "~5.0.1"
-    mime-types "~2.1.19"
-    oauth-sign "~0.9.0"
-    performance-now "^2.1.0"
-    qs "~6.5.2"
-    safe-buffer "^5.1.2"
-    tough-cookie "~2.4.3"
-    tunnel-agent "^0.6.0"
-    uuid "^3.3.2"
-
-request@^2.72.0, request@^2.85.0:
-  version "2.88.2"
-  resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
-  integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
-  dependencies:
-    aws-sign2 "~0.7.0"
-    aws4 "^1.8.0"
-    caseless "~0.12.0"
-    combined-stream "~1.0.6"
-    extend "~3.0.2"
-    forever-agent "~0.6.1"
-    form-data "~2.3.2"
-    har-validator "~5.1.3"
-    http-signature "~1.2.0"
-    is-typedarray "~1.0.0"
-    isstream "~0.1.2"
-    json-stringify-safe "~5.0.1"
-    mime-types "~2.1.19"
-    oauth-sign "~0.9.0"
-    performance-now "^2.1.0"
-    qs "~6.5.2"
-    safe-buffer "^5.1.2"
-    tough-cookie "~2.5.0"
-    tunnel-agent "^0.6.0"
-    uuid "^3.3.2"
-
-requirejs@^2.3.4:
-  version "2.3.6"
-  resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.6.tgz#e5093d9601c2829251258c0b9445d4d19fa9e7c9"
-  integrity sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==
-
-requires-port@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
-  integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
-
 resolve-alpn@^1.0.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9"
   integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==
 
-resolve-dir@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e"
-  integrity sha1-shklmlYC+sXFxJatiUpujMQwJh4=
-  dependencies:
-    expand-tilde "^1.2.2"
-    global-modules "^0.2.3"
-
-resolve-dir@^1.0.0, resolve-dir@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43"
-  integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=
-  dependencies:
-    expand-tilde "^2.0.0"
-    global-modules "^1.0.0"
-
-resolve-url@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
-  integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
-
-resolve@^1.1.6, resolve@^1.10.0, resolve@^1.11.1, resolve@^1.5.0:
-  version "1.20.0"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
-  integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
-  dependencies:
-    is-core-module "^2.2.0"
-    path-parse "^1.0.6"
-
-resolve@^1.11.0:
+resolve@^1.11.0, resolve@^1.11.1, resolve@^1.5.0:
   version "1.22.0"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
   integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
@@ -7438,34 +1662,6 @@
   dependencies:
     lowercase-keys "^2.0.0"
 
-restore-cursor@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
-  integrity sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=
-  dependencies:
-    exit-hook "^1.0.0"
-    onetime "^1.0.0"
-
-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"
-
-ret@~0.1.10:
-  version "0.1.15"
-  resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
-  integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
-
-rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3:
-  version "2.7.1"
-  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
-  integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
-  dependencies:
-    glob "^7.1.3"
-
 rimraf@^3.0.0:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
@@ -7473,13 +1669,6 @@
   dependencies:
     glob "^7.1.3"
 
-rimraf@~2.6.2:
-  version "2.6.3"
-  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
-  integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
-  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"
@@ -7491,6 +1680,16 @@
     resolve "^1.11.0"
     rollup-pluginutils "^2.8.1"
 
+rollup-plugin-define@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/rollup-plugin-define/-/rollup-plugin-define-1.0.1.tgz#45b027cec9d2e30df71118efa156170e3846fd3d"
+  integrity sha512-SM/CKFpLvWq5xBEf84ff/ooT3KodXPVITCkRliyNccuq8SZMpzthN/Bp7JkWScbGTX5lo1SF3cjxKKDjnnFCuA==
+  dependencies:
+    "@rollup/pluginutils" "^4.0.0"
+    ast-matcher "^1.1.1"
+    escape-string-regexp "^4.0.0"
+    magic-string "^0.25.7"
+
 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"
@@ -7530,77 +1729,17 @@
     acorn "^7.1.0"
 
 rollup@^2.3.4:
-  version "2.56.3"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.56.3.tgz#b63edadd9851b0d618a6d0e6af8201955a77aeff"
-  integrity sha512-Au92NuznFklgQCUcV96iXlxUbHuB1vQMaH76DHl5M11TotjOHwqk9CwcrT78+Tnv4FN9uTBxq6p4EJoYkpyekg==
+  version "2.75.5"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.75.5.tgz#7985c1962483235dd07966f09fdad5c5f89f16d0"
+  integrity sha512-JzNlJZDison3o2mOxVmb44Oz7t74EfSd1SQrplQk0wSaXV7uLQXtVdHbxlcT3w+8tZ1TL4r/eLfc7nAbz38BBA==
   optionalDependencies:
     fsevents "~2.3.2"
 
-run-async@^2.0.0, run-async@^2.2.0, run-async@^2.4.0:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
-  integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==
-
-rx@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782"
-  integrity sha1-pfE/957zt0D+MKqAP7CfmIBdR4I=
-
-rxjs@^6.4.0, rxjs@^6.6.0:
-  version "6.6.7"
-  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
-  integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==
-  dependencies:
-    tslib "^1.9.0"
-
-safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
-  version "5.1.2"
-  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
-  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
-
-safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
+safe-buffer@^5.1.0, safe-buffer@~5.2.0:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
 
-safe-regex@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"
-  integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4=
-  dependencies:
-    ret "~0.1.10"
-
-"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
-  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
-
-samsam@1.x, samsam@^1.1.3:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50"
-  integrity sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==
-
-sauce-connect-launcher@^1.0.0:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.3.2.tgz#dfc675a258550809a8eaf457eb9162b943ddbaf0"
-  integrity sha512-wf0coUlidJ7rmeClgVVBh6Kw55/yalZCY/Un5RgjSnTXRAeGqagnTsTYpZaqC4dCtrY4myuYpOAZXCdbO7lHfQ==
-  dependencies:
-    adm-zip "~0.4.3"
-    async "^2.1.2"
-    https-proxy-agent "^5.0.0"
-    lodash "^4.16.6"
-    rimraf "^2.5.4"
-
-scoped-regex@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-1.0.0.tgz#a346bb1acd4207ae70bd7c0c7ca9e566b6baddb8"
-  integrity sha1-o0a7Gs1CB65wvXwMfKnlZra63bg=
-
-select-hose@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
-  integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=
-
 selenium-standalone@^6.7.0:
   version "6.24.0"
   resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.24.0.tgz#cca7c1c36bfa3429078a8e6a1a4fd373f641a7c8"
@@ -7619,73 +1758,11 @@
     which "^2.0.2"
     yauzl "^2.10.0"
 
-semver-diff@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36"
-  integrity sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=
-  dependencies:
-    semver "^5.0.3"
-
-"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.5.0:
-  version "5.7.1"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
-  integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
-
 semver@5.6.0:
   version "5.6.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
   integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
 
-semver@^6.0.0, semver@^6.3.0:
-  version "6.3.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
-  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
-
-semver@^7.1.3, semver@^7.2.1:
-  version "7.3.5"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
-  integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
-  dependencies:
-    lru-cache "^6.0.0"
-
-send@0.17.1:
-  version "0.17.1"
-  resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
-  integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
-  dependencies:
-    debug "2.6.9"
-    depd "~1.1.2"
-    destroy "~1.0.4"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    fresh "0.5.2"
-    http-errors "~1.7.2"
-    mime "1.6.0"
-    ms "2.1.1"
-    on-finished "~2.3.0"
-    range-parser "~1.2.1"
-    statuses "~1.5.0"
-
-send@^0.16.1, send@^0.16.2:
-  version "0.16.2"
-  resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1"
-  integrity sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==
-  dependencies:
-    debug "2.6.9"
-    depd "~1.1.2"
-    destroy "~1.0.4"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    fresh "0.5.2"
-    http-errors "~1.6.2"
-    mime "1.4.1"
-    ms "2.0.0"
-    on-finished "~2.3.0"
-    range-parser "~1.2.0"
-    statuses "~1.4.0"
-
 serialize-javascript@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa"
@@ -7693,72 +1770,11 @@
   dependencies:
     randombytes "^2.1.0"
 
-serve-static@1.14.1:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
-  integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
-  dependencies:
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    parseurl "~1.3.3"
-    send "0.17.1"
-
-server-destroy@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/server-destroy/-/server-destroy-1.0.1.tgz#f13bf928e42b9c3e79383e61cc3998b5d14e6cdd"
-  integrity sha1-8Tv5KOQrnD55OD5hzDmYtdFObN0=
-
-serviceworker-cache-polyfill@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/serviceworker-cache-polyfill/-/serviceworker-cache-polyfill-4.0.0.tgz#de19ee73bef21ab3c0740a37b33db62464babdeb"
-  integrity sha1-3hnuc77yGrPAdAo3sz22JGS6ves=
-
-set-getter@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/set-getter/-/set-getter-0.1.1.tgz#a3110e1b461d31a9cfc8c5c9ee2e9737ad447102"
-  integrity sha512-9sVWOy+gthr+0G9DzqqLaYNA7+5OKkSmcqjL9cBpDEaZrr3ShQlyX2cZ/O/ozE41oxn/Tt0LGEM/w4Rub3A3gw==
-  dependencies:
-    to-object-path "^0.3.0"
-
-set-value@^2.0.0, set-value@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
-  integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==
-  dependencies:
-    extend-shallow "^2.0.1"
-    is-extendable "^0.1.1"
-    is-plain-object "^2.0.3"
-    split-string "^3.0.1"
-
-setprototypeof@1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
-  integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
-
-setprototypeof@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
-  integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
-
 shady-css-parser@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/shady-css-parser/-/shady-css-parser-0.1.0.tgz#534dc79c8ca5884c5ed92a4e5a13d6d863bca428"
   integrity sha512-irfJUUkEuDlNHKZNAp2r7zOyMlmbfVJ+kWSfjlCYYUx/7dJnANLCyTzQZsuxy5NJkvtNwSxY5Gj8MOlqXUQPyA==
 
-shallow-clone@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3"
-  integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==
-  dependencies:
-    kind-of "^6.0.2"
-
-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"
@@ -7766,178 +1782,11 @@
   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==
 
-shelljs@^0.8.0, shelljs@^0.8.4:
-  version "0.8.4"
-  resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2"
-  integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==
-  dependencies:
-    glob "^7.0.0"
-    interpret "^1.0.0"
-    rechoir "^0.6.2"
-
-signal-exit@^3.0.0, signal-exit@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
-  integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
-
-simple-swizzle@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
-  integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
-  dependencies:
-    is-arrayish "^0.3.1"
-
-sinon-chai@^2.10.0:
-  version "2.14.0"
-  resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-2.14.0.tgz#da7dd4cc83cd6a260b67cca0f7a9fdae26a1205d"
-  integrity sha512-9stIF1utB0ywNHNT7RgiXbdmen8QDCRsrTjw+G9TgKt1Yexjiv8TOWZ6WHsTPz57Yky3DIswZvEqX8fpuHNDtQ==
-
-sinon@^2.3.5:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/sinon/-/sinon-2.4.1.tgz#021fd64b54cb77d9d2fb0d43cdedfae7629c3a36"
-  integrity sha512-vFTrO9Wt0ECffDYIPSP/E5bBugt0UjcBQOfQUMh66xzkyPEnhl/vM2LRZi2ajuTdkH07sA6DzrM6KvdvGIH8xw==
-  dependencies:
-    diff "^3.1.0"
-    formatio "1.2.0"
-    lolex "^1.6.0"
-    native-promise-only "^0.8.1"
-    path-to-regexp "^1.7.0"
-    samsam "^1.1.3"
-    text-encoding "0.6.4"
-    type-detect "^4.0.0"
-
-slash@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
-  integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=
-
-slash@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"
-  integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==
-
-slide@^1.1.5:
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
-  integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=
-
-snapdragon-node@^2.0.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
-  integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==
-  dependencies:
-    define-property "^1.0.0"
-    isobject "^3.0.0"
-    snapdragon-util "^3.0.1"
-
-snapdragon-util@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2"
-  integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==
-  dependencies:
-    kind-of "^3.2.0"
-
-snapdragon@^0.8.1:
-  version "0.8.2"
-  resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d"
-  integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==
-  dependencies:
-    base "^0.11.1"
-    debug "^2.2.0"
-    define-property "^0.2.5"
-    extend-shallow "^2.0.1"
-    map-cache "^0.2.2"
-    source-map "^0.5.6"
-    source-map-resolve "^0.5.0"
-    use "^3.1.0"
-
-socket.io-adapter@~1.1.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz#ab3f0d6f66b8fc7fca3959ab5991f82221789be9"
-  integrity sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==
-
-socket.io-client@2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.4.0.tgz#aafb5d594a3c55a34355562fc8aea22ed9119a35"
-  integrity sha512-M6xhnKQHuuZd4Ba9vltCLT9oa+YvTsP8j9NcEiLElfIg8KeYPyhWOes6x4t+LTAC8enQbE/995AdTem2uNyKKQ==
-  dependencies:
-    backo2 "1.0.2"
-    component-bind "1.0.0"
-    component-emitter "~1.3.0"
-    debug "~3.1.0"
-    engine.io-client "~3.5.0"
-    has-binary2 "~1.0.2"
-    indexof "0.0.1"
-    parseqs "0.0.6"
-    parseuri "0.0.6"
-    socket.io-parser "~3.3.0"
-    to-array "0.1.4"
-
-socket.io-parser@~3.3.0:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.2.tgz#ef872009d0adcf704f2fbe830191a14752ad50b6"
-  integrity sha512-FJvDBuOALxdCI9qwRrO/Rfp9yfndRtc1jSgVgV8FDraihmSP/MLGD5PEuJrNfjALvcQ+vMDM/33AWOYP/JSjDg==
-  dependencies:
-    component-emitter "~1.3.0"
-    debug "~3.1.0"
-    isarray "2.0.1"
-
-socket.io-parser@~3.4.0:
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.4.1.tgz#b06af838302975837eab2dc980037da24054d64a"
-  integrity sha512-11hMgzL+WCLWf1uFtHSNvliI++tcRUWdoeYuwIl+Axvwy9z2gQM+7nJyN3STj1tLj5JyIUH8/gpDGxzAlDdi0A==
-  dependencies:
-    component-emitter "1.2.1"
-    debug "~4.1.0"
-    isarray "2.0.1"
-
-socket.io@^2.0.3:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.4.1.tgz#95ad861c9a52369d7f1a68acf0d4a1b16da451d2"
-  integrity sha512-Si18v0mMXGAqLqCVpTxBa8MGqriHGQh8ccEOhmsmNS3thNCGBwO8WGrwMibANsWtQQ5NStdZwHqZR3naJVFc3w==
-  dependencies:
-    debug "~4.1.0"
-    engine.io "~3.5.0"
-    has-binary2 "~1.0.2"
-    socket.io-adapter "~1.1.0"
-    socket.io-client "2.4.0"
-    socket.io-parser "~3.4.0"
-
-sort-keys-length@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/sort-keys-length/-/sort-keys-length-1.0.1.tgz#9cb6f4f4e9e48155a6aa0671edd336ff1479a188"
-  integrity sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=
-  dependencies:
-    sort-keys "^1.0.0"
-
-sort-keys@^1.0.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
-  integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0=
-  dependencies:
-    is-plain-obj "^1.0.0"
-
-source-map-resolve@^0.5.0:
-  version "0.5.3"
-  resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"
-  integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==
-  dependencies:
-    atob "^2.1.2"
-    decode-uri-component "^0.2.0"
-    resolve-url "^0.2.1"
-    source-map-url "^0.4.0"
-    urix "^0.1.0"
-
 source-map-support@0.5.9:
   version "0.5.9"
   resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.9.tgz#41bc953b2534267ea2d605bccfa7bfa3111ced5f"
@@ -7947,198 +1796,33 @@
     source-map "^0.6.0"
 
 source-map-support@~0.5.12:
-  version "0.5.19"
-  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
-  integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
+  version "0.5.21"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
+  integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
   dependencies:
     buffer-from "^1.0.0"
     source-map "^0.6.0"
 
-source-map-url@^0.4.0:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56"
-  integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==
-
-source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7:
+source-map@^0.5.6, source-map@^0.5.7:
   version "0.5.7"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
   integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
 
-source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
+source-map@^0.6.0, source-map@~0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
-sourcemap-codec@^1.4.4:
+sourcemap-codec@^1.4.8:
   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"
-  integrity sha1-sAeZVX63+wyDdsKdROih6mfldHY=
-  dependencies:
-    concat-stream "^1.4.7"
-    os-shim "^0.1.2"
-
-spdx-correct@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
-  integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==
-  dependencies:
-    spdx-expression-parse "^3.0.0"
-    spdx-license-ids "^3.0.0"
-
-spdx-exceptions@^2.1.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d"
-  integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==
-
-spdx-expression-parse@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679"
-  integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==
-  dependencies:
-    spdx-exceptions "^2.1.0"
-    spdx-license-ids "^3.0.0"
-
-spdx-license-ids@^3.0.0:
-  version "3.0.10"
-  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz#0d9becccde7003d6c658d487dd48a32f0bf3014b"
-  integrity sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==
-
-spdy-transport@^2.0.18:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-2.1.1.tgz#c54815d73858aadd06ce63001e7d25fa6441623b"
-  integrity sha512-q7D8c148escoB3Z7ySCASadkegMmUZW8Wb/Q1u0/XBgDKMO880rLQDj8Twiew/tYi7ghemKUi/whSYOwE17f5Q==
-  dependencies:
-    debug "^2.6.8"
-    detect-node "^2.0.3"
-    hpack.js "^2.1.6"
-    obuf "^1.1.1"
-    readable-stream "^2.2.9"
-    safe-buffer "^5.0.1"
-    wbuf "^1.7.2"
-
-spdy@^3.3.3:
-  version "3.4.7"
-  resolved "https://registry.yarnpkg.com/spdy/-/spdy-3.4.7.tgz#42ff41ece5cc0f99a3a6c28aabb73f5c3b03acbc"
-  integrity sha1-Qv9B7OXMD5mjpsKKq7c/XDsDrLw=
-  dependencies:
-    debug "^2.6.8"
-    handle-thing "^1.2.5"
-    http-deceiver "^1.2.7"
-    safe-buffer "^5.0.1"
-    select-hose "^2.0.0"
-    spdy-transport "^2.0.18"
-
-split-string@^3.0.1, split-string@^3.0.2:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
-  integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==
-  dependencies:
-    extend-shallow "^3.0.0"
-
-sshpk@^1.7.0:
-  version "1.16.1"
-  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
-  integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
-  dependencies:
-    asn1 "~0.2.3"
-    assert-plus "^1.0.0"
-    bcrypt-pbkdf "^1.0.0"
-    dashdash "^1.12.0"
-    ecc-jsbn "~0.1.1"
-    getpass "^0.1.1"
-    jsbn "~0.1.0"
-    safer-buffer "^2.0.2"
-    tweetnacl "~0.14.0"
-
 stable@^0.1.6:
   version "0.1.8"
   resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
   integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
 
-stack-trace@0.0.x:
-  version "0.0.10"
-  resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
-  integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
-
-stacky@^1.3.1:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/stacky/-/stacky-1.3.1.tgz#3f117e5187b9a73d23f876d69f05c85b11804a12"
-  integrity sha1-PxF+UYe5pz0j+HbWnwXIWxGAShI=
-  dependencies:
-    chalk "^1.1.1"
-    lodash "^3.0.0"
-
-static-extend@^0.1.1:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
-  integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=
-  dependencies:
-    define-property "^0.2.5"
-    object-copy "^0.1.0"
-
-"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
-  integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
-
-statuses@~1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
-  integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==
-
-stream-shift@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
-  integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
-
-stream@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/stream/-/stream-0.0.2.tgz#7f5363f057f6592c5595f00bc80a27f5cec1f0ef"
-  integrity sha1-f1Nj8Ff2WSxVlfALyAon9c7B8O8=
-  dependencies:
-    emitter-component "^1.1.1"
-
-streamsearch@0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
-  integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
-
-string-template@~0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
-  integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0=
-
-string-width@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
-  integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
-  dependencies:
-    code-point-at "^1.0.0"
-    is-fullwidth-code-point "^1.0.0"
-    strip-ansi "^3.0.0"
-
-string-width@^2.0.0, string-width@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
-  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
-  dependencies:
-    is-fullwidth-code-point "^2.0.0"
-    strip-ansi "^4.0.0"
-
-string-width@^4.1.0:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"
-  integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.0"
-
 string_decoder@^1.1.1:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
@@ -8146,18 +1830,6 @@
   dependencies:
     safe-buffer "~5.2.0"
 
-string_decoder@~0.10.x:
-  version "0.10.31"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
-  integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
-
-string_decoder@~1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
-  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
-  dependencies:
-    safe-buffer "~5.1.0"
-
 strip-ansi@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
@@ -8165,87 +1837,11 @@
   dependencies:
     ansi-regex "^2.0.0"
 
-strip-ansi@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
-  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
-  dependencies:
-    ansi-regex "^3.0.0"
-
-strip-ansi@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532"
-  integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==
-  dependencies:
-    ansi-regex "^5.0.0"
-
-strip-ansi@~0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.1.1.tgz#39e8a98d044d150660abe4a6808acf70bb7bc991"
-  integrity sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=
-
-strip-bom-buf@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom-buf/-/strip-bom-buf-1.0.0.tgz#1cb45aaf57530f4caf86c7f75179d2c9a51dd572"
-  integrity sha1-HLRar1dTD0yvhsf3UXnSyaUd1XI=
-  dependencies:
-    is-utf8 "^0.2.1"
-
-strip-bom-stream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-1.0.0.tgz#e7144398577d51a6bed0fa1994fa05f43fd988ee"
-  integrity sha1-5xRDmFd9Uaa+0PoZlPoF9D/ZiO4=
-  dependencies:
-    first-chunk-stream "^1.0.0"
-    strip-bom "^2.0.0"
-
-strip-bom-stream@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-2.0.0.tgz#f87db5ef2613f6968aa545abfe1ec728b6a829ca"
-  integrity sha1-+H217yYT9paKpUWr/h7HKLaoKco=
-  dependencies:
-    first-chunk-stream "^2.0.0"
-    strip-bom "^2.0.0"
-
-strip-bom@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
-  integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=
-  dependencies:
-    is-utf8 "^0.2.0"
-
-strip-bom@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
-  integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=
-
-strip-eof@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
-  integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=
-
-strip-final-newline@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
-  integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
-
-strip-indent@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
-  integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=
-  dependencies:
-    get-stdin "^4.0.1"
-
 strip-indent@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
   integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=
 
-strip-json-comments@~2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
-  integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
-
 supports-color@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
@@ -8265,42 +1861,11 @@
   dependencies:
     has-flag "^3.0.0"
 
-supports-color@^7.1.0:
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
-  integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
-  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"
-  integrity sha512-8FAy+BP/FXE+ILfiVTt+GQJ6UEf4CVHD9OfhzH0JX+3zoy2uFk7Vn9EfXASOtVmmIVbL3jE/W8Z66VgPSZcMhw==
-  dependencies:
-    dom-urls "^1.1.0"
-    es6-promise "^4.0.5"
-    glob "^7.1.1"
-    lodash.defaults "^4.2.0"
-    lodash.template "^4.4.0"
-    meow "^3.7.0"
-    mkdirp "^0.5.1"
-    pretty-bytes "^4.0.2"
-    sw-toolbox "^3.4.0"
-    update-notifier "^2.3.0"
-
-sw-toolbox@^3.4.0:
-  version "3.6.0"
-  resolved "https://registry.yarnpkg.com/sw-toolbox/-/sw-toolbox-3.6.0.tgz#26df1d1c70348658e4dea2884319149b7b3183b5"
-  integrity sha1-Jt8dHHA0hljk3qKIQxkUm3sxg7U=
-  dependencies:
-    path-to-regexp "^1.0.1"
-    serviceworker-cache-polyfill "^4.0.0"
-
 table-layout@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-0.3.0.tgz#6ee20dc483db371b3e5c87f704ed2f7c799d2c9a"
@@ -8324,17 +1889,7 @@
     typical "^2.6.1"
     wordwrapjs "^3.0.0"
 
-tar-fs@^1.12.0:
-  version "1.16.3"
-  resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-1.16.3.tgz#966a628841da2c4010406a82167cbd5e0c72d509"
-  integrity sha512-NvCeXpYx7OsmOh8zIOP/ebG55zZmxLE0etfWRbWok+q2Qo8x/vOR/IJT1taADXPe+jsiu9axDb3X4B+iIgNlKw==
-  dependencies:
-    chownr "^1.0.1"
-    mkdirp "^0.5.1"
-    pump "^1.0.0"
-    tar-stream "^1.1.2"
-
-tar-stream@2.2.0, tar-stream@^2.1.0:
+tar-stream@2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
   integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
@@ -8345,43 +1900,6 @@
     inherits "^2.0.3"
     readable-stream "^3.1.1"
 
-tar-stream@^1.1.2:
-  version "1.6.2"
-  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555"
-  integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==
-  dependencies:
-    bl "^1.0.0"
-    buffer-alloc "^1.2.0"
-    end-of-stream "^1.0.0"
-    fs-constants "^1.0.0"
-    readable-stream "^2.3.0"
-    to-buffer "^1.1.1"
-    xtend "^4.0.0"
-
-temp@^0.8.1, temp@^0.8.3:
-  version "0.8.4"
-  resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.4.tgz#8c97a33a4770072e0a05f919396c7665a7dd59f2"
-  integrity sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==
-  dependencies:
-    rimraf "~2.6.2"
-
-term-size@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
-  integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=
-  dependencies:
-    execa "^0.7.0"
-
-ternary-stream@^2.0.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ternary-stream/-/ternary-stream-2.1.1.tgz#4ad64b98668d796a085af2c493885a435a8a8bfc"
-  integrity sha512-j6ei9hxSoyGlqTmoMjOm+QNvUKDOIY6bNl4Uh1lhBvl6yjPW2iLqxDUYyfDPZknQ4KdRziFl+ec99iT4l7g0cw==
-  dependencies:
-    duplexify "^3.5.0"
-    fork-stream "^0.0.4"
-    merge-stream "^1.0.0"
-    through2 "^2.0.1"
-
 terser@^4.6.2:
   version "4.8.0"
   resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
@@ -8399,126 +1917,6 @@
     array-back "^1.0.3"
     typical "^2.6.0"
 
-text-encoding@0.6.4:
-  version "0.6.4"
-  resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"
-  integrity sha1-45mpgiV6J22uQou5KEXLcb3CbRk=
-
-text-hex@1.0.x:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5"
-  integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==
-
-text-table@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
-  integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
-
-textextensions@^2.5.0:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-2.6.0.tgz#d7e4ab13fe54e32e08873be40d51b74229b00fc4"
-  integrity sha512-49WtAWS+tcsy93dRt6P0P3AMD2m5PvXRhuEA0kaXos5ZLlujtYmpmFsB+QvWUSxE1ZsstmYXfQ7L40+EcQgpAQ==
-
-thenify-all@^1.0.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
-  integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=
-  dependencies:
-    thenify ">= 3.1.0 < 4"
-
-"thenify@>= 3.1.0 < 4":
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
-  integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
-  dependencies:
-    any-promise "^1.0.0"
-
-through2-filter@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-2.0.0.tgz#60bc55a0dacb76085db1f9dae99ab43f83d622ec"
-  integrity sha1-YLxVoNrLdghdsfna6Zq0P4PWIuw=
-  dependencies:
-    through2 "~2.0.0"
-    xtend "~4.0.0"
-
-through2-filter@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254"
-  integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==
-  dependencies:
-    through2 "~2.0.0"
-    xtend "~4.0.0"
-
-through2@^0.6.0:
-  version "0.6.5"
-  resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.5.tgz#41ab9c67b29d57209071410e1d7a7a968cd3ad48"
-  integrity sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=
-  dependencies:
-    readable-stream ">=1.0.33-1 <1.1.0-0"
-    xtend ">=4.0.0 <4.1.0-0"
-
-through2@^2.0.0, through2@^2.0.1, through2@^2.0.3, through2@~2.0.0:
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
-  integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==
-  dependencies:
-    readable-stream "~2.3.6"
-    xtend "~4.0.1"
-
-through2@^3.0.0, through2@^3.0.1, through2@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/through2/-/through2-3.0.2.tgz#99f88931cfc761ec7678b41d5d7336b5b6a07bf4"
-  integrity sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==
-  dependencies:
-    inherits "^2.0.4"
-    readable-stream "2 || 3"
-
-"through@>=2.2.7 <3", through@^2.3.6:
-  version "2.3.8"
-  resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
-  integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
-
-timed-out@^3.0.0:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-3.1.3.tgz#95860bfcc5c76c277f8f8326fd0f5b2e20eba217"
-  integrity sha1-lYYL/MXHbCd/j4Mm/Q9bLiDrohc=
-
-timed-out@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
-  integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=
-
-tmp@^0.0.29:
-  version "0.0.29"
-  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.29.tgz#f25125ff0dd9da3ccb0c2dd371ee1288bb9128c0"
-  integrity sha1-8lEl/w3Z2jzLDC3Tce4SiLuRKMA=
-  dependencies:
-    os-tmpdir "~1.0.1"
-
-tmp@^0.0.33:
-  version "0.0.33"
-  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
-  integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
-  dependencies:
-    os-tmpdir "~1.0.2"
-
-to-absolute-glob@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-0.1.1.tgz#1cdfa472a9ef50c239ee66999b662ca0eb39937f"
-  integrity sha1-HN+kcqnvUMI57maZm2YsoOs5k38=
-  dependencies:
-    extend-shallow "^2.0.1"
-
-to-array@0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
-  integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA=
-
-to-buffer@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80"
-  integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==
-
 to-fast-properties@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
@@ -8529,52 +1927,6 @@
   resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
   integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
 
-to-object-path@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
-  integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=
-  dependencies:
-    kind-of "^3.0.2"
-
-to-regex-range@^2.1.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38"
-  integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=
-  dependencies:
-    is-number "^3.0.0"
-    repeat-string "^1.6.1"
-
-to-regex@^3.0.1, to-regex@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce"
-  integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==
-  dependencies:
-    define-property "^2.0.2"
-    extend-shallow "^3.0.2"
-    regex-not "^1.0.2"
-    safe-regex "^1.1.0"
-
-toidentifier@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
-  integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
-
-tough-cookie@~2.4.3:
-  version "2.4.3"
-  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
-  integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==
-  dependencies:
-    psl "^1.1.24"
-    punycode "^1.4.1"
-
-tough-cookie@~2.5.0:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
-  integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
-  dependencies:
-    psl "^1.1.28"
-    punycode "^2.1.1"
-
 tr46@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
@@ -8582,31 +1934,16 @@
   dependencies:
     punycode "^2.1.0"
 
-trim-newlines@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
-  integrity sha1-WIeWa7WCpFA6QetST301ARgVphM=
-
 trim-right@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
   integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=
 
-triple-beam@^1.2.0, triple-beam@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
-  integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
-
 tslib@^1.8.1:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
-tslib@^1.9.0:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
-  integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
-
 tsutils@3.21.0:
   version "3.21.0"
   resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
@@ -8614,50 +1951,10 @@
   dependencies:
     tslib "^1.8.1"
 
-tunnel-agent@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
-  integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
-  dependencies:
-    safe-buffer "^5.0.1"
-
-tweetnacl@^0.14.3, tweetnacl@~0.14.0:
-  version "0.14.5"
-  resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
-  integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
-
-type-detect@^4.0.0:
-  version "4.0.8"
-  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-fest@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
-  integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
-
-type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
-  version "1.6.18"
-  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
-  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
-  dependencies:
-    media-typer "0.3.0"
-    mime-types "~2.1.24"
-
-typedarray@^0.0.6:
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
-  integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
-
-typescript@4.3.2:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805"
-  integrity sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==
+typescript@^4.7.2:
+  version "4.7.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.2.tgz#1f9aa2ceb9af87cca227813b4310fff0b51593c4"
+  integrity sha512-Mamb1iX2FDUpcTRzltPxgWMKy3fhg0TN378ylbktPGPK/99KbDtMQ4W1hwgsbPAsG3a0xKa1vmw4VKZQbkvz5A==
 
 typical@^2.6.0, typical@^2.6.1:
   version "2.6.1"
@@ -8669,309 +1966,16 @@
   resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
   integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
 
-ua-parser-js@^0.7.15:
-  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==
-
-uglify-js@3.4.x:
-  version "3.4.10"
-  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f"
-  integrity sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==
-  dependencies:
-    commander "~2.19.0"
-    source-map "~0.6.1"
-
 underscore@^1.8.3:
-  version "1.13.1"
-  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.1.tgz#0c1c6bd2df54b6b69f2314066d65b6cde6fcf9d1"
-  integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==
+  version "1.13.4"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.4.tgz#7886b46bbdf07f768e0052f1828e1dcab40c0dee"
+  integrity sha512-BQFnUDuAQ4Yf/cYY5LNrK9NCJFKriaRbD9uR1fTeXnBeoa97W0i41qkZfGO9pSo8I5KzjAcSY2XYtdf0oKd7KQ==
 
-underscore@~1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.6.0.tgz#8b38b10cacdef63337b8b24e4ff86d45aea529a8"
-  integrity sha1-izixDKze9jM3uLJOT/htRa6lKag=
-
-unicode-canonical-property-names-ecmascript@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
-  integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==
-
-unicode-match-property-ecmascript@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c"
-  integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==
-  dependencies:
-    unicode-canonical-property-names-ecmascript "^1.0.4"
-    unicode-property-aliases-ecmascript "^1.0.4"
-
-unicode-match-property-value-ecmascript@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz#0d91f600eeeb3096aa962b1d6fc88876e64ea531"
-  integrity sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==
-
-unicode-property-aliases-ecmascript@^1.0.4:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4"
-  integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==
-
-union-value@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
-  integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==
-  dependencies:
-    arr-union "^3.1.0"
-    get-value "^2.0.6"
-    is-extendable "^0.1.1"
-    set-value "^2.0.1"
-
-unique-stream@^2.0.2:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac"
-  integrity sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==
-  dependencies:
-    json-stable-stringify-without-jsonify "^1.0.1"
-    through2-filter "^3.0.0"
-
-unique-string@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a"
-  integrity sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=
-  dependencies:
-    crypto-random-string "^1.0.0"
-
-universal-user-agent@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-4.0.1.tgz#fd8d6cb773a679a709e967ef8288a31fcc03e557"
-  integrity sha512-LnST3ebHwVL2aNe4mejI9IQh2HfZ1RLo8Io2HugSif8ekzD1TlWpHpColOB/eh8JHMLkGH3Akqf040I+4ylNxg==
-  dependencies:
-    os-name "^3.1.0"
-
-universal-user-agent@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee"
-  integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==
-
-unpipe@1.0.0, unpipe@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
-  integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
-
-unset-value@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"
-  integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=
-  dependencies:
-    has-value "^0.3.1"
-    isobject "^3.0.0"
-
-untildify@^2.0.0, untildify@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/untildify/-/untildify-2.1.0.tgz#17eb2807987f76952e9c0485fc311d06a826a2e0"
-  integrity sha1-F+soB5h/dpUunASF/DEdBqgmouA=
-  dependencies:
-    os-homedir "^1.0.0"
-
-untildify@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/untildify/-/untildify-3.0.3.tgz#1e7b42b140bcfd922b22e70ca1265bfe3634c7c9"
-  integrity sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==
-
-unzip-response@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-1.0.2.tgz#b984f0877fc0a89c2c773cc1ef7b5b232b5b06fe"
-  integrity sha1-uYTwh3/AqJwsdzzB73tbIytbBv4=
-
-unzip-response@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
-  integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=
-
-update-notifier@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-1.0.3.tgz#8f92c515482bd6831b7c93013e70f87552c7cf5a"
-  integrity sha1-j5LFFUgr1oMbfJMBPnD4dVLHz1o=
-  dependencies:
-    boxen "^0.6.0"
-    chalk "^1.0.0"
-    configstore "^2.0.0"
-    is-npm "^1.0.0"
-    latest-version "^2.0.0"
-    lazy-req "^1.1.0"
-    semver-diff "^2.0.0"
-    xdg-basedir "^2.0.0"
-
-update-notifier@^2.2.0, update-notifier@^2.3.0:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6"
-  integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==
-  dependencies:
-    boxen "^1.2.1"
-    chalk "^2.0.1"
-    configstore "^3.0.0"
-    import-lazy "^2.1.0"
-    is-ci "^1.0.10"
-    is-installed-globally "^0.1.0"
-    is-npm "^1.0.0"
-    latest-version "^3.0.0"
-    semver-diff "^2.0.0"
-    xdg-basedir "^3.0.0"
-
-upper-case@^1.1.1:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598"
-  integrity sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=
-
-uri-js@^4.2.2:
-  version "4.4.1"
-  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
-  integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
-  dependencies:
-    punycode "^2.1.0"
-
-urijs@^1.16.1:
-  version "1.19.7"
-  resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.7.tgz#4f594e59113928fea63c00ce688fb395b1168ab9"
-  integrity sha512-Id+IKjdU0Hx+7Zx717jwLPsPeUqz7rAtuVBRLLs+qn+J2nf9NGITWVCxcijgYxBqe83C7sqsQPs6H1pyz3x9gA==
-
-urix@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
-  integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
-
-url-parse-lax@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73"
-  integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=
-  dependencies:
-    prepend-http "^1.0.1"
-
-url-to-options@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9"
-  integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=
-
-use@^3.1.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
-  integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
-
-util-deprecate@^1.0.1, util-deprecate@~1.0.1:
+util-deprecate@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
 
-utils-merge@1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
-  integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
-
-uuid@^2.0.1:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a"
-  integrity sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=
-
-uuid@^3.2.1, uuid@^3.3.2:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
-  integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
-
-vali-date@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/vali-date/-/vali-date-1.0.0.tgz#1b904a59609fb328ef078138420934f6b86709a6"
-  integrity sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=
-
-validate-element-name@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/validate-element-name/-/validate-element-name-2.1.1.tgz#8ff75f7da69f73e7c510588362130508b7ac644e"
-  integrity sha1-j/dffaafc+fFEFiDYhMFCLesZE4=
-  dependencies:
-    is-potential-custom-element-name "^1.0.0"
-    log-symbols "^1.0.0"
-    meow "^3.7.0"
-
-validate-npm-package-license@^3.0.1:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
-  integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==
-  dependencies:
-    spdx-correct "^3.0.0"
-    spdx-expression-parse "^3.0.0"
-
-vargs@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/vargs/-/vargs-0.1.0.tgz#6b6184da6520cc3204ce1b407cac26d92609ebff"
-  integrity sha1-a2GE2mUgzDIEzhtAfKwm2SYJ6/8=
-
-vary@^1, vary@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
-  integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
-
-verror@1.10.0:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
-  integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
-  dependencies:
-    assert-plus "^1.0.0"
-    core-util-is "1.0.2"
-    extsprintf "^1.2.0"
-
-vinyl-file@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/vinyl-file/-/vinyl-file-3.0.0.tgz#b104d9e4409ffa325faadd520642d0a3b488b365"
-  integrity sha1-sQTZ5ECf+jJfqt1SBkLQo7SIs2U=
-  dependencies:
-    graceful-fs "^4.1.2"
-    pify "^2.3.0"
-    strip-bom-buf "^1.0.0"
-    strip-bom-stream "^2.0.0"
-    vinyl "^2.0.1"
-
-vinyl-fs@^2.4.3, vinyl-fs@^2.4.4:
-  version "2.4.4"
-  resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-2.4.4.tgz#be6ff3270cb55dfd7d3063640de81f25d7532239"
-  integrity sha1-vm/zJwy1Xf19MGNkDegfJddTIjk=
-  dependencies:
-    duplexify "^3.2.0"
-    glob-stream "^5.3.2"
-    graceful-fs "^4.0.0"
-    gulp-sourcemaps "1.6.0"
-    is-valid-glob "^0.3.0"
-    lazystream "^1.0.0"
-    lodash.isequal "^4.0.0"
-    merge-stream "^1.0.0"
-    mkdirp "^0.5.0"
-    object-assign "^4.0.0"
-    readable-stream "^2.0.4"
-    strip-bom "^2.0.0"
-    strip-bom-stream "^1.0.0"
-    through2 "^2.0.0"
-    through2-filter "^2.0.0"
-    vali-date "^1.0.0"
-    vinyl "^1.0.0"
-
-vinyl@^1.0.0, vinyl@^1.1.1, vinyl@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-1.2.0.tgz#5c88036cf565e5df05558bfc911f8656df218884"
-  integrity sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=
-  dependencies:
-    clone "^1.0.0"
-    clone-stats "^0.0.1"
-    replace-ext "0.0.1"
-
-vinyl@^2.0.1, vinyl@^2.2.0, vinyl@^2.2.1:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.1.tgz#23cfb8bbab5ece3803aa2c0a1eb28af7cbba1974"
-  integrity sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw==
-  dependencies:
-    clone "^2.1.1"
-    clone-buffer "^1.0.0"
-    clone-stats "^1.0.0"
-    cloneable-readable "^1.0.0"
-    remove-trailing-separator "^1.0.1"
-    replace-ext "^1.0.0"
-
 vlq@^0.2.2:
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"
@@ -8982,14 +1986,7 @@
   resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.6.tgz#6b8f141b0bbc44ad7b07e94f82f168ac7608ad4d"
   integrity sha512-sLI2L0uGov3wKVb9EB+vIQBl9tVP90nqRvxSoJ35vI3NjxE8jfsE5DSOhWgSunHSZmKS4OCi2jrtfxK7uyp2ww==
 
-wbuf@^1.1.0, wbuf@^1.7.2:
-  version "1.7.3"
-  resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"
-  integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==
-  dependencies:
-    minimalistic-assert "^1.0.0"
-
-wct-local@2.1.6, wct-local@^2.1.1:
+wct-local@2.1.6:
   version "2.1.6"
   resolved "https://registry.yarnpkg.com/wct-local/-/wct-local-2.1.6.tgz#2d099c52996e77265d16e03a5d6d897b77ea9967"
   integrity sha512-jvTzgOIIfJ43H3DXUfruHPTQ/TJ269SDk4R2CfCpU13EYbwxn3U1B6L5NHYRFu/cgdJmOHraGrn/wREHH6xeXQ==
@@ -9005,67 +2002,6 @@
     selenium-standalone "^6.7.0"
     which "^1.0.8"
 
-wct-sauce@^2.0.2:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/wct-sauce/-/wct-sauce-2.1.0.tgz#67d0be346aabbbc28384e8d143b8d3ca7ba774c0"
-  integrity sha512-c3R4PJcbpS7Gxv2vZ4HDAqpXV6cT9peslAWMU7hHH9PMhKDPbn8RNa6E4DVL0tOmZznB+3cRmtZ6+vJ/aDwu1A==
-  dependencies:
-    chalk "^2.4.1"
-    cleankill "^2.0.0"
-    lodash "^4.17.10"
-    request "^2.85.0"
-    sauce-connect-launcher "^1.0.0"
-    temp "^0.8.1"
-    uuid "^3.2.1"
-
-wd@^1.2.0:
-  version "1.14.0"
-  resolved "https://registry.yarnpkg.com/wd/-/wd-1.14.0.tgz#1fe6450b5baef37caa135e7755292c6998ca8a90"
-  integrity sha512-X7ZfGHHYlQ5zYpRlgP16LUsvYti+Al/6fz3T/ClVyivVCpCZQpESTDdz6zbK910O5OIvujO23Ym2DBBo3XsQlA==
-  dependencies:
-    archiver "^3.0.0"
-    async "^2.0.0"
-    lodash "^4.0.0"
-    mkdirp "^0.5.1"
-    q "^1.5.1"
-    request "2.88.0"
-    vargs "^0.1.0"
-
-web-component-tester@^6.9.0:
-  version "6.9.2"
-  resolved "https://registry.yarnpkg.com/web-component-tester/-/web-component-tester-6.9.2.tgz#40a7b824f2cf3cbc4305552bdfc3357977ded48a"
-  integrity sha512-s2kB/+IE8XWcnxY1fqSpqTiiHEGHWgUWariAbiRlxmAvWSuvaCVNALHYebsZrLCNCLHKcJR8/sGv/bw0MWMvjw==
-  dependencies:
-    "@polymer/sinonjs" "^1.14.1"
-    "@polymer/test-fixture" "^0.0.3"
-    "@webcomponents/webcomponentsjs" "^1.0.7"
-    accessibility-developer-tools "^2.12.0"
-    async "^2.4.1"
-    body-parser "^1.17.2"
-    bower-config "^1.4.0"
-    chalk "^1.1.3"
-    cleankill "^2.0.0"
-    express "^4.15.3"
-    findup-sync "^2.0.0"
-    glob "^7.1.2"
-    lodash "^3.10.1"
-    multer "^1.3.0"
-    nomnom "^1.8.1"
-    polyserve "^0.27.13"
-    resolve "^1.5.0"
-    semver "^5.3.0"
-    send "^0.16.1"
-    server-destroy "^1.0.1"
-    sinon "^2.3.5"
-    sinon-chai "^2.10.0"
-    socket.io "^2.0.3"
-    stacky "^1.3.1"
-    wd "^1.2.0"
-  optionalDependencies:
-    update-notifier "^2.2.0"
-    wct-local "^2.1.1"
-    wct-sauce "^2.0.2"
-
 webidl-conversions@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
@@ -9080,7 +2016,7 @@
     tr46 "^1.0.1"
     webidl-conversions "^4.0.2"
 
-which@^1.0.8, which@^1.2.12, which@^1.2.14, which@^1.2.9:
+which@^1.0.8:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
   integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
@@ -9094,64 +2030,6 @@
   dependencies:
     isexe "^2.0.0"
 
-widest-line@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-1.0.0.tgz#0c09c85c2a94683d0d7eaf8ee097d564bf0e105c"
-  integrity sha1-DAnIXCqUaD0Nfq+O4JfVZL8OEFw=
-  dependencies:
-    string-width "^1.0.1"
-
-widest-line@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc"
-  integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA==
-  dependencies:
-    string-width "^2.1.1"
-
-windows-release@^3.1.0:
-  version "3.3.3"
-  resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.3.3.tgz#1c10027c7225743eec6b89df160d64c2e0293999"
-  integrity sha512-OSOGH1QYiW5yVor9TtmXKQvt2vjQqbYS+DqmsZw+r7xDwLXEeT3JGW0ZppFmHx4diyXmxt238KFR3N9jzevBRg==
-  dependencies:
-    execa "^1.0.0"
-
-winston-transport@^4.2.0, winston-transport@^4.4.0:
-  version "4.4.0"
-  resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.0.tgz#17af518daa690d5b2ecccaa7acf7b20ca7925e59"
-  integrity sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw==
-  dependencies:
-    readable-stream "^2.3.7"
-    triple-beam "^1.2.0"
-
-winston@^3.0.0:
-  version "3.3.3"
-  resolved "https://registry.yarnpkg.com/winston/-/winston-3.3.3.tgz#ae6172042cafb29786afa3d09c8ff833ab7c9170"
-  integrity sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw==
-  dependencies:
-    "@dabh/diagnostics" "^2.0.2"
-    async "^3.1.0"
-    is-stream "^2.0.0"
-    logform "^2.2.0"
-    one-time "^1.0.0"
-    readable-stream "^3.4.0"
-    stack-trace "0.0.x"
-    triple-beam "^1.3.0"
-    winston-transport "^4.4.0"
-
-with-open-file@^0.1.6:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/with-open-file/-/with-open-file-0.1.7.tgz#e2de8d974e8a8ae6e58886be4fe8e7465b58a729"
-  integrity sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==
-  dependencies:
-    p-finally "^1.0.0"
-    p-try "^2.1.0"
-    pify "^4.0.1"
-
-wordwrap@^0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
-  integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc=
-
 wordwrapjs@^2.0.0-0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-2.0.0.tgz#ab55f695e6118da93858fdd70c053d1c5e01ac20"
@@ -9175,41 +2053,6 @@
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
-write-file-atomic@^1.1.2:
-  version "1.3.4"
-  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.3.4.tgz#f807a4f0b1d9e913ae7a48112e6cc3af1991b45f"
-  integrity sha1-+Aek8LHZ6ROuekgRLmzDrxmRtF8=
-  dependencies:
-    graceful-fs "^4.1.11"
-    imurmurhash "^0.1.4"
-    slide "^1.1.5"
-
-write-file-atomic@^2.0.0:
-  version "2.4.3"
-  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481"
-  integrity sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==
-  dependencies:
-    graceful-fs "^4.1.11"
-    imurmurhash "^0.1.4"
-    signal-exit "^3.0.2"
-
-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==
-
-xdg-basedir@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2"
-  integrity sha1-7byQPMOF/ARSPZZqM1UEtVBNG9I=
-  dependencies:
-    os-homedir "^1.0.0"
-
-xdg-basedir@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
-  integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=
-
 xmlbuilder@8.2.2:
   version "8.2.2"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773"
@@ -9220,26 +2063,6 @@
   resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
   integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
 
-xmlhttprequest-ssl@~1.6.2:
-  version "1.6.3"
-  resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6"
-  integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q==
-
-"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
-  integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
-
-yallist@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
-  integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
-
-yallist@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
-  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
-
 yauzl@^2.10.0:
   version "2.10.0"
   resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
@@ -9247,125 +2070,3 @@
   dependencies:
     buffer-crc32 "~0.2.3"
     fd-slicer "~1.1.0"
-
-yeast@0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
-  integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
-
-yeoman-environment@^1.5.2:
-  version "1.6.6"
-  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-1.6.6.tgz#cd85fa67d156060e440d7807d7ef7cf0d2d1d671"
-  integrity sha1-zYX6Z9FWBg5EDXgH1+988NLR1nE=
-  dependencies:
-    chalk "^1.0.0"
-    debug "^2.0.0"
-    diff "^2.1.2"
-    escape-string-regexp "^1.0.2"
-    globby "^4.0.0"
-    grouped-queue "^0.3.0"
-    inquirer "^1.0.2"
-    lodash "^4.11.1"
-    log-symbols "^1.0.1"
-    mem-fs "^1.1.0"
-    text-table "^0.2.0"
-    untildify "^2.0.0"
-
-yeoman-environment@^2.0.5, yeoman-environment@^2.9.5:
-  version "2.10.3"
-  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-2.10.3.tgz#9d8f42b77317414434cc0e51fb006a4bdd54688e"
-  integrity sha512-pLIhhU9z/G+kjOXmJ2bPFm3nejfbH+f1fjYRSOteEXDBrv1EoJE/e+kuHixSXfCYfTkxjYsvRaDX+1QykLCnpQ==
-  dependencies:
-    chalk "^2.4.1"
-    debug "^3.1.0"
-    diff "^3.5.0"
-    escape-string-regexp "^1.0.2"
-    execa "^4.0.0"
-    globby "^8.0.1"
-    grouped-queue "^1.1.0"
-    inquirer "^7.1.0"
-    is-scoped "^1.0.0"
-    lodash "^4.17.10"
-    log-symbols "^2.2.0"
-    mem-fs "^1.1.0"
-    mem-fs-editor "^6.0.0"
-    npm-api "^1.0.0"
-    semver "^7.1.3"
-    strip-ansi "^4.0.0"
-    text-table "^0.2.0"
-    untildify "^3.0.3"
-    yeoman-generator "^4.8.2"
-
-yeoman-generator@^3.1.1:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/yeoman-generator/-/yeoman-generator-3.2.0.tgz#02077d2d7ff28fedc1ed7dad7f9967fd7c3604cc"
-  integrity sha512-iR/qb2je3GdXtSfxgvOXxUW0Cp8+C6LaZaNlK2BAICzFNzwHtM10t/QBwz5Ea9nk6xVDQNj4Q889TjCXGuIv8w==
-  dependencies:
-    async "^2.6.0"
-    chalk "^2.3.0"
-    cli-table "^0.3.1"
-    cross-spawn "^6.0.5"
-    dargs "^6.0.0"
-    dateformat "^3.0.3"
-    debug "^4.1.0"
-    detect-conflict "^1.0.0"
-    error "^7.0.2"
-    find-up "^3.0.0"
-    github-username "^4.0.0"
-    istextorbinary "^2.2.1"
-    lodash "^4.17.10"
-    make-dir "^1.1.0"
-    mem-fs-editor "^5.0.0"
-    minimist "^1.2.0"
-    pretty-bytes "^5.1.0"
-    read-chunk "^3.0.0"
-    read-pkg-up "^4.0.0"
-    rimraf "^2.6.2"
-    run-async "^2.0.0"
-    shelljs "^0.8.0"
-    text-table "^0.2.0"
-    through2 "^3.0.0"
-    yeoman-environment "^2.0.5"
-
-yeoman-generator@^4.8.2:
-  version "4.13.0"
-  resolved "https://registry.yarnpkg.com/yeoman-generator/-/yeoman-generator-4.13.0.tgz#a6caeed8491fceea1f84f53e31795f25888b4672"
-  integrity sha512-f2/5N5IR3M2Ozm+QocvZQudlQITv2DwI6Mcxfy7R7gTTzaKgvUpgo/pQMJ+WQKm0KN0YMWCFOZpj0xFGxevc1w==
-  dependencies:
-    async "^2.6.2"
-    chalk "^2.4.2"
-    cli-table "^0.3.1"
-    cross-spawn "^6.0.5"
-    dargs "^6.1.0"
-    dateformat "^3.0.3"
-    debug "^4.1.1"
-    diff "^4.0.1"
-    error "^7.0.2"
-    find-up "^3.0.0"
-    github-username "^3.0.0"
-    istextorbinary "^2.5.1"
-    lodash "^4.17.11"
-    make-dir "^3.0.0"
-    mem-fs-editor "^7.0.1"
-    minimist "^1.2.5"
-    pretty-bytes "^5.2.0"
-    read-chunk "^3.2.0"
-    read-pkg-up "^5.0.0"
-    rimraf "^2.6.3"
-    run-async "^2.0.0"
-    semver "^7.2.1"
-    shelljs "^0.8.4"
-    text-table "^0.2.0"
-    through2 "^3.0.1"
-  optionalDependencies:
-    grouped-queue "^1.1.0"
-    yeoman-environment "^2.9.5"
-
-zip-stream@^2.1.2:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-2.1.3.tgz#26cc4bdb93641a8590dd07112e1f77af1758865b"
-  integrity sha512-EkXc2JGcKhO5N5aZ7TmuNo45budRaFGHOmz24wtJR7znbNqDPmdZtUauKX6et8KAVseAMBOyWJqEpXcHTBsh7Q==
-  dependencies:
-    archiver-utils "^2.1.0"
-    compress-commons "^2.1.1"
-    readable-stream "^3.4.0"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index d8f7020..37d8b9c 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -243,36 +243,36 @@
         sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
     )
 
-    LUCENE_VERS = "6.6.5"
+    LUCENE_VERS = "7.7.3"
 
     maven_jar(
         name = "lucene-core",
         artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
-        sha1 = "2983f80b1037e098209657b0ca9176827892d0c0",
+        sha1 = "5faa5ae56f7599019fce6184accc6c968b7519e7",
     )
 
     maven_jar(
         name = "lucene-analyzers-common",
         artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
-        sha1 = "6094f91071d90570b7f5f8ce481d5de7d2d2e9d5",
+        sha1 = "0a76cbf5e21bbbb0c2d6288b042450236248214e",
     )
 
     maven_jar(
         name = "backward-codecs",
         artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
-        sha1 = "460a19e8d1aa7d31e9614cf528a6cb508c9e823d",
+        sha1 = "40207d0dd023a0e2868a23dd87d72f1a3cdbb893",
     )
 
     maven_jar(
         name = "lucene-misc",
         artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
-        sha1 = "ce3a1b7b6a92b9af30791356a4bd46d1cea6cc1e",
+        sha1 = "3aca078edf983059722fe61a81b7b7bd5ecdb222",
     )
 
     maven_jar(
         name = "lucene-queryparser",
         artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
-        sha1 = "2db9ca0086a4b8e0b9bc9f08a9b420303168e37c",
+        sha1 = "685fc6166d29eb3e3441ae066873bb442aa02df1",
     )
 
     # JGit's transitive dependencies
diff --git a/tools/polygerrit-updater/.gitignore b/tools/polygerrit-updater/.gitignore
deleted file mode 100644
index 8619a37..0000000
--- a/tools/polygerrit-updater/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-/.idea/
-/node_modules/
-/js/
\ No newline at end of file
diff --git a/tools/polygerrit-updater/package-lock.json b/tools/polygerrit-updater/package-lock.json
deleted file mode 100644
index 9256997..0000000
--- a/tools/polygerrit-updater/package-lock.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
-  "name": "polygerrit-updater",
-  "version": "1.0.0",
-  "lockfileVersion": 1,
-  "requires": true,
-  "dependencies": {
-    "@types/node": {
-      "version": "12.7.12",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.12.tgz",
-      "integrity": "sha512-KPYGmfD0/b1eXurQ59fXD1GBzhSQfz6/lKBxkaHX9dKTzjXbK68Zt7yGUxUsCS1jeTy/8aL+d9JEr+S54mpkWQ=="
-    },
-    "typescript": {
-      "version": "3.6.4",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz",
-      "integrity": "sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg=="
-    }
-  }
-}
diff --git a/tools/polygerrit-updater/package.json b/tools/polygerrit-updater/package.json
deleted file mode 100644
index 3609dad..0000000
--- a/tools/polygerrit-updater/package.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
-  "name": "polygerrit-updater",
-  "version": "1.0.0",
-  "description": "Polygerrit source code updater",
-  "scripts": {
-    "compile": "tsc",
-    "convert": "npm run compile && node js/src/index.js"
-  },
-  "author": "",
-  "license": "Apache-2.0",
-  "dependencies": {
-    "@types/node": "^12.7.12",
-    "typescript": "^3.6.4"
-  }
-}
diff --git a/tools/polygerrit-updater/readme.txt b/tools/polygerrit-updater/readme.txt
deleted file mode 100644
index 2b2cea8..0000000
--- a/tools/polygerrit-updater/readme.txt
+++ /dev/null
@@ -1,56 +0,0 @@
-This folder contains tool to update Polymer components to class based components.
-This is a temporary tools, it will be removed in a few weeks.
-
-How to use this tool: initial steps
-1) Important - Commit and push all your changes. Otherwise, you can loose you work.
-
-2) Ensure, that tools/polygerrit-updater is your current directory
-
-3) Run
-npm install
-
-4) If you want to convert the whole project, run
-npm run convert -- --i \
-  --root ../../polygerrit-ui --src app/elements --r \
-  --exclude app/elements/core/gr-reporting/gr-reporting.js \
-     app/elements/diff/gr-comment-api/gr-comment-api-mock.js \
-     app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
-
-You can convert only specific files (can be useful if you want to convert some files in your change)
-npm run convert -- --i \
-  --root ../../polygerrit-ui
-  --src app/elements/file1.js \
-      app/elements/folder/file2.js
-
-4) Search for the following string in all .js files:
-//This file has the following problems with comments:
-
-If you find such string in a .js file - you must manually fix comments in this file.
-(It is expected that you shouldn't have such problems)
-
-5) Go to the gerrit root folder and run
-npm run eslintfix
-
-(If you are doing it for the first time, run the following command before in gerrit root folder:
-npm run install)
-
-Fix error after eslintfix (if exists)
-
-6) If you are doing conversion for the whole project, make the followin changes:
-
-a) Add
-<link rel="import" href="../../../types/polymer-behaviors.js">
-to
-polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
-
-b) Update polymer.json with the following rules:
-  "lint": {
-    "rules": ["polymer-2"],
-    "ignoreWarnings": ["deprecated-dom-call"]
-  }
-
-
-
-5) Commit changed files.
-
-6) You can update excluded files later.
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/funcToClassBasedElementConverter.ts b/tools/polygerrit-updater/src/funcToClassConversion/funcToClassBasedElementConverter.ts
deleted file mode 100644
index b92a6e9..0000000
--- a/tools/polygerrit-updater/src/funcToClassConversion/funcToClassBasedElementConverter.ts
+++ /dev/null
@@ -1,131 +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.
-
-import {LegacyLifecycleMethodsArray, LegacyPolymerComponent} from './polymerComponentParser';
-import {LifecycleMethodsBuilder} from './lifecycleMethodsBuilder';
-import {ClassBasedPolymerElement, PolymerElementBuilder} from './polymerElementBuilder';
-import * as codeUtils from '../utils/codeUtils';
-import * as ts from 'typescript';
-
-export class PolymerFuncToClassBasedConverter {
-  public static convert(component: LegacyPolymerComponent): ClassBasedPolymerElement {
-    const legacySettings = component.componentSettings;
-    const reservedDeclarations = legacySettings.reservedDeclarations;
-
-    if(!reservedDeclarations.is) {
-      throw new Error("Legacy component doesn't have 'is' property");
-    }
-    const className = this.generateClassNameFromTagName(reservedDeclarations.is.data);
-    const updater = new PolymerElementBuilder(component, className);
-    updater.addIsAccessor(reservedDeclarations.is.data);
-
-    if(reservedDeclarations.properties) {
-      updater.addPolymerPropertiesAccessor(reservedDeclarations.properties);
-    }
-
-    updater.addMixin("Polymer.Element");
-    updater.addMixin("Polymer.LegacyElementMixin");
-    updater.addMixin("Polymer.GestureEventListeners");
-
-    if(reservedDeclarations._legacyUndefinedCheck) {
-      updater.addMixin("Polymer.LegacyDataMixin");
-    }
-
-    if(reservedDeclarations.behaviors) {
-      updater.addMixin("Polymer.mixinBehaviors", [reservedDeclarations.behaviors.data]);
-      const mixinNames = this.getMixinNamesFromBehaviors(reservedDeclarations.behaviors.data);
-      const jsDocLines = mixinNames.map(mixinName => {
-        return `@appliesMixin ${mixinName}`;
-      });
-      updater.addClassJSDocComments(jsDocLines);
-    }
-
-    if(reservedDeclarations.observers) {
-      updater.addPolymerPropertiesObservers(reservedDeclarations.observers.data);
-    }
-
-    if(reservedDeclarations.keyBindings) {
-      updater.addKeyBindings(reservedDeclarations.keyBindings.data);
-    }
-
-
-    const lifecycleBuilder = new LifecycleMethodsBuilder();
-    if (reservedDeclarations.listeners) {
-      lifecycleBuilder.addListeners(reservedDeclarations.listeners.data, legacySettings.ordinaryMethods);
-    }
-
-    if (reservedDeclarations.hostAttributes) {
-      lifecycleBuilder.addHostAttributes(reservedDeclarations.hostAttributes.data);
-    }
-
-    for(const name of LegacyLifecycleMethodsArray) {
-      const existingMethod = legacySettings.lifecycleMethods.get(name);
-      if(existingMethod) {
-        lifecycleBuilder.addLegacyLifecycleMethod(name, existingMethod)
-      }
-    }
-
-    const newLifecycleMethods = lifecycleBuilder.buildNewMethods();
-    updater.addLifecycleMethods(newLifecycleMethods);
-
-
-    updater.addOrdinaryMethods(legacySettings.ordinaryMethods);
-    updater.addOrdinaryGetAccessors(legacySettings.ordinaryGetAccessors);
-    updater.addOrdinaryShorthandProperties(legacySettings.ordinaryShorthandProperties);
-    updater.addOrdinaryPropertyAssignments(legacySettings.ordinaryPropertyAssignments);
-
-    return updater.build();
-  }
-
-  private static generateClassNameFromTagName(tagName: string) {
-    let result = "";
-    let nextUppercase = true;
-    for(const ch of tagName) {
-      if (ch === '-') {
-        nextUppercase = true;
-        continue;
-      }
-      result += nextUppercase ? ch.toUpperCase() : ch;
-      nextUppercase = false;
-    }
-    return result;
-  }
-
-  private static getMixinNamesFromBehaviors(behaviors: ts.ArrayLiteralExpression): string[] {
-    return behaviors.elements.map((expression) => {
-      const propertyAccessExpression = codeUtils.assertNodeKind(expression, ts.SyntaxKind.PropertyAccessExpression) as ts.PropertyAccessExpression;
-      const namespaceName = codeUtils.assertNodeKind(propertyAccessExpression.expression, ts.SyntaxKind.Identifier) as ts.Identifier;
-      const behaviorName = propertyAccessExpression.name;
-      if(namespaceName.text === 'Gerrit') {
-        let behaviorNameText = behaviorName.text;
-        const suffix = 'Behavior';
-        if(behaviorNameText.endsWith(suffix)) {
-          behaviorNameText =
-              behaviorNameText.substr(0, behaviorNameText.length - suffix.length);
-        }
-        const mixinName = behaviorNameText + 'Mixin';
-        return `${namespaceName.text}.${mixinName}`
-      } else if(namespaceName.text === 'Polymer') {
-        let behaviorNameText = behaviorName.text;
-        if(behaviorNameText === "IronFitBehavior") {
-          return "Polymer.IronFitMixin";
-        } else if(behaviorNameText === "IronOverlayBehavior") {
-          return "";
-        }
-        throw new Error(`Unsupported behavior: ${propertyAccessExpression.getText()}`);
-      }
-      throw new Error(`Unsupported behavior name ${expression.getFullText()}`)
-    }).filter(name => name.length > 0);
-  }
-}
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/legacyPolymerFuncReplacer.ts b/tools/polygerrit-updater/src/funcToClassConversion/legacyPolymerFuncReplacer.ts
deleted file mode 100644
index 57b7b8d..0000000
--- a/tools/polygerrit-updater/src/funcToClassConversion/legacyPolymerFuncReplacer.ts
+++ /dev/null
@@ -1,74 +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.
-
-import * as ts from 'typescript';
-import * as codeUtils from '../utils/codeUtils'
-import {LegacyPolymerComponent} from './polymerComponentParser';
-import {ClassBasedPolymerElement} from './polymerElementBuilder';
-
-export class LegacyPolymerFuncReplaceResult {
-  public constructor(
-      private readonly transformationResult: ts.TransformationResult<ts.SourceFile>,
-      public readonly leadingComments: string[]) {
-  }
-  public get file(): ts.SourceFile {
-    return this.transformationResult.transformed[0];
-  }
-  public dispose() {
-    this.transformationResult.dispose();
-  }
-
-}
-
-export class LegacyPolymerFuncReplacer {
-  private readonly callStatement: ts.ExpressionStatement;
-  private readonly parentBlock: ts.Block;
-  private readonly callStatementIndexInBlock: number;
-  public constructor(private readonly legacyComponent: LegacyPolymerComponent) {
-    this.callStatement = codeUtils.assertNodeKind(legacyComponent.polymerFuncCallExpr.parent, ts.SyntaxKind.ExpressionStatement);
-    this.parentBlock = codeUtils.assertNodeKind(this.callStatement.parent, ts.SyntaxKind.Block);
-    this.callStatementIndexInBlock = this.parentBlock.statements.indexOf(this.callStatement);
-    if(this.callStatementIndexInBlock < 0) {
-      throw new Error("Internal error! Couldn't find statement in its own parent");
-    }
-  }
-  public replace(classBasedElement: ClassBasedPolymerElement): LegacyPolymerFuncReplaceResult {
-    const classDeclarationWithComments = this.appendLeadingCommentToClassDeclaration(classBasedElement.classDeclaration);
-    return new LegacyPolymerFuncReplaceResult(
-        this.replaceLegacyPolymerFunction(classDeclarationWithComments.classDeclarationWithCommentsPlaceholder, classBasedElement.componentRegistration),
-        classDeclarationWithComments.leadingComments);
-  }
-  private appendLeadingCommentToClassDeclaration(classDeclaration: ts.ClassDeclaration): {classDeclarationWithCommentsPlaceholder: ts.ClassDeclaration, leadingComments: string[]} {
-    const text = this.callStatement.getFullText();
-    let classDeclarationWithCommentsPlaceholder = classDeclaration;
-    const leadingComments: string[] = [];
-    ts.forEachLeadingCommentRange(text, 0, (pos, end, kind, hasTrailingNewLine) => {
-      classDeclarationWithCommentsPlaceholder = codeUtils.addReplacableCommentBeforeNode(classDeclarationWithCommentsPlaceholder, String(leadingComments.length));
-      leadingComments.push(text.substring(pos, end));
-    });
-    return {
-      classDeclarationWithCommentsPlaceholder: classDeclarationWithCommentsPlaceholder,
-      leadingComments: leadingComments
-    }
-  }
-  private replaceLegacyPolymerFunction(classDeclaration: ts.ClassDeclaration, componentRegistration: ts.ExpressionStatement): ts.TransformationResult<ts.SourceFile> {
-    const newStatements = Array.from(this.parentBlock.statements);
-    newStatements.splice(this.callStatementIndexInBlock, 1, classDeclaration, componentRegistration);
-
-    const updatedBlock = ts.getMutableClone(this.parentBlock);
-    updatedBlock.statements = ts.createNodeArray(newStatements);
-    return codeUtils.replaceNode(this.legacyComponent.parsedFile, this.parentBlock, updatedBlock);
-
-  }
-}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/lifecycleMethodsBuilder.ts b/tools/polygerrit-updater/src/funcToClassConversion/lifecycleMethodsBuilder.ts
deleted file mode 100644
index e9e13f5..0000000
--- a/tools/polygerrit-updater/src/funcToClassConversion/lifecycleMethodsBuilder.ts
+++ /dev/null
@@ -1,140 +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.
-
-import * as ts from 'typescript';
-import * as codeUtils from '../utils/codeUtils';
-import {LegacyLifecycleMethodName, OrdinaryMethods} from './polymerComponentParser';
-
-interface LegacyLifecycleMethodContent {
-  codeAtMethodStart: ts.Statement[];
-  existingMethod?: ts.MethodDeclaration;
-  codeAtMethodEnd: ts.Statement[];
-}
-
-export interface LifecycleMethod {
-  originalPos: number;//-1 - no original method exists
-  method: ts.MethodDeclaration;
-  name: LegacyLifecycleMethodName;
-}
-
-export class LifecycleMethodsBuilder {
-  private readonly methods: Map<LegacyLifecycleMethodName, LegacyLifecycleMethodContent> = new Map();
-
-  private getMethodContent(name: LegacyLifecycleMethodName): LegacyLifecycleMethodContent {
-    if(!this.methods.has(name)) {
-      this.methods.set(name, {
-        codeAtMethodStart: [],
-        codeAtMethodEnd: []
-      });
-    }
-    return this.methods.get(name)!;
-  }
-
-  public addListeners(legacyListeners: ts.ObjectLiteralExpression, legacyOrdinaryMethods: OrdinaryMethods) {
-    for(const listener of legacyListeners.properties) {
-      const propertyAssignment = codeUtils.assertNodeKind(listener, ts.SyntaxKind.PropertyAssignment) as ts.PropertyAssignment;
-      if(!propertyAssignment.name) {
-        throw new Error("Listener must have event name");
-      }
-      let eventNameLiteral: ts.StringLiteral;
-      let commentsToRestore: string[] = [];
-      if(propertyAssignment.name.kind === ts.SyntaxKind.StringLiteral) {
-        //We don't loose comment in this case, because we keep literal as is
-        eventNameLiteral = propertyAssignment.name;
-      } else if(propertyAssignment.name.kind === ts.SyntaxKind.Identifier) {
-        eventNameLiteral = ts.createStringLiteral(propertyAssignment.name.text);
-        commentsToRestore = codeUtils.getLeadingComments(propertyAssignment);
-      } else {
-        throw new Error(`Unsupported type ${ts.SyntaxKind[propertyAssignment.name.kind]}`);
-      }
-
-      const handlerLiteral = codeUtils.assertNodeKind(propertyAssignment.initializer, ts.SyntaxKind.StringLiteral) as ts.StringLiteral;
-      const handlerImpl = legacyOrdinaryMethods.get(handlerLiteral.text);
-      if(!handlerImpl) {
-        throw new Error(`Can't find event handler '${handlerLiteral.text}'`);
-      }
-      const eventHandlerAccess = ts.createPropertyAccess(ts.createThis(), handlerLiteral.text);
-      //ts.forEachChild(handler)
-      const args: ts.Identifier[] = handlerImpl.parameters.map((arg) => codeUtils.assertNodeKind(arg.name, ts.SyntaxKind.Identifier));
-      const eventHandlerCall = ts.createCall(eventHandlerAccess, [], args);
-      let arrowFunc = ts.createArrowFunction([], [], handlerImpl.parameters, undefined, undefined, eventHandlerCall);
-      arrowFunc = codeUtils.addNewLineBeforeNode(arrowFunc);
-
-      const methodContent = this.getMethodContent("created");
-      //See https://polymer-library.polymer-project.org/3.0/docs/devguide/gesture-events for a list of events
-      if(["down", "up", "tap", "track"].indexOf(eventNameLiteral.text) >= 0) {
-        const methodCall = ts.createCall(codeUtils.createNameExpression("Polymer.Gestures.addListener"), [], [ts.createThis(), eventNameLiteral, arrowFunc]);
-        methodContent.codeAtMethodEnd.push(ts.createExpressionStatement(methodCall));
-      }
-      else {
-        let methodCall = ts.createCall(ts.createPropertyAccess(ts.createThis(), "addEventListener"), [], [eventNameLiteral, arrowFunc]);
-        methodCall = codeUtils.restoreLeadingComments(methodCall, commentsToRestore);
-        methodContent.codeAtMethodEnd.push(ts.createExpressionStatement(methodCall));
-      }
-    }
-  }
-
-  public addHostAttributes(legacyHostAttributes: ts.ObjectLiteralExpression) {
-    for(const listener of legacyHostAttributes.properties) {
-      const propertyAssignment = codeUtils.assertNodeKind(listener, ts.SyntaxKind.PropertyAssignment) as ts.PropertyAssignment;
-      if(!propertyAssignment.name) {
-        throw new Error("Listener must have event name");
-      }
-      let attributeNameLiteral: ts.StringLiteral;
-      if(propertyAssignment.name.kind === ts.SyntaxKind.StringLiteral) {
-        attributeNameLiteral = propertyAssignment.name;
-      } else if(propertyAssignment.name.kind === ts.SyntaxKind.Identifier) {
-        attributeNameLiteral = ts.createStringLiteral(propertyAssignment.name.text);
-      } else {
-        throw new Error(`Unsupported type ${ts.SyntaxKind[propertyAssignment.name.kind]}`);
-      }
-      let attributeValueLiteral: ts.StringLiteral | ts.NumericLiteral;
-      if(propertyAssignment.initializer.kind === ts.SyntaxKind.StringLiteral) {
-        attributeValueLiteral = propertyAssignment.initializer as ts.StringLiteral;
-      } else if(propertyAssignment.initializer.kind === ts.SyntaxKind.NumericLiteral) {
-        attributeValueLiteral = propertyAssignment.initializer as ts.NumericLiteral;
-      } else {
-        throw new Error(`Unsupported type ${ts.SyntaxKind[propertyAssignment.initializer.kind]}`);
-      }
-      const methodCall = ts.createCall(ts.createPropertyAccess(ts.createThis(), "_ensureAttribute"), [], [attributeNameLiteral, attributeValueLiteral]);
-      this.getMethodContent("ready").codeAtMethodEnd.push(ts.createExpressionStatement(methodCall));
-    }
-  }
-
-  public addLegacyLifecycleMethod(name: LegacyLifecycleMethodName, method: ts.MethodDeclaration) {
-    const content = this.getMethodContent(name);
-    if(content.existingMethod) {
-      throw new Error(`Legacy lifecycle method ${name} already added`);
-    }
-    content.existingMethod = method;
-  }
-
-  public buildNewMethods(): LifecycleMethod[] {
-    const result = [];
-    for(const [name, content] of this.methods) {
-      const newMethod = this.createLifecycleMethod(name, content.existingMethod, content.codeAtMethodStart, content.codeAtMethodEnd);
-      if(!newMethod) continue;
-      result.push({
-        name,
-        originalPos: content.existingMethod ? content.existingMethod.pos : -1,
-        method: newMethod
-      })
-    }
-    return result;
-  }
-
-  private createLifecycleMethod(name: string, methodDecl: ts.MethodDeclaration | undefined, codeAtStart: ts.Statement[], codeAtEnd: ts.Statement[]): ts.MethodDeclaration | undefined {
-    return codeUtils.createMethod(name, methodDecl, codeAtStart, codeAtEnd, true);
-  }
-}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/polymerComponentParser.ts b/tools/polygerrit-updater/src/funcToClassConversion/polymerComponentParser.ts
deleted file mode 100644
index 6006608..0000000
--- a/tools/polygerrit-updater/src/funcToClassConversion/polymerComponentParser.ts
+++ /dev/null
@@ -1,301 +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.
-
-import * as ts from "typescript";
-import * as fs from "fs";
-import * as path from "path";
-import { unexpectedValue } from "../utils/unexpectedValue";
-import * as codeUtils from "../utils/codeUtils";
-import {CommentsParser} from '../utils/commentsParser';
-
-export class LegacyPolymerComponentParser {
-  public constructor(private readonly rootDir: string, private readonly htmlFiles: Set<string>) {
-  }
-  public async parse(jsFile: string): Promise<ParsedPolymerComponent | null> {
-    const sourceFile: ts.SourceFile  = this.parseJsFile(jsFile);
-    const legacyComponent = this.tryParseLegacyComponent(sourceFile);
-    if (legacyComponent) {
-      return legacyComponent;
-    }
-    return null;
-  }
-  private parseJsFile(jsFile: string): ts.SourceFile {
-    return ts.createSourceFile(jsFile, fs.readFileSync(path.resolve(this.rootDir, jsFile)).toString(), ts.ScriptTarget.ES2015, true);
-  }
-
-  private tryParseLegacyComponent(sourceFile: ts.SourceFile): ParsedPolymerComponent | null {
-    const polymerFuncCalls: ts.CallExpression[] = [];
-
-    function addPolymerFuncCall(node: ts.Node) {
-      if(node.kind === ts.SyntaxKind.CallExpression) {
-        const callExpression: ts.CallExpression = node as ts.CallExpression;
-        if(callExpression.expression.kind === ts.SyntaxKind.Identifier) {
-          const identifier = callExpression.expression as ts.Identifier;
-          if(identifier.text === "Polymer") {
-            polymerFuncCalls.push(callExpression);
-          }
-        }
-      }
-      ts.forEachChild(node, addPolymerFuncCall);
-    }
-
-    addPolymerFuncCall(sourceFile);
-
-
-    if (polymerFuncCalls.length === 0) {
-      return null;
-    }
-    if (polymerFuncCalls.length > 1) {
-      throw new Error("Each .js file must contain only one Polymer component");
-    }
-    const parsedPath = path.parse(sourceFile.fileName);
-    const htmlFullPath = path.format({
-      dir: parsedPath.dir,
-      name: parsedPath.name,
-      ext: ".html"
-    });
-    if (!this.htmlFiles.has(htmlFullPath)) {
-      throw new Error("Legacy .js component dosn't have associated .html file");
-    }
-
-    const polymerFuncCall = polymerFuncCalls[0];
-    if(polymerFuncCall.arguments.length !== 1) {
-      throw new Error("The Polymer function must be called with exactly one parameter");
-    }
-    const argument = polymerFuncCall.arguments[0];
-    if(argument.kind !== ts.SyntaxKind.ObjectLiteralExpression) {
-      throw new Error("The parameter for Polymer function must be ObjectLiteralExpression (i.e. '{...}')");
-    }
-    const infoArg = argument as ts.ObjectLiteralExpression;
-
-    return {
-      jsFile: sourceFile.fileName,
-      htmlFile: htmlFullPath,
-      parsedFile: sourceFile,
-      polymerFuncCallExpr: polymerFuncCalls[0],
-      componentSettings: this.parseLegacyComponentSettings(infoArg),
-    };
-  }
-
-  private parseLegacyComponentSettings(info: ts.ObjectLiteralExpression): LegacyPolymerComponentSettings {
-    const props: Map<string, ts.ObjectLiteralElementLike> = new Map();
-    for(const property of info.properties) {
-      const name = property.name;
-      if (name === undefined) {
-        throw new Error("Property name is not defined");
-      }
-      switch(name.kind) {
-        case ts.SyntaxKind.Identifier:
-        case ts.SyntaxKind.StringLiteral:
-          if (props.has(name.text)) {
-            throw new Error(`Property ${name.text} appears more than once`);
-          }
-          props.set(name.text, property);
-          break;
-        case ts.SyntaxKind.ComputedPropertyName:
-          continue;
-        default:
-          unexpectedValue(ts.SyntaxKind[name.kind]);
-      }
-    }
-
-    if(props.has("_noAccessors")) {
-      throw new Error("_noAccessors is not supported");
-    }
-
-    const legacyLifecycleMethods: LegacyLifecycleMethods = new Map();
-    for(const name of LegacyLifecycleMethodsArray) {
-      const methodDecl = this.getLegacyMethodDeclaration(props, name);
-      if(methodDecl) {
-        legacyLifecycleMethods.set(name, methodDecl);
-      }
-    }
-
-    const ordinaryMethods: OrdinaryMethods = new Map();
-    const ordinaryShorthandProperties: OrdinaryShorthandProperties = new Map();
-    const ordinaryGetAccessors: OrdinaryGetAccessors = new Map();
-    const ordinaryPropertyAssignments: OrdinaryPropertyAssignments = new Map();
-    for(const [name, val] of props) {
-      if(RESERVED_NAMES.hasOwnProperty(name)) continue;
-      switch(val.kind) {
-        case ts.SyntaxKind.MethodDeclaration:
-          ordinaryMethods.set(name, val as ts.MethodDeclaration);
-          break;
-        case ts.SyntaxKind.ShorthandPropertyAssignment:
-          ordinaryShorthandProperties.set(name, val as ts.ShorthandPropertyAssignment);
-          break;
-        case ts.SyntaxKind.GetAccessor:
-          ordinaryGetAccessors.set(name, val as ts.GetAccessorDeclaration);
-          break;
-        case ts.SyntaxKind.PropertyAssignment:
-          ordinaryPropertyAssignments.set(name, val as ts.PropertyAssignment);
-          break;
-        default:
-          throw new Error(`Unsupported element kind: ${ts.SyntaxKind[val.kind]}`);
-      }
-      //ordinaryMethods.set(name, tsUtils.assertNodeKind(val, ts.SyntaxKind.MethodDeclaration) as ts.MethodDeclaration);
-    }
-
-    const eventsComments: string[] = this.getEventsComments(info.getFullText());
-
-    return {
-      reservedDeclarations: {
-        is: this.getStringLiteralValueWithComments(this.getLegacyPropertyInitializer(props, "is")),
-        _legacyUndefinedCheck: this.getBooleanLiteralValueWithComments(this.getLegacyPropertyInitializer(props, "_legacyUndefinedCheck")),
-        properties: this.getObjectLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "properties")),
-        behaviors: this.getArrayLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "behaviors")),
-        observers: this.getArrayLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "observers")),
-        listeners: this.getObjectLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "listeners")),
-        hostAttributes: this.getObjectLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "hostAttributes")),
-        keyBindings: this.getObjectLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "keyBindings")),
-      },
-      eventsComments: eventsComments,
-      lifecycleMethods: legacyLifecycleMethods,
-      ordinaryMethods: ordinaryMethods,
-      ordinaryShorthandProperties: ordinaryShorthandProperties,
-      ordinaryGetAccessors: ordinaryGetAccessors,
-      ordinaryPropertyAssignments: ordinaryPropertyAssignments,
-    };
-  }
-
-  private convertLegacyProeprtyInitializer<T>(initializer: LegacyPropertyInitializer | undefined, converter: (exp: ts.Expression) => T): DataWithComments<T> | undefined {
-    if(!initializer) {
-      return undefined;
-    }
-    return {
-      data: converter(initializer.data),
-      leadingComments: initializer.leadingComments,
-    }
-  }
-
-  private getObjectLiteralExpressionWithComments(initializer: LegacyPropertyInitializer | undefined): DataWithComments<ts.ObjectLiteralExpression> | undefined {
-    return this.convertLegacyProeprtyInitializer(initializer,
-        expr => codeUtils.getObjectLiteralExpression(expr));
-  }
-
-  private getStringLiteralValueWithComments(initializer: LegacyPropertyInitializer | undefined): DataWithComments<string> | undefined {
-    return this.convertLegacyProeprtyInitializer(initializer,
-        expr => codeUtils.getStringLiteralValue(expr));
-  }
-
-  private getBooleanLiteralValueWithComments(initializer: LegacyPropertyInitializer | undefined): DataWithComments<boolean> | undefined {
-    return this.convertLegacyProeprtyInitializer(initializer,
-        expr => codeUtils.getBooleanLiteralValue(expr));
-  }
-
-
-  private getArrayLiteralExpressionWithComments(initializer: LegacyPropertyInitializer | undefined): DataWithComments<ts.ArrayLiteralExpression> | undefined {
-    return this.convertLegacyProeprtyInitializer(initializer,
-        expr => codeUtils.getArrayLiteralExpression(expr));
-  }
-
-  private getLegacyPropertyInitializer(props: Map<String, ts.ObjectLiteralElementLike>, propName: string): LegacyPropertyInitializer | undefined {
-    const property = props.get(propName);
-    if (!property) {
-      return undefined;
-    }
-    const assignment = codeUtils.getPropertyAssignment(property);
-    if (!assignment) {
-      return undefined;
-    }
-    const comments: string[] = codeUtils.getLeadingComments(property)
-          .filter(c => !this.isEventComment(c));
-    return {
-      data: assignment.initializer,
-      leadingComments: comments,
-    };
-  }
-
-  private isEventComment(comment: string): boolean {
-    return comment.indexOf('@event') >= 0;
-  }
-
-  private getEventsComments(polymerComponentSource: string): string[] {
-    return CommentsParser.collectAllComments(polymerComponentSource)
-        .filter(c => this.isEventComment(c));
-  }
-
-  private getLegacyMethodDeclaration(props: Map<String, ts.ObjectLiteralElementLike>, propName: string): ts.MethodDeclaration | undefined {
-    const property = props.get(propName);
-    if (!property) {
-      return undefined;
-    }
-    return codeUtils.assertNodeKind(property, ts.SyntaxKind.MethodDeclaration) as ts.MethodDeclaration;
-  }
-
-}
-
-export type ParsedPolymerComponent = LegacyPolymerComponent;
-
-export interface LegacyPolymerComponent {
-  jsFile: string;
-  htmlFile: string;
-  parsedFile: ts.SourceFile;
-  polymerFuncCallExpr: ts.CallExpression;
-  componentSettings: LegacyPolymerComponentSettings;
-}
-
-export interface LegacyReservedDeclarations {
-  is?: DataWithComments<string>;
-  _legacyUndefinedCheck?: DataWithComments<boolean>;
-  properties?: DataWithComments<ts.ObjectLiteralExpression>;
-  behaviors?: DataWithComments<ts.ArrayLiteralExpression>,
-  observers? :DataWithComments<ts.ArrayLiteralExpression>,
-  listeners? :DataWithComments<ts.ObjectLiteralExpression>,
-  hostAttributes?: DataWithComments<ts.ObjectLiteralExpression>,
-  keyBindings?: DataWithComments<ts.ObjectLiteralExpression>,
-}
-
-export const LegacyLifecycleMethodsArray = <const>["beforeRegister", "registered", "created", "ready", "attached" , "detached", "attributeChanged"];
-export type LegacyLifecycleMethodName = typeof LegacyLifecycleMethodsArray[number];
-export type LegacyLifecycleMethods = Map<LegacyLifecycleMethodName, ts.MethodDeclaration>;
-export type OrdinaryMethods = Map<string, ts.MethodDeclaration>;
-export type OrdinaryShorthandProperties = Map<string, ts.ShorthandPropertyAssignment>;
-export type OrdinaryGetAccessors = Map<string, ts.GetAccessorDeclaration>;
-export type OrdinaryPropertyAssignments = Map<string, ts.PropertyAssignment>;
-export type ReservedName = LegacyLifecycleMethodName | keyof LegacyReservedDeclarations;
-export const RESERVED_NAMES: {[x in ReservedName]: boolean} = {
-  attached: true,
-  detached: true,
-  ready: true,
-  created: true,
-  beforeRegister: true,
-  registered: true,
-  attributeChanged: true,
-  is: true,
-  _legacyUndefinedCheck: true,
-  properties: true,
-  behaviors: true,
-  observers: true,
-  listeners: true,
-  hostAttributes: true,
-  keyBindings: true,
-};
-
-export interface LegacyPolymerComponentSettings {
-  reservedDeclarations: LegacyReservedDeclarations;
-  lifecycleMethods: LegacyLifecycleMethods,
-  ordinaryMethods: OrdinaryMethods,
-  ordinaryShorthandProperties: OrdinaryShorthandProperties,
-  ordinaryGetAccessors: OrdinaryGetAccessors,
-  ordinaryPropertyAssignments: OrdinaryPropertyAssignments,
-  eventsComments: string[];
-}
-
-export interface DataWithComments<T> {
-  data: T;
-  leadingComments: string[];
-}
-
-type LegacyPropertyInitializer = DataWithComments<ts.Expression>;
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/polymerElementBuilder.ts b/tools/polygerrit-updater/src/funcToClassConversion/polymerElementBuilder.ts
deleted file mode 100644
index d6e113c..0000000
--- a/tools/polygerrit-updater/src/funcToClassConversion/polymerElementBuilder.ts
+++ /dev/null
@@ -1,142 +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.
-
-import {DataWithComments, LegacyPolymerComponent, LegacyReservedDeclarations, OrdinaryGetAccessors, OrdinaryMethods, OrdinaryPropertyAssignments, OrdinaryShorthandProperties} from './polymerComponentParser';
-import * as ts from 'typescript';
-import * as codeUtils from '../utils/codeUtils';
-import {LifecycleMethod} from './lifecycleMethodsBuilder';
-import {PolymerClassBuilder} from '../utils/polymerClassBuilder';
-import {SyntaxKind} from 'typescript';
-
-export interface ClassBasedPolymerElement {
-  classDeclaration: ts.ClassDeclaration;
-  componentRegistration: ts.ExpressionStatement;
-  eventsComments: string[];
-  generatedComments: string[];
-}
-
-export class PolymerElementBuilder {
-  private readonly reservedDeclarations: LegacyReservedDeclarations;
-  private readonly classBuilder: PolymerClassBuilder;
-  private mixins: ts.ExpressionWithTypeArguments | null;
-
-  public constructor(private readonly legacyComponent: LegacyPolymerComponent, className: string) {
-    this.reservedDeclarations = legacyComponent.componentSettings.reservedDeclarations;
-    this.classBuilder = new PolymerClassBuilder(className);
-    this.mixins = null;
-  }
-
-  public addIsAccessor(tagName: string) {
-    this.classBuilder.addIsAccessor(this.createIsAccessor(tagName));
-  }
-
-  public addPolymerPropertiesAccessor(legacyProperties: DataWithComments<ts.ObjectLiteralExpression>) {
-    const returnStatement = ts.createReturn(legacyProperties.data);
-    const block = ts.createBlock([returnStatement]);
-    let propertiesAccessor = ts.createGetAccessor(undefined, [ts.createModifier(ts.SyntaxKind.StaticKeyword)], "properties", [], undefined, block);
-    if(legacyProperties.leadingComments.length > 0) {
-      propertiesAccessor = codeUtils.restoreLeadingComments(propertiesAccessor, legacyProperties.leadingComments);
-    }
-    this.classBuilder.addPolymerPropertiesAccessor(legacyProperties.data.pos, propertiesAccessor);
-  }
-
-  public addPolymerPropertiesObservers(legacyObservers: ts.ArrayLiteralExpression) {
-    const returnStatement = ts.createReturn(legacyObservers);
-    const block = ts.createBlock([returnStatement]);
-    const propertiesAccessor = ts.createGetAccessor(undefined, [ts.createModifier(ts.SyntaxKind.StaticKeyword)], "observers", [], undefined, block);
-
-    this.classBuilder.addPolymerObserversAccessor(legacyObservers.pos, propertiesAccessor);
-  }
-
-  public addKeyBindings(keyBindings: ts.ObjectLiteralExpression) {
-    //In Polymer 2 keyBindings must be a property with get accessor
-    const returnStatement = ts.createReturn(keyBindings);
-    const block = ts.createBlock([returnStatement]);
-    const keyBindingsAccessor = ts.createGetAccessor(undefined, [], "keyBindings", [], undefined, block);
-
-    this.classBuilder.addGetAccessor(keyBindings.pos, keyBindingsAccessor);
-  }
-  public addOrdinaryMethods(ordinaryMethods: OrdinaryMethods) {
-    for(const [name, method] of ordinaryMethods) {
-      this.classBuilder.addMethod(method.pos, method);
-    }
-  }
-
-  public addOrdinaryGetAccessors(ordinaryGetAccessors: OrdinaryGetAccessors) {
-    for(const [name, accessor] of ordinaryGetAccessors) {
-      this.classBuilder.addGetAccessor(accessor.pos, accessor);
-    }
-  }
-
-  public addOrdinaryShorthandProperties(ordinaryShorthandProperties: OrdinaryShorthandProperties) {
-    for (const [name, property] of ordinaryShorthandProperties) {
-      this.classBuilder.addClassFieldInitializer(property.name, property.name);
-    }
-  }
-
-  public addOrdinaryPropertyAssignments(ordinaryPropertyAssignments: OrdinaryPropertyAssignments) {
-    for (const [name, property] of ordinaryPropertyAssignments) {
-      const propertyName = codeUtils.assertNodeKind(property.name, ts.SyntaxKind.Identifier) as ts.Identifier;
-      this.classBuilder.addClassFieldInitializer(propertyName, property.initializer);
-    }
-  }
-
-  public addMixin(name: string, mixinArguments?: ts.Expression[]) {
-    let fullMixinArguments: ts.Expression[] = [];
-    if(mixinArguments) {
-      fullMixinArguments.push(...mixinArguments);
-    }
-    if(this.mixins) {
-      fullMixinArguments.push(this.mixins.expression);
-    }
-    if(fullMixinArguments.length > 0) {
-      this.mixins = ts.createExpressionWithTypeArguments([], ts.createCall(codeUtils.createNameExpression(name), [], fullMixinArguments.length > 0 ? fullMixinArguments : undefined));
-    }
-    else {
-      this.mixins = ts.createExpressionWithTypeArguments([], codeUtils.createNameExpression(name));
-    }
-  }
-
-  public addClassJSDocComments(lines: string[]) {
-    this.classBuilder.addClassJSDocComments(lines);
-  }
-
-  public build(): ClassBasedPolymerElement {
-    if(this.mixins) {
-      this.classBuilder.setBaseType(this.mixins);
-    }
-    const className = this.classBuilder.className;
-    const callExpression = ts.createCall(ts.createPropertyAccess(ts.createIdentifier("customElements"), "define"), undefined, [ts.createPropertyAccess(ts.createIdentifier(className), "is"), ts.createIdentifier(className)]);
-    const classBuilderResult = this.classBuilder.build();
-    return {
-      classDeclaration: classBuilderResult.classDeclaration,
-      generatedComments: classBuilderResult.generatedComments,
-      componentRegistration: ts.createExpressionStatement(callExpression),
-      eventsComments: this.legacyComponent.componentSettings.eventsComments,
-    };
-  }
-
-  private createIsAccessor(tagName: string): ts.GetAccessorDeclaration {
-    const returnStatement = ts.createReturn(ts.createStringLiteral(tagName));
-    const block = ts.createBlock([returnStatement]);
-    const accessor = ts.createGetAccessor([], [ts.createModifier(ts.SyntaxKind.StaticKeyword)], "is", [], undefined, block);
-    return codeUtils.addReplacableCommentAfterNode(accessor, "eventsComments");
-  }
-
-  public addLifecycleMethods(newLifecycleMethods: LifecycleMethod[]) {
-    for(const lifecycleMethod of newLifecycleMethods) {
-      this.classBuilder.addLifecycleMethod(lifecycleMethod.name, lifecycleMethod.originalPos, lifecycleMethod.method);
-    }
-  }
-}
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/updatedFileWriter.ts b/tools/polygerrit-updater/src/funcToClassConversion/updatedFileWriter.ts
deleted file mode 100644
index a147f50..0000000
--- a/tools/polygerrit-updater/src/funcToClassConversion/updatedFileWriter.ts
+++ /dev/null
@@ -1,248 +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.
-
-import {LegacyPolymerComponent} from './polymerComponentParser';
-import * as ts from 'typescript';
-import * as codeUtils from '../utils/codeUtils';
-import * as path from "path";
-import * as fs from "fs";
-import {LegacyPolymerFuncReplaceResult} from './legacyPolymerFuncReplacer';
-import {CommentsParser} from '../utils/commentsParser';
-
-export interface UpdatedFileWriterParameters {
-  out: string;
-  inplace: boolean;
-  writeOutput: boolean;
-  rootDir: string;
-}
-
-interface Replacement {
-  start: number;
-  length: number;
-  newText: string;
-}
-
-const elementRegistrationRegex = /^(\s*)customElements.define\((\w+).is, \w+\);$/m;
-const maxLineLength = 80;
-
-export class UpdatedFileWriter {
-  public constructor(private readonly component: LegacyPolymerComponent, private readonly params: UpdatedFileWriterParameters) {
-  }
-
-  public write(replaceResult: LegacyPolymerFuncReplaceResult, eventsComments: string[], generatedComments: string[]) {
-    const options: ts.PrinterOptions = {
-      removeComments: false,
-      newLine: ts.NewLineKind.LineFeed,
-    };
-    const printer = ts.createPrinter(options);
-    let newContent = codeUtils.applyNewLines(printer.printFile(replaceResult.file));
-    //ts printer doesn't keep original formatting of the file (spacing, new lines, comments, etc...).
-    //The following code tries restore original formatting
-
-    const existingComments = this.collectAllComments(newContent, []);
-
-    newContent = this.restoreEventsComments(newContent, eventsComments, existingComments);
-    newContent = this.restoreLeadingComments(newContent, replaceResult.leadingComments);
-    newContent = this.restoreFormating(printer, newContent);
-    newContent = this.splitLongLines(newContent);
-    newContent = this.addCommentsWarnings(newContent, generatedComments);
-
-    if (this.params.writeOutput) {
-      const outDir = this.params.inplace ? this.params.rootDir : this.params.out;
-      const fullOutPath = path.resolve(outDir, this.component.jsFile);
-      const fullOutDir = path.dirname(fullOutPath);
-      if (!fs.existsSync(fullOutDir)) {
-        fs.mkdirSync(fullOutDir, {
-          recursive: true,
-          mode: fs.lstatSync(this.params.rootDir).mode
-        });
-      }
-      fs.writeFileSync(fullOutPath, newContent);
-    }
-  }
-
-  private restoreEventsComments(content: string, eventsComments: string[], existingComments: Map<string, number>): string {
-    //In some cases Typescript compiler keep existing comments. These comments
-    // must not be restored here
-    eventsComments = eventsComments.filter(c => !existingComments.has(this.getNormalizedComment(c)));
-    return codeUtils.replaceComment(content, "eventsComments", "\n" + eventsComments.join("\n\n") + "\n");
-  }
-
-  private restoreLeadingComments(content: string, leadingComments: string[]): string {
-    return leadingComments.reduce(
-        (newContent, comment, commentIndex) =>
-            codeUtils.replaceComment(newContent, String(commentIndex), comment),
-        content);
-  }
-
-  private restoreFormating(printer: ts.Printer, newContent: string): string {
-    const originalFile = this.component.parsedFile;
-    const newFile = ts.createSourceFile(originalFile.fileName, newContent, originalFile.languageVersion, true, ts.ScriptKind.JS);
-    const textMap = new Map<ts.SyntaxKind, Map<string, Set<string>>>();
-    const comments = new Set<string>();
-    this.collectAllStrings(printer, originalFile, textMap);
-
-    const replacements: Replacement[] = [];
-    this.collectReplacements(printer, newFile, textMap, replacements);
-    replacements.sort((a, b) => b.start - a.start);
-    let result = newFile.getFullText();
-    let prevReplacement: Replacement | null = null;
-    for (const replacement of replacements) {
-      if (prevReplacement) {
-        if (replacement.start + replacement.length > prevReplacement.start) {
-          throw new Error('Internal error! Replacements must not intersect');
-        }
-      }
-      result = result.substring(0, replacement.start) + replacement.newText + result.substring(replacement.start + replacement.length);
-      prevReplacement = replacement;
-    }
-    return result;
-  }
-
-  private splitLongLines(content: string): string {
-    content = content.replace(elementRegistrationRegex, (match, indent, className) => {
-      if (match.length > maxLineLength) {
-        return `${indent}customElements.define(${className}.is,\n` +
-            `${indent}  ${className});`;
-      }
-      else {
-        return match;
-      }
-    });
-
-    return content
-        .replace(
-            "Polymer.LegacyDataMixin(Polymer.GestureEventListeners(Polymer.LegacyElementMixin(Polymer.Element)))",
-            "Polymer.LegacyDataMixin(\nPolymer.GestureEventListeners(\nPolymer.LegacyElementMixin(\nPolymer.Element)))")
-        .replace(
-            "Polymer.GestureEventListeners(Polymer.LegacyElementMixin(Polymer.Element))",
-            "Polymer.GestureEventListeners(\nPolymer.LegacyElementMixin(\nPolymer.Element))");
-
-  }
-
-  private addCommentsWarnings(newContent: string, generatedComments: string[]): string {
-    const expectedComments = this.collectAllComments(this.component.parsedFile.getFullText(), generatedComments);
-    const newComments = this.collectAllComments(newContent, []);
-    const commentsWarnings = [];
-    for (const [text, count] of expectedComments) {
-      const newCount = newComments.get(text);
-      if (!newCount) {
-        commentsWarnings.push(`Comment '${text}' is missing in the new content.`);
-      }
-      else if (newCount != count) {
-        commentsWarnings.push(`Comment '${text}' appears ${newCount} times in the new file and ${count} times in the old file.`);
-      }
-    }
-
-    for (const [text, newCount] of newComments) {
-      if (!expectedComments.has(text)) {
-        commentsWarnings.push(`Comment '${text}' appears only in the new content`);
-      }
-    }
-    if (commentsWarnings.length === 0) {
-      return newContent;
-    }
-    let commentsProblemStr = "";
-    if (commentsWarnings.length > 0) {
-      commentsProblemStr = commentsWarnings.join("-----------------------------\n");
-      console.log(commentsProblemStr);
-    }
-
-    return "//This file has the following problems with comments:\n" + commentsProblemStr + "\n" + newContent;
-
-  }
-
-  private collectAllComments(content: string, additionalComments: string[]): Map<string, number> {
-    const comments = CommentsParser.collectAllComments(content);
-    comments.push(...additionalComments);
-    const result = new Map<string, number>();
-    for (const comment of comments) {
-      let normalizedComment = this.getNormalizedComment(comment);
-      const count = result.get(normalizedComment);
-      if (count) {
-        result.set(normalizedComment, count + 1);
-      } else {
-        result.set(normalizedComment, 1);
-      }
-    }
-    return result;
-  }
-
-  private getNormalizedComment(comment: string): string {
-    if(comment.startsWith('/**')) {
-      comment = comment.replace(/^\s+\*/gm, "*");
-    }
-    return comment;
-  }
-
-  private collectAllStrings(printer: ts.Printer, node: ts.Node, map: Map<ts.SyntaxKind, Map<string, Set<string>>>) {
-    const formattedText = printer.printNode(ts.EmitHint.Unspecified, node, node.getSourceFile())
-    const originalText = node.getFullText();
-    this.addIfNotExists(map, node.kind, formattedText, originalText);
-    ts.forEachChild(node, child => this.collectAllStrings(printer, child, map));
-  }
-
-  private collectReplacements(printer: ts.Printer, node: ts.Node, map: Map<ts.SyntaxKind, Map<string, Set<string>>>, replacements: Replacement[]) {
-    if(node.kind === ts.SyntaxKind.ThisKeyword || node.kind === ts.SyntaxKind.Identifier || node.kind === ts.SyntaxKind.StringLiteral || node.kind === ts.SyntaxKind.NumericLiteral) {
-      return;
-    }
-    const replacement = this.getReplacement(printer, node, map);
-    if(replacement) {
-      replacements.push(replacement);
-      return;
-    }
-    ts.forEachChild(node, child => this.collectReplacements(printer, child, map, replacements));
-  }
-
-  private addIfNotExists(map: Map<ts.SyntaxKind, Map<string, Set<string>>>, kind: ts.SyntaxKind, formattedText: string, originalText: string) {
-    let mapForKind = map.get(kind);
-    if(!mapForKind) {
-      mapForKind = new Map();
-      map.set(kind, mapForKind);
-    }
-
-    let existingOriginalText = mapForKind.get(formattedText);
-    if(!existingOriginalText) {
-      existingOriginalText = new Set<string>();
-      mapForKind.set(formattedText, existingOriginalText);
-      //throw new Error(`Different formatting of the same string exists. Kind: ${ts.SyntaxKind[kind]}.\nFormatting 1:\n${originalText}\nFormatting2:\n${existingOriginalText}\n `);
-    }
-    existingOriginalText.add(originalText);
-  }
-
-  private getReplacement(printer: ts.Printer, node: ts.Node, map: Map<ts.SyntaxKind, Map<string, Set<string>>>): Replacement | undefined {
-    const replacementsForKind = map.get(node.kind);
-    if(!replacementsForKind) {
-      return;
-    }
-    // Use printer instead of getFullText to "isolate" node content.
-    // node.getFullText returns text with indents from the original file.
-    const newText = printer.printNode(ts.EmitHint.Unspecified, node, node.getSourceFile());
-    const originalSet = replacementsForKind.get(newText);
-    if(!originalSet || originalSet.size === 0) {
-      return;
-    }
-    if(originalSet.size >= 2) {
-      console.log(`Multiple replacements possible. Formatting of some lines can be changed`);
-    }
-    const replacementText: string = originalSet.values().next().value;
-    const nodeText = node.getFullText();
-    return {
-      start: node.pos,
-      length: nodeText.length,//Do not use newText here!
-      newText: replacementText,
-    }
-  }
-
-}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/index.ts b/tools/polygerrit-updater/src/index.ts
deleted file mode 100644
index 1b7c315..0000000
--- a/tools/polygerrit-updater/src/index.ts
+++ /dev/null
@@ -1,168 +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.
-
-import * as fs from "fs";
-import * as path from "path";
-import {LegacyPolymerComponent, LegacyPolymerComponentParser} from './funcToClassConversion/polymerComponentParser';
-import {ClassBasedPolymerElement} from './funcToClassConversion/polymerElementBuilder';
-import {PolymerFuncToClassBasedConverter} from './funcToClassConversion/funcToClassBasedElementConverter';
-import {LegacyPolymerFuncReplacer} from './funcToClassConversion/legacyPolymerFuncReplacer';
-import {UpdatedFileWriter} from './funcToClassConversion/updatedFileWriter';
-import {CommandLineParser} from './utils/commandLineParser';
-
-interface UpdaterParameters {
-  htmlFiles: Set<string>;
-  jsFiles: Set<string>;
-  out: string;
-  inplace: boolean;
-  writeOutput: boolean;
-  rootDir: string;
-}
-
-interface InputFilesFilter {
-  includeDir(path: string): boolean;
-  includeFile(path: string): boolean;
-}
-
-function addFile(filePath: string, params: UpdaterParameters, filter: InputFilesFilter) {
-  const parsedPath = path.parse(filePath);
-  const ext = parsedPath.ext.toLowerCase();
-  const relativePath = path.relative(params.rootDir, filePath);
-  if(!filter.includeFile(relativePath)) return;
-  if(relativePath.startsWith("../")) {
-    throw new Error(`${filePath} is not in rootDir ${params.rootDir}`);
-  }
-  if(ext === ".html") {
-    params.htmlFiles.add(relativePath);
-  } if(ext === ".js") {
-    params.jsFiles.add(relativePath);
-  }
-}
-
-function addDirectory(dirPath: string, params: UpdaterParameters, recursive: boolean, filter: InputFilesFilter): void {
-  const entries = fs.readdirSync(dirPath, {withFileTypes: true});
-  for(const entry of entries) {
-    const dirEnt = entry as fs.Dirent;
-    const fullPath = path.join(dirPath, dirEnt.name);
-    const relativePath = path.relative(params.rootDir, fullPath);
-    if(dirEnt.isDirectory()) {
-      if (!filter.includeDir(relativePath)) continue;
-      if(recursive) {
-        addDirectory(fullPath, params, recursive, filter);
-      }
-    }
-    else if(dirEnt.isFile()) {
-      addFile(fullPath, params, filter);
-    } else {
-      throw Error(`Unsupported dir entry '${entry.name}' in '${fullPath}'`);
-    }
-  }
-}
-
-async function updateLegacyComponent(component: LegacyPolymerComponent, params: UpdaterParameters) {
-  const classBasedElement: ClassBasedPolymerElement = PolymerFuncToClassBasedConverter.convert(component);
-
-  const replacer = new LegacyPolymerFuncReplacer(component);
-  const replaceResult = replacer.replace(classBasedElement);
-  try {
-    const writer = new UpdatedFileWriter(component, params);
-    writer.write(replaceResult, classBasedElement.eventsComments, classBasedElement.generatedComments);
-  }
-  finally {
-    replaceResult.dispose();
-  }
-}
-
-async function main() {
-  const params: UpdaterParameters = await getParams();
-  if(params.jsFiles.size === 0) {
-    console.log("No files found");
-    return;
-  }
-  const legacyPolymerComponentParser = new LegacyPolymerComponentParser(params.rootDir, params.htmlFiles)
-  for(const jsFile of params.jsFiles) {
-    console.log(`Processing ${jsFile}`);
-    const legacyComponent = await legacyPolymerComponentParser.parse(jsFile);
-    if(legacyComponent) {
-      await updateLegacyComponent(legacyComponent, params);
-      continue;
-    }
-  }
-}
-
-interface CommandLineParameters {
-  src: string[];
-  recursive: boolean;
-  excludes: string[];
-  out: string;
-  inplace: boolean;
-  noOutput: boolean;
-  rootDir: string;
-}
-
-async function getParams(): Promise<UpdaterParameters> {
-  const parser = new CommandLineParser({
-    src: CommandLineParser.createStringArrayOption("src", ".js file or folder to process", []),
-    recursive: CommandLineParser.createBooleanOption("r", "process folder recursive", false),
-    excludes: CommandLineParser.createStringArrayOption("exclude", "List of file prefixes to exclude. If relative file path starts with one of the prefixes, it will be excluded", []),
-    out: CommandLineParser.createStringOption("out", "Output folder.", null),
-    rootDir: CommandLineParser.createStringOption("root", "Root directory for src files", "/"),
-    inplace: CommandLineParser.createBooleanOption("i", "Update files inplace", false),
-    noOutput: CommandLineParser.createBooleanOption("noout", "Do everything, but do not write anything to files", false),
-  });
-  const commandLineParams: CommandLineParameters = parser.parse(process.argv) as CommandLineParameters;
-
-  const params: UpdaterParameters = {
-    htmlFiles: new Set(),
-    jsFiles: new Set(),
-    writeOutput: !commandLineParams.noOutput,
-    inplace: commandLineParams.inplace,
-    out: commandLineParams.out,
-    rootDir: path.resolve(commandLineParams.rootDir)
-  };
-
-  if(params.writeOutput && !params.inplace && !params.out) {
-    throw new Error("You should specify output directory (--out directory_name)");
-  }
-
-  const filter = new ExcludeFilesFilter(commandLineParams.excludes);
-  for(const srcPath of commandLineParams.src) {
-    const resolvedPath = path.resolve(params.rootDir, srcPath);
-    if(fs.lstatSync(resolvedPath).isFile()) {
-      addFile(resolvedPath, params, filter);
-    } else {
-      addDirectory(resolvedPath, params, commandLineParams.recursive, filter);
-    }
-  }
-  return params;
-}
-
-class ExcludeFilesFilter implements InputFilesFilter {
-  public constructor(private readonly excludes: string[]) {
-  }
-  includeDir(path: string): boolean {
-    return this.excludes.every(exclude => !path.startsWith(exclude));
-  }
-
-  includeFile(path: string): boolean {
-    return this.excludes.every(exclude => !path.startsWith(exclude));
-  }
-}
-
-main().then(() => {
-  process.exit(0);
-}).catch(e => {
-  console.error(e);
-  process.exit(1);
-});
diff --git a/tools/polygerrit-updater/src/utils/codeUtils.ts b/tools/polygerrit-updater/src/utils/codeUtils.ts
deleted file mode 100644
index 53a7f0d..0000000
--- a/tools/polygerrit-updater/src/utils/codeUtils.ts
+++ /dev/null
@@ -1,183 +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.
-
-import * as ts from 'typescript';
-import {SyntaxKind} from 'typescript';
-import {Node} from 'typescript';
-
-export function assertNodeKind<T extends U, U extends ts.Node>(node: U, expectedKind: ts.SyntaxKind): T {
-  if (node.kind !== expectedKind) {
-    throw new Error(`Invlid node kind. Expected: ${ts.SyntaxKind[expectedKind]}, actual: ${ts.SyntaxKind[node.kind]}`);
-  }
-  return node as T;
-}
-
-export function assertNodeKindOrUndefined<T extends U, U extends ts.Node>(node: U | undefined, expectedKind: ts.SyntaxKind): T | undefined {
-  if (!node) {
-    return undefined;
-  }
-  return assertNodeKind<T, U>(node, expectedKind);
-}
-
-export function getPropertyAssignment(expression?: ts.ObjectLiteralElementLike): ts.PropertyAssignment | undefined {
-  return assertNodeKindOrUndefined(expression, ts.SyntaxKind.PropertyAssignment);
-}
-
-export function getStringLiteralValue(expression: ts.Expression): string {
-  const literal: ts.StringLiteral = assertNodeKind(expression, ts.SyntaxKind.StringLiteral);
-  return literal.text;
-}
-
-export function getBooleanLiteralValue(expression: ts.Expression): boolean {
-  if (expression.kind === ts.SyntaxKind.TrueKeyword) {
-    return true;
-  }
-  if (expression.kind === ts.SyntaxKind.FalseKeyword) {
-    return false;
-  }
-  throw new Error(`Invalid expression kind - ${expression.kind}`);
-}
-
-export function getObjectLiteralExpression(expression: ts.Expression): ts.ObjectLiteralExpression {
-  return assertNodeKind(expression, ts.SyntaxKind.ObjectLiteralExpression);
-}
-
-export function getArrayLiteralExpression(expression: ts.Expression): ts.ArrayLiteralExpression {
-  return assertNodeKind(expression, ts.SyntaxKind.ArrayLiteralExpression);
-}
-
-export function replaceNode(file: ts.SourceFile, originalNode: ts.Node, newNode: ts.Node): ts.TransformationResult<ts.SourceFile> {
-  const nodeReplacerTransformer: ts.TransformerFactory<ts.SourceFile> = (context: ts.TransformationContext) => {
-    const visitor: ts.Visitor = (node) => {
-      if(node === originalNode) {
-        return newNode;
-      }
-      return ts.visitEachChild(node, visitor, context);
-    };
-
-
-    return source => ts.visitNode(source, visitor);
-  };
-  return ts.transform(file, [nodeReplacerTransformer]);
-}
-
-export type NameExpression = ts.Identifier | ts.ThisExpression | ts.PropertyAccessExpression;
-export function createNameExpression(fullPath: string): NameExpression {
-  const parts = fullPath.split(".");
-  let result: NameExpression = parts[0] === "this" ? ts.createThis() : ts.createIdentifier(parts[0]);
-  for(let i = 1; i < parts.length; i++) {
-    result = ts.createPropertyAccess(result, parts[i]);
-  }
-  return result;
-}
-
-const generatedCommentNewLineAfterText = "-Generated code - new line after - 9cb292bc-5d88-4c5e-88f4-49535c93beb9 -";
-const generatedCommentNewLineBeforeText = "-Generated code - new line-before - 9cb292bc-5d88-4c5e-88f4-49535c93beb9 -";
-const generatedCommentNewLineAfterRegExp = new RegExp("//" + generatedCommentNewLineAfterText, 'g');
-const generatedCommentNewLineBeforeRegExp = new RegExp("//" + generatedCommentNewLineBeforeText + "\n", 'g');
-const replacableCommentText = "- Replacepoint - 9cb292bc-5d88-4c5e-88f4-49535c93beb9 -";
-
-export function addNewLineAfterNode<T extends ts.Node>(node: T): T {
-  const comment = ts.getSyntheticTrailingComments(node);
-  if(comment && comment.some(c => c.text === generatedCommentNewLineAfterText)) {
-    return node;
-  }
-  return ts.addSyntheticTrailingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, generatedCommentNewLineAfterText, true);
-}
-
-export function addNewLineBeforeNode<T extends ts.Node>(node: T): T {
-  const comment = ts.getSyntheticLeadingComments(node);
-  if(comment && comment.some(c => c.text === generatedCommentNewLineBeforeText)) {
-    return node;
-  }
-  return ts.addSyntheticLeadingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, generatedCommentNewLineBeforeText, true);
-}
-
-
-export function applyNewLines(text: string): string {
-  return text.replace(generatedCommentNewLineAfterRegExp, "").replace(generatedCommentNewLineBeforeRegExp, "");
-
-}
-export function addReplacableCommentAfterNode<T extends ts.Node>(node: T, name: string): T {
-  return ts.addSyntheticTrailingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, replacableCommentText + name, true);
-}
-
-export function addReplacableCommentBeforeNode<T extends ts.Node>(node: T, name: string): T {
-  return ts.addSyntheticLeadingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, replacableCommentText + name, true);
-}
-
-export function replaceComment(text: string, commentName: string, newContent: string): string {
-  return text.replace("//" + replacableCommentText + commentName, newContent);
-}
-
-export function createMethod(name: string, methodDecl: ts.MethodDeclaration | undefined, codeAtStart: ts.Statement[], codeAtEnd: ts.Statement[], callSuperMethod: boolean): ts.MethodDeclaration | undefined {
-  if(!methodDecl && (codeAtEnd.length > 0 || codeAtEnd.length > 0)) {
-    methodDecl = ts.createMethod([], [], undefined, name, undefined, [], [],undefined, ts.createBlock([]));
-  }
-  if(!methodDecl) {
-    return;
-  }
-  if (!methodDecl.body) {
-    throw new Error("Method must have a body");
-  }
-  if(methodDecl.parameters.length > 0) {
-    throw new Error("Methods with parameters are not supported");
-  }
-  let newStatements = [...codeAtStart];
-  if(callSuperMethod) {
-    const superCall: ts.CallExpression = ts.createCall(ts.createPropertyAccess(ts.createSuper(), assertNodeKind(methodDecl.name, ts.SyntaxKind.Identifier) as ts.Identifier), [], []);
-    const superCallExpression = ts.createExpressionStatement(superCall);
-    newStatements.push(superCallExpression);
-  }
-  newStatements.push(...codeAtEnd);
-  const newBody = ts.getMutableClone(methodDecl.body);
-
-  newStatements = newStatements.map(m => addNewLineAfterNode(m));
-  newStatements.splice(codeAtStart.length + 1, 0, ...newBody.statements);
-
-  newBody.statements = ts.createNodeArray(newStatements);
-
-  const newMethod = ts.getMutableClone(methodDecl);
-  newMethod.body = newBody;
-
-  return newMethod;
-}
-
-export function restoreLeadingComments<T extends Node>(node: T, originalComments: string[]): T {
-  if(originalComments.length === 0) {
-    return node;
-  }
-  for(const comment of originalComments) {
-    if(comment.startsWith("//")) {
-      node = ts.addSyntheticLeadingComment(node, SyntaxKind.SingleLineCommentTrivia, comment.substr(2), true);
-    } else if(comment.startsWith("/*")) {
-      if(!comment.endsWith("*/")) {
-        throw new Error(`Not support comment: ${comment}`);
-      }
-      node = ts.addSyntheticLeadingComment(node, SyntaxKind.MultiLineCommentTrivia, comment.substr(2, comment.length - 4), true);
-    } else {
-      throw new Error(`Not supported comment: ${comment}`);
-    }
-  }
-  return node;
-}
-
-export function getLeadingComments(node: ts.Node): string[] {
-  const nodeText = node.getFullText();
-  const commentRanges = ts.getLeadingCommentRanges(nodeText, 0);
-  if(!commentRanges) {
-    return [];
-  }
-  return commentRanges.map(range => nodeText.substring(range.pos, range.end))
-}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/utils/commandLineParser.ts b/tools/polygerrit-updater/src/utils/commandLineParser.ts
deleted file mode 100644
index 658b7ff..0000000
--- a/tools/polygerrit-updater/src/utils/commandLineParser.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed un  der the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 class CommandLineParser {
-  public static createStringArrayOption(optionName: string, help: string, defaultValue: string[]): CommandLineArgument {
-    return new StringArrayOption(optionName, help, defaultValue);
-  }
-  public static createBooleanOption(optionName: string, help: string, defaultValue: boolean): CommandLineArgument {
-    return new BooleanOption(optionName, help, defaultValue);
-  }
-  public static createStringOption(optionName: string, help: string, defaultValue: string | null): CommandLineArgument {
-    return new StringOption(optionName, help, defaultValue);
-  }
-
-  public constructor(private readonly argumentTypes: {[name: string]: CommandLineArgument}) {
-  }
-  public parse(argv: string[]): object {
-    const result = Object.assign({});
-    let index = 2; //argv[0] - node interpreter, argv[1] - index.js
-    for(const argumentField in this.argumentTypes) {
-      result[argumentField] = this.argumentTypes[argumentField].getDefaultValue();
-    }
-    while(index < argv.length) {
-      let knownArgument = false;
-      for(const argumentField in this.argumentTypes) {
-        const argumentType = this.argumentTypes[argumentField];
-        const argumentValue = argumentType.tryRead(argv, index);
-        if(argumentValue) {
-          knownArgument = true;
-          index += argumentValue.consumed;
-          result[argumentField] = argumentValue.value;
-          break;
-        }
-      }
-      if(!knownArgument) {
-        throw new Error(`Unknown argument ${argv[index]}`);
-      }
-    }
-    return result;
-  }
-}
-
-interface CommandLineArgumentReadResult {
-  consumed: number;
-  value: any;
-}
-
-export interface CommandLineArgument {
-  getDefaultValue(): any;
-  tryRead(argv: string[], startIndex: number): CommandLineArgumentReadResult | null;
-}
-
-abstract class CommandLineOption implements CommandLineArgument {
-  protected constructor(protected readonly optionName: string, protected readonly help: string, private readonly defaultValue: any) {
-  }
-  public tryRead(argv: string[], startIndex: number): CommandLineArgumentReadResult | null  {
-    if(argv[startIndex] !== "--" + this.optionName) {
-      return null;
-    }
-    const readArgumentsResult = this.readArguments(argv, startIndex + 1);
-    if(!readArgumentsResult) {
-      return null;
-    }
-    readArgumentsResult.consumed++; // Add option name
-    return readArgumentsResult;
-  }
-  public getDefaultValue(): any {
-    return this.defaultValue;
-  }
-
-  protected abstract readArguments(argv: string[], startIndex: number) : CommandLineArgumentReadResult | null;
-}
-
-class StringArrayOption extends CommandLineOption {
-  public constructor(optionName: string, help: string, defaultValue: string[]) {
-    super(optionName, help, defaultValue);
-  }
-
-  protected readArguments(argv: string[], startIndex: number): CommandLineArgumentReadResult {
-    const result = [];
-    let index = startIndex;
-    while(index < argv.length) {
-      if(argv[index].startsWith("--")) {
-        break;
-      }
-      result.push(argv[index]);
-      index++;
-    }
-    return {
-      consumed: index - startIndex,
-      value: result
-    }
-  }
-}
-
-class BooleanOption extends CommandLineOption {
-  public constructor(optionName: string, help: string, defaultValue: boolean) {
-    super(optionName, help, defaultValue);
-  }
-
-  protected readArguments(argv: string[], startIndex: number): CommandLineArgumentReadResult {
-    return {
-      consumed: 0,
-      value: true
-    }
-  }
-}
-
-class StringOption extends CommandLineOption {
-  public constructor(optionName: string, help: string, defaultValue: string | null) {
-    super(optionName, help, defaultValue);
-  }
-
-  protected readArguments(argv: string[], startIndex: number): CommandLineArgumentReadResult | null {
-    if(startIndex >= argv.length) {
-      return null;
-    }
-    return {
-      consumed: 1,
-      value: argv[startIndex]
-    }
-  }
-}
diff --git a/tools/polygerrit-updater/src/utils/commentsParser.ts b/tools/polygerrit-updater/src/utils/commentsParser.ts
deleted file mode 100644
index b849829..0000000
--- a/tools/polygerrit-updater/src/utils/commentsParser.ts
+++ /dev/null
@@ -1,79 +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.
-
-enum CommentScannerState {
-  Text,
-  SingleLineComment,
-  MultLineComment
-}
-export class CommentsParser {
-  public static collectAllComments(text: string): string[] {
-    const result: string[] = [];
-    let state = CommentScannerState.Text;
-    let pos = 0;
-    function readSingleLineComment() {
-      const startPos = pos;
-      while(pos < text.length && text[pos] !== '\n') {
-        pos++;
-      }
-      return text.substring(startPos, pos);
-    }
-    function readMultiLineComment() {
-      const startPos = pos;
-      while(pos < text.length) {
-        if(pos < text.length - 1 && text[pos] === '*' && text[pos + 1] === '/') {
-          pos += 2;
-          break;
-        }
-        pos++;
-      }
-      return text.substring(startPos, pos);
-    }
-
-    function skipString(lastChar: string) {
-      pos++;
-      while(pos < text.length) {
-        if(text[pos] === lastChar) {
-          pos++;
-          return;
-        } else if(text[pos] === '\\') {
-          pos+=2;
-          continue;
-        }
-        pos++;
-      }
-    }
-
-
-    while(pos < text.length - 1) {
-      if(text[pos] === '/' && text[pos + 1] === '/') {
-        result.push(readSingleLineComment());
-      } else if(text[pos] === '/' && text[pos + 1] === '*') {
-        result.push(readMultiLineComment());
-      } else if(text[pos] === "'") {
-        skipString("'");
-      } else if(text[pos] === '"') {
-        skipString('"');
-      } else if(text[pos] === '`') {
-        skipString('`');
-      } else if(text[pos] == '/') {
-        skipString('/');
-      } {
-        pos++;
-      }
-
-    }
-    return result;
-  }
-}
diff --git a/tools/polygerrit-updater/src/utils/polymerClassBuilder.ts b/tools/polygerrit-updater/src/utils/polymerClassBuilder.ts
deleted file mode 100644
index b1a4320..0000000
--- a/tools/polygerrit-updater/src/utils/polymerClassBuilder.ts
+++ /dev/null
@@ -1,270 +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.
-
-import * as ts from 'typescript';
-import * as codeUtils from './codeUtils';
-import {LegacyLifecycleMethodName, LegacyLifecycleMethodsArray} from '../funcToClassConversion/polymerComponentParser';
-import {SyntaxKind} from 'typescript';
-
-enum PolymerClassMemberType {
-  IsAccessor,
-  Constructor,
-  PolymerPropertiesAccessor,
-  PolymerObserversAccessor,
-  Method,
-  ExistingLifecycleMethod,
-  NewLifecycleMethod,
-  GetAccessor,
-}
-
-type PolymerClassMember = PolymerClassIsAccessor | PolymerClassConstructor | PolymerClassExistingLifecycleMethod | PolymerClassNewLifecycleMethod | PolymerClassSimpleMember;
-
-interface PolymerClassExistingLifecycleMethod {
-  member: ts.MethodDeclaration;
-  memberType: PolymerClassMemberType.ExistingLifecycleMethod;
-  name: string;
-  lifecycleOrder: number;
-  originalPos: number;
-}
-
-interface PolymerClassNewLifecycleMethod {
-  member: ts.MethodDeclaration;
-  memberType: PolymerClassMemberType.NewLifecycleMethod;
-  name: string;
-  lifecycleOrder: number;
-  originalPos: -1
-}
-
-interface PolymerClassIsAccessor {
-  member: ts.GetAccessorDeclaration;
-  memberType: PolymerClassMemberType.IsAccessor;
-  originalPos: -1
-}
-
-interface PolymerClassConstructor {
-  member: ts.ConstructorDeclaration;
-  memberType: PolymerClassMemberType.Constructor;
-  originalPos: -1
-}
-
-interface PolymerClassSimpleMember {
-  memberType: PolymerClassMemberType.PolymerPropertiesAccessor | PolymerClassMemberType.PolymerObserversAccessor | PolymerClassMemberType.Method | PolymerClassMemberType.GetAccessor;
-  member: ts.ClassElement;
-  originalPos: number;
-}
-
-export interface PolymerClassBuilderResult {
-  classDeclaration: ts.ClassDeclaration;
-  generatedComments: string[];
-}
-
-export class PolymerClassBuilder {
-  private readonly members: PolymerClassMember[] = [];
-  public readonly constructorStatements: ts.Statement[] = [];
-  private baseType: ts.ExpressionWithTypeArguments | undefined;
-  private classJsDocComments: string[];
-
-  public constructor(public readonly className: string) {
-    this.classJsDocComments = [];
-  }
-
-  public addIsAccessor(accessor: ts.GetAccessorDeclaration) {
-    this.members.push({
-      member: accessor,
-      memberType: PolymerClassMemberType.IsAccessor,
-      originalPos: -1
-    });
-  }
-
-  public addPolymerPropertiesAccessor(originalPos: number, accessor: ts.GetAccessorDeclaration) {
-    this.members.push({
-      member: accessor,
-      memberType: PolymerClassMemberType.PolymerPropertiesAccessor,
-      originalPos: originalPos
-    });
-  }
-
-  public addPolymerObserversAccessor(originalPos: number, accessor: ts.GetAccessorDeclaration) {
-    this.members.push({
-      member: accessor,
-      memberType: PolymerClassMemberType.PolymerObserversAccessor,
-      originalPos: originalPos
-    });
-  }
-
-
-  public addClassFieldInitializer(name: string | ts.Identifier, initializer: ts.Expression) {
-    const assignment = ts.createAssignment(ts.createPropertyAccess(ts.createThis(), name), initializer);
-    this.constructorStatements.push(codeUtils.addNewLineAfterNode(ts.createExpressionStatement(assignment)));
-  }
-  public addMethod(originalPos: number, method: ts.MethodDeclaration) {
-    this.members.push({
-      member: method,
-      memberType: PolymerClassMemberType.Method,
-      originalPos: originalPos
-    });
-  }
-
-  public addGetAccessor(originalPos: number, accessor: ts.GetAccessorDeclaration) {
-    this.members.push({
-      member: accessor,
-      memberType: PolymerClassMemberType.GetAccessor,
-      originalPos: originalPos
-    });
-  }
-
-  public addLifecycleMethod(name: LegacyLifecycleMethodName, originalPos: number, method: ts.MethodDeclaration) {
-    const lifecycleOrder = LegacyLifecycleMethodsArray.indexOf(name);
-    if(lifecycleOrder < 0) {
-      throw new Error(`Invalid lifecycle name`);
-    }
-    if(originalPos >= 0) {
-      this.members.push({
-        member: method,
-        memberType: PolymerClassMemberType.ExistingLifecycleMethod,
-        originalPos: originalPos,
-        name: name,
-        lifecycleOrder: lifecycleOrder
-      })
-    } else {
-      this.members.push({
-        member: method,
-        memberType: PolymerClassMemberType.NewLifecycleMethod,
-        name: name,
-        lifecycleOrder: lifecycleOrder,
-        originalPos: -1
-      })
-    }
-  }
-
-  public setBaseType(type: ts.ExpressionWithTypeArguments) {
-    if(this.baseType) {
-      throw new Error("Class can have only one base type");
-    }
-    this.baseType = type;
-  }
-
-  public build(): PolymerClassBuilderResult {
-    let heritageClauses: ts.HeritageClause[] = [];
-    if (this.baseType) {
-      const extendClause = ts.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [this.baseType]);
-      heritageClauses.push(extendClause);
-    }
-    const finalMembers: PolymerClassMember[] = [];
-    const isAccessors = this.members.filter(member => member.memberType === PolymerClassMemberType.IsAccessor);
-    if(isAccessors.length !== 1) {
-      throw new Error("Class must have exactly one 'is'");
-    }
-    finalMembers.push(isAccessors[0]);
-    const constructorMember = this.createConstructor();
-    if(constructorMember) {
-      finalMembers.push(constructorMember);
-    }
-
-    const newLifecycleMethods: PolymerClassNewLifecycleMethod[] = [];
-    this.members.forEach(member => {
-      if(member.memberType === PolymerClassMemberType.NewLifecycleMethod) {
-        newLifecycleMethods.push(member);
-      }
-    });
-
-    const methodsWithKnownPosition = this.members.filter(member => member.originalPos >= 0);
-    methodsWithKnownPosition.sort((a, b) => a.originalPos - b.originalPos);
-
-    finalMembers.push(...methodsWithKnownPosition);
-
-
-    for(const newLifecycleMethod of newLifecycleMethods) {
-      //Number of methods is small - use brute force solution
-      let closestNextIndex = -1;
-      let closestNextOrderDiff: number = LegacyLifecycleMethodsArray.length;
-      let closestPrevIndex = -1;
-      let closestPrevOrderDiff: number = LegacyLifecycleMethodsArray.length;
-      for (let i = 0; i < finalMembers.length; i++) {
-        const member = finalMembers[i];
-        if (member.memberType !== PolymerClassMemberType.NewLifecycleMethod && member.memberType !== PolymerClassMemberType.ExistingLifecycleMethod) {
-          continue;
-        }
-        const orderDiff = member.lifecycleOrder - newLifecycleMethod.lifecycleOrder;
-        if (orderDiff > 0) {
-          if (orderDiff < closestNextOrderDiff) {
-            closestNextIndex = i;
-            closestNextOrderDiff = orderDiff;
-          }
-        } else if (orderDiff < 0) {
-          if (orderDiff < closestPrevOrderDiff) {
-            closestPrevIndex = i;
-            closestPrevOrderDiff = orderDiff;
-          }
-        }
-      }
-      let insertIndex;
-      if (closestNextIndex !== -1 || closestPrevIndex !== -1) {
-        insertIndex = closestNextOrderDiff < closestPrevOrderDiff ?
-            closestNextIndex : closestPrevIndex + 1;
-      } else {
-        insertIndex = Math.max(
-            finalMembers.findIndex(m => m.memberType === PolymerClassMemberType.Constructor),
-            finalMembers.findIndex(m => m.memberType === PolymerClassMemberType.IsAccessor),
-            finalMembers.findIndex(m => m.memberType === PolymerClassMemberType.PolymerPropertiesAccessor),
-            finalMembers.findIndex(m => m.memberType === PolymerClassMemberType.PolymerObserversAccessor),
-        );
-        if(insertIndex < 0) {
-          insertIndex = finalMembers.length;
-        } else {
-          insertIndex++;//Insert after
-        }
-      }
-      finalMembers.splice(insertIndex, 0, newLifecycleMethod);
-    }
-    //Asserts about finalMembers
-    const nonConstructorMembers = finalMembers.filter(m => m.memberType !== PolymerClassMemberType.Constructor);
-
-    if(nonConstructorMembers.length !== this.members.length) {
-      throw new Error(`Internal error! Some methods are missed`);
-    }
-    let classDeclaration = ts.createClassDeclaration(undefined, undefined, this.className, undefined, heritageClauses, finalMembers.map(m => m.member))
-    const generatedComments: string[] = [];
-    if(this.classJsDocComments.length > 0) {
-      const commentContent = '*\n' + this.classJsDocComments.map(line => `* ${line}`).join('\n') + '\n';
-      classDeclaration = ts.addSyntheticLeadingComment(classDeclaration, ts.SyntaxKind.MultiLineCommentTrivia, commentContent, true);
-      generatedComments.push(`/*${commentContent}*/`);
-    }
-    return {
-      classDeclaration,
-      generatedComments,
-    };
-
-  }
-
-  private createConstructor(): PolymerClassConstructor | null {
-    if(this.constructorStatements.length === 0) {
-      return null;
-    }
-    let superCall: ts.CallExpression = ts.createCall(ts.createSuper(), [], []);
-    const superCallExpression = ts.createExpressionStatement(superCall);
-    const statements = [superCallExpression, ...this.constructorStatements];
-    const constructorDeclaration = ts.createConstructor([], [], [], ts.createBlock(statements, true));
-
-    return {
-      memberType: PolymerClassMemberType.Constructor,
-      member: constructorDeclaration,
-      originalPos: -1
-    };
-  }
-
-  public addClassJSDocComments(lines: string[]) {
-    this.classJsDocComments.push(...lines);
-  }
-}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/tsconfig.json b/tools/polygerrit-updater/tsconfig.json
deleted file mode 100644
index 80f60c1..0000000
--- a/tools/polygerrit-updater/tsconfig.json
+++ /dev/null
@@ -1,67 +0,0 @@
-{
-  "compilerOptions": {
-    /* Basic Options */
-    // "incremental": true,                   /* Enable incremental compilation */
-    "target": "es2019", 		      /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
-    "module": "es2015", 		      /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
-    // "lib": [],                             /* Specify library files to be included in the compilation. */
-    // "allowJs": true,                       /* Allow javascript files to be compiled. */
-    // "checkJs": true,                       /* Report errors in .js files. */
-    // "jsx": "preserve",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
-    // "declaration": true,                   /* Generates corresponding '.d.ts' file. */
-    // "declarationMap": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */
-    "sourceMap": true,                     /* Generates corresponding '.map' file. */
-    // "outFile": "./",                       /* Concatenate and emit output to single file. */
-    "outDir": "./js",                        /* Redirect output structure to the directory. */
-    "rootDir": ".",                       /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
-    // "composite": true,                     /* Enable project compilation */
-    // "tsBuildInfoFile": "./",               /* Specify file to store incremental compilation information */
-    // "removeComments": true,                /* Do not emit comments to output. */
-    // "noEmit": true,                        /* Do not emit outputs. */
-    // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */
-    // "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
-    // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
-
-    /* Strict Type-Checking Options */
-    "strict": true,                           /* Enable all strict type-checking options. */
-    "noImplicitAny": true,                 /* Raise error on expressions and declarations with an implied 'any' type. */
-    // "strictNullChecks": true,              /* Enable strict null checks. */
-    // "strictFunctionTypes": true,           /* Enable strict checking of function types. */
-    // "strictBindCallApply": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
-    // "strictPropertyInitialization": true,  /* Enable strict checking of property initialization in classes. */
-    // "noImplicitThis": true,                /* Raise error on 'this' expressions with an implied 'any' type. */
-    // "alwaysStrict": true,                  /* Parse in strict mode and emit "use strict" for each source file. */
-
-    /* Additional Checks */
-    // "noUnusedLocals": true,                /* Report errors on unused locals. */
-    // "noUnusedParameters": true,            /* Report errors on unused parameters. */
-    // "noImplicitReturns": true,             /* Report error when not all code paths in function return a value. */
-    // "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */
-
-    /* Module Resolution Options */
-    "moduleResolution": "node",            /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
-    "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */
-    "paths": {
-      "*": [ "node_modules/*" ]
-    },                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
-    // "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */
-    // "typeRoots": [],                       /* List of folders to include type definitions from. */
-    // "types": [],                           /* Type declaration files to be included in compilation. */
-    "allowSyntheticDefaultImports": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
-    "esModuleInterop": true                   /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
-    // "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */
-    // "allowUmdGlobalAccess": true,          /* Allow accessing UMD globals from modules. */
-
-    /* Source Map Options */
-    // "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */
-    // "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */
-    // "inlineSourceMap": true,               /* Emit a single file with source maps instead of having a separate file. */
-    // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
-
-    /* Experimental Options */
-    // "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
-    // "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
-
-  },
-  "include": ["./src/**/*"]
-}
diff --git a/version.bzl b/version.bzl
index 367b172..f358ebf 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.6.3-SNAPSHOT"
+GERRIT_VERSION = "3.7.0-SNAPSHOT"
diff --git a/web-dev-server.config.mjs b/web-dev-server.config.mjs
new file mode 100644
index 0000000..2a7dca4
--- /dev/null
+++ b/web-dev-server.config.mjs
@@ -0,0 +1,47 @@
+import { esbuildPlugin } from "@web/dev-server-esbuild";
+import cors from "@koa/cors";
+
+/** @type {import('@web/dev-server').DevServerConfig} */
+export default {
+  port: 8081,
+  plugins: [
+    esbuildPlugin({
+      ts: true,
+      target: "es2020",
+      tsconfig: "polygerrit-ui/app/tsconfig.json",
+    }),
+  ],
+  nodeResolve: true,
+  rootDir: "polygerrit-ui/app",
+  middleware: [
+    // Allow files served from the localhost domain to be used on any domain
+    // (ex: gerrit-review.googlesource.com), which happens during local
+    // development with Gerrit FE Helper extension.
+    cors({ origin: "*" }),
+    // The issue solved here is that our production index.html does not load
+    // 'gr-app.js' as an ESM module due to our build process, but in development
+    // all our source code is written as ESM modules. When using the Gerrit FE
+    // Helper extension to see our local changes on a production site we see a
+    // syntax error due to this mismatch. The trick used to fix this is to
+    // rewrite the response for 'gr-app.js' to be a dynamic import() statement
+    // for a fake file 'gr-app.mjs'. This fake file will be loaded as an ESM
+    // module and when the server receives the request it returns the real
+    // contents of 'gr-app.js'.
+    async (context, next) => {
+      const isGrAppMjs = context.url.includes("gr-app.mjs");
+      if (isGrAppMjs) {
+        // Load the .ts file of the entrypoint instead of .js to trigger esbuild
+        // which will convert every .ts file to .js on request.
+        context.url = context.url.replace("gr-app.mjs", "gr-app.ts");
+      }
+
+      // Pass control to the next middleware which eventually loads the file.
+      // see https://koajs.com/#cascading
+      await next();
+
+      if (!isGrAppMjs && context.url.includes("gr-app.js")) {
+        context.body = "import('./gr-app.mjs')";
+      }
+    },
+  ],
+};
diff --git a/yarn.lock b/yarn.lock
index 406f567..ab1eb89 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,13 +2,6 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@7.12.11":
-  version "7.12.11"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f"
-  integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==
-  dependencies:
-    "@babel/highlight" "^7.10.4"
-
 "@babel/code-frame@^7.0.0":
   version "7.14.5"
   resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
@@ -16,12 +9,24 @@
   dependencies:
     "@babel/highlight" "^7.14.5"
 
+"@babel/code-frame@^7.12.11":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a"
+  integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==
+  dependencies:
+    "@babel/highlight" "^7.18.6"
+
 "@babel/helper-validator-identifier@^7.14.5":
   version "7.14.9"
   resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz#6654d171b2024f6d8ee151bf2509699919131d48"
   integrity sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==
 
-"@babel/highlight@^7.10.4", "@babel/highlight@^7.14.5":
+"@babel/helper-validator-identifier@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076"
+  integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==
+
+"@babel/highlight@^7.14.5":
   version "7.14.5"
   resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9"
   integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==
@@ -30,6 +35,15 @@
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
+"@babel/highlight@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf"
+  integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.18.6"
+    chalk "^2.0.0"
+    js-tokens "^4.0.0"
+
 "@babel/runtime@^7.10.2":
   version "7.15.4"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.4.tgz#fd17d16bfdf878e6dd02d19753a39fa8a8d9c84a"
@@ -37,73 +51,99 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@bazel/concatjs@^5.1.0":
-  version "5.4.0"
-  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.4.0.tgz#04e752a6ea3e684f00879e6683657c4ede72df6e"
-  integrity sha512-jlupaDKxqFS3B1lttOIgkKxirP7v5Qx7KCFtOXO7JxtvYJD/qKtKXEQggTrGKJqLPyiZlNiYimHHGICLSWIZcQ==
+"@bazel/concatjs@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.5.0.tgz#e6104ed70595cae59463ae6b0b5389252566221e"
+  integrity sha512-hwG+ahivR20Z3iTOlkUz3OdwnW/PUaZyyz8BIX+GNUTg6U3rPHK51CavUirMupOU/LRJ5GyCwBNAAtjCyquo2g==
   dependencies:
     protobufjs "6.8.8"
     source-map-support "0.5.9"
     tsutils "3.21.0"
 
-"@bazel/rollup@^5.1.0":
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-5.1.0.tgz#dc858ddc93c9fdb9cc2e7982e632c939c646ebdc"
-  integrity sha512-wEiWdSyVbsycSirSYjR6FGfPGbRNI7sGNAYmrV0hIzYIi+KqXeTNcwKIRSE9PESP3mb0VWbZmHvXvmrWk6daPQ==
+"@bazel/rollup@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-5.5.0.tgz#1e152d6147ef5583ec9fd872756c9d0635db73c7"
+  integrity sha512-8SRbgVfaYdNb6PyIypj8jzzJHhlIRyMH3s5KpXODsjD+mXECH4jQxJ8VcRkt0f0exsgB12gK5dmoUK/F2PDKCw==
   dependencies:
-    "@bazel/worker" "5.1.0"
+    "@bazel/worker" "5.5.0"
 
-"@bazel/terser@^5.1.0":
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-5.1.0.tgz#5c82b93f4d9def8103c16be2dd33900d156fa066"
-  integrity sha512-uE3hTqfkZr4nvlk3jwi0xx6URqqI7r6GGPtDAU02/PVei+O4PfThaov7cwHO+D1FnoLncDqChb9Iolr7Crw/8A==
+"@bazel/terser@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-5.5.0.tgz#3b2b582a417d99d59ae99b50d74576ca0719c03a"
+  integrity sha512-aBjNmJ7TbcD7cKAdFErYQYXn4OqTvrmqrtN6Z6Wnv82d+23kbEsF427ixgdCO3GTQJDw7+x7K9TP2CGogaGtcg==
 
-"@bazel/typescript@^5.1.0":
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-5.1.0.tgz#348552355cc92a43f22e637fabce76ed64505548"
-  integrity sha512-E7wYv1tBFtcsFp0YN7Cf9Lv184xOzvT5WJKwZxt+43oq8R5tGmTSuqQwm4c9JmEq6s0eZmwUaRv+WXp9hxsE4A==
+"@bazel/typescript@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-5.5.0.tgz#053c255acb1b3cac23d24984cd8d5d5542fe1f7c"
+  integrity sha512-Ord0+nCj+B1M4NDbe0uqZf2FyOCzaDAlc4DAsr5UKJrArCipIbMTEAxlsEk+WAYBNAFGO/FS9/zlDtLceqpHqw==
   dependencies:
-    "@bazel/worker" "5.1.0"
+    "@bazel/worker" "5.5.0"
     protobufjs "6.8.8"
     semver "5.6.0"
     source-map-support "0.5.9"
     tsutils "3.21.0"
 
-"@bazel/worker@5.1.0":
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/worker/-/worker-5.1.0.tgz#6f1e0f3ef628e3449d424cacd341c9abd09a3735"
-  integrity sha512-u3aU93UtHz3vL6ozezq0jnw83s1cNT4dAnW+vvB7M++YKFlB3CWzZFb0JRJbCp1b6DDe30ML0WOdd3nVYuylpw==
+"@bazel/worker@5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/worker/-/worker-5.5.0.tgz#d30b75e46f2052d33bcda251b328d36655a5636f"
+  integrity sha512-pYfjJKg4D1CQ/AJ1UGC5ySyH09gDqNiBrQJ0uMYVewIBW24uOAkKsJfTE2y4ES0UL1Ik758WO0la0mJeFOKScg==
   dependencies:
     google-protobuf "^3.6.1"
 
-"@eslint/eslintrc@^0.4.3":
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c"
-  integrity sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==
+"@es-joy/jsdoccomment@~0.31.0":
+  version "0.31.0"
+  resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.31.0.tgz#dbc342cc38eb6878c12727985e693eaef34302bc"
+  integrity sha512-tc1/iuQcnaiSIUVad72PBierDFpsxdUHtEF/OrfqvM1CBAsIoMP51j52jTMb3dXriwhieTo289InzZj72jL3EQ==
+  dependencies:
+    comment-parser "1.3.1"
+    esquery "^1.4.0"
+    jsdoc-type-pratt-parser "~3.1.0"
+
+"@esbuild/linux-loong64@0.14.54":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
+  integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
+
+"@eslint/eslintrc@^1.3.0":
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f"
+  integrity sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==
   dependencies:
     ajv "^6.12.4"
-    debug "^4.1.1"
-    espree "^7.3.0"
-    globals "^13.9.0"
-    ignore "^4.0.6"
+    debug "^4.3.2"
+    espree "^9.3.2"
+    globals "^13.15.0"
+    ignore "^5.2.0"
     import-fresh "^3.2.1"
-    js-yaml "^3.13.1"
-    minimatch "^3.0.4"
+    js-yaml "^4.1.0"
+    minimatch "^3.1.2"
     strip-json-comments "^3.1.1"
 
-"@humanwhocodes/config-array@^0.5.0":
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9"
-  integrity sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==
+"@humanwhocodes/config-array@^0.9.2":
+  version "0.9.5"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7"
+  integrity sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==
   dependencies:
-    "@humanwhocodes/object-schema" "^1.2.0"
+    "@humanwhocodes/object-schema" "^1.2.1"
     debug "^4.1.1"
     minimatch "^3.0.4"
 
-"@humanwhocodes/object-schema@^1.2.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf"
-  integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==
+"@humanwhocodes/object-schema@^1.2.1":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
+  integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
+
+"@koa/cors@^3.3.0":
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.3.0.tgz#b4c1c7ee303b7c968c8727f2a638a74675b50bb2"
+  integrity sha512-lzlkqLlL5Ond8jb6JLnVVDmD2OPym0r5kvZlMgAWiS9xle+Q5ulw1T358oW+RVguxUkANquZQz82i/STIRmsqQ==
+  dependencies:
+    vary "^1.1.2"
+
+"@mdn/browser-compat-data@^4.0.0":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz#1fead437f3957ceebe2e8c3f46beccdb9bc575b8"
+  integrity sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==
 
 "@mrmlnc/readdir-enhanced@^2.2.1":
   version "2.2.1"
@@ -192,6 +232,27 @@
   resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
   integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
 
+"@rollup/plugin-node-resolve@^13.0.4":
+  version "13.3.0"
+  resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.3.0.tgz#da1c5c5ce8316cef96a2f823d111c1e4e498801c"
+  integrity sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw==
+  dependencies:
+    "@rollup/pluginutils" "^3.1.0"
+    "@types/resolve" "1.17.1"
+    deepmerge "^4.2.2"
+    is-builtin-module "^3.1.0"
+    is-module "^1.0.0"
+    resolve "^1.19.0"
+
+"@rollup/pluginutils@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
+  integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
+  dependencies:
+    "@types/estree" "0.0.39"
+    estree-walker "^1.0.1"
+    picomatch "^2.2.2"
+
 "@sindresorhus/is@^0.14.0":
   version "0.14.0"
   resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
@@ -204,25 +265,127 @@
   dependencies:
     defer-to-connect "^1.0.1"
 
-"@types/json-schema@^7.0.7":
-  version "7.0.9"
-  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
-  integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==
+"@types/accepts@*":
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
+  integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/body-parser@*":
+  version "1.19.2"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
+  integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==
+  dependencies:
+    "@types/connect" "*"
+    "@types/node" "*"
+
+"@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"
+  integrity sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==
+
+"@types/connect@*":
+  version "3.4.35"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
+  integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/content-disposition@*":
+  version "0.5.5"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.5.tgz#650820e95de346e1f84e30667d168c8fd25aa6e3"
+  integrity sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==
+
+"@types/cookies@*":
+  version "0.7.7"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.7.tgz#7a92453d1d16389c05a5301eef566f34946cfd81"
+  integrity sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==
+  dependencies:
+    "@types/connect" "*"
+    "@types/express" "*"
+    "@types/keygrip" "*"
+    "@types/node" "*"
+
+"@types/estree@0.0.39":
+  version "0.0.39"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
+  integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
+
+"@types/express-serve-static-core@^4.17.18":
+  version "4.17.30"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.30.tgz#0f2f99617fa8f9696170c46152ccf7500b34ac04"
+  integrity sha512-gstzbTWro2/nFed1WXtf+TtrpwxH7Ggs4RLYTLbeVgIkUQOI3WG/JKjgeOU1zXDvezllupjrf8OPIdvTbIaVOQ==
+  dependencies:
+    "@types/node" "*"
+    "@types/qs" "*"
+    "@types/range-parser" "*"
+
+"@types/express@*":
+  version "4.17.13"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034"
+  integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==
+  dependencies:
+    "@types/body-parser" "*"
+    "@types/express-serve-static-core" "^4.17.18"
+    "@types/qs" "*"
+    "@types/serve-static" "*"
+
+"@types/http-assert@*":
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661"
+  integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==
+
+"@types/http-errors@*":
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.2.tgz#7315b4c4c54f82d13fa61c228ec5c2ea5cc9e0e1"
+  integrity sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==
+
+"@types/json-schema@^7.0.9":
+  version "7.0.11"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
+  integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
 
 "@types/json5@^0.0.29":
   version "0.0.29"
   resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
   integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
 
+"@types/keygrip@*":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
+  integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==
+
+"@types/koa-compose@*":
+  version "3.2.5"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d"
+  integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==
+  dependencies:
+    "@types/koa" "*"
+
+"@types/koa@*", "@types/koa@^2.11.6":
+  version "2.13.5"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.5.tgz#64b3ca4d54e08c0062e89ec666c9f45443b21a61"
+  integrity sha512-HSUOdzKz3by4fnqagwthW/1w/yJspTgppyyalPVbgZf8jQWvdIXcVW5h2DGtw4zYntOaeRGx49r1hxoPWrD4aA==
+  dependencies:
+    "@types/accepts" "*"
+    "@types/content-disposition" "*"
+    "@types/cookies" "*"
+    "@types/http-assert" "*"
+    "@types/http-errors" "*"
+    "@types/keygrip" "*"
+    "@types/koa-compose" "*"
+    "@types/node" "*"
+
 "@types/long@^4.0.0":
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
   integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
 
-"@types/minimatch@3.0.3":
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
-  integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
+"@types/mime@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
+  integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
 
 "@types/minimist@^1.2.0":
   version "1.2.2"
@@ -230,9 +393,9 @@
   integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
 
 "@types/node@*":
-  version "16.9.6"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.6.tgz#040a64d7faf9e5d9e940357125f0963012e66f04"
-  integrity sha512-YHUZhBOMTM3mjFkXVcK+WwAcYmyhe1wL4lfqNtzI0b3qAy7yuSetnM7QJazgE5PFmgVTNGiLOgRFfJMqW7XpSQ==
+  version "18.7.2"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.2.tgz#22306626110c459aedd2cdf131c749ec781e3b34"
+  integrity sha512-ce7MIiaYWCFv6A83oEultwhBXb22fxwNOQf5DIxWA4WXvDQ7K+L0fbWl/YOfCzlR5B/uFkSnVBhPcOfOECcWvA==
 
 "@types/node@^10.1.0":
   version "10.17.60"
@@ -244,84 +407,227 @@
   resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
   integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
 
-"@typescript-eslint/eslint-plugin@^4.2.0", "@typescript-eslint/eslint-plugin@^4.29.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.30.0.tgz#4a0c1ae96b953f4e67435e20248d812bfa55e4fb"
-  integrity sha512-NgAnqk55RQ/SD+tZFD9aPwNSeHmDHHe5rtUyhIq0ZeCWZEvo4DK9rYz7v9HDuQZFvn320Ot+AikaCKMFKLlD0g==
+"@types/page@^1.11.5":
+  version "1.11.5"
+  resolved "https://registry.yarnpkg.com/@types/page/-/page-1.11.5.tgz#7e60f8c78a05f5b0c26a0b4334647490e074de68"
+  integrity sha512-v0uoRBrJOnYWI/HcqmhLFbkWPW6tF183FdorVLYsep+HKxW1vFT/G+yaUymvS26uL8NPKj8hit4QEtphGDTwxA==
+
+"@types/parse5@^6.0.1":
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb"
+  integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
+
+"@types/qs@*":
+  version "6.9.7"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
+  integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
+
+"@types/range-parser@*":
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
+  integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
+
+"@types/resolve@1.17.1":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
+  integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==
   dependencies:
-    "@typescript-eslint/experimental-utils" "4.30.0"
-    "@typescript-eslint/scope-manager" "4.30.0"
-    debug "^4.3.1"
+    "@types/node" "*"
+
+"@types/serve-static@*":
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.0.tgz#c7930ff61afb334e121a9da780aac0d9b8f34155"
+  integrity sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==
+  dependencies:
+    "@types/mime" "*"
+    "@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" "*"
+
+"@typescript-eslint/eslint-plugin@^4.2.0", "@typescript-eslint/eslint-plugin@^5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.27.0.tgz#23d82a4f21aaafd8f69dbab7e716323bb6695cc8"
+  integrity sha512-DDrIA7GXtmHXr1VCcx9HivA39eprYBIFxbQEHI6NyraRDxCGpxAFiYQAT/1Y0vh1C+o2vfBiy4IuPoXxtTZCAQ==
+  dependencies:
+    "@typescript-eslint/scope-manager" "5.27.0"
+    "@typescript-eslint/type-utils" "5.27.0"
+    "@typescript-eslint/utils" "5.27.0"
+    debug "^4.3.4"
     functional-red-black-tree "^1.0.1"
-    regexpp "^3.1.0"
-    semver "^7.3.5"
+    ignore "^5.2.0"
+    regexpp "^3.2.0"
+    semver "^7.3.7"
     tsutils "^3.21.0"
 
-"@typescript-eslint/experimental-utils@4.30.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.30.0.tgz#9e49704fef568432ae16fc0d6685c13d67db0fd5"
-  integrity sha512-K8RNIX9GnBsv5v4TjtwkKtqMSzYpjqAQg/oSphtxf3xxdt6T0owqnpojztjjTcatSteH3hLj3t/kklKx87NPqw==
+"@typescript-eslint/parser@^4.2.0", "@typescript-eslint/parser@^5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.27.0.tgz#62bb091ed5cf9c7e126e80021bb563dcf36b6b12"
+  integrity sha512-8oGjQF46c52l7fMiPPvX4It3u3V3JipssqDfHQ2hcR0AeR8Zge+OYyKUCm5b70X72N1qXt0qgHenwN6Gc2SXZA==
   dependencies:
-    "@types/json-schema" "^7.0.7"
-    "@typescript-eslint/scope-manager" "4.30.0"
-    "@typescript-eslint/types" "4.30.0"
-    "@typescript-eslint/typescript-estree" "4.30.0"
+    "@typescript-eslint/scope-manager" "5.27.0"
+    "@typescript-eslint/types" "5.27.0"
+    "@typescript-eslint/typescript-estree" "5.27.0"
+    debug "^4.3.4"
+
+"@typescript-eslint/scope-manager@5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.27.0.tgz#a272178f613050ed62f51f69aae1e19e870a8bbb"
+  integrity sha512-VnykheBQ/sHd1Vt0LJ1JLrMH1GzHO+SzX6VTXuStISIsvRiurue/eRkTqSrG0CexHQgKG8shyJfR4o5VYioB9g==
+  dependencies:
+    "@typescript-eslint/types" "5.27.0"
+    "@typescript-eslint/visitor-keys" "5.27.0"
+
+"@typescript-eslint/type-utils@5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.27.0.tgz#36fd95f6747412251d79c795b586ba766cf0974b"
+  integrity sha512-vpTvRRchaf628Hb/Xzfek+85o//zEUotr1SmexKvTfs7czXfYjXVT/a5yDbpzLBX1rhbqxjDdr1Gyo0x1Fc64g==
+  dependencies:
+    "@typescript-eslint/utils" "5.27.0"
+    debug "^4.3.4"
+    tsutils "^3.21.0"
+
+"@typescript-eslint/types@5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.27.0.tgz#c3f44b9dda6177a9554f94a74745ca495ba9c001"
+  integrity sha512-lY6C7oGm9a/GWhmUDOs3xAVRz4ty/XKlQ2fOLr8GAIryGn0+UBOoJDWyHer3UgrHkenorwvBnphhP+zPmzmw0A==
+
+"@typescript-eslint/typescript-estree@5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.27.0.tgz#7965f5b553c634c5354a47dcce0b40b94611e995"
+  integrity sha512-QywPMFvgZ+MHSLRofLI7BDL+UczFFHyj0vF5ibeChDAJgdTV8k4xgEwF0geFhVlPc1p8r70eYewzpo6ps+9LJQ==
+  dependencies:
+    "@typescript-eslint/types" "5.27.0"
+    "@typescript-eslint/visitor-keys" "5.27.0"
+    debug "^4.3.4"
+    globby "^11.1.0"
+    is-glob "^4.0.3"
+    semver "^7.3.7"
+    tsutils "^3.21.0"
+
+"@typescript-eslint/utils@5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.27.0.tgz#d0021cbf686467a6a9499bd0589e19665f9f7e71"
+  integrity sha512-nZvCrkIJppym7cIbP3pOwIkAefXOmfGPnCM0LQfzNaKxJHI6VjI8NC662uoiPlaf5f6ymkTy9C3NQXev2mdXmA==
+  dependencies:
+    "@types/json-schema" "^7.0.9"
+    "@typescript-eslint/scope-manager" "5.27.0"
+    "@typescript-eslint/types" "5.27.0"
+    "@typescript-eslint/typescript-estree" "5.27.0"
     eslint-scope "^5.1.1"
     eslint-utils "^3.0.0"
 
-"@typescript-eslint/parser@^4.2.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.30.0.tgz#6abd720f66bd790f3e0e80c3be77180c8fcb192d"
-  integrity sha512-HJ0XuluSZSxeboLU7Q2VQ6eLlCwXPBOGnA7CqgBnz2Db3JRQYyBDJgQnop6TZ+rsbSx5gEdWhw4rE4mDa1FnZg==
+"@typescript-eslint/visitor-keys@5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.27.0.tgz#97aa9a5d2f3df8215e6d3b77f9d214a24db269bd"
+  integrity sha512-46cYrteA2MrIAjv9ai44OQDUoCZyHeGIc4lsjCUX2WT6r4C+kidz1bNiR4017wHOPUythYeH+Sc7/cFP97KEAA==
   dependencies:
-    "@typescript-eslint/scope-manager" "4.30.0"
-    "@typescript-eslint/types" "4.30.0"
-    "@typescript-eslint/typescript-estree" "4.30.0"
-    debug "^4.3.1"
+    "@typescript-eslint/types" "5.27.0"
+    eslint-visitor-keys "^3.3.0"
 
-"@typescript-eslint/scope-manager@4.30.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.30.0.tgz#1a3ffbb385b1a06be85cd5165a22324f069a85ee"
-  integrity sha512-VJ/jAXovxNh7rIXCQbYhkyV2Y3Ac/0cVHP/FruTJSAUUm4Oacmn/nkN5zfWmWFEanN4ggP0vJSHOeajtHq3f8A==
+"@web/config-loader@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@web/config-loader/-/config-loader-0.1.3.tgz#8325ea54f75ef2ee7166783e64e66936db25bff7"
+  integrity sha512-XVKH79pk4d3EHRhofete8eAnqto1e8mCRAqPV00KLNFzCWSe8sWmLnqKCqkPNARC6nksMaGrATnA5sPDRllMpQ==
   dependencies:
-    "@typescript-eslint/types" "4.30.0"
-    "@typescript-eslint/visitor-keys" "4.30.0"
+    semver "^7.3.4"
 
-"@typescript-eslint/types@4.30.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.30.0.tgz#fb9d9b0358426f18687fba82eb0b0f869780204f"
-  integrity sha512-YKldqbNU9K4WpTNwBqtAerQKLLW/X2A/j4yw92e3ZJYLx+BpKLeheyzoPfzIXHfM8BXfoleTdiYwpsvVPvHrDw==
-
-"@typescript-eslint/typescript-estree@4.30.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.30.0.tgz#ae57833da72a753f4846cd3053758c771670c2ac"
-  integrity sha512-6WN7UFYvykr/U0Qgy4kz48iGPWILvYL34xXJxvDQeiRE018B7POspNRVtAZscWntEPZpFCx4hcz/XBT+erenfg==
+"@web/dev-server-core@^0.3.19":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.3.19.tgz#b61f9a0b92351371347a758b30ba19e683c72e94"
+  integrity sha512-Q/Xt4RMVebLWvALofz1C0KvP8qHbzU1EmdIA2Y1WMPJwiFJFhPxdr75p9YxK32P2t0hGs6aqqS5zE0HW9wYzYA==
   dependencies:
-    "@typescript-eslint/types" "4.30.0"
-    "@typescript-eslint/visitor-keys" "4.30.0"
-    debug "^4.3.1"
-    globby "^11.0.3"
-    is-glob "^4.0.1"
-    semver "^7.3.5"
-    tsutils "^3.21.0"
+    "@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 "^1.0.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"
 
-"@typescript-eslint/visitor-keys@4.30.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.30.0.tgz#a47c6272fc71b0c627d1691f68eaecf4ad71445e"
-  integrity sha512-pNaaxDt/Ol/+JZwzP7MqWc8PJQTUhZwoee/PVlQ+iYoYhagccvoHnC9e4l+C/krQYYkENxznhVSDwClIbZVxRw==
+"@web/dev-server-esbuild@^0.3.2":
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-esbuild/-/dev-server-esbuild-0.3.2.tgz#d4f43c1677123021f6c5805beaac902318f7e083"
+  integrity sha512-Jn9b+Rs1ck4QN+ksue6qFdvUc2r/+NHpMW0R86W4Kqw5WjE7dT44pCGkKNfB8Fph4dNi0MgDaMhIkW2fcSpogA==
   dependencies:
-    "@typescript-eslint/types" "4.30.0"
-    eslint-visitor-keys "^2.0.0"
+    "@mdn/browser-compat-data" "^4.0.0"
+    "@web/dev-server-core" "^0.3.19"
+    esbuild "^0.12 || ^0.13 || ^0.14"
+    parse5 "^6.0.1"
+    ua-parser-js "^1.0.2"
 
-acorn-jsx@^5.3.1:
+"@web/dev-server-rollup@^0.3.19":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-rollup/-/dev-server-rollup-0.3.19.tgz#188f3a37bcc38f4dc1b208663b14ab2d17321a57"
+  integrity sha512-IwiwI+fyX0YuvAOldStlYJ+Zm/JfSCk9OSGIs7+fWbOYysEHwkEVvBwoPowaclSZA44Tobvqt+6ej9udbbZ/WQ==
+  dependencies:
+    "@rollup/plugin-node-resolve" "^13.0.4"
+    "@web/dev-server-core" "^0.3.19"
+    nanocolors "^0.2.1"
+    parse5 "^6.0.1"
+    rollup "^2.67.0"
+    whatwg-url "^11.0.0"
+
+"@web/dev-server@^0.1.33":
+  version "0.1.33"
+  resolved "https://registry.yarnpkg.com/@web/dev-server/-/dev-server-0.1.33.tgz#0f0723257823b6f51fc7e02704549162128acd1e"
+  integrity sha512-ge8fL6TbeUeDxfbiB0EiZl+KE+EjEc9gAur0OxOQvNKUZFOkqWn1s2X/6AuaN+aQnUAeUebvChzyDuQwtBuKWg==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/command-line-args" "^5.0.0"
+    "@web/config-loader" "^0.1.3"
+    "@web/dev-server-core" "^0.3.19"
+    "@web/dev-server-rollup" "^0.3.19"
+    camelcase "^6.2.0"
+    command-line-args "^5.1.1"
+    command-line-usage "^6.1.1"
+    debounce "^1.2.0"
+    deepmerge "^4.2.2"
+    ip "^1.1.5"
+    nanocolors "^0.2.1"
+    open "^8.0.2"
+    portfinder "^1.0.28"
+
+"@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"
+
+accepts@^1.3.5:
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
+  integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
+  dependencies:
+    mime-types "~2.1.34"
+    negotiator "0.6.3"
+
+acorn-jsx@^5.3.2:
   version "5.3.2"
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
 
-acorn@^7.4.0:
-  version "7.4.1"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
-  integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
+acorn@^8.7.1:
+  version "8.7.1"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
+  integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
 
 ajv@^6.10.0, ajv@^6.12.4:
   version "6.12.6"
@@ -333,16 +639,6 @@
     json-schema-traverse "^0.4.1"
     uri-js "^4.2.2"
 
-ajv@^8.0.1:
-  version "8.6.2"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.6.2.tgz#2fb45e0e5fcbc0813326c1c3da535d1881bb0571"
-  integrity sha512-9807RlWAgT564wT+DjeyU5OFMPjmzxVobvDFmNAhY+5zD6A2ly3jDp6sgnfyDtlIQ+7H97oc/DGCzzfu9rjw9w==
-  dependencies:
-    fast-deep-equal "^3.1.1"
-    json-schema-traverse "^1.0.0"
-    require-from-string "^2.0.2"
-    uri-js "^4.2.2"
-
 ansi-align@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.0.tgz#b536b371cf687caaef236c18d3e21fe3797467cb"
@@ -350,11 +646,6 @@
   dependencies:
     string-width "^3.0.0"
 
-ansi-colors@^4.1.1:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
-  integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
-
 ansi-escapes@^4.2.1:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
@@ -372,6 +663,11 @@
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
   integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
 
+ansi-regex@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+  integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
 ansi-styles@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@@ -386,12 +682,18 @@
   dependencies:
     color-convert "^2.0.1"
 
-argparse@^1.0.7:
-  version "1.0.10"
-  resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
-  integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==
+anymatch@~3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
+  integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
   dependencies:
-    sprintf-js "~1.0.2"
+    normalize-path "^3.0.0"
+    picomatch "^2.0.4"
+
+argparse@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
+  integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
 
 arr-diff@^4.0.0:
   version "4.0.0"
@@ -408,16 +710,26 @@
   resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
   integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
 
-array-includes@^3.1.3:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.3.tgz#c7f619b382ad2afaf5326cddfdc0afc61af7690a"
-  integrity sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==
+array-back@^3.0.1, array-back@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
+  integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
+
+array-back@^4.0.1, array-back@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e"
+  integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==
+
+array-includes@^3.1.4:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.5.tgz#2c320010db8d31031fd2a5f6b3bbd4b1aad31bdb"
+  integrity sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ==
   dependencies:
     call-bind "^1.0.2"
-    define-properties "^1.1.3"
-    es-abstract "^1.18.0-next.2"
+    define-properties "^1.1.4"
+    es-abstract "^1.19.5"
     get-intrinsic "^1.1.1"
-    is-string "^1.0.5"
+    is-string "^1.0.7"
 
 array-union@^2.1.0:
   version "2.1.0"
@@ -429,14 +741,15 @@
   resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
   integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
 
-array.prototype.flat@^1.2.4:
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz#6ef638b43312bd401b4c6199fdec7e2dc9e9a123"
-  integrity sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==
+array.prototype.flat@^1.2.5:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz#0b0c1567bf57b38b56b4c97b8aa72ab45e4adc7b"
+  integrity sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw==
   dependencies:
-    call-bind "^1.0.0"
+    call-bind "^1.0.2"
     define-properties "^1.1.3"
-    es-abstract "^1.18.0-next.1"
+    es-abstract "^1.19.2"
+    es-shim-unscopables "^1.0.0"
 
 arrify@^1.0.1:
   version "1.0.1"
@@ -448,10 +761,12 @@
   resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
   integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
 
-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.4:
+  version "2.6.4"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
+  integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
+  dependencies:
+    lodash "^4.17.14"
 
 atob@^2.1.2:
   version "2.1.2"
@@ -476,10 +791,10 @@
     mixin-deep "^1.2.0"
     pascalcase "^0.1.1"
 
-boolbase@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
-  integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
+binary-extensions@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
+  integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
 
 boxen@^5.0.0:
   version "5.0.1"
@@ -495,7 +810,7 @@
     widest-line "^3.1.0"
     wrap-ansi "^7.0.0"
 
-brace-expansion@^1.0.0, brace-expansion@^1.1.7:
+brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
   integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
@@ -519,7 +834,7 @@
     split-string "^3.0.2"
     to-regex "^3.0.1"
 
-braces@^3.0.1:
+braces@^3.0.1, 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==
@@ -531,6 +846,11 @@
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
   integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
 
+builtin-modules@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
+  integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
+
 cache-base@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
@@ -546,6 +866,14 @@
     union-value "^1.0.0"
     unset-value "^1.0.0"
 
+cache-content-type@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c"
+  integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==
+  dependencies:
+    mime-types "^2.1.18"
+    ylru "^1.2.0"
+
 cacheable-request@^6.0.0:
   version "6.1.0"
   resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912"
@@ -618,17 +946,20 @@
   resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
   integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
 
-cheerio@1.0.0-rc.2:
-  version "1.0.0-rc.2"
-  resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.2.tgz#4b9f53a81b27e4d5dac31c0ffd0cfa03cc6830db"
-  integrity sha1-S59TqBsn5NXawxwP/Qz6A8xoMNs=
+chokidar@^3.4.3:
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
+  integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
   dependencies:
-    css-select "~1.2.0"
-    dom-serializer "~0.1.0"
-    entities "~1.1.1"
-    htmlparser2 "^3.9.1"
-    lodash "^4.15.0"
-    parse5 "^3.0.1"
+    anymatch "~3.1.2"
+    braces "~3.0.2"
+    glob-parent "~5.1.2"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.6.0"
+  optionalDependencies:
+    fsevents "~2.3.2"
 
 ci-info@^2.0.0:
   version "2.0.0"
@@ -678,6 +1009,16 @@
   dependencies:
     mimic-response "^1.0.0"
 
+clone@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
+  integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
+
+co@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
+  integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==
+
 collection-visit@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
@@ -710,15 +1051,35 @@
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
+command-line-args@^5.1.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
+  integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
+  dependencies:
+    array-back "^3.1.0"
+    find-replace "^3.0.0"
+    lodash.camelcase "^4.3.0"
+    typical "^4.0.0"
+
+command-line-usage@^6.1.1:
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.3.tgz#428fa5acde6a838779dfa30e44686f4b6761d957"
+  integrity sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==
+  dependencies:
+    array-back "^4.0.2"
+    chalk "^2.4.2"
+    table-layout "^1.0.2"
+    typical "^5.2.0"
+
 commander@^2.20.0:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
   integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
 
-comment-parser@1.1.5:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.1.5.tgz#453627ef8f67dbcec44e79a9bd5baa37f0bce9b2"
-  integrity sha512-RePCE4leIhBlmrqiYTvaqEeGYg7qpSl4etaIabKtdOQVi+mSTIBBklGUwIr79GXYnl3LpMwmDw4KeR2stNc6FA==
+comment-parser@1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.3.1.tgz#3d7ea3adaf9345594aedee6563f422348f165c1b"
+  integrity sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==
 
 component-emitter@^1.2.1:
   version "1.3.0"
@@ -742,6 +1103,26 @@
     write-file-atomic "^3.0.0"
     xdg-basedir "^4.0.0"
 
+content-disposition@~0.5.2:
+  version "0.5.4"
+  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
+  integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
+  dependencies:
+    safe-buffer "5.2.1"
+
+content-type@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
+  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
+
+cookies@~0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
+  integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==
+  dependencies:
+    depd "~2.0.0"
+    keygrip "~1.1.0"
+
 copy-descriptor@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
@@ -772,20 +1153,10 @@
   resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
   integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
 
-css-select@~1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858"
-  integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=
-  dependencies:
-    boolbase "~1.0.0"
-    css-what "2.1"
-    domutils "1.5.1"
-    nth-check "~1.0.1"
-
-css-what@2.1:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
-  integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==
+debounce@^1.2.0:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
+  integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
 
 debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
   version "2.6.9"
@@ -794,20 +1165,27 @@
   dependencies:
     ms "2.0.0"
 
-debug@^3.2.7:
+debug@^3.1.0, debug@^3.2.7:
   version "3.2.7"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
   integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
   dependencies:
     ms "^2.1.1"
 
-debug@^4.0.1, debug@^4.1.1, debug@^4.3.1:
+debug@^4.1.1:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
   integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
   dependencies:
     ms "2.1.2"
 
+debug@^4.3.2, debug@^4.3.4:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
+  integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
+  dependencies:
+    ms "2.1.2"
+
 decamelize-keys@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9"
@@ -833,7 +1211,12 @@
   dependencies:
     mimic-response "^1.0.0"
 
-deep-extend@^0.6.0:
+deep-equal@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
+  integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==
+
+deep-extend@^0.6.0, deep-extend@~0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
   integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
@@ -843,11 +1226,21 @@
   resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
   integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
 
+deepmerge@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
+  integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
+
 defer-to-connect@^1.0.1:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591"
   integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==
 
+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"
@@ -855,6 +1248,14 @@
   dependencies:
     object-keys "^1.0.12"
 
+define-properties@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1"
+  integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==
+  dependencies:
+    has-property-descriptors "^1.0.0"
+    object-keys "^1.1.1"
+
 define-property@^0.2.5:
   version "0.2.5"
   resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116"
@@ -877,6 +1278,26 @@
     is-descriptor "^1.0.2"
     isobject "^3.0.1"
 
+delegates@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+  integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
+
+depd@^2.0.0, depd@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+  integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+
+depd@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
+  integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
+
+destroy@^1.0.4:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
+  integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+
 didyoumean2@4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/didyoumean2/-/didyoumean2-4.1.0.tgz#f813cb7c82c249443e599be077f76e88f24b85e4"
@@ -907,14 +1328,6 @@
   dependencies:
     esutils "^2.0.2"
 
-dom-serializer@0:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
-  integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==
-  dependencies:
-    domelementtype "^2.0.1"
-    entities "^2.0.0"
-
 dom-serializer@^1.0.1:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91"
@@ -924,55 +1337,26 @@
     domhandler "^4.2.0"
     entities "^2.0.0"
 
-dom-serializer@~0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0"
-  integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==
-  dependencies:
-    domelementtype "^1.3.0"
-    entities "^1.1.1"
-
-domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f"
-  integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==
-
 domelementtype@^2.0.1, domelementtype@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57"
   integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
 
-domhandler@^2.3.0:
-  version "2.4.2"
-  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803"
-  integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==
-  dependencies:
-    domelementtype "1"
-
-domhandler@^4.0.0, domhandler@^4.2.0:
+domhandler@^4.2.0:
   version "4.2.2"
   resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.2.tgz#e825d721d19a86b8c201a35264e226c678ee755f"
   integrity sha512-PzE9aBMsdZO8TK4BnuJwH0QT41wgMbRzuZrHUcpYncEjmQazq8QEaBWgLG7ZyC/DAZKEgglpIA6j4Qn/HmxS3w==
   dependencies:
     domelementtype "^2.2.0"
 
-domutils@1.5.1:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
-  integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=
+domhandler@^4.2.2:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c"
+  integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==
   dependencies:
-    dom-serializer "0"
-    domelementtype "1"
+    domelementtype "^2.2.0"
 
-domutils@^1.5.1:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
-  integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==
-  dependencies:
-    dom-serializer "0"
-    domelementtype "1"
-
-domutils@^2.5.2:
+domutils@^2.8.0:
   version "2.8.0"
   resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135"
   integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==
@@ -993,6 +1377,11 @@
   resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
   integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
 
+ee-first@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+  integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
+
 emoji-regex@^7.0.1:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
@@ -1003,6 +1392,11 @@
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
   integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
 
+encodeurl@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+  integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
+
 end-of-stream@^1.1.0:
   version "1.4.4"
   resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
@@ -1010,23 +1404,16 @@
   dependencies:
     once "^1.4.0"
 
-enquirer@^2.3.5:
-  version "2.3.6"
-  resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d"
-  integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==
-  dependencies:
-    ansi-colors "^4.1.1"
-
-entities@^1.1.1, entities@~1.1.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
-  integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==
-
 entities@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
   integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
 
+entities@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4"
+  integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==
+
 error-ex@^1.3.1:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
@@ -1034,28 +1421,34 @@
   dependencies:
     is-arrayish "^0.2.1"
 
-es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next.2, es-abstract@^1.18.2:
-  version "1.18.5"
-  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.5.tgz#9b10de7d4c206a3581fd5b2124233e04db49ae19"
-  integrity sha512-DDggyJLoS91CkJjgauM5c0yZMjiD1uK3KcaCeAmffGwZ+ODWzOkPN4QwRbsK5DOFf06fywmyLci3ZD8jLGhVYA==
+es-abstract@^1.19.0, es-abstract@^1.19.2, es-abstract@^1.19.5:
+  version "1.20.1"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.1.tgz#027292cd6ef44bd12b1913b828116f54787d1814"
+  integrity sha512-WEm2oBhfoI2sImeM4OF2zE2V3BYdSF+KnSi9Sidz51fQHd7+JuF8Xgcj9/0o+OWeIeIS/MiuNnlruQrJf16GQA==
   dependencies:
     call-bind "^1.0.2"
     es-to-primitive "^1.2.1"
     function-bind "^1.1.1"
+    function.prototype.name "^1.1.5"
     get-intrinsic "^1.1.1"
+    get-symbol-description "^1.0.0"
     has "^1.0.3"
-    has-symbols "^1.0.2"
+    has-property-descriptors "^1.0.0"
+    has-symbols "^1.0.3"
     internal-slot "^1.0.3"
-    is-callable "^1.2.3"
-    is-negative-zero "^2.0.1"
-    is-regex "^1.1.3"
-    is-string "^1.0.6"
-    object-inspect "^1.11.0"
+    is-callable "^1.2.4"
+    is-negative-zero "^2.0.2"
+    is-regex "^1.1.4"
+    is-shared-array-buffer "^1.0.2"
+    is-string "^1.0.7"
+    is-weakref "^1.0.2"
+    object-inspect "^1.12.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"
+    regexp.prototype.flags "^1.4.3"
+    string.prototype.trimend "^1.0.5"
+    string.prototype.trimstart "^1.0.5"
+    unbox-primitive "^1.0.2"
 
 es-abstract@^1.19.1:
   version "1.19.1"
@@ -1083,6 +1476,18 @@
     string.prototype.trimstart "^1.0.4"
     unbox-primitive "^1.0.1"
 
+es-module-lexer@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.0.3.tgz#f0d8d35b36d13024110000d5e6fadc8eeaeb66b8"
+  integrity sha512-iC67eXHToclrlVhQfpRawDiF8D8sQxNxmbqw5oebegOaJkyx/w9C/k57/5e6yJR2zIByRt9OXdqX50DV2t6ZKw==
+
+es-shim-unscopables@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241"
+  integrity sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==
+  dependencies:
+    has "^1.0.3"
+
 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"
@@ -1092,11 +1497,143 @@
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
 
+esbuild-android-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
+  integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
+
+esbuild-android-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
+  integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
+
+esbuild-darwin-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
+  integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
+
+esbuild-darwin-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
+  integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
+
+esbuild-freebsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
+  integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
+
+esbuild-freebsd-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
+  integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
+
+esbuild-linux-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
+  integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
+
+esbuild-linux-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652"
+  integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
+
+esbuild-linux-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
+  integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
+
+esbuild-linux-arm@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
+  integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
+
+esbuild-linux-mips64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
+  integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
+
+esbuild-linux-ppc64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
+  integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
+
+esbuild-linux-riscv64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
+  integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
+
+esbuild-linux-s390x@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
+  integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
+
+esbuild-netbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
+  integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
+
+esbuild-openbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
+  integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
+
+esbuild-sunos-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
+  integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
+
+esbuild-windows-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
+  integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
+
+esbuild-windows-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
+  integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
+
+esbuild-windows-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
+  integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
+
+"esbuild@^0.12 || ^0.13 || ^0.14":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2"
+  integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==
+  optionalDependencies:
+    "@esbuild/linux-loong64" "0.14.54"
+    esbuild-android-64 "0.14.54"
+    esbuild-android-arm64 "0.14.54"
+    esbuild-darwin-64 "0.14.54"
+    esbuild-darwin-arm64 "0.14.54"
+    esbuild-freebsd-64 "0.14.54"
+    esbuild-freebsd-arm64 "0.14.54"
+    esbuild-linux-32 "0.14.54"
+    esbuild-linux-64 "0.14.54"
+    esbuild-linux-arm "0.14.54"
+    esbuild-linux-arm64 "0.14.54"
+    esbuild-linux-mips64le "0.14.54"
+    esbuild-linux-ppc64le "0.14.54"
+    esbuild-linux-riscv64 "0.14.54"
+    esbuild-linux-s390x "0.14.54"
+    esbuild-netbsd-64 "0.14.54"
+    esbuild-openbsd-64 "0.14.54"
+    esbuild-sunos-64 "0.14.54"
+    esbuild-windows-32 "0.14.54"
+    esbuild-windows-64 "0.14.54"
+    esbuild-windows-arm64 "0.14.54"
+
 escape-goat@^2.0.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
   integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
 
+escape-html@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+  integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
+
 escape-string-regexp@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
@@ -1125,13 +1662,13 @@
     debug "^3.2.7"
     resolve "^1.20.0"
 
-eslint-module-utils@^2.6.2:
-  version "2.6.2"
-  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.6.2.tgz#94e5540dd15fe1522e8ffa3ec8db3b7fa7e7a534"
-  integrity sha512-QG8pcgThYOuqxupd06oYTZoNOGaUdTY1PqK+oS6ElF6vs4pBdk/aYxFVQQXzcrAqp9m7cl7lb2ubazX+g16k2Q==
+eslint-module-utils@^2.7.3:
+  version "2.7.3"
+  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz#ad7e3a10552fdd0642e1e55292781bd6e34876ee"
+  integrity sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==
   dependencies:
     debug "^3.2.7"
-    pkg-dir "^2.0.0"
+    find-up "^2.1.0"
 
 eslint-plugin-es@^3.0.0:
   version "3.0.1"
@@ -1141,51 +1678,49 @@
     eslint-utils "^2.0.0"
     regexpp "^3.0.0"
 
-eslint-plugin-html@^6.1.2:
-  version "6.1.2"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.1.2.tgz#fa26e4804428956c80e963b6499c192061c2daf3"
-  integrity sha512-bhBIRyZFqI4EoF12lGDHAmgfff8eLXx6R52/K3ESQhsxzCzIE6hdebS7Py651f7U3RBotqroUnC3L29bR7qJWQ==
+eslint-plugin-html@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.2.0.tgz#715bc00b50bbd0d996e28f953c289a5ebec69d43"
+  integrity sha512-vi3NW0E8AJombTvt8beMwkL1R/fdRWl4QSNRNMhVQKWm36/X0KF0unGNAY4mqUF06mnwVWZcIcerrCnfn9025g==
   dependencies:
-    htmlparser2 "^6.0.1"
+    htmlparser2 "^7.1.2"
 
-eslint-plugin-import@^2.22.1:
-  version "2.24.2"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.24.2.tgz#2c8cd2e341f3885918ee27d18479910ade7bb4da"
-  integrity sha512-hNVtyhiEtZmpsabL4neEj+6M5DCLgpYyG9nzJY8lZQeQXEn5UPW1DpUdsMHMXsq98dbNm7nt1w9ZMSVpfJdi8Q==
+eslint-plugin-import@^2.26.0:
+  version "2.26.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b"
+  integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==
   dependencies:
-    array-includes "^3.1.3"
-    array.prototype.flat "^1.2.4"
+    array-includes "^3.1.4"
+    array.prototype.flat "^1.2.5"
     debug "^2.6.9"
     doctrine "^2.1.0"
     eslint-import-resolver-node "^0.3.6"
-    eslint-module-utils "^2.6.2"
-    find-up "^2.0.0"
+    eslint-module-utils "^2.7.3"
     has "^1.0.3"
-    is-core-module "^2.6.0"
-    minimatch "^3.0.4"
-    object.values "^1.1.4"
-    pkg-up "^2.0.0"
-    read-pkg-up "^3.0.0"
-    resolve "^1.20.0"
-    tsconfig-paths "^3.11.0"
+    is-core-module "^2.8.1"
+    is-glob "^4.0.3"
+    minimatch "^3.1.2"
+    object.values "^1.1.5"
+    resolve "^1.22.0"
+    tsconfig-paths "^3.14.1"
 
-eslint-plugin-jsdoc@^32.3.0:
-  version "32.3.4"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-32.3.4.tgz#6888f3b2dbb9f73fb551458c639a4e8c84fe9ddc"
-  integrity sha512-xSWfsYvffXnN0OkwLnB7MoDDDDjqcp46W7YlY1j7JyfAQBQ+WnGCfLov3gVNZjUGtK9Otj8mEhTZTqJu4QtIGA==
+eslint-plugin-jsdoc@^39.3.2:
+  version "39.3.2"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.3.2.tgz#b9c3becdbd860a75b8bd07bd04a0eaaad7c79403"
+  integrity sha512-RSGN94RYzIJS/WfW3l6cXzRLfJWxvJgNQZ4w0WCaxJWDJMigtwTsILEAfKqmmPkT2rwMH/s3C7G5ChDE6cwPJg==
   dependencies:
-    comment-parser "1.1.5"
-    debug "^4.3.1"
-    jsdoctypeparser "^9.0.0"
-    lodash "^4.17.21"
-    regextras "^0.7.1"
-    semver "^7.3.5"
+    "@es-joy/jsdoccomment" "~0.31.0"
+    comment-parser "1.3.1"
+    debug "^4.3.4"
+    escape-string-regexp "^4.0.0"
+    esquery "^1.4.0"
+    semver "^7.3.7"
     spdx-expression-parse "^3.0.1"
 
-eslint-plugin-lit@^1.5.1:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-lit/-/eslint-plugin-lit-1.5.1.tgz#e5b86fee4aeb6023ad4bb90b3d9e462ca8eff755"
-  integrity sha512-pYB0QM11uyOk5L55QfGhBmWi8a56PkNsnx+zVpY4bxz9YVquEo4BeRnFmf9AwFyT89rhGud9QruFhM2xJ4piwg==
+eslint-plugin-lit@^1.6.1:
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-lit/-/eslint-plugin-lit-1.6.1.tgz#e1f51fe9e580d4095b58cc4bc4dc6b44409af6b0"
+  integrity sha512-BpPoWVhf8dQ/Sz5Pi9NlqbGoH5BcMcVyXhi2XTx2XGMAO9U2lS+GTSsqJjI5hL3OuxCicNiUEWXazAwi9cAGxQ==
   dependencies:
     parse5 "^6.0.1"
     parse5-htmlparser2-tree-adapter "^6.0.1"
@@ -1203,17 +1738,24 @@
     resolve "^1.10.1"
     semver "^6.1.0"
 
-eslint-plugin-prettier@^3.1.4, eslint-plugin-prettier@^3.4.0:
+eslint-plugin-prettier@^3.1.4:
   version "3.4.1"
   resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.4.1.tgz#e9ddb200efb6f3d05ffe83b1665a716af4a387e5"
   integrity sha512-htg25EUYUeIhKHXjOinK4BgCcDwtLHjqaxCDsMy5nbnUMkKFvIhMVCp+5GFUXQ4Nr8lBsPqtGAqBenbpFqAA2g==
   dependencies:
     prettier-linter-helpers "^1.0.0"
 
-eslint-plugin-regex@^1.8.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-regex/-/eslint-plugin-regex-1.8.0.tgz#4bd111cf5235fb76a4a7f77d7ffcb7b3777b8a77"
-  integrity sha512-rmzVvpoxHKgvcYDo9d1X9RMFOtyOV3A6taD3KWE6gIID2dHoc8RPd0YAjDSJ0LG35wnehQBfsNB+F7q4eYqXqw==
+eslint-plugin-prettier@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.0.0.tgz#8b99d1e4b8b24a762472b4567992023619cb98e0"
+  integrity sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ==
+  dependencies:
+    prettier-linter-helpers "^1.0.0"
+
+eslint-plugin-regex@^1.9.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-regex/-/eslint-plugin-regex-1.9.0.tgz#4eb4f903edeeec3d641a7fcc10ad4d5209cb783d"
+  integrity sha512-T7/Rn6qp/Wp9VlLraimUvGW81wkJ661wFicHyHrm4iSJfl33yyUFJEwknYjFjrUTXAsYA6wCCvPpBss/gOlVNA==
 
 eslint-scope@^5.1.1:
   version "5.1.1"
@@ -1223,7 +1765,15 @@
     esrecurse "^4.3.0"
     estraverse "^4.1.1"
 
-eslint-utils@^2.0.0, eslint-utils@^2.1.0:
+eslint-scope@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642"
+  integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==
+  dependencies:
+    esrecurse "^4.3.0"
+    estraverse "^5.2.0"
+
+eslint-utils@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27"
   integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==
@@ -1237,7 +1787,7 @@
   dependencies:
     eslint-visitor-keys "^2.0.0"
 
-eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0:
+eslint-visitor-keys@^1.1.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e"
   integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==
@@ -1247,65 +1797,60 @@
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303"
   integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==
 
-eslint@^7.10.0, eslint@^7.24.0:
-  version "7.32.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d"
-  integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==
+eslint-visitor-keys@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826"
+  integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
+
+eslint@^7.10.0, eslint@^8.16.0:
+  version "8.16.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.16.0.tgz#6d936e2d524599f2a86c708483b4c372c5d3bbae"
+  integrity sha512-MBndsoXY/PeVTDJeWsYj7kLZ5hQpJOfMYLsF6LicLHQWbRDG19lK5jOix4DPl8yY4SUFcE3txy86OzFLWT+yoA==
   dependencies:
-    "@babel/code-frame" "7.12.11"
-    "@eslint/eslintrc" "^0.4.3"
-    "@humanwhocodes/config-array" "^0.5.0"
+    "@eslint/eslintrc" "^1.3.0"
+    "@humanwhocodes/config-array" "^0.9.2"
     ajv "^6.10.0"
     chalk "^4.0.0"
     cross-spawn "^7.0.2"
-    debug "^4.0.1"
+    debug "^4.3.2"
     doctrine "^3.0.0"
-    enquirer "^2.3.5"
     escape-string-regexp "^4.0.0"
-    eslint-scope "^5.1.1"
-    eslint-utils "^2.1.0"
-    eslint-visitor-keys "^2.0.0"
-    espree "^7.3.1"
+    eslint-scope "^7.1.1"
+    eslint-utils "^3.0.0"
+    eslint-visitor-keys "^3.3.0"
+    espree "^9.3.2"
     esquery "^1.4.0"
     esutils "^2.0.2"
     fast-deep-equal "^3.1.3"
     file-entry-cache "^6.0.1"
     functional-red-black-tree "^1.0.1"
-    glob-parent "^5.1.2"
-    globals "^13.6.0"
-    ignore "^4.0.6"
+    glob-parent "^6.0.1"
+    globals "^13.15.0"
+    ignore "^5.2.0"
     import-fresh "^3.0.0"
     imurmurhash "^0.1.4"
     is-glob "^4.0.0"
-    js-yaml "^3.13.1"
+    js-yaml "^4.1.0"
     json-stable-stringify-without-jsonify "^1.0.1"
     levn "^0.4.1"
     lodash.merge "^4.6.2"
-    minimatch "^3.0.4"
+    minimatch "^3.1.2"
     natural-compare "^1.4.0"
     optionator "^0.9.1"
-    progress "^2.0.0"
-    regexpp "^3.1.0"
-    semver "^7.2.1"
-    strip-ansi "^6.0.0"
+    regexpp "^3.2.0"
+    strip-ansi "^6.0.1"
     strip-json-comments "^3.1.0"
-    table "^6.0.9"
     text-table "^0.2.0"
     v8-compile-cache "^2.0.3"
 
-espree@^7.3.0, espree@^7.3.1:
-  version "7.3.1"
-  resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6"
-  integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==
+espree@^9.3.2:
+  version "9.3.2"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.2.tgz#f58f77bd334731182801ced3380a8cc859091596"
+  integrity sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA==
   dependencies:
-    acorn "^7.4.0"
-    acorn-jsx "^5.3.1"
-    eslint-visitor-keys "^1.3.0"
-
-esprima@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
-  integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
+    acorn "^8.7.1"
+    acorn-jsx "^5.3.2"
+    eslint-visitor-keys "^3.3.0"
 
 esquery@^1.4.0:
   version "1.4.0"
@@ -1331,11 +1876,21 @@
   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880"
   integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==
 
+estree-walker@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
+  integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==
+
 esutils@^2.0.2:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
   integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
 
+etag@^1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+  integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
+
 execa@^5.0.0:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
@@ -1424,7 +1979,7 @@
     merge2 "^1.2.3"
     micromatch "^3.1.10"
 
-fast-glob@^3.1.1, fast-glob@^3.2.2:
+fast-glob@^3.2.2:
   version "3.2.7"
   resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1"
   integrity sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==
@@ -1435,6 +1990,17 @@
     merge2 "^1.3.0"
     micromatch "^4.0.4"
 
+fast-glob@^3.2.9:
+  version "3.2.11"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9"
+  integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==
+  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"
@@ -1483,7 +2049,14 @@
   dependencies:
     to-regex-range "^5.0.1"
 
-find-up@^2.0.0, find-up@^2.1.0:
+find-replace@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
+  integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==
+  dependencies:
+    array-back "^3.0.1"
+
+find-up@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7"
   integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c=
@@ -1523,6 +2096,11 @@
   dependencies:
     map-cache "^0.2.2"
 
+fresh@~0.5.2:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+  integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
+
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@@ -1538,11 +2116,26 @@
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
   integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
 
+function.prototype.name@^1.1.5:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621"
+  integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.1.3"
+    es-abstract "^1.19.0"
+    functions-have-names "^1.2.2"
+
 functional-red-black-tree@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
   integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
 
+functions-have-names@^1.2.2:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
+  integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
+
 get-caller-file@^2.0.1:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
@@ -1597,13 +2190,20 @@
     is-glob "^3.1.0"
     path-dirname "^1.0.0"
 
-glob-parent@^5.1.2:
+glob-parent@^5.1.2, 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==
   dependencies:
     is-glob "^4.0.1"
 
+glob-parent@^6.0.1:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
+  integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
+  dependencies:
+    is-glob "^4.0.3"
+
 glob-to-regexp@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
@@ -1628,23 +2228,23 @@
   dependencies:
     ini "2.0.0"
 
-globals@^13.6.0, globals@^13.9.0:
-  version "13.11.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-13.11.0.tgz#40ef678da117fe7bd2e28f1fab24951bd0255be7"
-  integrity sha512-08/xrJ7wQjK9kkkRoI3OFUBbLx4f+6x3SGwcPvQ0QH6goFDrOU2oyAWrmh3dJezu65buo+HBMzAMQy6rovVC3g==
+globals@^13.15.0:
+  version "13.15.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-13.15.0.tgz#38113218c907d2f7e98658af246cef8b77e90bac"
+  integrity sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog==
   dependencies:
     type-fest "^0.20.2"
 
-globby@^11.0.3:
-  version "11.0.4"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5"
-  integrity sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==
+globby@^11.1.0:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
+  integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==
   dependencies:
     array-union "^2.1.0"
     dir-glob "^3.0.1"
-    fast-glob "^3.1.1"
-    ignore "^5.1.4"
-    merge2 "^1.3.0"
+    fast-glob "^3.2.9"
+    ignore "^5.2.0"
+    merge2 "^1.4.1"
     slash "^3.0.0"
 
 google-protobuf@^3.6.1:
@@ -1706,6 +2306,11 @@
   resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113"
   integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==
 
+has-bigints@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa"
+  integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==
+
 has-flag@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
@@ -1716,11 +2321,23 @@
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
+has-property-descriptors@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861"
+  integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==
+  dependencies:
+    get-intrinsic "^1.1.1"
+
 has-symbols@^1.0.1, has-symbols@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
   integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
 
+has-symbols@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
+  integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
+
 has-tostringtag@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
@@ -1783,33 +2400,50 @@
   dependencies:
     lru-cache "^6.0.0"
 
-htmlparser2@^3.9.1:
-  version "3.10.1"
-  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
-  integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==
-  dependencies:
-    domelementtype "^1.3.1"
-    domhandler "^2.3.0"
-    domutils "^1.5.1"
-    entities "^1.1.1"
-    inherits "^2.0.1"
-    readable-stream "^3.1.1"
-
-htmlparser2@^6.0.1:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7"
-  integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==
+htmlparser2@^7.1.2:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-7.2.0.tgz#8817cdea38bbc324392a90b1990908e81a65f5a5"
+  integrity sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==
   dependencies:
     domelementtype "^2.0.1"
-    domhandler "^4.0.0"
-    domutils "^2.5.2"
-    entities "^2.0.0"
+    domhandler "^4.2.2"
+    domutils "^2.8.0"
+    entities "^3.0.1"
+
+http-assert@^1.3.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f"
+  integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==
+  dependencies:
+    deep-equal "~1.0.1"
+    http-errors "~1.8.0"
 
 http-cache-semantics@^4.0.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
   integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
 
+http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
+  integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.1"
+
+http-errors@~1.6.2:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
+  integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.0"
+    statuses ">= 1.4.0 < 2"
+
 human-signals@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
@@ -1822,16 +2456,16 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-ignore@^4.0.6:
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
-  integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
-
-ignore@^5.1.1, ignore@^5.1.4:
+ignore@^5.1.1:
   version "5.1.8"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
   integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
 
+ignore@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
+  integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
+
 import-fresh@^3.0.0, import-fresh@^3.2.1:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@@ -1863,11 +2497,16 @@
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@^2.0.1, inherits@^2.0.3:
+inherits@2, inherits@2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
+inherits@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+  integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==
+
 ini@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5"
@@ -1906,6 +2545,11 @@
     has "^1.0.3"
     side-channel "^1.0.4"
 
+ip@^1.1.5:
+  version "1.1.8"
+  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
+  integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==
+
 is-accessor-descriptor@^0.1.6:
   version "0.1.6"
   resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
@@ -1932,6 +2576,13 @@
   dependencies:
     has-bigints "^1.0.1"
 
+is-binary-path@~2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+  integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+  dependencies:
+    binary-extensions "^2.0.0"
+
 is-boolean-object@^1.1.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719"
@@ -1945,7 +2596,14 @@
   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.2.4:
+is-builtin-module@^3.1.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.0.tgz#bb0310dfe881f144ca83f30100ceb10cf58835e0"
+  integrity sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw==
+  dependencies:
+    builtin-modules "^3.3.0"
+
+is-callable@^1.1.4, 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==
@@ -1957,13 +2615,27 @@
   dependencies:
     ci-info "^2.0.0"
 
-is-core-module@^2.2.0, is-core-module@^2.5.0, is-core-module@^2.6.0:
+is-core-module@^2.2.0, is-core-module@^2.5.0:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.6.0.tgz#d7553b2526fe59b92ba3e40c8df757ec8a709e19"
   integrity sha512-wShG8vs60jKfPWpF2KZRaAtvt3a20OAn7+IJ6hLPECpSABLcKtFKTTI4ZtH5QcBruBHlq+WsdHWyz0BCZW7svQ==
   dependencies:
     has "^1.0.3"
 
+is-core-module@^2.8.1:
+  version "2.9.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69"
+  integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==
+  dependencies:
+    has "^1.0.3"
+
+is-core-module@^2.9.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
+  integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==
+  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"
@@ -2003,6 +2675,11 @@
     is-data-descriptor "^1.0.0"
     kind-of "^6.0.2"
 
+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==
+
 is-extendable@^0.1.0, is-extendable@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
@@ -2030,6 +2707,13 @@
   resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
   integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
 
+is-generator-function@^1.0.7:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72"
+  integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==
+  dependencies:
+    has-tostringtag "^1.0.0"
+
 is-glob@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
@@ -2044,6 +2728,13 @@
   dependencies:
     is-extglob "^2.1.1"
 
+is-glob@^4.0.3, is-glob@~4.0.1:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+  integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+  dependencies:
+    is-extglob "^2.1.1"
+
 is-installed-globally@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520"
@@ -2052,11 +2743,21 @@
     global-dirs "^3.0.0"
     is-path-inside "^3.0.2"
 
+is-module@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
+  integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
+
 is-negative-zero@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24"
   integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==
 
+is-negative-zero@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150"
+  integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==
+
 is-npm@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-5.0.0.tgz#43e8d65cc56e1b67f8d47262cf667099193f45a8"
@@ -2103,7 +2804,7 @@
   dependencies:
     isobject "^3.0.1"
 
-is-regex@^1.1.3, is-regex@^1.1.4:
+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==
@@ -2116,12 +2817,19 @@
   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-shared-array-buffer@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79"
+  integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==
+  dependencies:
+    call-bind "^1.0.2"
+
 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.7:
+is-string@^1.0.5, 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==
@@ -2147,11 +2855,25 @@
   dependencies:
     call-bind "^1.0.0"
 
+is-weakref@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2"
+  integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==
+  dependencies:
+    call-bind "^1.0.2"
+
 is-windows@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
   integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
 
+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==
+  dependencies:
+    is-docker "^2.0.0"
+
 is-yarn-global@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232"
@@ -2162,6 +2884,11 @@
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
   integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
 
+isbinaryfile@^4.0.6:
+  version "4.0.10"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3"
+  integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==
+
 isexe@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@@ -2184,18 +2911,17 @@
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
   integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
 
-js-yaml@^3.13.1:
-  version "3.14.1"
-  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537"
-  integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==
+js-yaml@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
+  integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
   dependencies:
-    argparse "^1.0.7"
-    esprima "^4.0.0"
+    argparse "^2.0.1"
 
-jsdoctypeparser@^9.0.0:
-  version "9.0.0"
-  resolved "https://registry.yarnpkg.com/jsdoctypeparser/-/jsdoctypeparser-9.0.0.tgz#8c97e2fb69315eb274b0f01377eaa5c940bd7b26"
-  integrity sha512-jrTA2jJIL6/DAEILBEh2/w9QxCuwmvNXIry39Ay/HVfhE3o2yVV0U44blYkqdHA/OKloJEqvJy0xU+GSdE2SIw==
+jsdoc-type-pratt-parser@~3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-3.1.0.tgz#a4a56bdc6e82e5865ffd9febc5b1a227ff28e67e"
+  integrity sha512-MgtD0ZiCDk9B+eI73BextfRrVQl0oyzRG8B2BjORts6jbunj4ScKPcyXGTbB6eXL4y9TzxCm6hyeLq/2ASzNdw==
 
 json-buffer@3.0.0:
   version "3.0.0"
@@ -2217,11 +2943,6 @@
   resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
   integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
 
-json-schema-traverse@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
-  integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
-
 json-stable-stringify-without-jsonify@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
@@ -2241,6 +2962,13 @@
   dependencies:
     minimist "^1.2.5"
 
+keygrip@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
+  integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==
+  dependencies:
+    tsscmp "1.0.6"
+
 keyv@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
@@ -2272,6 +3000,72 @@
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
   integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
 
+koa-compose@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877"
+  integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==
+
+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@^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-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==
+  dependencies:
+    debug "^4.1.1"
+    http-errors "^1.7.3"
+    resolve-path "^1.4.0"
+
+koa-static@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/koa-static/-/koa-static-5.0.0.tgz#5e92fc96b537ad5219f425319c95b64772776943"
+  integrity sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==
+  dependencies:
+    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"
+
 latest-version@^5.1.0:
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face"
@@ -2336,10 +3130,10 @@
   dependencies:
     p-locate "^4.1.0"
 
-lodash.clonedeep@^4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
-  integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
+lodash.camelcase@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
+  integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
 
 lodash.deburr@^4.1.0:
   version "4.1.0"
@@ -2351,12 +3145,7 @@
   resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
   integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
 
-lodash.truncate@^4.4.2:
-  version "4.4.2"
-  resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
-  integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=
-
-lodash@4.17.21, lodash@^4.15.0, lodash@^4.17.19, lodash@^4.17.21:
+lodash@^4.17.14, lodash@^4.17.19:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -2412,6 +3201,11 @@
   dependencies:
     object-visit "^1.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 sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
+
 memorystream@^0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2"
@@ -2440,7 +3234,7 @@
   resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
   integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
 
-merge2@^1.2.3, merge2@^1.3.0:
+merge2@^1.2.3, merge2@^1.3.0, merge2@^1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
   integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
@@ -2472,6 +3266,18 @@
     braces "^3.0.1"
     picomatch "^2.2.3"
 
+mime-db@1.52.0:
+  version "1.52.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34:
+  version "2.1.35"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+  dependencies:
+    mime-db "1.52.0"
+
 mimic-fn@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
@@ -2487,20 +3293,20 @@
   resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
   integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
 
-minimatch@3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.3.tgz#2a4e4090b96b2db06a9d7df01055a62a77c9b774"
-  integrity sha1-Kk5AkLlrLbBqnX3wEFWmKnfJt3Q=
-  dependencies:
-    brace-expansion "^1.0.0"
-
 minimatch@^3.0.4:
   version "3.0.4"
-  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+  resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz"
   integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
   dependencies:
     brace-expansion "^1.1.7"
 
+minimatch@^3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+  integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
+  dependencies:
+    brace-expansion "^1.1.7"
+
 minimist-options@4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619"
@@ -2515,6 +3321,11 @@
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
   integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
+minimist@^1.2.6:
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
+  integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
+
 mixin-deep@^1.2.0:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
@@ -2523,6 +3334,13 @@
     for-in "^1.0.2"
     is-extendable "^1.0.1"
 
+mkdirp@^0.5.6:
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+  integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
+  dependencies:
+    minimist "^1.2.6"
+
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@@ -2543,6 +3361,11 @@
   resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
   integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
 
+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==
+
 nanomatch@^1.2.9:
   version "1.2.13"
   resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
@@ -2570,6 +3393,11 @@
   resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
   integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=
 
+negotiator@0.6.3:
+  version "0.6.3"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
+  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
+
 nice-try@^1.0.4:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
@@ -2595,6 +3423,11 @@
     semver "^7.3.4"
     validate-npm-package-license "^3.0.1"
 
+normalize-path@^3.0.0, normalize-path@~3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
 normalize-url@^4.1.0:
   version "4.5.1"
   resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a"
@@ -2622,13 +3455,6 @@
   dependencies:
     path-key "^3.0.0"
 
-nth-check@~1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c"
-  integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==
-  dependencies:
-    boolbase "~1.0.0"
-
 object-copy@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
@@ -2643,6 +3469,11 @@
   resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1"
   integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==
 
+object-inspect@^1.12.0:
+  version "1.12.2"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
+  integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
+
 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"
@@ -2672,14 +3503,21 @@
   dependencies:
     isobject "^3.0.1"
 
-object.values@^1.1.4:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.4.tgz#0d273762833e816b693a637d30073e7051535b30"
-  integrity sha512-TnGo7j4XSnKQoK3MfvkzqKCi0nVe/D9I9IjwTNYdb/fxYHpjrluHVOgw0AF6jrRFGMPHdfuidR09tIDiIvnaSg==
+object.values@^1.1.5:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.5.tgz#959f63e3ce9ef108720333082131e4a459b716ac"
+  integrity sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==
   dependencies:
     call-bind "^1.0.2"
     define-properties "^1.1.3"
-    es-abstract "^1.18.2"
+    es-abstract "^1.19.1"
+
+on-finished@^2.3.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
+  integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
+  dependencies:
+    ee-first "1.1.1"
 
 once@^1.3.0, once@^1.3.1, once@^1.4.0:
   version "1.4.0"
@@ -2695,6 +3533,20 @@
   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"
+  integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==
+
+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"
+
 optionator@^0.9.1:
   version "0.9.1"
   resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499"
@@ -2802,18 +3654,16 @@
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2"
   integrity sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==
 
-parse5@^3.0.1:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/parse5/-/parse5-3.0.3.tgz#042f792ffdd36851551cf4e9e066b3874ab45b5c"
-  integrity sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==
-  dependencies:
-    "@types/node" "*"
-
 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:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
+  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+
 pascalcase@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
@@ -2834,7 +3684,7 @@
   resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
   integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
 
-path-is-absolute@^1.0.0:
+path-is-absolute@1.0.1, path-is-absolute@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
@@ -2849,7 +3699,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==
@@ -2866,6 +3716,11 @@
   resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
   integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
 
+picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+  integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+
 picomatch@^2.2.3:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
@@ -2881,19 +3736,14 @@
   resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
   integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
 
-pkg-dir@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b"
-  integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=
+portfinder@^1.0.28:
+  version "1.0.29"
+  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.29.tgz#d06ff886f4ff91274ed3e25c7e6b0c68d2a0735a"
+  integrity sha512-Z5+DarHWCKlufshB9Z1pN95oLtANoY5Wn9X3JGELGyQ6VhEcBfT2t+1fGUBq7MwUant6g/mqowH+4HifByPbiQ==
   dependencies:
-    find-up "^2.1.0"
-
-pkg-up@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f"
-  integrity sha1-yBmscoBZpGHKscOImivjxJoATX8=
-  dependencies:
-    find-up "^2.1.0"
+    async "^2.6.4"
+    debug "^3.2.7"
+    mkdirp "^0.5.6"
 
 posix-character-classes@^0.1.0:
   version "0.1.1"
@@ -2917,20 +3767,10 @@
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@2.3.1:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.1.tgz#76903c3f8c4449bc9ac597acefa24dc5ad4cbea6"
-  integrity sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==
-
-prettier@^2.1.2:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.2.tgz#ef280a05ec253712e486233db5c6f23441e7342d"
-  integrity sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==
-
-progress@^2.0.0:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
-  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+prettier@2.6.2, prettier@^2.1.2:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032"
+  integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==
 
 protobufjs@6.8.8:
   version "6.8.8"
@@ -2959,7 +3799,7 @@
     end-of-stream "^1.1.0"
     once "^1.3.1"
 
-punycode@^2.1.0:
+punycode@^2.1.0, punycode@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
@@ -2991,14 +3831,6 @@
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
 
-read-pkg-up@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-3.0.0.tgz#3ed496685dba0f8fe118d0691dc51f4a1ff96f07"
-  integrity sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=
-  dependencies:
-    find-up "^2.0.0"
-    read-pkg "^3.0.0"
-
 read-pkg-up@^7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"
@@ -3027,14 +3859,12 @@
     parse-json "^5.0.0"
     type-fest "^0.6.0"
 
-readable-stream@^3.1.1:
+readdirp@~3.6.0:
   version "3.6.0"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
-  integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+  integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
   dependencies:
-    inherits "^2.0.3"
-    string_decoder "^1.1.1"
-    util-deprecate "^1.0.1"
+    picomatch "^2.2.1"
 
 redent@^3.0.0:
   version "3.0.0"
@@ -3044,6 +3874,11 @@
     indent-string "^4.0.0"
     strip-indent "^3.0.0"
 
+reduce-flatten@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
+  integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==
+
 regenerator-runtime@^0.13.4:
   version "0.13.9"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
@@ -3057,16 +3892,20 @@
     extend-shallow "^3.0.2"
     safe-regex "^1.1.0"
 
-regexpp@^3.0.0, regexpp@^3.1.0:
+regexp.prototype.flags@^1.4.3:
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac"
+  integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.1.3"
+    functions-have-names "^1.2.2"
+
+regexpp@^3.0.0, regexpp@^3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
   integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==
 
-regextras@^0.7.1:
-  version "0.7.1"
-  resolved "https://registry.yarnpkg.com/regextras/-/regextras-0.7.1.tgz#be95719d5f43f9ef0b9fa07ad89b7c606995a3b2"
-  integrity sha512-9YXf6xtW+qzQ+hcMQXx95MOvfqXFgsKDZodX3qZB0x2n5Z94ioetIITsBtvJbiOyxa/6s9AtyweBLCdPmPko/w==
-
 registry-auth-token@^4.0.0:
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.1.tgz#6d7b4006441918972ccd5fedcd41dc322c79b250"
@@ -3096,11 +3935,6 @@
   resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
   integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I=
 
-require-from-string@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
-  integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
-
 require-main-filename@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
@@ -3116,6 +3950,14 @@
   resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
   integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
 
+resolve-path@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7"
+  integrity sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==
+  dependencies:
+    http-errors "~1.6.2"
+    path-is-absolute "1.0.1"
+
 resolve-url@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
@@ -3129,6 +3971,24 @@
     is-core-module "^2.2.0"
     path-parse "^1.0.6"
 
+resolve@^1.19.0:
+  version "1.22.1"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
+  integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
+  dependencies:
+    is-core-module "^2.9.0"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
+resolve@^1.22.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@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7"
@@ -3168,6 +4028,13 @@
   optionalDependencies:
     fsevents "~2.3.2"
 
+rollup@^2.67.0:
+  version "2.77.3"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.77.3.tgz#8f00418d3a2740036e15deb653bed1a90ee0cc12"
+  integrity sha512-/qxNTG7FbmefJWoeeYJFbHehJ2HNWnjkAFRKzWN/45eNBBF/r8lo992CwcJXEzyVxs5FmfId+vTSTQDb+bxA+g==
+  optionalDependencies:
+    fsevents "~2.3.2"
+
 run-async@^2.4.0:
   version "2.4.1"
   resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
@@ -3187,7 +4054,7 @@
   dependencies:
     tslib "^1.9.0"
 
-safe-buffer@~5.2.0:
+safe-buffer@5.2.1:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -3226,13 +4093,20 @@
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
-semver@^7.2.1, semver@^7.3.4, semver@^7.3.5:
+semver@^7.3.4:
   version "7.3.5"
   resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
   integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
   dependencies:
     lru-cache "^6.0.0"
 
+semver@^7.3.7:
+  version "7.3.7"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
+  integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
+  dependencies:
+    lru-cache "^6.0.0"
+
 set-blocking@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
@@ -3248,6 +4122,16 @@
     is-plain-object "^2.0.3"
     split-string "^3.0.1"
 
+setprototypeof@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
+  integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
+
+setprototypeof@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
+  integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
+
 shebang-command@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
@@ -3296,15 +4180,6 @@
   resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
   integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
 
-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"
-
 snapdragon-node@^2.0.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
@@ -3415,11 +4290,6 @@
   dependencies:
     extend-shallow "^3.0.0"
 
-sprintf-js@~1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
-  integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
-
 static-extend@^0.1.1:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
@@ -3428,6 +4298,11 @@
     define-property "^0.2.5"
     object-copy "^0.1.0"
 
+"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
+  integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
+
 string-width@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
@@ -3463,6 +4338,15 @@
     call-bind "^1.0.2"
     define-properties "^1.1.3"
 
+string.prototype.trimend@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz#914a65baaab25fbdd4ee291ca7dde57e869cb8d0"
+  integrity sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.1.4"
+    es-abstract "^1.19.5"
+
 string.prototype.trimstart@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed"
@@ -3471,12 +4355,14 @@
     call-bind "^1.0.2"
     define-properties "^1.1.3"
 
-string_decoder@^1.1.1:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
-  integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+string.prototype.trimstart@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz#5466d93ba58cfa2134839f81d7f42437e8c01fef"
+  integrity sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg==
   dependencies:
-    safe-buffer "~5.2.0"
+    call-bind "^1.0.2"
+    define-properties "^1.1.4"
+    es-abstract "^1.19.5"
 
 strip-ansi@^5.1.0:
   version "5.2.0"
@@ -3492,6 +4378,13 @@
   dependencies:
     ansi-regex "^5.0.0"
 
+strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
 strip-bom@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
@@ -3533,17 +4426,20 @@
   dependencies:
     has-flag "^4.0.0"
 
-table@^6.0.9:
-  version "6.7.1"
-  resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2"
-  integrity sha512-ZGum47Yi6KOOFDE8m223td53ath2enHcYLgOCjGr5ngu8bdIARQk6mN/wRMv4yMRcHnCSnHbCEha4sobQx5yWg==
+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==
+
+table-layout@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.2.tgz#c4038a1853b0136d63365a734b6931cf4fad4a04"
+  integrity sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==
   dependencies:
-    ajv "^8.0.1"
-    lodash.clonedeep "^4.5.0"
-    lodash.truncate "^4.4.2"
-    slice-ansi "^4.0.0"
-    string-width "^4.2.0"
-    strip-ansi "^6.0.0"
+    array-back "^4.0.1"
+    deep-extend "~0.6.0"
+    typical "^5.2.0"
+    wordwrapjs "^4.0.0"
 
 terser@^5.6.1:
   version "5.7.2"
@@ -3608,6 +4504,18 @@
     regex-not "^1.0.2"
     safe-regex "^1.1.0"
 
+toidentifier@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
+tr46@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9"
+  integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==
+  dependencies:
+    punycode "^2.1.1"
+
 trim-newlines@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
@@ -3625,14 +4533,14 @@
   resolved "https://registry.yarnpkg.com/ts-simple-type/-/ts-simple-type-1.0.7.tgz#03930af557528dd40eaa121913c7035a0baaacf8"
   integrity sha512-zKmsCQs4dZaeSKjEA7pLFDv7FHHqAFLPd0Mr//OIJvu8M+4p4bgSFJwZSEBEg3ec9W7RzRz1vi8giiX0+mheBQ==
 
-tsconfig-paths@^3.11.0:
-  version "3.11.0"
-  resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.11.0.tgz#954c1fe973da6339c78e06b03ce2e48810b65f36"
-  integrity sha512-7ecdYDnIdmv639mmDwslG6KQg1Z9STTz1j7Gcz0xa+nshh/gKDAHcPxRbWOsA3SPp0tXP2leTcY9Kw+NAkfZzA==
+tsconfig-paths@^3.14.1:
+  version "3.14.1"
+  resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a"
+  integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==
   dependencies:
     "@types/json5" "^0.0.29"
     json5 "^1.0.1"
-    minimist "^1.2.0"
+    minimist "^1.2.6"
     strip-bom "^3.0.0"
 
 tslib@^1.8.1, tslib@^1.9.0:
@@ -3640,6 +4548,11 @@
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
+tsscmp@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
+  integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
+
 tsutils@3.21.0, tsutils@^3.21.0:
   version "3.21.0"
   resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
@@ -3647,16 +4560,6 @@
   dependencies:
     tslib "^1.8.1"
 
-twinkie@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/twinkie/-/twinkie-1.1.3.tgz#1a6f0cd11c59e245bc2d16c7c9fc1ec13e477229"
-  integrity sha512-8Y1U/UCtf8JC4snuV4KAo4e9nwJcKZUoMVOApihJzua4JJWeGB/2RYqAusKk3cUczJeZRGzirHpP1hkArcbA8A==
-  dependencies:
-    "@types/minimatch" "3.0.3"
-    cheerio "1.0.0-rc.2"
-    minimatch "3.0.3"
-    typescript "4.0.5"
-
 type-check@^0.4.0, type-check@~0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@@ -3689,6 +4592,14 @@
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
   integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
 
+type-is@^1.6.16:
+  version "1.6.18"
+  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
+  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
+  dependencies:
+    media-typer "0.3.0"
+    mime-types "~2.1.24"
+
 typedarray-to-buffer@^3.1.5:
   version "3.1.5"
   resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
@@ -3696,16 +4607,31 @@
   dependencies:
     is-typedarray "^1.0.0"
 
-typescript@4.0.5, typescript@4.3.2:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805"
-  integrity sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==
-
 typescript@^3.8.3:
   version "3.9.10"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8"
   integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==
 
+typescript@^4.7.2:
+  version "4.7.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.2.tgz#1f9aa2ceb9af87cca227813b4310fff0b51593c4"
+  integrity sha512-Mamb1iX2FDUpcTRzltPxgWMKy3fhg0TN378ylbktPGPK/99KbDtMQ4W1hwgsbPAsG3a0xKa1vmw4VKZQbkvz5A==
+
+typical@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
+  integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
+
+typical@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066"
+  integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
+
+ua-parser-js@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.2.tgz#e2976c34dbfb30b15d2c300b2a53eac87c57a775"
+  integrity sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg==
+
 unbox-primitive@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
@@ -3716,6 +4642,16 @@
     has-symbols "^1.0.2"
     which-boxed-primitive "^1.0.2"
 
+unbox-primitive@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"
+  integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==
+  dependencies:
+    call-bind "^1.0.2"
+    has-bigints "^1.0.2"
+    has-symbols "^1.0.3"
+    which-boxed-primitive "^1.0.2"
+
 union-value@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
@@ -3785,11 +4721,6 @@
   resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
   integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
 
-util-deprecate@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
-  integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
-
 v8-compile-cache@^2.0.3:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
@@ -3803,6 +4734,11 @@
     spdx-correct "^3.0.0"
     spdx-expression-parse "^3.0.0"
 
+vary@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+  integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
+
 vscode-css-languageservice@4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/vscode-css-languageservice/-/vscode-css-languageservice-4.3.0.tgz#40c797d664ab6188cace33cfbb19b037580a9318"
@@ -3853,6 +4789,19 @@
     typescript "^3.8.3"
     yargs "^15.3.1"
 
+webidl-conversions@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
+  integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
+
+whatwg-url@^11.0.0:
+  version "11.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018"
+  integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==
+  dependencies:
+    tr46 "^3.0.0"
+    webidl-conversions "^7.0.0"
+
 which-boxed-primitive@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
@@ -3895,6 +4844,14 @@
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
+wordwrapjs@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.1.tgz#d9790bccfb110a0fc7836b5ebce0937b37a8b98f"
+  integrity sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==
+  dependencies:
+    reduce-flatten "^2.0.0"
+    typical "^5.2.0"
+
 wrap-ansi@^6.2.0:
   version "6.2.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
@@ -3928,6 +4885,11 @@
     signal-exit "^3.0.2"
     typedarray-to-buffer "^3.1.5"
 
+ws@^7.4.2:
+  version "7.5.9"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
+  integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
+
 xdg-basedir@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
@@ -3972,3 +4934,8 @@
     which-module "^2.0.0"
     y18n "^4.0.0"
     yargs-parser "^18.1.2"
+
+ylru@^1.2.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785"
+  integrity sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==