Merge changes I711e72e8,Ie23c829e,I04c40d11

* changes:
  Fix JdkObsolete issues with SortedMap
  Fix JdkObsolete issues with SortedSet
  Fix/ignore JdkObsolete issues with StringBuffer
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index 18d7fbc..b65fb96 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -35,7 +35,7 @@
    * complexity was reduced to the bare minimum at the cost of small discrepancies to the Unicode
    * spec.
    */
-  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_\n"));
+  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_=\n"));
 
   private final FieldDef<I, ?> def;
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 63c5297..d57f800 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.IsSubmittablePredicate;
 import com.google.gerrit.server.query.change.OrSource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -182,6 +183,7 @@
   private Predicate<ChangeData> rewriteImpl(
       Predicate<ChangeData> in, ChangeIndex index, QueryOptions opts, MutableInteger leafTerms)
       throws QueryParseException {
+    in = IsSubmittablePredicate.rewrite(in);
     if (isIndexPredicate(in, index)) {
       if (++leafTerms.value > config.maxTerms()) {
         throw new TooManyTermsInQueryException();
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 4cecb3f..80b3322 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -730,7 +730,7 @@
             Predicate.not(new SubmittablePredicate(SubmitRecord.Status.RULE_ERROR)));
       }
       checkFieldAvailable(ChangeField.IS_SUBMITTABLE, "is:submittable");
-      return new BooleanPredicate(ChangeField.IS_SUBMITTABLE);
+      return new IsSubmittablePredicate();
     }
 
     if ("ignored".equalsIgnoreCase(value)) {
diff --git a/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java b/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
new file mode 100644
index 0000000..17de132
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.query.NotPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class IsSubmittablePredicate extends BooleanPredicate {
+  public IsSubmittablePredicate() {
+    super(ChangeField.IS_SUBMITTABLE);
+  }
+
+  /**
+   * Rewrite the is:submittable predicate.
+   *
+   * <p>If we run a query with "is:submittable OR -is:submittable" the result should match all
+   * changes. In Lucene, we keep separate sub-indexes for open and closed changes. The Lucene
+   * backend inspects the input predicate and depending on all its child predicates decides if the
+   * query should run against the open sub-index, closed sub-index or both.
+   *
+   * <p>The "is:submittable" operator is implemented as:
+   *
+   * <p>issubmittable:1
+   *
+   * <p>But we want to exclude closed changes from being matched by this query. For the normal case,
+   * we rewrite the query as:
+   *
+   * <p>issubmittable:1 AND status:new
+   *
+   * <p>Hence Lucene will match the query against the open sub-index. For the negated case (i.e.
+   * "-is:submittable"), we cannot just negate the previous query because it would result in:
+   *
+   * <p>-(issubmittable:1 AND status:new)
+   *
+   * <p>Lucene will conclude that it should look for changes that are <b>not</b> new and hence will
+   * run the query against the closed sub-index, not matching with changes that are open but not
+   * submittable. For this case, we need to rewrite the query to match with closed changes <b>or</b>
+   * changes that are not submittable.
+   */
+  public static Predicate<ChangeData> rewrite(Predicate<ChangeData> in) {
+    if (in instanceof IsSubmittablePredicate) {
+      return Predicate.and(
+          new BooleanPredicate(ChangeField.IS_SUBMITTABLE), ChangeStatusPredicate.open());
+    }
+    if (in instanceof NotPredicate && in.getChild(0) instanceof IsSubmittablePredicate) {
+      return Predicate.or(
+          Predicate.not(new BooleanPredicate(ChangeField.IS_SUBMITTABLE)),
+          ChangeStatusPredicate.closed());
+    }
+    return in;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index ba86976..d911512 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -46,16 +47,21 @@
 
   @Test
   public void newPatchSetsNotifyConfig() throws Exception {
-    Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig.Builder nc = NotifyConfig.builder();
-    nc.addAddress(addr);
-    nc.setName("new-patch-set");
-    nc.setHeader(NotifyConfig.Header.CC);
-    nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
-    nc.setFilter("message:sekret");
-
+    ImmutableList<String> messageFilters =
+        ImmutableList.of("message:subject-with-tokens", "message:subject-with-tokens=secret");
+    ImmutableList.Builder<Address> watchers = ImmutableList.builder();
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("watch", nc.build());
+      for (int i = 0; i < messageFilters.size(); i++) {
+        Address addr = Address.create("Watcher#" + i, String.format("watcher-%s@example.com", i));
+        watchers.add(addr);
+        NotifyConfig.Builder nc = NotifyConfig.builder();
+        nc.addAddress(addr);
+        nc.setName("new-patch-set" + i);
+        nc.setHeader(NotifyConfig.Header.CC);
+        nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
+        nc.setFilter(messageFilters.get(i));
+        u.getConfig().putNotifyConfig("watch" + i, nc.build());
+      }
       u.save();
     }
 
@@ -67,7 +73,13 @@
 
     r =
         pushFactory
-            .create(admin.newIdent(), testRepo, "super sekret subject", "a", "a2", r.getChangeId())
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "super sekret subject\n\nsubject-with-tokens=secret subject",
+                "a",
+                "a2",
+                r.getChangeId())
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -80,7 +92,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(addr);
+    assertThat(m.rcpt()).containsExactlyElementsIn(watchers.build());
     assertThat(m.body()).contains("Change subject: super sekret subject\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 2\n");
   }
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 3990c1f..4853376 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -3041,6 +3041,10 @@
     // NEED records don't have associated users.
     assertQuery("label:CodE-RevieW=need,user1");
     assertQuery("label:CodE-RevieW=need,user");
+
+    gApi.changes().id(change1.getId().get()).current().submit();
+    assertQuery("is:submittable");
+    assertQuery("-is:submittable", change1, change2);
   }
 
   @Test
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 90d38b0..c008982 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -19,6 +19,7 @@
 dropwizard-core
 duct-tape
 eddsa
+error-prone-annotations
 flogger
 flogger-log4j-backend
 flogger-system-backend
diff --git a/plugins/replication b/plugins/replication
index 5a2cb73..36ed18a 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 5a2cb73d8131d1f20e08e815803f46266ca9f74f
+Subproject commit 36ed18af69d005a7cf89a9bba2f2585ead8d46da
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 23a6a2a..898de14 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -97,8 +97,6 @@
     "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-change-list-view/gr-change-list-view_html.ts",
-    "elements/change-list/gr-change-list/gr-change-list_html.ts",
     "elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts",
     "elements/change/gr-change-actions/gr-change-actions_html.ts",
     "elements/change/gr-change-metadata/gr-change-metadata_html.ts",
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 b68892f..142abaa 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -19,12 +19,8 @@
 import '../gr-change-list/gr-change-list';
 import '../gr-repo-header/gr-repo-header';
 import '../gr-user-header/gr-user-header';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-list-view_html';
 import {page} from '../../../utils/page-wrapper-utils';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {AppElementParams} from '../../gr-app-types';
 import {
   AccountDetailInfo,
@@ -36,10 +32,14 @@
 } from '../../../types/common';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {ChangeListViewState} from '../../../types/types';
-import {fireTitleChange} from '../../../utils/event-util';
+import {fire, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {GerritView} from '../../../services/router/router-model';
 import {RELOAD_DASHBOARD_INTERVAL_MS} from '../../../constants/constants';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state, query} from 'lit/decorators';
+import {ValueChangedEvent} from '../../../types/events';
 
 const LOOKUP_QUERY_PATTERNS: RegExp[] = [
   /^\s*i?[0-9a-f]{7,40}\s*$/i, // CHANGE_ID
@@ -54,60 +54,50 @@
 
 const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
-export interface GrChangeListView {
-  $: {
-    prevArrow: HTMLAnchorElement;
-    nextArrow: HTMLAnchorElement;
-  };
-}
-
 @customElement('gr-change-list-view')
-export class GrChangeListView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrChangeListView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
    * @event title-change
    */
 
-  @property({type: Object, observer: '_paramsChanged'})
-  params?: AppElementParams;
+  @query('#prevArrow') protected prevArrow?: HTMLAnchorElement;
 
-  @property({type: Boolean, computed: '_computeLoggedIn(account)'})
-  _loggedIn?: boolean;
+  @query('#nextArrow') protected nextArrow?: HTMLAnchorElement;
+
+  @property({type: Object})
+  params?: AppElementParams;
 
   @property({type: Object})
   account: AccountDetailInfo | null = null;
 
-  @property({type: Object, notify: true})
+  @property({type: Object})
   viewState: ChangeListViewState = {};
 
   @property({type: Object})
   preferences?: PreferencesInput;
 
-  @property({type: Number})
-  _changesPerPage?: number;
+  // private but used in test
+  @state() changesPerPage?: number;
 
-  @property({type: String})
-  _query = '';
+  // private but used in test
+  @state() query = '';
 
-  @property({type: Number})
-  _offset?: number;
+  // private but used in test
+  @state() offset?: number;
 
-  @property({type: Array, observer: '_changesChanged'})
-  _changes?: ChangeInfo[];
+  // private but used in test
+  @state() changes?: ChangeInfo[];
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: String})
-  _userId: AccountId | EmailAddress | null = null;
+  // private but used in test
+  @state() userId: AccountId | EmailAddress | null = null;
 
-  @property({type: String})
-  _repo: RepoName | null = null;
+  // private but used in test
+  @state() repo: RepoName | null = null;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -117,8 +107,8 @@
 
   constructor() {
     super();
-    this.addEventListener('next-page', () => this._handleNextPage());
-    this.addEventListener('previous-page', () => this._handlePreviousPage());
+    this.addEventListener('next-page', () => this.handleNextPage());
+    this.addEventListener('previous-page', () => this.handlePreviousPage());
     this.addEventListener('reload', () => this.reload());
     // We are not currently verifying if the view is actually visible. We rely
     // on gr-app-element to restamp the component if view changes
@@ -137,37 +127,176 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadPreferences();
+    this.loadPreferences();
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        .loading {
+          color: var(--deemphasized-text-color);
+          padding: var(--spacing-l);
+        }
+        gr-change-list {
+          width: 100%;
+        }
+        gr-user-header,
+        gr-repo-header {
+          border-bottom: 1px solid var(--border-color);
+        }
+        nav {
+          align-items: center;
+          display: flex;
+          height: 3rem;
+          justify-content: flex-end;
+          margin-right: 20px;
+        }
+        nav,
+        iron-icon {
+          color: var(--deemphasized-text-color);
+        }
+        iron-icon {
+          height: 1.85rem;
+          margin-left: 16px;
+          width: 1.85rem;
+        }
+        .hide {
+          display: none;
+        }
+        @media only screen and (max-width: 50em) {
+          .loading,
+          .error {
+            padding: 0 var(--spacing-l);
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (this.loading) return html`<div class="loading">Loading...</div>`;
+    const loggedIn = !!(this.account && Object.keys(this.account).length > 0);
+    return html`
+      <div>
+        ${this.renderRepoHeader()} ${this.renderUserHeader(loggedIn)}
+        <gr-change-list
+          .account=${this.account}
+          .changes=${this.changes}
+          .preferences=${this.preferences}
+          .selectedIndex=${this.viewState.selectedChangeIndex}
+          .showStar=${loggedIn}
+          @selected-index-changed=${(e: ValueChangedEvent<number>) => {
+            this.handleSelectedIndexChanged(e);
+          }}
+          @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
+            this.handleToggleStar(e);
+          }}
+        ></gr-change-list>
+        ${this.renderChangeListViewNav()}
+      </div>
+    `;
+  }
+
+  private renderRepoHeader() {
+    if (!this.repo) return;
+
+    return html` <gr-repo-header .repo=${this.repo}></gr-repo-header> `;
+  }
+
+  private renderUserHeader(loggedIn: boolean) {
+    if (!this.userId) return;
+
+    return html`
+      <gr-user-header
+        .userId=${this.userId}
+        showDashboardLink
+        .loggedIn=${loggedIn}
+      ></gr-user-header>
+    `;
+  }
+
+  private renderChangeListViewNav() {
+    if (this.loading || !this.changes || !this.changes.length) return;
+
+    return html`
+      <nav>
+        Page ${this.computePage()} ${this.renderPrevArrow()}
+        ${this.renderNextArrow()}
+      </nav>
+    `;
+  }
+
+  private renderPrevArrow() {
+    if (this.offset === 0) return;
+
+    return html`
+      <a id="prevArrow" href="${this.computeNavLink(-1)}">
+        <iron-icon icon="gr-icons:chevron-left" aria-label="Older"> </iron-icon>
+      </a>
+    `;
+  }
+
+  private renderNextArrow() {
+    if (
+      !(
+        this.changes?.length &&
+        this.changes[this.changes.length - 1]._more_changes
+      )
+    )
+      return;
+
+    return html`
+      <a id="nextArrow" href="${this.computeNavLink(1)}">
+        <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
+        </iron-icon>
+      </a>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this.paramsChanged();
+    }
+
+    if (changedProperties.has('changes')) {
+      this.changesChanged();
+    }
   }
 
   reload() {
-    if (this._loading) return;
-    this._loading = true;
-    this._getChanges().then(changes => {
-      this._changes = changes || [];
-      this._loading = false;
+    if (this.loading) return;
+    this.loading = true;
+    this.getChanges().then(changes => {
+      this.changes = changes || [];
+      this.loading = false;
     });
   }
 
-  _paramsChanged(value: AppElementParams) {
-    if (value.view !== GerritView.SEARCH) return;
+  private paramsChanged() {
+    const value = this.params;
+    if (!value || value.view !== GerritView.SEARCH) return;
 
-    this._loading = true;
-    this._query = value.query;
+    this.loading = true;
+    this.query = value.query;
     const offset = Number(value.offset);
-    this._offset = isNaN(offset) ? 0 : offset;
+    this.offset = isNaN(offset) ? 0 : offset;
     if (
-      this.viewState.query !== this._query ||
-      this.viewState.offset !== this._offset
+      this.viewState.query !== this.query ||
+      this.viewState.offset !== this.offset
     ) {
-      this.set('viewState.selectedChangeIndex', 0);
-      this.set('viewState.query', this._query);
-      this.set('viewState.offset', this._offset);
+      this.viewState.selectedChangeIndex = 0;
+      this.viewState.query = this.query;
+      this.viewState.offset = this.offset;
+      fire(this, 'view-state-changed', {value: this.viewState});
     }
 
     // NOTE: This method may be called before attachment. Fire title-change
     // in an async so that attachment to the DOM can take place first.
-    setTimeout(() => fireTitleChange(this, this._query));
+    setTimeout(() => fireTitleChange(this, this.query));
 
     this.restApiService
       .getPreferences()
@@ -175,14 +304,14 @@
         if (!prefs) {
           throw new Error('getPreferences returned undefined');
         }
-        this._changesPerPage = prefs.changes_per_page;
-        return this._getChanges();
+        this.changesPerPage = prefs.changes_per_page;
+        return this.getChanges();
       })
       .then(changes => {
         changes = changes || [];
-        if (this._query && changes.length === 1) {
+        if (this.query && changes.length === 1) {
           for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
-            if (this._query.match(queryPattern)) {
+            if (this.query.match(queryPattern)) {
               // "Back"/"Forward" buttons work correctly only with
               // opt_redirect options
               GerritNav.navigateToChange(changes[0], {
@@ -192,12 +321,12 @@
             }
           }
         }
-        this._changes = changes;
-        this._loading = false;
+        this.changes = changes;
+        this.loading = false;
       });
   }
 
-  _loadPreferences() {
+  private loadPreferences() {
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         this.restApiService.getPreferences().then(preferences => {
@@ -209,15 +338,18 @@
     });
   }
 
-  _getChanges() {
+  // private but used in test
+  getChanges() {
     return this.restApiService.getChanges(
-      this._changesPerPage,
-      this._query,
-      this._offset
+      this.changesPerPage,
+      this.query,
+      this.offset
     );
   }
 
-  _limitFor(query: string, defaultLimit: number) {
+  // private but used in test
+  limitFor(query: string, defaultLimit?: number) {
+    if (defaultLimit === undefined) return 0;
     const match = query.match(LIMIT_OPERATOR_PATTERN);
     if (!match) {
       return defaultLimit;
@@ -225,78 +357,53 @@
     return Number(match[1]);
   }
 
-  _computeNavLink(
-    query: string,
-    offset: number | undefined,
-    direction: number,
-    changesPerPage: number
-  ) {
-    offset = offset ?? 0;
-    const limit = this._limitFor(query, changesPerPage);
+  // private but used in test
+  computeNavLink(direction: number) {
+    const offset = this.offset ?? 0;
+    const limit = this.limitFor(this.query, this.changesPerPage);
     const newOffset = Math.max(0, offset + limit * direction);
-    return GerritNav.getUrlForSearchQuery(query, newOffset);
+    return GerritNav.getUrlForSearchQuery(this.query, newOffset);
   }
 
-  _computePrevArrowClass(offset?: number) {
-    return offset === 0 ? 'hide' : '';
+  // private but used in test
+  handleNextPage() {
+    if (!this.nextArrow || !this.changesPerPage) return;
+    page.show(this.computeNavLink(1));
   }
 
-  _computeNextArrowClass(changes?: ChangeInfo[]) {
-    const more = changes?.length && changes[changes.length - 1]._more_changes;
-    return more ? '' : 'hide';
+  // private but used in test
+  handlePreviousPage() {
+    if (!this.prevArrow || !this.changesPerPage) return;
+    page.show(this.computeNavLink(-1));
   }
 
-  _computeNavClass(loading?: boolean) {
-    return loading || !this._changes || !this._changes.length ? 'hide' : '';
-  }
-
-  _handleNextPage() {
-    if (this.$.nextArrow.hidden || !this._changesPerPage) return;
-    page.show(
-      this._computeNavLink(this._query, this._offset, 1, this._changesPerPage)
-    );
-  }
-
-  _handlePreviousPage() {
-    if (this.$.prevArrow.hidden || !this._changesPerPage) return;
-    page.show(
-      this._computeNavLink(this._query, this._offset, -1, this._changesPerPage)
-    );
-  }
-
-  _changesChanged(changes?: ChangeInfo[]) {
-    this._userId = null;
-    this._repo = null;
+  private changesChanged() {
+    this.userId = null;
+    this.repo = null;
+    const changes = this.changes;
     if (!changes || !changes.length) {
       return;
     }
-    if (USER_QUERY_PATTERN.test(this._query)) {
+    if (USER_QUERY_PATTERN.test(this.query)) {
       const owner = changes[0].owner;
       const userId = owner._account_id ? owner._account_id : owner.email;
       if (userId) {
-        this._userId = userId;
+        this.userId = userId;
         return;
       }
     }
-    if (REPO_QUERY_PATTERN.test(this._query)) {
-      this._repo = changes[0].project;
+    if (REPO_QUERY_PATTERN.test(this.query)) {
+      this.repo = changes[0].project;
     }
   }
 
-  _computeHeaderClass(id?: string) {
-    return id ? '' : 'hide';
+  // private but used in test
+  computePage() {
+    if (this.offset === undefined || this.changesPerPage === undefined) return;
+    return this.offset / this.changesPerPage + 1;
   }
 
-  _computePage(offset?: number, changesPerPage?: number) {
-    if (offset === undefined || changesPerPage === undefined) return;
-    return offset / changesPerPage + 1;
-  }
-
-  _computeLoggedIn(account?: AccountDetailInfo) {
-    return !!(account && Object.keys(account).length > 0);
-  }
-
-  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+  private handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-change-list');
     }
@@ -306,16 +413,17 @@
     );
   }
 
-  /**
-   * Returns `this` as the visibility observer target for the keyboard shortcut
-   * mixin to decide whether shortcuts should be enabled or not.
-   */
-  _computeObserverTarget() {
-    return this;
+  private handleSelectedIndexChanged(e: ValueChangedEvent<number>) {
+    if (!this.viewState) return;
+    this.viewState.selectedChangeIndex = e.detail.value;
+    fire(this, 'view-state-changed', {value: this.viewState});
   }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'view-state-changed': ValueChangedEvent<ChangeListViewState>;
+  }
   interface HTMLElementTagNameMap {
     'gr-change-list-view': GrChangeListView;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
deleted file mode 100644
index 355ef45..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    .loading {
-      color: var(--deemphasized-text-color);
-      padding: var(--spacing-l);
-    }
-    gr-change-list {
-      width: 100%;
-    }
-    gr-user-header,
-    gr-repo-header {
-      border-bottom: 1px solid var(--border-color);
-    }
-    nav {
-      align-items: center;
-      display: flex;
-      height: 3rem;
-      justify-content: flex-end;
-      margin-right: 20px;
-    }
-    nav,
-    iron-icon {
-      color: var(--deemphasized-text-color);
-    }
-    iron-icon {
-      height: 1.85rem;
-      margin-left: 16px;
-      width: 1.85rem;
-    }
-    .hide {
-      display: none;
-    }
-    @media only screen and (max-width: 50em) {
-      .loading,
-      .error {
-        padding: 0 var(--spacing-l);
-      }
-    }
-  </style>
-  <div class="loading" hidden$="[[!_loading]]" hidden="">Loading...</div>
-  <div hidden$="[[_loading]]" hidden="">
-    <gr-repo-header
-      repo="[[_repo]]"
-      class$="[[_computeHeaderClass(_repo)]]"
-    ></gr-repo-header>
-    <gr-user-header
-      user-id="[[_userId]]"
-      showDashboardLink=""
-      logged-in="[[_loggedIn]]"
-      class$="[[_computeHeaderClass(_userId)]]"
-    ></gr-user-header>
-    <gr-change-list
-      account="[[account]]"
-      changes="{{_changes}}"
-      preferences="[[preferences]]"
-      selected-index="{{viewState.selectedChangeIndex}}"
-      show-star="[[_loggedIn]]"
-      on-toggle-star="_handleToggleStar"
-      observer-target="[[_computeObserverTarget()]]"
-    ></gr-change-list>
-    <nav class$="[[_computeNavClass(_loading)]]">
-      Page [[_computePage(_offset, _changesPerPage)]]
-      <a
-        id="prevArrow"
-        href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
-        class$="[[_computePrevArrowClass(_offset)]]"
-      >
-        <iron-icon icon="gr-icons:chevron-left" aria-label="Older"> </iron-icon>
-      </a>
-      <a
-        id="nextArrow"
-        href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
-        class$="[[_computeNextArrowClass(_changes)]]"
-      >
-        <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
-        </iron-icon>
-      </a>
-    </nav>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
deleted file mode 100644
index 03b6858..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
+++ /dev/null
@@ -1,249 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-change-list-view.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import 'lodash/lodash.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-change-list-view');
-
-const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
-const COMMIT_HASH = '12345678';
-
-suite('gr-change-list-view tests', () => {
-  let element;
-
-  setup(() => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getChanges').returns(Promise.resolve([]));
-    stubRestApi('getAccountDetails').returns(Promise.resolve({}));
-    stubRestApi('getAccountStatus').returns(Promise.resolve({}));
-    element = basicFixture.instantiate();
-  });
-
-  teardown(async () => {
-    await flush();
-  });
-
-  test('_computePage', () => {
-    assert.equal(element._computePage(0, 25), 1);
-    assert.equal(element._computePage(50, 25), 3);
-  });
-
-  test('_limitFor', () => {
-    const defaultLimit = 25;
-    const _limitFor = q => element._limitFor(q, defaultLimit);
-    assert.equal(_limitFor(''), defaultLimit);
-    assert.equal(_limitFor('limit:10'), 10);
-    assert.equal(_limitFor('xlimit:10'), defaultLimit);
-    assert.equal(_limitFor('x(limit:10'), 10);
-  });
-
-  test('_computeNavLink', () => {
-    const getUrlStub = sinon.stub(GerritNav, 'getUrlForSearchQuery')
-        .returns('');
-    const query = 'status:open';
-    let offset = 0;
-    let direction = 1;
-    const changesPerPage = 5;
-
-    element._computeNavLink(query, offset, direction, changesPerPage);
-    assert.equal(getUrlStub.lastCall.args[1], 5);
-
-    direction = -1;
-    element._computeNavLink(query, offset, direction, changesPerPage);
-    assert.equal(getUrlStub.lastCall.args[1], 0);
-
-    offset = 5;
-    direction = 1;
-    element._computeNavLink(query, offset, direction, changesPerPage);
-    assert.equal(getUrlStub.lastCall.args[1], 10);
-  });
-
-  test('_computePrevArrowClass', () => {
-    let offset = 0;
-    assert.equal(element._computePrevArrowClass(offset), 'hide');
-    offset = 5;
-    assert.equal(element._computePrevArrowClass(offset), '');
-  });
-
-  test('_computeNextArrowClass', () => {
-    let changes = _.times(25, _.constant({_more_changes: true}));
-    assert.equal(element._computeNextArrowClass(changes), '');
-    changes = _.times(25, _.constant({}));
-    assert.equal(element._computeNextArrowClass(changes), 'hide');
-  });
-
-  test('_computeNavClass', () => {
-    let loading = true;
-    assert.equal(element._computeNavClass(loading), 'hide');
-    loading = false;
-    assert.equal(element._computeNavClass(loading), 'hide');
-    element._changes = [];
-    assert.equal(element._computeNavClass(loading), 'hide');
-    element._changes = _.times(5, _.constant({}));
-    assert.equal(element._computeNavClass(loading), '');
-  });
-
-  test('_handleNextPage', () => {
-    const showStub = sinon.stub(page, 'show');
-    element._changesPerPage = 10;
-    element.$.nextArrow.hidden = true;
-    element._handleNextPage();
-    assert.isFalse(showStub.called);
-    element.$.nextArrow.hidden = false;
-    element._handleNextPage();
-    assert.isTrue(showStub.called);
-  });
-
-  test('_handlePreviousPage', () => {
-    const showStub = sinon.stub(page, 'show');
-    element._changesPerPage = 10;
-    element.$.prevArrow.hidden = true;
-    element._handlePreviousPage();
-    assert.isFalse(showStub.called);
-    element.$.prevArrow.hidden = false;
-    element._handlePreviousPage();
-    assert.isTrue(showStub.called);
-  });
-
-  test('_userId query', async () => {
-    assert.isNull(element._userId);
-    element._query = 'owner: foo@bar';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    await flush();
-    assert.equal(element._userId, 'foo@bar');
-
-    element._query = 'foo bar baz';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    assert.isNull(element._userId);
-  });
-
-  test('_userId query without email', async () => {
-    assert.isNull(element._userId);
-    element._query = 'owner: foo@bar';
-    element._changes = [{owner: {}}];
-    await flush();
-    assert.isNull(element._userId);
-  });
-
-  test('_repo query', async () => {
-    assert.isNull(element._repo);
-    element._query = 'project: test-repo';
-    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-    await flush();
-    assert.equal(element._repo, 'test-repo');
-    element._query = 'foo bar baz';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    assert.isNull(element._repo);
-  });
-
-  test('_repo query with open status', async () => {
-    assert.isNull(element._repo);
-    element._query = 'project:test-repo status:open';
-    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-    await flush();
-    assert.equal(element._repo, 'test-repo');
-    element._query = 'foo bar baz';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    assert.isNull(element._repo);
-  });
-
-  suite('query based navigation', () => {
-    setup(() => {
-    });
-
-    teardown(async () => {
-      await flush();
-      sinon.restore();
-    });
-
-    test('Searching for a change ID redirects to change', async () => {
-      const change = {_number: 1};
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([change]));
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake(
-          (url, opt) => {
-            assert.equal(url, change);
-            assert.isTrue(opt.redirect);
-            promise.resolve();
-          });
-
-      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      await promise;
-    });
-
-    test('Searching for a change num redirects to change', async () => {
-      const change = {_number: 1};
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([change]));
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake(
-          (url, opt) => {
-            assert.equal(url, change);
-            assert.isTrue(opt.redirect);
-            promise.resolve();
-          });
-
-      element.params = {view: GerritNav.View.SEARCH, query: '1'};
-      await promise;
-    });
-
-    test('Commit hash redirects to change', async () => {
-      const change = {_number: 1};
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([change]));
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake(
-          (url, opt) => {
-            assert.equal(url, change);
-            assert.isTrue(opt.redirect);
-            promise.resolve();
-          });
-
-      element.params = {view: GerritNav.View.SEARCH, query: COMMIT_HASH};
-      await promise;
-    });
-
-    test('Searching for an invalid change ID searches', async () => {
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([]));
-      const stub = sinon.stub(GerritNav, 'navigateToChange');
-
-      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      await flush();
-
-      assert.isFalse(stub.called);
-    });
-
-    test('Change ID with multiple search results searches', async () => {
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([{}, {}]));
-      const stub = sinon.stub(GerritNav, 'navigateToChange');
-
-      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      await flush();
-
-      assert.isFalse(stub.called);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
new file mode 100644
index 0000000..0639620
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -0,0 +1,308 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-list-view';
+import {GrChangeListView} from './gr-change-list-view';
+import {page} from '../../../utils/page-wrapper-utils';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import 'lodash/lodash';
+import {mockPromise, query, stubRestApi} from '../../../test/test-utils';
+import {createChange} from '../../../test/test-data-generators.js';
+import {
+  ChangeInfo,
+  EmailAddress,
+  NumericChangeId,
+  RepoName,
+} from '../../../api/rest-api.js';
+
+const basicFixture = fixtureFromElement('gr-change-list-view');
+
+const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
+const COMMIT_HASH = '12345678';
+
+suite('gr-change-list-view tests', () => {
+  let element: GrChangeListView;
+
+  setup(async () => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getChanges').returns(Promise.resolve([]));
+    stubRestApi('getAccountDetails').returns(Promise.resolve(undefined));
+    stubRestApi('getAccountStatus').returns(Promise.resolve(undefined));
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  teardown(async () => {
+    await element.updateComplete;
+  });
+
+  test('computePage', () => {
+    element.offset = 0;
+    element.changesPerPage = 25;
+    assert.equal(element.computePage(), 1);
+    element.offset = 50;
+    element.changesPerPage = 25;
+    assert.equal(element.computePage(), 3);
+  });
+
+  test('limitFor', () => {
+    const defaultLimit = 25;
+    const limitFor = (q: string) => element.limitFor(q, defaultLimit);
+    assert.equal(limitFor(''), defaultLimit);
+    assert.equal(limitFor('limit:10'), 10);
+    assert.equal(limitFor('xlimit:10'), defaultLimit);
+    assert.equal(limitFor('x(limit:10'), 10);
+  });
+
+  test('computeNavLink', () => {
+    const getUrlStub = sinon
+      .stub(GerritNav, 'getUrlForSearchQuery')
+      .returns('');
+    element.query = 'status:open';
+    element.offset = 0;
+    element.changesPerPage = 5;
+    let direction = 1;
+
+    element.computeNavLink(direction);
+    assert.equal(getUrlStub.lastCall.args[1], 5);
+
+    direction = -1;
+    element.computeNavLink(direction);
+    assert.equal(getUrlStub.lastCall.args[1], 0);
+
+    element.offset = 5;
+    direction = 1;
+    element.computeNavLink(direction);
+    assert.equal(getUrlStub.lastCall.args[1], 10);
+  });
+
+  test('prevArrow', async () => {
+    element.changes = _.times(25, _.constant(createChange())) as ChangeInfo[];
+    element.offset = 0;
+    element.loading = false;
+    await element.updateComplete;
+    assert.isNotOk(query(element, '#prevArrow'));
+
+    element.offset = 5;
+    await element.updateComplete;
+    assert.isOk(query(element, '#prevArrow'));
+  });
+
+  test('nextArrow', async () => {
+    element.changes = _.times(
+      25,
+      _.constant({...createChange(), _more_changes: true})
+    ) as ChangeInfo[];
+    element.loading = false;
+    await element.updateComplete;
+    assert.isOk(query(element, '#nextArrow'));
+
+    element.changes = _.times(25, _.constant(createChange())) as ChangeInfo[];
+    await element.updateComplete;
+    assert.isNotOk(query(element, '#nextArrow'));
+  });
+
+  test('handleNextPage', async () => {
+    const showStub = sinon.stub(page, 'show');
+    element.changes = _.times(25, _.constant(createChange())) as ChangeInfo[];
+    element.changesPerPage = 10;
+    element.loading = false;
+    await element.updateComplete;
+    element.handleNextPage();
+    assert.isFalse(showStub.called);
+
+    element.changes = _.times(
+      25,
+      _.constant({...createChange(), _more_changes: true})
+    ) as ChangeInfo[];
+    element.loading = false;
+    await element.updateComplete;
+    element.handleNextPage();
+    assert.isTrue(showStub.called);
+  });
+
+  test('handlePreviousPage', async () => {
+    const showStub = sinon.stub(page, 'show');
+    element.offset = 0;
+    element.changes = _.times(25, _.constant(createChange())) as ChangeInfo[];
+    element.changesPerPage = 10;
+    element.loading = false;
+    await element.updateComplete;
+    element.handlePreviousPage();
+    assert.isFalse(showStub.called);
+
+    element.offset = 25;
+    await element.updateComplete;
+    element.handlePreviousPage();
+    assert.isTrue(showStub.called);
+  });
+
+  test('userId query', async () => {
+    assert.isNull(element.userId);
+    element.query = 'owner: foo@bar';
+    element.changes = [
+      {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+    ];
+    await element.updateComplete;
+    assert.equal(element.userId, 'foo@bar' as EmailAddress);
+
+    element.query = 'foo bar baz';
+    element.changes = [
+      {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+    ];
+    await element.updateComplete;
+    assert.isNull(element.userId);
+  });
+
+  test('userId query without email', async () => {
+    assert.isNull(element.userId);
+    element.query = 'owner: foo@bar';
+    element.changes = [{...createChange(), owner: {}}];
+    await element.updateComplete;
+    assert.isNull(element.userId);
+  });
+
+  test('repo query', async () => {
+    assert.isNull(element.repo);
+    element.query = 'project: test-repo';
+    element.changes = [
+      {
+        ...createChange(),
+        owner: {email: 'foo@bar' as EmailAddress},
+        project: 'test-repo' as RepoName,
+      },
+    ];
+    await element.updateComplete;
+    assert.equal(element.repo, 'test-repo' as RepoName);
+
+    element.query = 'foo bar baz';
+    element.changes = [
+      {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+    ];
+    await element.updateComplete;
+    assert.isNull(element.repo);
+  });
+
+  test('repo query with open status', async () => {
+    assert.isNull(element.repo);
+    element.query = 'project:test-repo status:open';
+    element.changes = [
+      {
+        ...createChange(),
+        owner: {email: 'foo@bar' as EmailAddress},
+        project: 'test-repo' as RepoName,
+      },
+    ];
+    await element.updateComplete;
+    assert.equal(element.repo, 'test-repo' as RepoName);
+
+    element.query = 'foo bar baz';
+    element.changes = [
+      {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+    ];
+    await element.updateComplete;
+    assert.isNull(element.repo);
+  });
+
+  suite('query based navigation', () => {
+    setup(() => {});
+
+    teardown(async () => {
+      await element.updateComplete;
+      sinon.restore();
+    });
+
+    test('Searching for a change ID redirects to change', async () => {
+      const change = {...createChange(), _number: 1 as NumericChangeId};
+      sinon.stub(element, 'getChanges').returns(Promise.resolve([change]));
+      const promise = mockPromise();
+      sinon.stub(GerritNav, 'navigateToChange').callsFake((url, opt) => {
+        assert.equal(url, change);
+        assert.isTrue(opt!.redirect);
+        promise.resolve();
+      });
+
+      element.params = {
+        view: GerritNav.View.SEARCH,
+        query: CHANGE_ID,
+        offset: '',
+      };
+      await promise;
+    });
+
+    test('Searching for a change num redirects to change', async () => {
+      const change = {...createChange(), _number: 1 as NumericChangeId};
+      sinon.stub(element, 'getChanges').returns(Promise.resolve([change]));
+      const promise = mockPromise();
+      sinon.stub(GerritNav, 'navigateToChange').callsFake((url, opt) => {
+        assert.equal(url, change);
+        assert.isTrue(opt!.redirect);
+        promise.resolve();
+      });
+
+      element.params = {view: GerritNav.View.SEARCH, query: '1', offset: ''};
+      await promise;
+    });
+
+    test('Commit hash redirects to change', async () => {
+      const change = {...createChange(), _number: 1 as NumericChangeId};
+      sinon.stub(element, 'getChanges').returns(Promise.resolve([change]));
+      const promise = mockPromise();
+      sinon.stub(GerritNav, 'navigateToChange').callsFake((url, opt) => {
+        assert.equal(url, change);
+        assert.isTrue(opt!.redirect);
+        promise.resolve();
+      });
+
+      element.params = {
+        view: GerritNav.View.SEARCH,
+        query: COMMIT_HASH,
+        offset: '',
+      };
+      await promise;
+    });
+
+    test('Searching for an invalid change ID searches', async () => {
+      sinon.stub(element, 'getChanges').returns(Promise.resolve([]));
+      const stub = sinon.stub(GerritNav, 'navigateToChange');
+
+      element.params = {
+        view: GerritNav.View.SEARCH,
+        query: CHANGE_ID,
+        offset: '',
+      };
+      await element.updateComplete;
+
+      assert.isFalse(stub.called);
+    });
+
+    test('Change ID with multiple search results searches', async () => {
+      sinon.stub(element, 'getChanges').returns(Promise.resolve(undefined));
+      const stub = sinon.stub(GerritNav, 'navigateToChange');
+
+      element.params = {
+        view: GerritNav.View.SEARCH,
+        query: CHANGE_ID,
+        offset: '',
+      };
+      await element.updateComplete;
+
+      assert.isFalse(stub.called);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 185a730..1185fe8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -15,31 +15,19 @@
  * limitations under the License.
  */
 
-import '../../../styles/gr-change-list-styles';
-import '../../../styles/gr-font-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
 import '../gr-change-list-item/gr-change-list-item';
+import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-list_html';
 import {getAppContext} from '../../../services/app-context';
 import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {
   GerritNav,
-  DashboardSection,
   YOUR_TURN,
   CLOSED,
 } from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {isOwner} from '../../../utils/change-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {
   AccountInfo,
@@ -48,15 +36,23 @@
   PreferencesInput,
 } from '../../../types/common';
 import {hasAttention} from '../../../utils/attention-set-util';
-import {fireEvent, fireReload} from '../../../utils/event-util';
+import {fire, fireEvent, fireReload} from '../../../utils/event-util';
 import {ScrollMode} from '../../../constants/constants';
-import {listen} from '../../../services/shortcuts/shortcuts-service';
 import {
   getRequirements,
   showNewSubmitRequirements,
 } from '../../../utils/label-util';
 import {addGlobalShortcut, Key} from '../../../utils/dom-util';
 import {unique} from '../../../utils/common-util';
+import {changeListStyles} from '../../../styles/gr-change-list-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {queryAll} from '../../../utils/common-util';
+import {ValueChangedEvent} from '../../../types/events';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -78,24 +74,15 @@
 ];
 
 export interface ChangeListSection {
+  countLabel?: string;
+  isOutgoing?: boolean;
   name?: string;
   query?: string;
   results: ChangeInfo[];
 }
 
-export interface GrChangeList {
-  $: {};
-}
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-change-list')
-export class GrChangeList extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrChangeList extends LitElement {
   /**
    * Fired when next page key shortcut was pressed.
    *
@@ -115,7 +102,7 @@
   @property({type: Object})
   account: AccountInfo | undefined = undefined;
 
-  @property({type: Array, observer: '_changesChanged'})
+  @property({type: Array})
   changes?: ChangeInfo[];
 
   /**
@@ -125,13 +112,9 @@
   @property({type: Array})
   sections: ChangeListSection[] = [];
 
-  @property({type: Array, computed: '_computeLabelNames(sections)'})
-  labelNames?: string[];
+  @state() private dynamicHeaderEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicHeaderEndpoints?: string[];
-
-  @property({type: Number, notify: true})
+  @property({type: Number})
   selectedIndex?: number;
 
   @property({type: Boolean})
@@ -155,24 +138,14 @@
   @property({type: Boolean})
   isCursorMoving = false;
 
-  @property({type: Object})
-  _config?: ServerInfo;
+  // private but used in test
+  @state() config?: ServerInfo;
 
   private readonly flagsService = getAppContext().flagsService;
 
   private readonly restApiService = getAppContext().restApiService;
 
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [
-      listen(Shortcut.CURSOR_NEXT_CHANGE, _ => this._nextChange()),
-      listen(Shortcut.CURSOR_PREV_CHANGE, _ => this._prevChange()),
-      listen(Shortcut.NEXT_PAGE, _ => this._nextPage()),
-      listen(Shortcut.PREV_PAGE, _ => this._prevPage()),
-      listen(Shortcut.OPEN_CHANGE, _ => this.openChange()),
-      listen(Shortcut.TOGGLE_CHANGE_STAR, _ => this._toggleChangeStar()),
-      listen(Shortcut.REFRESH_CHANGE_LIST, _ => this._refreshChangeList()),
-    ];
-  }
+  private readonly shortcuts = new ShortcutController(this);
 
   private cursor = new GrCursorManager();
 
@@ -180,22 +153,33 @@
     super();
     this.cursor.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.cursor.focusOnMove = true;
+    this.shortcuts.addAbstract(Shortcut.CURSOR_NEXT_CHANGE, () =>
+      this.nextChange()
+    );
+    this.shortcuts.addAbstract(Shortcut.CURSOR_PREV_CHANGE, () =>
+      this.prevChange()
+    );
+    this.shortcuts.addAbstract(Shortcut.NEXT_PAGE, () => this.nextPage());
+    this.shortcuts.addAbstract(Shortcut.PREV_PAGE, () => this.prevPage());
+    this.shortcuts.addAbstract(Shortcut.OPEN_CHANGE, () => this.openChange());
+    this.shortcuts.addAbstract(Shortcut.TOGGLE_CHANGE_STAR, () =>
+      this.toggleChangeStar()
+    );
+    this.shortcuts.addAbstract(Shortcut.REFRESH_CHANGE_LIST, () =>
+      this.refreshChangeList()
+    );
     addGlobalShortcut({key: Key.ENTER}, () => this.openChange());
   }
 
-  override ready() {
-    super.ready();
-    this.restApiService.getConfig().then(config => {
-      this._config = config;
-    });
-  }
-
   override connectedCallback() {
     super.connectedCallback();
+    this.restApiService.getConfig().then(config => {
+      this.config = config;
+    });
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._dynamicHeaderEndpoints =
+        this.dynamicHeaderEndpoints =
           getPluginEndpoints().getDynamicEndpoints('change-list-header');
       });
   }
@@ -205,37 +189,259 @@
     super.disconnectedCallback();
   }
 
-  _lowerCase(column: string) {
-    return column.toLowerCase();
+  static override get styles() {
+    return [
+      changeListStyles,
+      fontStyles,
+      sharedStyles,
+      css`
+        #changeList {
+          border-collapse: collapse;
+          width: 100%;
+        }
+        .section-count-label {
+          color: var(--deemphasized-text-color);
+          font-family: var(--font-family);
+          font-size: var(--font-size-small);
+          font-weight: var(--font-weight-normal);
+          line-height: var(--line-height-small);
+        }
+        a.section-title:hover {
+          text-decoration: none;
+        }
+        a.section-title:hover .section-count-label {
+          text-decoration: none;
+        }
+        a.section-title:hover .section-name {
+          text-decoration: underline;
+        }
+      `,
+    ];
   }
 
-  @observe('account', 'preferences', '_config', 'sections')
-  _computePreferences(
-    account?: AccountInfo,
-    preferences?: PreferencesInput,
-    config?: ServerInfo,
-    sections?: ChangeListSection[]
+  override render() {
+    const labelNames = this.computeLabelNames(this.sections);
+    return html`
+      <table id="changeList">
+        ${this.sections.map((changeSection, sectionIndex) =>
+          this.renderSections(changeSection, sectionIndex, labelNames)
+        )}
+      </table>
+    `;
+  }
+
+  private renderSections(
+    changeSection: ChangeListSection,
+    sectionIndex: number,
+    labelNames: string[]
   ) {
-    if (!config) {
-      return;
+    return html`
+      ${this.renderSectionHeader(changeSection, labelNames)}
+      <tbody class="groupContent">
+        ${this.isEmpty(changeSection)
+          ? this.renderNoChangesRow(changeSection, labelNames)
+          : this.renderColumnHeaders(changeSection, labelNames)}
+        ${changeSection.results.map((change, index) =>
+          this.renderChangeRow(
+            changeSection,
+            change,
+            index,
+            sectionIndex,
+            labelNames
+          )
+        )}
+      </tbody>
+    `;
+  }
+
+  private renderSectionHeader(
+    changeSection: ChangeListSection,
+    labelNames: string[]
+  ) {
+    if (!changeSection.name) return;
+
+    return html`
+      <tbody>
+        <tr class="groupHeader">
+          <td aria-hidden="true" class="leftPadding"></td>
+          <td aria-hidden="true" class="star" ?hidden=${!this.showStar}></td>
+          <td
+            class="cell"
+            colspan="${this.computeColspan(changeSection, labelNames)}"
+          >
+            <h2 class="heading-3">
+              <a
+                href="${this.sectionHref(changeSection.query)}"
+                class="section-title"
+              >
+                <span class="section-name">${changeSection.name}</span>
+                <span class="section-count-label"
+                  >${changeSection.countLabel}</span
+                >
+              </a>
+            </h2>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+
+  private renderNoChangesRow(
+    changeSection: ChangeListSection,
+    labelNames: string[]
+  ) {
+    return html`
+      <tr class="noChanges">
+        <td class="leftPadding" ?aria-hidden="true"></td>
+        <td
+          class="star"
+          ?aria-hidden=${!this.showStar}
+          ?hidden=${!this.showStar}
+        ></td>
+        <td
+          class="cell"
+          colspan="${this.computeColspan(changeSection, labelNames)}"
+        >
+          ${this.getSpecialEmptySlot(changeSection)
+            ? html`<slot
+                name="${this.getSpecialEmptySlot(changeSection)}"
+              ></slot>`
+            : 'No changes'}
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderColumnHeaders(
+    changeSection: ChangeListSection,
+    labelNames: string[]
+  ) {
+    return html`
+      <tr class="groupTitle">
+        <td class="leftPadding" ?aria-hidden="true"></td>
+        <td
+          class="star"
+          aria-label="Star status column"
+          ?hidden=${!this.showStar}
+        ></td>
+        <td class="number" ?hidden=${!this.showNumber}>#</td>
+        ${this.computeColumns(changeSection).map(item =>
+          this.renderHeaderCell(item)
+        )}
+        ${labelNames?.map(labelName => this.renderLabelHeader(labelName))}
+        ${this.dynamicHeaderEndpoints?.map(pluginHeader =>
+          this.renderEndpointHeader(pluginHeader)
+        )}
+      </tr>
+    `;
+  }
+
+  private renderHeaderCell(item: string) {
+    return html`<td class="${item.toLowerCase()}">${item}</td>`;
+  }
+
+  private renderLabelHeader(labelName: string) {
+    return html`
+      <td class="label" title="${labelName}">
+        ${this.computeLabelShortcut(labelName)}
+      </td>
+    `;
+  }
+
+  private renderEndpointHeader(pluginHeader: string) {
+    return html`
+      <td class="endpoint">
+        <gr-endpoint-decorator .name="${pluginHeader}"></gr-endpoint-decorator>
+      </td>
+    `;
+  }
+
+  private renderChangeRow(
+    changeSection: ChangeListSection,
+    change: ChangeInfo,
+    index: number,
+    sectionIndex: number,
+    labelNames: string[]
+  ) {
+    const ariaLabel = this.computeAriaLabel(change, changeSection.name);
+    const highlight = this.computeItemHighlight(
+      this.account,
+      change,
+      changeSection.name
+    );
+    const selected = this.computeItemSelected(
+      sectionIndex,
+      index,
+      this.selectedIndex
+    );
+    const tabindex = this.computeTabIndex(
+      sectionIndex,
+      index,
+      this.isCursorMoving,
+      this.selectedIndex
+    );
+    const visibleChangeTableColumns = this.computeColumns(changeSection);
+    return html`
+      <gr-change-list-item
+        .account=${this.account}
+        ?selected=${selected}
+        .highlight=${highlight}
+        .change=${change}
+        .config=${this.config}
+        .sectionName=${changeSection.name}
+        .visibleChangeTableColumns=${visibleChangeTableColumns}
+        .showNumber=${this.showNumber}
+        .showStar=${this.showStar}
+        ?tabindex=${tabindex}
+        .labelNames=${labelNames}
+        aria-label=${ariaLabel}
+      ></gr-change-list-item>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (
+      changedProperties.has('account') ||
+      changedProperties.has('preferences') ||
+      changedProperties.has('config') ||
+      changedProperties.has('sections')
+    ) {
+      this.computePreferences();
     }
 
-    const changes = (sections ?? []).map(section => section.results).flat();
+    if (changedProperties.has('changes')) {
+      this.changesChanged();
+    }
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('sections')) {
+      this.sectionsChanged();
+    }
+  }
+
+  private computePreferences() {
+    if (!this.config) return;
+
+    const changes = (this.sections ?? [])
+      .map(section => section.results)
+      .flat();
     this.changeTableColumns = columnNames;
     this.showNumber = false;
     this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
-      this._isColumnEnabled(col, config, changes)
+      this._isColumnEnabled(col, this.config, changes)
     );
-    if (account && preferences) {
-      this.showNumber = !!(
-        preferences && preferences.legacycid_in_change_table
-      );
-      if (preferences.change_table && preferences.change_table.length > 0) {
-        const prefColumns = preferences.change_table.map(column =>
+    if (this.account && this.preferences) {
+      this.showNumber = !!this.preferences?.legacycid_in_change_table;
+      if (
+        this.preferences?.change_table &&
+        this.preferences.change_table.length > 0
+      ) {
+        const prefColumns = this.preferences.change_table.map(column =>
           column === 'Project' ? 'Repo' : column
         );
         this.visibleChangeTableColumns = prefColumns.filter(col =>
-          this._isColumnEnabled(col, config, changes)
+          this._isColumnEnabled(col, this.config, changes)
         );
       }
     }
@@ -270,12 +476,9 @@
    *
    * @param visibleColumns are the columns according to configs and user prefs
    */
-  _computeColumns(
-    section?: ChangeListSection,
-    visibleColumns?: string[]
-  ): string[] {
-    if (!section || !visibleColumns) return [];
-    const cols = [...visibleColumns];
+  private computeColumns(section?: ChangeListSection): string[] {
+    if (!section || !this.visibleChangeTableColumns) return [];
+    const cols = [...this.visibleChangeTableColumns];
     const updatedIndex = cols.indexOf('Updated');
     if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
       cols[updatedIndex] = 'Waiting';
@@ -286,20 +489,16 @@
     return cols;
   }
 
-  _computeColspan(
-    section?: ChangeListSection,
-    visibleColumns?: string[],
-    labelNames?: string[]
-  ) {
-    const cols = this._computeColumns(section, visibleColumns);
+  // private but used in test
+  computeColspan(section?: ChangeListSection, labelNames?: string[]) {
+    const cols = this.computeColumns(section);
     if (!cols || !labelNames) return 1;
     return cols.length + labelNames.length + NUMBER_FIXED_COLUMNS;
   }
 
-  _computeLabelNames(sections: ChangeListSection[]) {
-    if (!sections) {
-      return [];
-    }
+  // private but used in test
+  computeLabelNames(sections: ChangeListSection[]) {
+    if (!sections) return [];
     let labels: string[] = [];
     const nonExistingLabel = function (item: string) {
       return !labels.includes(item);
@@ -331,7 +530,8 @@
     return labels.sort();
   }
 
-  _computeLabelShortcut(labelName: string) {
+  // private but used in test
+  computeLabelShortcut(labelName: string) {
     if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
       labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
     }
@@ -346,11 +546,12 @@
       .slice(0, MAX_SHORTCUT_CHARS);
   }
 
-  _changesChanged(changes: ChangeInfo[]) {
-    this.sections = changes ? [{results: changes}] : [];
+  private changesChanged() {
+    this.sections = this.changes ? [{results: this.changes}] : [];
   }
 
-  _processQuery(query: string) {
+  // private but used in test
+  processQuery(query: string) {
     let tokens = query.split(' ');
     const invalidTokens = ['limit:', 'age:', '-age:'];
     tokens = tokens.filter(
@@ -360,19 +561,22 @@
     return tokens.join(' ');
   }
 
-  _sectionHref(query: string) {
-    return GerritNav.getUrlForSearchQuery(this._processQuery(query));
+  private sectionHref(query?: string) {
+    if (!query) return;
+    return GerritNav.getUrlForSearchQuery(this.processQuery(query));
   }
 
   /**
    * Maps an index local to a particular section to the absolute index
    * across all the changes on the page.
    *
+   * private but used in test
+   *
    * @param sectionIndex index of section
    * @param localIndex index of row within section
    * @return absolute index of row in the aggregate dashboard
    */
-  _computeItemAbsoluteIndex(sectionIndex: number, localIndex: number) {
+  computeItemAbsoluteIndex(sectionIndex: number, localIndex: number) {
     let idx = 0;
     for (let i = 0; i < sectionIndex; i++) {
       idx += this.sections[i].results.length;
@@ -380,28 +584,28 @@
     return idx + localIndex;
   }
 
-  _computeItemSelected(
+  private computeItemSelected(
     sectionIndex: number,
     index: number,
-    selectedIndex: number
+    selectedIndex?: number
   ) {
-    const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
+    const idx = this.computeItemAbsoluteIndex(sectionIndex, index);
     return idx === selectedIndex;
   }
 
-  _computeTabIndex(
+  private computeTabIndex(
     sectionIndex: number,
     index: number,
-    selectedIndex: number,
-    isCursorMoving: boolean
+    isCursorMoving: boolean,
+    selectedIndex?: number
   ) {
     if (isCursorMoving) return 0;
-    return this._computeItemSelected(sectionIndex, index, selectedIndex)
+    return this.computeItemSelected(sectionIndex, index, selectedIndex)
       ? 0
       : undefined;
   }
 
-  _computeItemHighlight(
+  private computeItemHighlight(
     account?: AccountInfo,
     change?: ChangeInfo,
     sectionName?: string
@@ -415,48 +619,45 @@
     );
   }
 
-  _nextChange() {
+  private nextChange() {
     this.isCursorMoving = true;
     this.cursor.next();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
+    fire(this, 'selected-index-changed', {value: this.cursor.index});
   }
 
-  _prevChange() {
+  private prevChange() {
     this.isCursorMoving = true;
     this.cursor.previous();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
+    fire(this, 'selected-index-changed', {value: this.cursor.index});
   }
 
-  openChange() {
-    const change = this._changeForIndex(this.selectedIndex);
+  private openChange() {
+    const change = this.changeForIndex(this.selectedIndex);
     if (change) GerritNav.navigateToChange(change);
   }
 
-  _nextPage() {
+  private nextPage() {
     fireEvent(this, 'next-page');
   }
 
-  _prevPage() {
-    this.dispatchEvent(
-      new CustomEvent('previous-page', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+  private prevPage() {
+    fireEvent(this, 'previous-page');
   }
 
-  _refreshChangeList() {
+  private refreshChangeList() {
     fireReload(this);
   }
 
-  _toggleChangeStar() {
-    this._toggleStarForIndex(this.selectedIndex);
+  private toggleChangeStar() {
+    this.toggleStarForIndex(this.selectedIndex);
   }
 
-  _toggleStarForIndex(index?: number) {
-    const changeEls = this._getListItems();
+  private toggleStarForIndex(index?: number) {
+    const changeEls = this.getListItems();
     if (index === undefined || index >= changeEls.length || !changeEls[index]) {
       return;
     }
@@ -466,46 +667,47 @@
     if (grChangeStar) grChangeStar.toggleStar();
   }
 
-  _changeForIndex(index?: number) {
-    const changeEls = this._getListItems();
+  private changeForIndex(index?: number) {
+    const changeEls = this.getListItems();
     if (index !== undefined && index < changeEls.length && changeEls[index]) {
       return changeEls[index].change;
     }
     return null;
   }
 
-  _getListItems() {
-    const items = this.root?.querySelectorAll('gr-change-list-item');
+  private getListItems() {
+    const items = queryAll<GrChangeListItem>(this, 'gr-change-list-item');
     return !items ? [] : Array.from(items);
   }
 
-  @observe('sections.*')
-  _sectionsChanged() {
-    // Flush DOM operations so that the list item elements will be loaded.
-    afterNextRender(this, () => {
-      this.cursor.stops = this._getListItems();
-      this.cursor.moveToStart();
-      if (this.selectedIndex) this.cursor.setCursorAtIndex(this.selectedIndex);
-    });
+  private sectionsChanged() {
+    this.cursor.stops = this.getListItems();
+    this.cursor.moveToStart();
+    if (this.selectedIndex) this.cursor.setCursorAtIndex(this.selectedIndex);
   }
 
-  _getSpecialEmptySlot(section: DashboardSection) {
+  // private but used in test
+  getSpecialEmptySlot(section: ChangeListSection) {
     if (section.isOutgoing) return 'empty-outgoing';
     if (section.name === YOUR_TURN.name) return 'empty-your-turn';
     return '';
   }
 
-  _isEmpty(section: DashboardSection) {
+  // private but used in test
+  isEmpty(section: ChangeListSection) {
     return !section.results?.length;
   }
 
-  _computeAriaLabel(change?: ChangeInfo, sectionName?: string) {
+  private computeAriaLabel(change?: ChangeInfo, sectionName?: string) {
     if (!change) return '';
     return change.subject + (sectionName ? `, section: ${sectionName}` : '');
   }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'selected-index-changed': ValueChangedEvent<number>;
+  }
   interface HTMLElementTagNameMap {
     'gr-change-list': GrChangeList;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
deleted file mode 100644
index 77320b9..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-change-list-styles">
-    #changeList {
-      border-collapse: collapse;
-      width: 100%;
-    }
-    .section-count-label {
-      color: var(--deemphasized-text-color);
-      font-family: var(--font-family);
-      font-size: var(--font-size-small);
-      font-weight: var(--font-weight-normal);
-      line-height: var(--line-height-small);
-    }
-    a.section-title:hover {
-      text-decoration: none;
-    }
-    a.section-title:hover .section-count-label {
-      text-decoration: none;
-    }
-    a.section-title:hover .section-name {
-      text-decoration: underline;
-    }
-  </style>
-  <table id="changeList">
-    <template
-      is="dom-repeat"
-      items="[[sections]]"
-      as="changeSection"
-      index-as="sectionIndex"
-    >
-      <template is="dom-if" if="[[changeSection.name]]">
-        <tbody>
-          <tr class="groupHeader">
-            <td aria-hidden="true" class="leftPadding"></td>
-            <td
-              aria-hidden="true"
-              class="star"
-              hidden$="[[!showStar]]"
-              hidden=""
-            ></td>
-            <td
-              class="cell"
-              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
-            >
-              <h2 class="heading-3">
-                <a
-                  href$="[[_sectionHref(changeSection.query)]]"
-                  class="section-title"
-                >
-                  <span class="section-name">[[changeSection.name]]</span>
-                  <span class="section-count-label"
-                    >[[changeSection.countLabel]]</span
-                  >
-                </a>
-              </h2>
-            </td>
-          </tr>
-        </tbody>
-      </template>
-      <tbody class="groupContent">
-        <template is="dom-if" if="[[_isEmpty(changeSection)]]">
-          <tr class="noChanges">
-            <td aria-hidden="true" class="leftPadding"></td>
-            <td
-              aria-hidden="[[!showStar]]"
-              class="star"
-              hidden$="[[!showStar]]"
-            ></td>
-            <td
-              class="cell"
-              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
-            >
-              <template
-                is="dom-if"
-                if="[[_getSpecialEmptySlot(changeSection)]]"
-              >
-                <slot name="[[_getSpecialEmptySlot(changeSection)]]"></slot>
-              </template>
-              <template
-                is="dom-if"
-                if="[[!_getSpecialEmptySlot(changeSection)]]"
-              >
-                No changes
-              </template>
-            </td>
-          </tr>
-        </template>
-        <template is="dom-if" if="[[!_isEmpty(changeSection)]]">
-          <tr class="groupTitle">
-            <td aria-hidden="true" class="leftPadding"></td>
-            <td
-              aria-label="Star status column"
-              class="star"
-              hidden$="[[!showStar]]"
-              hidden=""
-            ></td>
-            <td class="number" hidden$="[[!showNumber]]" hidden="">#</td>
-            <template
-              is="dom-repeat"
-              items="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
-              as="item"
-            >
-              <td class$="[[_lowerCase(item)]]">[[item]]</td>
-            </template>
-            <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-              <td class="label" title$="[[labelName]]">
-                [[_computeLabelShortcut(labelName)]]
-              </td>
-            </template>
-            <template
-              is="dom-repeat"
-              items="[[_dynamicHeaderEndpoints]]"
-              as="pluginHeader"
-            >
-              <td class="endpoint">
-                <gr-endpoint-decorator name$="[[pluginHeader]]">
-                </gr-endpoint-decorator>
-              </td>
-            </template>
-          </tr>
-        </template>
-        <template is="dom-repeat" items="[[changeSection.results]]" as="change">
-          <gr-change-list-item
-            account="[[account]]"
-            selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
-            highlight$="[[_computeItemHighlight(account, change, changeSection.name)]]"
-            change="[[change]]"
-            config="[[_config]]"
-            section-name="[[changeSection.name]]"
-            visible-change-table-columns="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
-            show-number="[[showNumber]]"
-            show-star="[[showStar]]"
-            tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex, isCursorMoving)]]"
-            label-names="[[labelNames]]"
-            aria-label$="[[_computeAriaLabel(change, changeSection.name)]]"
-          ></gr-change-list-item>
-        </template>
-      </tbody>
-    </template>
-  </table>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index ee15b44..50708c0 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
@@ -17,10 +17,8 @@
 import '../../../test/common-test-setup-karma';
 import './gr-change-list';
 import {GrChangeList} from './gr-change-list';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
-  mockPromise,
   pressKey,
   query,
   queryAll,
@@ -47,11 +45,12 @@
   });
 
   suite('test show change number not logged in', () => {
-    setup(() => {
+    setup(async () => {
       element = basicFixture.instantiate();
       element.account = undefined;
       element.preferences = undefined;
-      element._config = createServerInfo();
+      element.config = createServerInfo();
+      await element.updateComplete;
     });
 
     test('show number disabled', () => {
@@ -60,7 +59,7 @@
   });
 
   suite('test show change number preference enabled', () => {
-    setup(() => {
+    setup(async () => {
       element = basicFixture.instantiate();
       element.preferences = {
         legacycid_in_change_table: true,
@@ -68,8 +67,8 @@
         change_table: [],
       };
       element.account = {_account_id: 1001 as AccountId};
-      element._config = createServerInfo();
-      flush();
+      element.config = createServerInfo();
+      await element.updateComplete;
     });
 
     test('show number enabled', () => {
@@ -78,7 +77,7 @@
   });
 
   suite('test show change number preference disabled', () => {
-    setup(() => {
+    setup(async () => {
       element = basicFixture.instantiate();
       // legacycid_in_change_table is not set when false.
       element.preferences = {
@@ -86,8 +85,8 @@
         change_table: [],
       };
       element.account = {_account_id: 1001 as AccountId};
-      element._config = createServerInfo();
-      flush();
+      element.config = createServerInfo();
+      await element.updateComplete;
     });
 
     test('show number disabled', () => {
@@ -97,7 +96,7 @@
 
   test('computed fields', () => {
     assert.equal(
-      element._computeLabelNames([
+      element.computeLabelNames([
         {
           results: [
             {...createChange(), _number: 0 as NumericChangeId, labels: {}},
@@ -107,7 +106,7 @@
       0
     );
     assert.equal(
-      element._computeLabelNames([
+      element.computeLabelNames([
         {
           results: [
             {
@@ -137,49 +136,43 @@
       3
     );
 
-    assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
-    assert.equal(element._computeLabelShortcut('Verified'), 'V');
-    assert.equal(element._computeLabelShortcut('Library-Compliance'), 'LC');
-    assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
-    assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
+    assert.equal(element.computeLabelShortcut('Code-Review'), 'CR');
+    assert.equal(element.computeLabelShortcut('Verified'), 'V');
+    assert.equal(element.computeLabelShortcut('Library-Compliance'), 'LC');
+    assert.equal(element.computeLabelShortcut('PolyGerrit-Review'), 'PR');
+    assert.equal(element.computeLabelShortcut('polygerrit-review'), 'PR');
     assert.equal(
-      element._computeLabelShortcut(
-        'Invalid-Prolog-Rules-Label-Name--Verified'
-      ),
+      element.computeLabelShortcut('Invalid-Prolog-Rules-Label-Name--Verified'),
       'V'
     );
-    assert.equal(element._computeLabelShortcut('Some-Special-Label-7'), 'SSL7');
+    assert.equal(element.computeLabelShortcut('Some-Special-Label-7'), 'SSL7');
     assert.equal(
-      element._computeLabelShortcut('--Too----many----dashes---'),
+      element.computeLabelShortcut('--Too----many----dashes---'),
       'TMD'
     );
     assert.equal(
-      element._computeLabelShortcut(
+      element.computeLabelShortcut(
         'Really-rather-entirely-too-long-of-a-label-name'
       ),
       'RRETL'
     );
   });
 
-  test('colspans', () => {
+  test('colspans', async () => {
     element.sections = [{results: [{...createChange()}]}];
-    flush();
+    await element.updateComplete;
     const tdItemCount = queryAll<HTMLTableElement>(element, 'td').length;
 
-    const changeTableColumns: string[] | undefined = [];
+    element.visibleChangeTableColumns = [];
     const labelNames: string[] | undefined = [];
     assert.equal(
       tdItemCount,
-      element._computeColspan(
-        {results: [{...createChange()}]},
-        changeTableColumns,
-        labelNames
-      )
+      element.computeColspan({results: [{...createChange()}]}, labelNames)
     );
   });
 
   test('keyboard shortcuts', async () => {
-    sinon.stub(element, '_computeLabelNames');
+    sinon.stub(element, 'computeLabelNames');
     element.sections = [{results: new Array(1)}, {results: new Array(2)}];
     element.selectedIndex = 0;
     element.changes = [
@@ -187,12 +180,7 @@
       {...createChange(), _number: 1 as NumericChangeId},
       {...createChange(), _number: 2 as NumericChangeId},
     ];
-    await flush();
-    const promise = mockPromise();
-    afterNextRender(element, () => {
-      promise.resolve();
-    });
-    await promise;
+    await element.updateComplete;
     const elementItems = queryAll<GrChangeListItem>(
       element,
       'gr-change-list-item'
@@ -201,15 +189,18 @@
 
     assert.isTrue(elementItems[0].hasAttribute('selected'));
     pressKey(element, 'j');
+    await element.updateComplete;
     assert.equal(element.selectedIndex, 1);
     assert.isTrue(elementItems[1].hasAttribute('selected'));
     pressKey(element, 'j');
+    await element.updateComplete;
     assert.equal(element.selectedIndex, 2);
     assert.isTrue(elementItems[2].hasAttribute('selected'));
 
     const navStub = sinon.stub(GerritNav, 'navigateToChange');
     assert.equal(element.selectedIndex, 2);
     pressKey(element, Key.ENTER);
+    await element.updateComplete;
     assert.deepEqual(
       navStub.lastCall.args[0],
       {...createChange(), _number: 2 as NumericChangeId},
@@ -217,8 +208,10 @@
     );
 
     pressKey(element, 'k');
+    await element.updateComplete;
     assert.equal(element.selectedIndex, 1);
     pressKey(element, Key.ENTER);
+    await element.updateComplete;
     assert.deepEqual(
       navStub.lastCall.args[0],
       {...createChange(), _number: 1 as NumericChangeId},
@@ -231,9 +224,9 @@
     assert.equal(element.selectedIndex, 0);
   });
 
-  test('no changes', () => {
+  test('no changes', async () => {
     element.changes = [];
-    flush();
+    await element.updateComplete;
     const listItems = queryAll<GrChangeListItem>(
       element,
       'gr-change-list-item'
@@ -246,9 +239,9 @@
     assert.ok(noChangesMsg);
   });
 
-  test('empty sections', () => {
+  test('empty sections', async () => {
     element.sections = [{results: []}, {results: []}];
-    flush();
+    await element.updateComplete;
     const listItems = queryAll<GrChangeListItem>(
       element,
       'gr-change-list-item'
@@ -261,8 +254,8 @@
   suite('empty section', () => {
     test('not shown on empty non-outgoing sections', () => {
       const section = {name: 'test', query: 'test', results: []};
-      assert.isTrue(element._isEmpty(section));
-      assert.equal(element._getSpecialEmptySlot(section), '');
+      assert.isTrue(element.isEmpty(section));
+      assert.equal(element.getSpecialEmptySlot(section), '');
     });
 
     test('shown on empty outgoing sections', () => {
@@ -272,14 +265,14 @@
         results: [],
         isOutgoing: true,
       };
-      assert.isTrue(element._isEmpty(section));
-      assert.equal(element._getSpecialEmptySlot(section), 'empty-outgoing');
+      assert.isTrue(element.isEmpty(section));
+      assert.equal(element.getSpecialEmptySlot(section), 'empty-outgoing');
     });
 
     test('shown on empty outgoing sections', () => {
       const section = {name: YOUR_TURN.name, query: 'test', results: []};
-      assert.isTrue(element._isEmpty(section));
-      assert.equal(element._getSpecialEmptySlot(section), 'empty-your-turn');
+      assert.isTrue(element.isEmpty(section));
+      assert.equal(element.getSpecialEmptySlot(section), 'empty-your-turn');
     });
 
     test('not shown on non-empty outgoing sections', () => {
@@ -295,14 +288,14 @@
           },
         ],
       };
-      assert.isFalse(element._isEmpty(section));
+      assert.isFalse(element.isEmpty(section));
     });
   });
 
   suite('empty column preference', () => {
     let element: GrChangeList;
 
-    setup(() => {
+    setup(async () => {
       stubFlags('isEnabled').returns(true);
       element = basicFixture.instantiate();
       element.sections = [{results: [{...createChange()}]}];
@@ -312,8 +305,8 @@
         time_format: TimeFormat.HHMM_12,
         change_table: [],
       };
-      element._config = createServerInfo();
-      flush();
+      element.config = createServerInfo();
+      await element.updateComplete;
     });
 
     test('show number enabled', () => {
@@ -333,7 +326,7 @@
   suite('full column preference', () => {
     let element: GrChangeList;
 
-    setup(() => {
+    setup(async () => {
       stubFlags('isEnabled').returns(true);
       element = basicFixture.instantiate();
       element.sections = [{results: [{...createChange()}]}];
@@ -354,8 +347,8 @@
           ' Status ',
         ],
       };
-      element._config = createServerInfo();
-      flush();
+      element.config = createServerInfo();
+      await element.updateComplete;
     });
 
     test('all columns visible', () => {
@@ -371,7 +364,7 @@
   suite('partial column preference', () => {
     let element: GrChangeList;
 
-    setup(() => {
+    setup(async () => {
       stubFlags('isEnabled').returns(true);
       element = basicFixture.instantiate();
       element.sections = [{results: [{...createChange()}]}];
@@ -391,8 +384,8 @@
           ' Status ',
         ],
       };
-      element._config = createServerInfo();
-      flush();
+      element.config = createServerInfo();
+      await element.updateComplete;
     });
 
     test('all columns except repo visible', () => {
@@ -417,7 +410,7 @@
 
     /* This would only exist if somebody manually updated the config
     file. */
-    setup(() => {
+    setup(async () => {
       element = basicFixture.instantiate();
       element.account = {_account_id: 1001 as AccountId};
       element.preferences = {
@@ -425,7 +418,7 @@
         time_format: TimeFormat.HHMM_12,
         change_table: ['Bad'],
       };
-      flush();
+      await element.updateComplete;
     });
 
     test('bad column does not exist', () => {
@@ -446,43 +439,43 @@
 
     test('query without age and limit unchanged', () => {
       const query = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), query);
+      assert.deepEqual(element.processQuery(query), query);
     });
 
     test('query with age and limit', () => {
       const query = 'status:closed age:1week limit:10 owner:me';
       const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
+      assert.deepEqual(element.processQuery(query), expectedQuery);
     });
 
     test('query with age', () => {
       const query = 'status:closed age:1week owner:me';
       const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
+      assert.deepEqual(element.processQuery(query), expectedQuery);
     });
 
     test('query with limit', () => {
       const query = 'status:closed limit:10 owner:me';
       const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
+      assert.deepEqual(element.processQuery(query), expectedQuery);
     });
 
     test('query with age as value and not key', () => {
       const query = 'status:closed random:age';
       const expectedQuery = 'status:closed random:age';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
+      assert.deepEqual(element.processQuery(query), expectedQuery);
     });
 
     test('query with limit as value and not key', () => {
       const query = 'status:closed random:limit';
       const expectedQuery = 'status:closed random:limit';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
+      assert.deepEqual(element.processQuery(query), expectedQuery);
     });
 
     test('query with -age key', () => {
       const query = 'status:closed -age:1week';
       const expectedQuery = 'status:closed';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
+      assert.deepEqual(element.processQuery(query), expectedQuery);
     });
   });
 
@@ -518,12 +511,7 @@
           ],
         },
       ];
-      await flush();
-      const promise = mockPromise();
-      afterNextRender(element, () => {
-        promise.resolve();
-      });
-      await promise;
+      await element.updateComplete;
       const elementItems = queryAll<GrChangeListItem>(
         element,
         'gr-change-list-item'
@@ -565,23 +553,23 @@
       );
     });
 
-    test('_computeItemAbsoluteIndex', () => {
-      sinon.stub(element, '_computeLabelNames');
+    test('computeItemAbsoluteIndex', () => {
+      sinon.stub(element, 'computeLabelNames');
       element.sections = [
         {results: new Array(1)},
         {results: new Array(2)},
         {results: new Array(3)},
       ];
 
-      assert.equal(element._computeItemAbsoluteIndex(0, 0), 0);
+      assert.equal(element.computeItemAbsoluteIndex(0, 0), 0);
       // Out of range but no matter.
-      assert.equal(element._computeItemAbsoluteIndex(0, 1), 1);
+      assert.equal(element.computeItemAbsoluteIndex(0, 1), 1);
 
-      assert.equal(element._computeItemAbsoluteIndex(1, 0), 1);
-      assert.equal(element._computeItemAbsoluteIndex(1, 1), 2);
-      assert.equal(element._computeItemAbsoluteIndex(1, 2), 3);
-      assert.equal(element._computeItemAbsoluteIndex(2, 0), 3);
-      assert.equal(element._computeItemAbsoluteIndex(3, 0), 6);
+      assert.equal(element.computeItemAbsoluteIndex(1, 0), 1);
+      assert.equal(element.computeItemAbsoluteIndex(1, 1), 2);
+      assert.equal(element.computeItemAbsoluteIndex(1, 2), 3);
+      assert.equal(element.computeItemAbsoluteIndex(2, 0), 3);
+      assert.equal(element.computeItemAbsoluteIndex(3, 0), 6);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 9beaa58..1b04268 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
@@ -450,12 +450,8 @@
     this.$.commandsDialog.open();
   }
 
-  /**
-   * Returns `this` as the visibility observer target for the keyboard shortcut
-   * mixin to decide whether shortcuts should be enabled or not.
-   */
-  _computeObserverTarget() {
-    return this;
+  _handleSelectedIndexChanged(e: CustomEvent) {
+    this._selectedChangeIndex = Number(e.detail.value);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index a55befb..84cf6d9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -78,10 +78,10 @@
       show-star=""
       account="[[account]]"
       preferences="[[preferences]]"
-      selected-index="{{_selectedChangeIndex}}"
+      selected-index="[[_selectedChangeIndex]]"
       sections="[[_results]]"
+      on-selected-index-changed="_handleSelectedIndexChanged"
       on-toggle-star="_handleToggleStar"
-      observer-target="[[_computeObserverTarget()]]"
     >
       <div id="emptyOutgoing" slot="empty-outgoing">
         <template is="dom-if" if="[[_showNewUserHelp]]">
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 a6ee55d..21d9b97 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
@@ -37,8 +37,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators';
-import {ValueChangedEvent} from '../../../types/events';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fireEvent} from '../../../utils/event-util';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -103,9 +102,6 @@
 ]);
 
 declare global {
-  interface HTMLElementEventMap {
-    'search-query-changed': ValueChangedEvent;
-  }
   interface HTMLElementTagNameMap {
     'gr-main-header': GrMainHeader;
   }
@@ -383,9 +379,6 @@
         label="Search for changes"
         .searchQuery=${this.searchQuery}
         .serverConfig=${this.serverConfig}
-        @search-query-changed=${(e: ValueChangedEvent) => {
-          this.handleSearchQueryBindValueChanged(e);
-        }}
       ></gr-smart-search>
       <gr-endpoint-decorator
         class="hideOnMobile"
@@ -651,8 +644,4 @@
     e.stopPropagation();
     fireEvent(this, 'mobile-search');
   }
-
-  private handleSearchQueryBindValueChanged(e: ValueChangedEvent) {
-    fire(this, 'search-query-changed', {value: e.detail.value});
-  }
 }
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 71ffe18..ed6b822 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -26,7 +26,6 @@
 import {getAppContext} from '../../../services/app-context';
 import {LitElement, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
-import {fire} from '../../../utils/event-util';
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 const SELF_EXPRESSION = 'self';
@@ -73,9 +72,6 @@
         @handle-search=${(e: CustomEvent<SearchBarHandleSearchDetail>) => {
           this.handleSearch(e);
         }}
-        @value-changed=${(e: CustomEvent) => {
-          this.handleSearchValueChanged(e);
-        }}
       ></gr-search-bar>
     `;
   }
@@ -196,8 +192,4 @@
       GerritNav.navigateToSearchQuery(input);
     }
   }
-
-  private handleSearchValueChanged(e: CustomEvent) {
-    fire(this, 'search-query-changed', {value: e.detail.value});
-  }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
index 773e22e..6e43fdc 100644
--- a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
@@ -279,7 +279,7 @@
         : this.showAbove()
         ? 'aboveButton'
         : 'belowButton';
-      if (this.partialContent) {
+      if (this.group?.hasSkipGroup()) {
         // Expanding content would require load of more data
         text += ' (too large)';
       }
@@ -356,7 +356,7 @@
     return (e: Event) => {
       assertIsDefined(this.group);
       e.stopPropagation();
-      if (type === ContextButtonType.ALL && this.partialContent) {
+      if (type === ContextButtonType.ALL && this.group?.hasSkipGroup()) {
         fire(this, 'content-load-needed', {
           lineRange: this.group.lineRange,
         });
@@ -406,13 +406,6 @@
   }
 
   /**
-   * Checks if the collapsed section contains unavailable content (skip chunks).
-   */
-  private get partialContent() {
-    return this.group?.contextGroups.some(c => !!c.skip);
-  }
-
-  /**
    * Creates a container div with block expansion buttons (above and/or below).
    */
   private createBlockExpansionButtons() {
@@ -420,7 +413,7 @@
     if (
       !this.showPartialLinks() ||
       !this.renderPreferences?.use_block_expansion ||
-      this.partialContent
+      this.group?.hasSkipGroup()
     ) {
       return undefined;
     }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
index b913e3e6..7ab24ab 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -22,7 +22,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-builder-element_html';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffBuilder} from './gr-diff-builder';
+import {DiffContextExpandedEventDetail, GrDiffBuilder} 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';
@@ -42,12 +42,13 @@
 } from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
 import {DiffViewMode, RenderPreferences} from '../../../api/diff';
-import {Side} from '../../../constants/constants';
+import {createDefaultDiffPrefs, Side} from '../../../constants/constants';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
@@ -139,6 +140,12 @@
   path?: string;
 
   @property({type: Object})
+  prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+
+  @property({type: Object})
+  renderPrefs?: RenderPreferences;
+
+  @property({type: Object})
   _builder?: GrDiffBuilder;
 
   // This is written to only from the processor via property notify
@@ -193,6 +200,19 @@
   @property({type: Object})
   _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
 
+  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.rerenderSection(e.detail.groups, e.detail.section);
+        }
+      );
+    });
+  }
+
   override disconnectedCallback() {
     if (this._builder) {
       this._builder.clear();
@@ -213,19 +233,15 @@
     return coverageRanges.filter(range => range && range.side === 'right');
   }
 
-  render(
-    keyLocations: KeyLocations,
-    prefs: DiffPreferencesInfo,
-    renderPrefs?: RenderPreferences
-  ) {
+  render(keyLocations: KeyLocations) {
     // Setting up annotation layers must happen after plugins are
     // installed, and |render| satisfies the requirement, however,
     // |attached| doesn't because in the diff view page, the element is
     // attached before plugins are installed.
     this._setupAnnotationLayers();
 
-    this._showTabs = !!prefs.show_tabs;
-    this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
+    this._showTabs = this.prefs.show_tabs;
+    this._showTrailingWhitespace = this.prefs.show_whitespace_errors;
 
     // Stop the processor if it's running.
     this.cancel();
@@ -236,13 +252,16 @@
     if (!this.diff) {
       throw Error('Cannot render a diff without DiffInfo.');
     }
-    this._builder = this._getDiffBuilder(this.diff, prefs, renderPrefs);
+    this._builder = this._getDiffBuilder();
 
-    this.$.processor.context = prefs.context;
+    this.$.processor.context = this.prefs.context;
     this.$.processor.keyLocations = keyLocations;
 
     this._clearDiffContent();
-    this._builder.addColumns(this.diffElement, getLineNumberCellWidth(prefs));
+    this._builder.addColumns(
+      this.diffElement,
+      getLineNumberCellWidth(this.prefs)
+    );
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
@@ -323,7 +342,10 @@
    * @param newGroups The groups to be rendered in the place of the section.
    * @param sectionEl The context section that should be expanded from.
    */
-  rerenderSection(newGroups: readonly GrDiffGroup[], sectionEl: HTMLElement) {
+  private rerenderSection(
+    newGroups: readonly GrDiffGroup[],
+    sectionEl: HTMLElement
+  ) {
     if (!this._builder) return;
 
     const contextIndex = this._builder.getIndexOfSection(sectionEl);
@@ -348,20 +370,19 @@
     throw Error(`Invalid preference value: ${pref}`);
   }
 
-  _getDiffBuilder(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    renderPrefs?: RenderPreferences
-  ): GrDiffBuilder {
-    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+  _getDiffBuilder(): GrDiffBuilder {
+    if (!this.diff) {
+      throw Error('Cannot render a diff without DiffInfo.');
+    }
+    if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
       this._handlePreferenceError('tab size');
     }
 
-    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+    if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
       this._handlePreferenceError('diff width');
     }
 
-    const localPrefs = {...prefs};
+    const localPrefs = {...this.prefs};
     if (this.path === COMMIT_MSG_PATH) {
       // override line_length for commit msg the same way as
       // in gr-diff
@@ -371,32 +392,32 @@
     let builder = null;
     if (this.isImageDiff) {
       builder = new GrDiffBuilderImage(
-        diff,
+        this.diff,
         localPrefs,
         this.diffElement,
         this.baseImage,
         this.revisionImage,
-        renderPrefs,
+        this.renderPrefs,
         this.useNewImageDiffUi
       );
-    } else if (diff.binary) {
+    } else if (this.diff.binary) {
       // If the diff is binary, but not an image.
-      return new GrDiffBuilderBinary(diff, localPrefs, this.diffElement);
+      return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffElement);
     } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
       builder = new GrDiffBuilderSideBySide(
-        diff,
+        this.diff,
         localPrefs,
         this.diffElement,
         this._layers,
-        renderPrefs
+        this.renderPrefs
       );
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
       builder = new GrDiffBuilderUnified(
-        diff,
+        this.diff,
         localPrefs,
         this.diffElement,
         this._layers,
-        renderPrefs
+        this.renderPrefs
       );
     }
     if (!builder) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
index 413a292..9f42e9e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -16,9 +16,6 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-group.js';
-import './gr-diff-builder.js';
-import '../gr-context-controls/gr-context-controls.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-diff-builder-element.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
@@ -31,6 +28,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {DiffViewMode} from '../../../api/diff.js';
 import {stubRestApi} from '../../../test/test-utils.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 
 const basicFixture = fixtureFromTemplate(html`
     <gr-diff-builder>
@@ -48,6 +46,14 @@
     </gr-diff-builder>
 `);
 
+// GrDiffBuilderElement forces these prefs to be set - tests that do not care
+// about these values can just set these defaults.
+const DEFAULT_PREFS = {
+  line_length: 10,
+  show_tabs: true,
+  tab_size: 4,
+};
+
 suite('gr-diff-builder tests', () => {
   let prefs;
   let element;
@@ -61,11 +67,7 @@
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     stubRestApi('getProjectConfig').returns(Promise.resolve({}));
     stubBaseUrl('/r');
-    prefs = {
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
+    prefs = {...DEFAULT_PREFS};
     builder = new GrDiffBuilder({content: []}, prefs);
   });
 
@@ -142,18 +144,18 @@
         test(`line_length used for regular files under ${mode}`, () => {
           element.path = '/a.txt';
           element.viewMode = mode;
-          builder = element._getDiffBuilder(
-              {}, {tab_size: 4, line_length: 50}
-          );
+          element.diff = {};
+          element.prefs = {tab_size: 4, line_length: 50};
+          builder = element._getDiffBuilder();
           assert.equal(builder._prefs.line_length, 50);
         });
 
         test(`line_length ignored for commit msg under ${mode}`, () => {
           element.path = '/COMMIT_MSG';
           element.viewMode = mode;
-          builder = element._getDiffBuilder(
-              {}, {tab_size: 4, line_length: 50}
-          );
+          element.diff = {};
+          element.prefs = {tab_size: 4, line_length: 50};
+          builder = element._getDiffBuilder();
           assert.equal(builder._prefs.line_length, 72);
         });
       });
@@ -237,8 +239,8 @@
   });
 
   test('_handlePreferenceError throws with invalid preference', () => {
-    const prefs = {tab_size: 0};
-    assert.throws(() => element._getDiffBuilder(element.diff, prefs));
+    element.prefs = {tab_size: 0};
+    assert.throws(() => element._getDiffBuilder());
   });
 
   test('_handlePreferenceError triggers alert and javascript error', () => {
@@ -696,7 +698,6 @@
   suite('rendering text, images and binary files', () => {
     let processStub;
     let keyLocations;
-    let prefs;
     let content;
 
     setup(() => {
@@ -705,10 +706,8 @@
       processStub = sinon.stub(element.$.processor, 'process')
           .returns(Promise.resolve());
       keyLocations = {left: {}, right: {}};
-      prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
+      element.prefs = {
+        ...DEFAULT_PREFS,
         context: -1,
         syntax_highlighting: true,
       };
@@ -725,7 +724,7 @@
 
     test('text', () => {
       element.diff = {content};
-      return element.render(keyLocations, prefs).then(() => {
+      return element.render(keyLocations).then(() => {
         assert.isTrue(processStub.calledOnce);
         assert.isFalse(processStub.lastCall.args[1]);
       });
@@ -734,7 +733,7 @@
     test('image', () => {
       element.diff = {content, binary: true};
       element.isImageDiff = true;
-      return element.render(keyLocations, prefs).then(() => {
+      return element.render(keyLocations).then(() => {
         assert.isTrue(processStub.calledOnce);
         assert.isTrue(processStub.lastCall.args[1]);
       });
@@ -742,7 +741,7 @@
 
     test('binary', () => {
       element.diff = {content, binary: true};
-      return element.render(keyLocations, prefs).then(() => {
+      return element.render(keyLocations).then(() => {
         assert.isTrue(processStub.calledOnce);
         assert.isTrue(processStub.lastCall.args[1]);
       });
@@ -755,13 +754,7 @@
     let keyLocations;
 
     setup(async () => {
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-        syntax_highlighting: true,
-      };
+      const prefs = {...DEFAULT_PREFS};
       content = [
         {
           a: ['all work and no play make andybons a dull boy'],
@@ -775,6 +768,7 @@
         },
       ];
       element = basicFixture.instantiate();
+      sinon.stub(element, 'dispatchEvent');
       outputEl = element.querySelector('#diffTable');
       keyLocations = {left: {}, right: {}};
       sinon.stub(element, '_getDiffBuilder').callsFake(() => {
@@ -789,53 +783,113 @@
         return builder;
       });
       element.diff = {content};
-      await element.render(keyLocations, prefs);
+      element.prefs = prefs;
+      await element.render(keyLocations);
     });
 
-    test('addColumns is called', async () => {
-      await element.render(keyLocations, {});
+    test('addColumns is called', () => {
       assert.isTrue(element._builder.addColumns.called);
     });
 
-    test('getSectionsByLineRange one line', () => {
+    test('getGroupsByLineRange one line', () => {
       const section = outputEl.querySelector('stub:nth-of-type(3)');
-      const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
-      assert.equal(sections.length, 1);
-      assert.strictEqual(sections[0], section);
+      const groups = element._builder.getGroupsByLineRange(1, 1, 'left');
+      assert.equal(groups.length, 1);
+      assert.strictEqual(groups[0].element, section);
     });
 
-    test('getSectionsByLineRange over diff', () => {
+    test('getGroupsByLineRange over diff', () => {
       const section = [
         outputEl.querySelector('stub:nth-of-type(3)'),
         outputEl.querySelector('stub:nth-of-type(4)'),
       ];
-      const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
-      assert.equal(sections.length, 2);
-      assert.strictEqual(sections[0], section[0]);
-      assert.strictEqual(sections[1], section[1]);
+      const groups = element._builder.getGroupsByLineRange(1, 2, 'left');
+      assert.equal(groups.length, 2);
+      assert.strictEqual(groups[0].element, section[0]);
+      assert.strictEqual(groups[1].element, section[1]);
     });
 
     test('render-start and render-content are fired', async () => {
-      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-      await element.render(keyLocations, {});
-      const firedEventTypes = dispatchEventStub.getCalls()
+      const firedEventTypes = element.dispatchEvent.getCalls()
           .map(c => c.args[0].type);
       assert.include(firedEventTypes, 'render-start');
       assert.include(firedEventTypes, 'render-content');
     });
 
-    test('cancel', () => {
+    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();
+      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');
+    });
+  });
+
   suite('mock-diff', () => {
     let element;
     let builder;
     let diff;
-    let prefs;
     let keyLocations;
 
     setup(async () => {
@@ -843,14 +897,14 @@
       diff = getMockDiffResponse();
       element.diff = diff;
 
-      prefs = {
+      keyLocations = {left: {}, right: {}};
+
+      element.prefs = {
         line_length: 80,
         show_tabs: true,
         tab_size: 4,
       };
-      keyLocations = {left: {}, right: {}};
-
-      await element.render(keyLocations, prefs);
+      await element.render(keyLocations);
       builder = element._builder;
     });
 
@@ -988,7 +1042,7 @@
     test('_getLineNumberEl unified left', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations, prefs);
+      await element.render(keyLocations);
       builder = element._builder;
 
       const contentEl = builder.getContentByLine(5, 'left',
@@ -1001,7 +1055,7 @@
     test('_getLineNumberEl unified right', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations, prefs);
+      await element.render(keyLocations);
       builder = element._builder;
 
       const contentEl = builder.getContentByLine(5, 'right',
@@ -1038,7 +1092,7 @@
     test('_getNextContentOnSide unified left', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations, prefs);
+      await element.render(keyLocations);
       builder = element._builder;
 
       const startElem = builder.getContentByLine(5, 'left',
@@ -1055,7 +1109,7 @@
     test('_getNextContentOnSide unified right', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations, prefs);
+      await element.render(keyLocations);
       builder = element._builder;
 
       const startElem = builder.getContentByLine(5, 'right',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index ab2337e..28172e5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -226,38 +226,22 @@
     group.element = element;
   }
 
-  getGroupsByLineRange(
+  private getGroupsByLineRange(
     startLine: LineNumber,
     endLine: LineNumber,
-    side?: Side
+    side: Side
   ) {
-    const groups = [];
-    for (let i = 0; i < this.groups.length; i++) {
-      const group = this.groups[i];
-      if (group.lines.length === 0) {
-        continue;
-      }
-      let groupStartLine = 0;
-      let groupEndLine = 0;
-      if (side) {
-        const range = group.lineRange[side];
-        groupStartLine = range.start_line;
-        groupEndLine = range.end_line;
-      }
-
-      if (groupStartLine === 0) {
-        // Line was removed or added.
-        groupStartLine = groupEndLine;
-      }
-      if (groupEndLine === 0) {
-        // Line was removed or added.
-        groupEndLine = groupStartLine;
-      }
-      if (startLine <= groupEndLine && endLine >= groupStartLine) {
-        groups.push(group);
-      }
-    }
-    return groups;
+    const startIndex = this.groups.findIndex(group =>
+      group.containsLine(side, startLine)
+    );
+    const endIndex = this.groups.findIndex(group =>
+      group.containsLine(side, endLine)
+    );
+    // The filter preserves the legacy behavior to only return non-context
+    // groups
+    return this.groups
+      .slice(startIndex, endIndex + 1)
+      .filter(group => group.lines.length > 0);
   }
 
   getContentTdByLine(
@@ -356,16 +340,6 @@
     }
   }
 
-  getSectionsByLineRange(
-    startLine: LineNumber,
-    endLine: LineNumber,
-    side: Side
-  ) {
-    return this.getGroupsByLineRange(startLine, endLine, side).map(
-      group => group.element
-    );
-  }
-
   _createContextControls(
     section: HTMLElement,
     group: GrDiffGroup,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
index 9778bb7..d072145 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
@@ -16,6 +16,7 @@
  */
 import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
 import {LineRange, Side} from '../../../api/diff';
+import {LineNumber} from './gr-diff-line';
 
 export enum GrDiffGroupType {
   /** Unchanged context. */
@@ -409,6 +410,20 @@
     return pairs;
   }
 
+  /** Returns true if it is, or contains, a skip group. */
+  hasSkipGroup() {
+    return !!this.skip || this.contextGroups?.some(g => !!g.skip);
+  }
+
+  containsLine(side: Side, line: LineNumber) {
+    if (line === 'FILE' || line === 'LOST') {
+      // For FILE and LOST, beforeNumber and afterNumber are the same
+      return this.lines[0]?.beforeNumber === line;
+    }
+    const lineRange = this.lineRange[side];
+    return lineRange.start_line <= line && line <= lineRange.end_line;
+  }
+
   private _updateRangeWithNewLine(line: GrDiffLine) {
     if (
       line.beforeNumber === 'FILE' ||
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index 0b15933..6f9765d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -84,7 +84,6 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {
-  DiffContextExpandedEventDetail,
   getResponsiveMode,
   isResponsive,
 } from '../gr-diff-builder/gr-diff-builder';
@@ -543,11 +542,6 @@
     return classes.join(' ');
   }
 
-  _handleDiffContextExpanded(e: CustomEvent<DiffContextExpandedEventDetail>) {
-    // Don't stop propagation. The host may listen for reporting or resizing.
-    this.$.diffBuilder.rerenderSection(e.detail.groups, e.detail.section);
-  }
-
   _handleTap(e: CustomEvent) {
     const el = (dom(e) as EventApi).localTarget as Element;
 
@@ -858,18 +852,17 @@
     this._showWarning = false;
 
     const keyLocations = this._computeKeyLocations();
-    const bypassPrefs = this._getBypassPrefs(this.prefs);
-    this.$.diffBuilder
-      .render(keyLocations, bypassPrefs, this.renderPrefs)
-      .then(() => {
-        this.dispatchEvent(
-          new CustomEvent('render', {
-            bubbles: true,
-            composed: true,
-            detail: {contentRendered: true},
-          })
-        );
-      });
+    this.$.diffBuilder.prefs = this._getBypassPrefs(this.prefs);
+    this.$.diffBuilder.renderPrefs = this.renderPrefs;
+    this.$.diffBuilder.render(keyLocations).then(() => {
+      this.dispatchEvent(
+        new CustomEvent('render', {
+          bubbles: true,
+          composed: true,
+          detail: {contentRendered: true},
+        })
+      );
+    });
   }
 
   _handleRenderContent() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index 67b7a9f..d288008 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -570,7 +570,6 @@
   <div
     class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
     on-click="_handleTap"
-    on-diff-context-expanded="_handleDiffContextExpanded"
   >
     <gr-diff-selection diff="[[diff]]">
       <gr-diff-highlight
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
index 14f6f13..ef3ca39 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
@@ -183,9 +183,9 @@
       element.changeNum = 123;
       element.patchRange = {basePatchNum: 1, patchNum: 2};
       element.path = 'file.txt';
-
-      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
-          getMockDiffResponse(), {...MINIMAL_PREFS});
+      element.$.diffBuilder.diff = getMockDiffResponse();
+      element.$.diffBuilder.prefs = {...MINIMAL_PREFS};
+      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder();
 
       // No thread groups.
       assert.isNotOk(element._getThreadGroupForLine(contentEl));
@@ -497,21 +497,6 @@
       await promise;
     });
 
-    test('_handleTap context', async () => {
-      const rerenderSectionStub =
-          sinon.stub(element.$.diffBuilder, 'rerenderSection');
-      const el = document.createElement('div');
-      el.className = 'showContext';
-      const promise = mockPromise();
-      el.addEventListener('click', e => {
-        element._handleDiffContextExpanded(e);
-        assert.isTrue(rerenderSectionStub.called);
-        promise.resolve();
-      });
-      el.click();
-      await promise;
-    });
-
     test('_handleTap content', async () => {
       const content = document.createElement('div');
       const lineEl = document.createElement('div');
@@ -859,7 +844,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.equal(element._safetyBypass, -1);
-      assert.equal(renderStub.firstCall.args[1].context, -1);
+      assert.equal(element.$.diffBuilder.prefs.context, -1);
     });
 
     test('toggles collapse context from bypass', async () => {
@@ -872,7 +857,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.isNull(element._safetyBypass);
-      assert.equal(renderStub.firstCall.args[1].context, 3);
+      assert.equal(element.$.diffBuilder.prefs.context, 3);
     });
 
     test('toggles collapse context from pref using default', async () => {
@@ -884,7 +869,7 @@
 
       assert.equal(element.prefs.context, -1);
       assert.equal(element._safetyBypass, 10);
-      assert.equal(renderStub.firstCall.args[1].context, 10);
+      assert.equal(element.$.diffBuilder.prefs.context, 10);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index e302c8f..288489e 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -65,7 +65,6 @@
 import {
   AppElementJustRegisteredParams,
   AppElementParams,
-  AppElementSearchParam,
   isAppElementJustRegisteredParams,
 } from './gr-app-types';
 import {GrMainHeader} from './core/gr-main-header/gr-main-header';
@@ -79,7 +78,7 @@
   TitleChangeEventDetail,
   ValueChangedEvent,
 } from '../types/events';
-import {ViewState} from '../types/types';
+import {ChangeListViewState, ViewState} from '../types/types';
 import {GerritView} from '../services/router/router-model';
 import {LifeCycle} from '../constants/reporting';
 import {fireIronAnnounce} from '../utils/event-util';
@@ -621,9 +620,12 @@
       : 'app-theme-light';
   }
 
-  _handleSearchQueryChanged(e: ValueChangedEvent) {
-    if (!this.params) return;
-    (this.params as AppElementSearchParam).query = e.detail.value;
+  _handleViewStateChanged(e: ValueChangedEvent<ChangeListViewState>) {
+    if (!this._viewState) return;
+    this._viewState.changeListView = {
+      ...this._viewState.changeListView,
+      ...e.detail.value,
+    };
   }
 }
 
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.ts b/polygerrit-ui/app/elements/gr-app-element_html.ts
index a6afb33..fcb3435 100644
--- a/polygerrit-ui/app/elements/gr-app-element_html.ts
+++ b/polygerrit-ui/app/elements/gr-app-element_html.ts
@@ -100,7 +100,6 @@
   <gr-main-header
     id="mainHeader"
     search-query="[[params.query]]"
-    on-search-query-changed="_handleSearchQueryChanged"
     on-mobile-search="_mobileSearchToggle"
     on-show-keyboard-shortcuts="handleShowKeyboardShortcuts"
     mobile-search-hidden="[[!mobileSearch]]"
@@ -116,7 +115,6 @@
         search-query="[[params.query]]"
         server-config="[[_serverConfig]]"
         hidden="[[!mobileSearch]]"
-        on-search-query-changed="_handleSearchQueryChanged"
       >
       </gr-smart-search>
     </template>
@@ -124,7 +122,8 @@
       <gr-change-list-view
         params="[[params]]"
         account="[[_account]]"
-        view-state="{{_viewState.changeListView}}"
+        view-state="[[_viewState.changeListView]]"
+        on-view-state-changed="_handleViewStateChanged"
       ></gr-change-list-view>
     </template>
     <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
diff --git a/tools/deps.bzl b/tools/deps.bzl
index f64efff..a202405 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -82,12 +82,6 @@
     )
 
     maven_jar(
-        name = "error-prone-annotations",
-        artifact = "com.google.errorprone:error_prone_annotations:2.3.3",
-        sha1 = "42aa5155a54a87d70af32d4b0d06bf43779de0e2",
-    )
-
-    maven_jar(
         name = "gson",
         artifact = "com.google.code.gson:gson:2.8.7",
         sha1 = "69d9503ea0a40ee16f0bcdac7e3eaf83d0fa914a",
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 80db2fa..e567668 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -123,6 +123,12 @@
     # Google internal dependencies: these are developed at Google, so there is
     # no concern about version skew.
 
+    maven_jar(
+        name = "error-prone-annotations",
+        artifact = "com.google.errorprone:error_prone_annotations:2.3.3",
+        sha1 = "42aa5155a54a87d70af32d4b0d06bf43779de0e2",
+    )
+
     FLOGGER_VERS = "0.7.4"
 
     maven_jar(