Merge "Mark mergeTip as Nullable in ChangeMessageModifier"
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 980abb4..47c820a 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -95,7 +95,6 @@
 # TODO: fix problems reported by template checker in these files.
 ignore_templates_list = [
     "elements/admin/gr-admin-view/gr-admin-view_html.ts",
-    "elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts",
     "elements/admin/gr-group-members/gr-group-members_html.ts",
     "elements/admin/gr-permission/gr-permission_html.ts",
     "elements/admin/gr-plugin-list/gr-plugin-list_html.ts",
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index 63f6601..934e3fb 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -15,18 +15,11 @@
  * limitations under the License.
  */
 import '@polymer/iron-input/iron-input';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {GrSelect} from '../../shared/gr-select/gr-select';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-create-repo-dialog_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
   BranchName,
   GroupId,
@@ -35,63 +28,173 @@
 } from '../../../types/common';
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {appContext} from '../../../services/app-context';
+import {convertToString} from '../../../utils/string-util';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, query, property, state} from 'lit/decorators';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
+  interface HTMLElementEventMap {
+    'text-changed': CustomEvent;
+    'value-changed': CustomEvent;
+  }
   interface HTMLElementTagNameMap {
     'gr-create-repo-dialog': GrCreateRepoDialog;
   }
 }
 
-export interface GrCreateRepoDialog {
-  $: {
-    initialCommit: GrSelect;
-    parentRepo: GrSelect;
-    repoNameInput: HTMLInputElement;
-    rightsInheritFromInput: GrAutocomplete;
-  };
-}
-
 @customElement('gr-create-repo-dialog')
-export class GrCreateRepoDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrCreateRepoDialog extends LitElement {
+  /**
+   * Fired when repostiory name is entered.
+   *
+   * @event new-repo-name
+   */
 
-  @property({type: Boolean, notify: true})
-  hasNewRepoName = false;
+  @query('input')
+  input?: HTMLInputElement;
 
-  @property({type: Object})
-  _repoConfig: ProjectInput & {name: RepoName} = {
+  @property({type: Boolean})
+  nameChanged = false;
+
+  /* private but used in test */
+  @state() repoConfig: ProjectInput & {name: RepoName} = {
     create_empty_commit: true,
     permissions_only: false,
     name: '' as RepoName,
     branches: [],
   };
 
-  @property({type: String})
-  _defaultBranch?: BranchName;
+  /* private but used in test */
+  @state() defaultBranch?: BranchName;
 
-  @property({type: Boolean})
-  _repoCreated = false;
+  /* private but used in test */
+  @state() repoCreated = false;
 
-  @property({type: String})
-  _repoOwner?: string;
+  /* private but used in test */
+  @state() repoOwner?: string;
 
-  @property({type: String})
-  _repoOwnerId?: GroupId;
+  /* private but used in test */
+  @state() repoOwnerId?: GroupId;
 
-  @property({type: Object})
-  _query: AutocompleteQuery;
+  private readonly query: AutocompleteQuery;
 
-  @property({type: Object})
-  _queryGroups: AutocompleteQuery;
+  private readonly queryGroups: AutocompleteQuery;
 
   private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
-    this._query = (input: string) => this._getRepoSuggestions(input);
-    this._queryGroups = (input: string) => this._getGroupSuggestions(input);
+    this.query = (input: string) => this.getRepoSuggestions(input);
+    this.queryGroups = (input: string) => this.getGroupSuggestions(input);
+  }
+
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        input {
+          width: 20em;
+        }
+        gr-autocomplete {
+          width: 20em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <div id="form">
+          <section>
+            <span class="title">Repository name</span>
+            <iron-input
+              .bindValue=${convertToString(this.repoConfig.name)}
+              @bind-value-changed=${this.handleNameBindValueChanged}
+            >
+              <input id="repoNameInput" autocomplete="on" />
+            </iron-input>
+          </section>
+          <section>
+            <span class="title">Default Branch</span>
+            <iron-input
+              .bindValue=${convertToString(this.defaultBranch)}
+              @bind-value-changed=${this.handleBranchNameBindValueChanged}
+            >
+              <input id="defaultBranchNameInput" autocomplete="off" />
+            </iron-input>
+          </section>
+          <section>
+            <span class="title">Rights inherit from</span>
+            <span class="value">
+              <gr-autocomplete
+                id="rightsInheritFromInput"
+                .text=${convertToString(this.repoConfig.parent)}
+                .query=${this.query}
+                .placeholder="Optional, defaults to 'All-Projects'"
+                @text-changed=${this.handleRightsTextChanged}
+              >
+              </gr-autocomplete>
+            </span>
+          </section>
+          <section>
+            <span class="title">Owner</span>
+            <span class="value">
+              <gr-autocomplete
+                id="ownerInput"
+                .text=${convertToString(this.repoOwner)}
+                .value=${convertToString(this.repoOwnerId)}
+                .query=${this.queryGroups}
+                @text-changed=${this.handleOwnerTextChanged}
+                @value-changed=${this.handleOwnerValueChanged}
+              >
+              </gr-autocomplete>
+            </span>
+          </section>
+          <section>
+            <span class="title">Create initial empty commit</span>
+            <span class="value">
+              <gr-select
+                id="initialCommit"
+                .bindValue=${this.repoConfig.create_empty_commit}
+                @bind-value-changed=${this
+                  .handleCreateEmptyCommitBindValueChanged}
+              >
+                <select>
+                  <option value="false">False</option>
+                  <option value="true">True</option>
+                </select>
+              </gr-select>
+            </span>
+          </section>
+          <section>
+            <span class="title"
+              >Only serve as parent for other repositories</span
+            >
+            <span class="value">
+              <gr-select
+                id="parentRepo"
+                .bindValue=${this.repoConfig.permissions_only}
+                @bind-value-changed=${this
+                  .handlePermissionsOnlyBindValueChanged}
+              >
+                <select>
+                  <option value="false">False</option>
+                  <option value="true">True</option>
+                </select>
+              </gr-select>
+            </span>
+          </section>
+        </div>
+      </div>
+    `;
   }
 
   _computeRepoUrl(repoName: string) {
@@ -99,44 +202,76 @@
   }
 
   override focus() {
-    this.shadowRoot?.querySelector('input')?.focus();
+    this.input?.focus();
   }
 
-  @observe('_repoConfig.name')
-  _updateRepoName(name: string) {
-    this.hasNewRepoName = !!name;
+  async handleCreateRepo() {
+    if (this.defaultBranch) this.repoConfig.branches = [this.defaultBranch];
+    if (this.repoOwnerId) this.repoConfig.owners = [this.repoOwnerId];
+    const repoRegistered = await this.restApiService.createRepo(
+      this.repoConfig
+    );
+    if (repoRegistered.status === 201) {
+      this.repoCreated = true;
+      page.show(this._computeRepoUrl(this.repoConfig.name));
+    }
+    return repoRegistered;
   }
 
-  handleCreateRepo() {
-    if (this._defaultBranch) this._repoConfig.branches = [this._defaultBranch];
-    if (this._repoOwnerId) this._repoConfig.owners = [this._repoOwnerId];
-    return this.restApiService
-      .createRepo(this._repoConfig)
-      .then(repoRegistered => {
-        if (repoRegistered.status === 201) {
-          this._repoCreated = true;
-          page.show(this._computeRepoUrl(this._repoConfig.name));
-        }
-      });
+  private async getRepoSuggestions(input: string) {
+    const response = await this.restApiService.getSuggestedProjects(input);
+
+    const repos = [];
+    for (const [name, project] of Object.entries(response ?? {})) {
+      repos.push({name, value: project.id});
+    }
+    return repos;
   }
 
-  _getRepoSuggestions(input: string) {
-    return this.restApiService.getSuggestedProjects(input).then(response => {
-      const repos = [];
-      for (const [name, project] of Object.entries(response ?? {})) {
-        repos.push({name, value: project.id});
-      }
-      return repos;
-    });
+  private async getGroupSuggestions(input: string) {
+    const response = await this.restApiService.getSuggestedGroups(input);
+
+    const groups = [];
+    for (const [name, group] of Object.entries(response ?? {})) {
+      groups.push({name, value: decodeURIComponent(group.id)});
+    }
+    return groups;
   }
 
-  _getGroupSuggestions(input: string) {
-    return this.restApiService.getSuggestedGroups(input).then(response => {
-      const groups = [];
-      for (const [name, group] of Object.entries(response ?? {})) {
-        groups.push({name, value: decodeURIComponent(group.id)});
-      }
-      return groups;
-    });
+  private handleRightsTextChanged(e: CustomEvent) {
+    this.repoConfig.parent = e.detail.value as RepoName;
+    this.requestUpdate();
+  }
+
+  private handleOwnerTextChanged(e: CustomEvent) {
+    this.repoOwner = e.detail.value;
+  }
+
+  private handleOwnerValueChanged(e: CustomEvent) {
+    this.repoOwnerId = e.detail.value as GroupId;
+  }
+
+  private handleNameBindValueChanged(e: CustomEvent) {
+    this.repoConfig.name = e.detail.value as RepoName;
+    // nameChanged needs to be set before the event is fired,
+    // because when the event is fired, gr-repo-list gets
+    // the nameChanged value.
+    this.nameChanged = !!e.detail.value;
+    fireEvent(this, 'new-repo-name');
+    this.requestUpdate();
+  }
+
+  private handleBranchNameBindValueChanged(e: CustomEvent) {
+    this.defaultBranch = e.detail.value as BranchName;
+  }
+
+  private handleCreateEmptyCommitBindValueChanged(e: CustomEvent) {
+    this.repoConfig.create_empty_commit = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handlePermissionsOnlyBindValueChanged(e: CustomEvent) {
+    this.repoConfig.permissions_only = e.detail.value;
+    this.requestUpdate();
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
deleted file mode 100644
index d0a6b7f..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    :host {
-      display: inline-block;
-    }
-    input {
-      width: 20em;
-    }
-    gr-autocomplete {
-      width: 20em;
-    }
-  </style>
-
-  <div class="gr-form-styles">
-    <div id="form">
-      <section>
-        <span class="title">Repository name</span>
-        <iron-input bind-value="{{_repoConfig.name}}">
-          <input id="repoNameInput" autocomplete="on" />
-        </iron-input>
-      </section>
-      <section>
-        <span class="title">Default Branch</span>
-        <iron-input bind-value="{{_defaultBranch}}">
-          <input id="defaultBranchNameInput" autocomplete="off" />
-        </iron-input>
-      </section>
-      <section>
-        <span class="title">Rights inherit from</span>
-        <span class="value">
-          <gr-autocomplete
-            id="rightsInheritFromInput"
-            text="{{_repoConfig.parent}}"
-            query="[[_query]]"
-            placeholder="Optional, defaults to 'All-Projects'"
-          >
-          </gr-autocomplete>
-        </span>
-      </section>
-      <section>
-        <span class="title">Owner</span>
-        <span class="value">
-          <gr-autocomplete
-            id="ownerInput"
-            text="{{_repoOwner}}"
-            value="{{_repoOwnerId}}"
-            query="[[_queryGroups]]"
-          >
-          </gr-autocomplete>
-        </span>
-      </section>
-      <section>
-        <span class="title">Create initial empty commit</span>
-        <span class="value">
-          <gr-select
-            id="initialCommit"
-            bind-value="{{_repoConfig.create_empty_commit}}"
-          >
-            <select>
-              <option value="false">False</option>
-              <option value="true">True</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-      <section>
-        <span class="title">Only serve as parent for other repositories</span>
-        <span class="value">
-          <gr-select
-            id="parentRepo"
-            bind-value="{{_repoConfig.permissions_only}}"
-          >
-            <select>
-              <option value="false">False</option>
-              <option value="true">True</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
index 6485bae..d3e2171 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
@@ -18,26 +18,35 @@
 import '../../../test/common-test-setup-karma';
 import './gr-create-repo-dialog';
 import {GrCreateRepoDialog} from './gr-create-repo-dialog';
-import {stubRestApi} from '../../../test/test-utils';
+import {
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {BranchName, GroupId, RepoName} from '../../../types/common';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrSelect} from '../../shared/gr-select/gr-select';
 
 const basicFixture = fixtureFromElement('gr-create-repo-dialog');
 
 suite('gr-create-repo-dialog tests', () => {
   let element: GrCreateRepoDialog;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
   test('default values are populated', () => {
-    assert.isTrue(element.$.initialCommit.bindValue);
-    assert.isFalse(element.$.parentRepo.bindValue);
+    assert.isTrue(
+      queryAndAssert<GrSelect>(element, '#initialCommit').bindValue
+    );
+    assert.isFalse(queryAndAssert<GrSelect>(element, '#parentRepo').bindValue);
   });
 
   test('repo created', async () => {
     const configInputObj = {
-      name: 'test-repo' as RepoName,
+      name: 'test-repo-new' as RepoName,
       create_empty_commit: true,
       parent: 'All-Project' as RepoName,
       permissions_only: false,
@@ -47,27 +56,38 @@
       Promise.resolve(new Response())
     );
 
-    assert.isFalse(element.hasNewRepoName);
+    const promise = mockPromise();
+    element.addEventListener('new-repo-name', () => {
+      promise.resolve();
+    });
 
-    element._repoConfig = {
+    element.repoConfig = {
       name: 'test-repo' as RepoName,
       create_empty_commit: true,
       parent: 'All-Project' as RepoName,
       permissions_only: false,
     };
 
-    element._repoOwner = 'test';
-    element._repoOwnerId = 'testId' as GroupId;
-    element._defaultBranch = 'main' as BranchName;
+    element.repoOwner = 'test';
+    element.repoOwnerId = 'testId' as GroupId;
+    element.defaultBranch = 'main' as BranchName;
 
-    element.$.repoNameInput.value = configInputObj.name;
-    element.$.rightsInheritFromInput.value = configInputObj.parent;
-    element.$.initialCommit.bindValue = configInputObj.create_empty_commit;
-    element.$.parentRepo.bindValue = configInputObj.permissions_only;
+    const repoNameInput = queryAndAssert<HTMLInputElement>(
+      element,
+      '#repoNameInput'
+    );
+    repoNameInput.value = configInputObj.name;
+    repoNameInput.dispatchEvent(
+      new Event('input', {bubbles: true, composed: true})
+    );
+    queryAndAssert<GrAutocomplete>(element, '#rightsInheritFromInput').value =
+      configInputObj.parent;
+    queryAndAssert<GrSelect>(element, '#initialCommit').bindValue =
+      configInputObj.create_empty_commit;
+    queryAndAssert<GrSelect>(element, '#parentRepo').bindValue =
+      configInputObj.permissions_only;
 
-    assert.isTrue(element.hasNewRepoName);
-
-    assert.deepEqual(element._repoConfig, configInputObj);
+    assert.deepEqual(element.repoConfig, configInputObj);
 
     await element.handleCreateRepo();
     assert.isTrue(
@@ -77,5 +97,10 @@
         branches: ['main' as BranchName],
       })
     );
+
+    await promise;
+
+    assert.equal(element.repoConfig.name, configInputObj.name);
+    assert.equal(element.nameChanged, true);
   });
 });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index adcfb64..8e50b5f 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -14,24 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo-list_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe, computed} from '@polymer/decorators';
 import {AppElementAdminParams} from '../../gr-app-types';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {RepoName, ProjectInfoWithName} from '../../../types/common';
+import {
+  RepoName,
+  ProjectInfoWithName,
+  WebLinkInfo,
+} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
 import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -39,151 +42,259 @@
   }
 }
 
-export interface GrRepoList {
-  $: {
-    createOverlay: GrOverlay;
-    createNewModal: GrCreateRepoDialog;
-  };
-}
-
 @customElement('gr-repo-list')
-export class GrRepoList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrRepoList extends LitElement {
+  @query('#createOverlay')
+  createOverlay?: GrOverlay;
+
+  @query('#createNewModal')
+  createNewModal?: GrCreateRepoDialog;
 
   @property({type: Object})
   params?: AppElementAdminParams;
 
-  @property({type: Number})
-  _offset = 0;
+  @state() offset = 0;
 
-  @property({type: String})
-  readonly _path = '/admin/repos';
+  @state() newRepoName = false;
 
-  @property({type: Boolean})
-  _hasNewRepoName = false;
+  @state() createNewCapability = false;
 
-  @property({type: Boolean})
-  _createNewCapability = false;
+  @state() repos: ProjectInfoWithName[] = [];
 
-  @property({type: Array})
-  _repos: ProjectInfoWithName[] = [];
+  @state() reposPerPage = 25;
 
-  @property({type: Number})
-  _reposPerPage = 25;
+  @state() loading = true;
 
-  @property({type: Boolean})
-  _loading = true;
+  @state() filter = '';
 
-  @property({type: String})
-  _filter = '';
-
-  @computed('_repos')
-  get _shownRepos() {
-    return this._repos.slice(0, SHOWN_ITEMS_COUNT);
-  }
+  @state() readonly path = '/admin/repos';
 
   private readonly restApiService = appContext.restApiService;
 
-  override connectedCallback() {
+  override async connectedCallback() {
     super.connectedCallback();
-    this._getCreateRepoCapability();
+    await this.getCreateRepoCapability();
     fireTitleChange(this, 'Repos');
-    this._maybeOpenCreateOverlay(this.params);
+    this.maybeOpenCreateOverlay(this.params);
   }
 
-  @observe('params')
-  _paramsChanged(params: AppElementAdminParams) {
-    this._loading = true;
-    this._filter = params?.filter ?? '';
-    this._offset = Number(params?.offset ?? 0);
+  static override get styles() {
+    return [
+      tableStyles,
+      sharedStyles,
+      css`
+        .genericList tr td:last-of-type {
+          text-align: left;
+        }
+        .genericList tr th:last-of-type {
+          text-align: left;
+        }
+        .readOnly {
+          text-align: center;
+        }
+        .changesLink,
+        .name,
+        .repositoryBrowser,
+        .readOnly {
+          white-space: nowrap;
+        }
+      `,
+    ];
+  }
 
-    return this._getRepos(this._filter, this._reposPerPage, this._offset);
+  override render() {
+    return html`
+      <gr-list-view
+        .createNew=${this.createNewCapability}
+        .filter=${this.filter}
+        .itemsPerPage=${this.reposPerPage}
+        .items=${this.repos}
+        .loading=${this.loading}
+        .offset=${this.offset}
+        .path=${this.path}
+        @create-clicked=${this.handleCreateClicked}
+      >
+        <table id="list" class="genericList">
+          <tbody>
+            <tr class="headerRow">
+              <th class="name topHeader">Repository Name</th>
+              <th class="repositoryBrowser topHeader">Repository Browser</th>
+              <th class="changesLink topHeader">Changes</th>
+              <th class="topHeader readOnly">Read only</th>
+              <th class="description topHeader">Repository Description</th>
+            </tr>
+            <tr
+              id="loading"
+              class="loadingMsg ${this.computeLoadingClass(this.loading)}"
+            >
+              <td>Loading...</td>
+            </tr>
+          </tbody>
+          <tbody class="${this.computeLoadingClass(this.loading)}">
+            ${this.renderRepoList()}
+          </tbody>
+        </table>
+      </gr-list-view>
+      <gr-overlay id="createOverlay" with-backdrop>
+        <gr-dialog
+          id="createDialog"
+          class="confirmDialog"
+          ?disabled=${!this.newRepoName}
+          confirm-label="Create"
+          @confirm=${this.handleCreateRepo}
+          @cancel=${this.handleCloseCreate}
+        >
+          <div class="header" slot="header">Create Repository</div>
+          <div class="main" slot="main">
+            <gr-create-repo-dialog
+              id="createNewModal"
+              @new-repo-name=${this.handleNewRepoName}
+            ></gr-create-repo-dialog>
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private renderRepoList() {
+    const shownRepos = this.repos.slice(0, SHOWN_ITEMS_COUNT);
+    return shownRepos.map(item => this.renderRepo(item));
+  }
+
+  private renderRepo(item: ProjectInfoWithName) {
+    return html`
+      <tr class="table">
+        <td class="name">
+          <a href="${this.computeRepoUrl(item.name)}">${item.name}</a>
+        </td>
+        <td class="repositoryBrowser">${this.renderWebLinks(item)}</td>
+        <td class="changesLink">
+          <a href="${this.computeChangesLink(item.name)}">view all</a>
+        </td>
+        <td class="readOnly">
+          ${item.state === ProjectState.READ_ONLY ? 'Y' : ''}
+        </td>
+        <td class="description">${item.description}</td>
+      </tr>
+    `;
+  }
+
+  private renderWebLinks(links: ProjectInfoWithName) {
+    const webLinks = links.web_links ? links.web_links : [];
+    return webLinks.map(link => this.renderWebLink(link));
+  }
+
+  private renderWebLink(link: WebLinkInfo) {
+    return html`
+      <a href="${link.url}" class="webLink" rel="noopener" target="_blank">
+        ${link.name}
+      </a>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this._paramsChanged();
+    }
+  }
+
+  async _paramsChanged() {
+    const params = this.params;
+    this.loading = true;
+    this.filter = params?.filter ?? '';
+    this.offset = Number(params?.offset ?? 0);
+
+    return await this.getRepos();
   }
 
   /**
-   * Opens the create overlay if the route has a hash 'create'
+   * Opens the create overlay if the route has a hash 'create'.
+   *
+   * private but used in test
    */
-  _maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+  maybeOpenCreateOverlay(params?: AppElementAdminParams) {
     if (params?.openCreateModal) {
-      this.$.createOverlay.open();
+      this.createOverlay?.open();
     }
   }
 
-  _computeRepoUrl(name: string) {
-    return getBaseUrl() + this._path + '/' + encodeURL(name, true);
+  private computeRepoUrl(name: string) {
+    return `${getBaseUrl()}${this.path}/${encodeURL(name, true)}`;
   }
 
-  _computeChangesLink(name: string) {
+  private computeChangesLink(name: string) {
     return GerritNav.getUrlForProjectChanges(name as RepoName);
   }
 
-  _getCreateRepoCapability() {
-    return this.restApiService.getAccount().then(account => {
-      if (!account) {
-        return;
-      }
-      return this.restApiService
-        .getAccountCapabilities(['createProject'])
-        .then(capabilities => {
-          if (capabilities?.createProject) {
-            this._createNewCapability = true;
-          }
-        });
-    });
-  }
+  private async getCreateRepoCapability() {
+    const account = await this.restApiService.getAccount();
 
-  _getRepos(filter: string, reposPerPage: number, offset?: number) {
-    this._repos = [];
-    return this.restApiService
-      .getRepos(filter, reposPerPage, offset)
-      .then(repos => {
-        // Late response.
-        if (filter !== this._filter || !repos) {
-          return;
-        }
-        this._repos = repos.filter(repo =>
-          repo.name.toLowerCase().includes(filter.toLowerCase())
-        );
-        this._loading = false;
-      });
-  }
+    if (!account) return;
 
-  _refreshReposList() {
-    this.restApiService.invalidateReposCache();
-    return this._getRepos(this._filter, this._reposPerPage, this._offset);
-  }
-
-  _handleCreateRepo() {
-    this.$.createNewModal.handleCreateRepo().then(() => {
-      this._refreshReposList();
-    });
-  }
-
-  _handleCloseCreate() {
-    this.$.createOverlay.close();
-  }
-
-  _handleCreateClicked() {
-    this.$.createOverlay.open().then(() => {
-      this.$.createNewModal.focus();
-    });
-  }
-
-  _readOnly(repo: ProjectInfoWithName) {
-    return repo.state === ProjectState.READ_ONLY ? 'Y' : '';
-  }
-
-  _computeWeblink(repo: ProjectInfoWithName) {
-    if (!repo.web_links) {
-      return '';
+    const accountCapabilities =
+      await this.restApiService.getAccountCapabilities(['createProject']);
+    if (accountCapabilities?.createProject) {
+      this.createNewCapability = true;
     }
-    const webLinks = repo.web_links;
-    return webLinks.length ? webLinks : null;
+
+    return account;
   }
 
+  /* private but used in test */
+  async getRepos() {
+    this.repos = [];
+
+    // We save the filter before getting the repos
+    // and then we check the value hasn't changed aftwards.
+    const filter = this.filter;
+
+    const repos = await this.restApiService.getRepos(
+      this.filter,
+      this.reposPerPage,
+      this.offset
+    );
+
+    // Late response.
+    if (filter !== this.filter || !repos) return;
+
+    this.repos = repos.filter(repo =>
+      repo.name.toLowerCase().includes(filter.toLowerCase())
+    );
+    this.loading = false;
+
+    return repos;
+  }
+
+  private async refreshReposList() {
+    this.restApiService.invalidateReposCache();
+    return await this.getRepos();
+  }
+
+  /* private but used in test */
+  async handleCreateRepo() {
+    await this.createNewModal?.handleCreateRepo();
+    await this.refreshReposList();
+  }
+
+  /* private but used in test */
+  handleCloseCreate() {
+    this.createOverlay?.close();
+  }
+
+  /* private but used in test */
+  handleCreateClicked() {
+    this.createOverlay?.open().then(() => {
+      this.createNewModal?.focus();
+    });
+  }
+
+  /* private but used in test */
   computeLoadingClass(loading: boolean) {
     return loading ? 'loading' : '';
   }
+
+  private handleNewRepoName() {
+    if (!this.createNewModal) return;
+    this.newRepoName = this.createNewModal.nameChanged;
+  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
deleted file mode 100644
index e1a7f489..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style>
-    .genericList tr td:last-of-type {
-      text-align: left;
-    }
-    .genericList tr th:last-of-type {
-      text-align: left;
-    }
-    .readOnly {
-      text-align: center;
-    }
-    .changesLink,
-    .name,
-    .repositoryBrowser,
-    .readOnly {
-      white-space: nowrap;
-    }
-  </style>
-  <gr-list-view
-    create-new="[[_createNewCapability]]"
-    filter="[[_filter]]"
-    items-per-page="[[_reposPerPage]]"
-    items="[[_repos]]"
-    loading="[[_loading]]"
-    offset="[[_offset]]"
-    on-create-clicked="_handleCreateClicked"
-    path="[[_path]]"
-  >
-    <table id="list" class="genericList">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Repository Name</th>
-          <th class="repositoryBrowser topHeader">Repository Browser</th>
-          <th class="changesLink topHeader">Changes</th>
-          <th class="topHeader readOnly">Read only</th>
-          <th class="description topHeader">Repository Description</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_shownRepos]]">
-          <tr class="table">
-            <td class="name">
-              <a href$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a>
-            </td>
-            <td class="repositoryBrowser">
-              <template
-                is="dom-repeat"
-                items="[[_computeWeblink(item)]]"
-                as="link"
-              >
-                <a
-                  href$="[[link.url]]"
-                  class="webLink"
-                  rel="noopener"
-                  target="_blank"
-                >
-                  [[link.name]]
-                </a>
-              </template>
-            </td>
-            <td class="changesLink">
-              <a href$="[[_computeChangesLink(item.name)]]">view all</a>
-            </td>
-            <td class="readOnly">[[_readOnly(item)]]</td>
-            <td class="description">[[item.description]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </gr-list-view>
-  <gr-overlay id="createOverlay" with-backdrop="">
-    <gr-dialog
-      id="createDialog"
-      class="confirmDialog"
-      disabled="[[!_hasNewRepoName]]"
-      confirm-label="Create"
-      on-confirm="_handleCreateRepo"
-      on-cancel="_handleCloseCreate"
-    >
-      <div class="header" slot="header">Create Repository</div>
-      <div class="main" slot="main">
-        <gr-create-repo-dialog
-          has-new-repo-name="{{_hasNewRepoName}}"
-          id="createNewModal"
-        ></gr-create-repo-dialog>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
deleted file mode 100644
index 8fef4d0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
+++ /dev/null
@@ -1,189 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-repo-list.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import 'lodash/lodash.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-repo-list');
-
-function createRepo(name, counter) {
-  return {
-    id: `${name}${counter}`,
-    name: `${name}`,
-    state: 'ACTIVE',
-    web_links: [
-      {
-        name: 'diffusion',
-        url: `https://phabricator.example.org/r/project/${name}${counter}`,
-      },
-    ],
-  };
-}
-
-let counter;
-const repoGenerator = () => createRepo('test', ++counter);
-
-suite('gr-repo-list tests', () => {
-  let element;
-  let repos;
-
-  let value;
-
-  setup(() => {
-    sinon.stub(page, 'show');
-    element = basicFixture.instantiate();
-    counter = 0;
-  });
-
-  suite('list with repos', () => {
-    setup(async () => {
-      repos = _.times(26, repoGenerator);
-      stubRestApi('getRepos').returns(Promise.resolve(repos));
-      await element._paramsChanged(value);
-      await flush();
-    });
-
-    test('test for test repo in the list', async () => {
-      await flush();
-      assert.equal(element._repos[1].id, 'test2');
-    });
-
-    test('_shownRepos', () => {
-      assert.equal(element._shownRepos.length, 25);
-    });
-
-    test('_maybeOpenCreateOverlay', () => {
-      const overlayOpen = sinon.stub(element.$.createOverlay, 'open');
-      element._maybeOpenCreateOverlay();
-      assert.isFalse(overlayOpen.called);
-      const params = {};
-      element._maybeOpenCreateOverlay(params);
-      assert.isFalse(overlayOpen.called);
-      params.openCreateModal = true;
-      element._maybeOpenCreateOverlay(params);
-      assert.isTrue(overlayOpen.called);
-    });
-  });
-
-  suite('list with less then 25 repos', () => {
-    setup(async () => {
-      repos = _.times(25, repoGenerator);
-      stubRestApi('getRepos').returns(Promise.resolve(repos));
-      await element._paramsChanged(value);
-      await flush();
-    });
-
-    test('_shownRepos', () => {
-      assert.equal(element._shownRepos.length, 25);
-    });
-  });
-
-  suite('filter', () => {
-    let reposFiltered;
-    setup(() => {
-      repos = _.times(25, repoGenerator);
-      reposFiltered = _.times(1, repoGenerator);
-    });
-
-    test('_paramsChanged', async () => {
-      const repoStub = stubRestApi('getRepos');
-      repoStub.returns(Promise.resolve(repos));
-      const value = {
-        filter: 'test',
-        offset: 25,
-      };
-      await element._paramsChanged(value);
-      assert.isTrue(repoStub.lastCall.calledWithExactly('test', 25, 25));
-    });
-
-    test('latest repos requested are always set', async () => {
-      const repoStub = stubRestApi('getRepos');
-      repoStub.withArgs('test').returns(Promise.resolve(repos));
-      repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
-      element._filter = 'test';
-
-      // Repos are not set because the element._filter differs.
-      await element._getRepos('filter', 25, 0);
-      assert.deepEqual(element._repos, []);
-    });
-
-    test('filter is case insensitive', async () => {
-      const repoStub = stubRestApi('getRepos');
-      const repos = [createRepo('aSDf', 0)];
-      repoStub.withArgs('asdf').returns(Promise.resolve(repos));
-      element._filter = 'asdf';
-      await element._getRepos('asdf', 25, 0);
-      assert.equal(element._repos.length, 1);
-    });
-  });
-
-  suite('loading', () => {
-    test('correct contents are displayed', () => {
-      assert.isTrue(element._loading);
-      assert.equal(element.computeLoadingClass(element._loading), 'loading');
-      assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-      element._loading = false;
-      element._repos = _.times(25, repoGenerator);
-
-      flush();
-      assert.equal(element.computeLoadingClass(element._loading), '');
-      assert.equal(getComputedStyle(element.$.loading).display, 'none');
-    });
-  });
-
-  suite('create new', () => {
-    test('_handleCreateClicked called when create-click fired', () => {
-      sinon.stub(element, '_handleCreateClicked');
-      element.shadowRoot
-          .querySelector('gr-list-view').dispatchEvent(
-              new CustomEvent('create-clicked', {
-                composed: true, bubbles: true,
-              }));
-      assert.isTrue(element._handleCreateClicked.called);
-    });
-
-    test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
-          Promise.resolve());
-      element._handleCreateClicked();
-      assert.isTrue(openStub.called);
-    });
-
-    test('_handleCreateRepo called when confirm fired', () => {
-      sinon.stub(element, '_handleCreateRepo');
-      element.$.createDialog.dispatchEvent(
-          new CustomEvent('confirm', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCreateRepo.called);
-    });
-
-    test('_handleCloseCreate called when cancel fired', () => {
-      sinon.stub(element, '_handleCloseCreate');
-      element.$.createDialog.dispatchEvent(
-          new CustomEvent('cancel', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCloseCreate.called);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
new file mode 100644
index 0000000..142a838
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
@@ -0,0 +1,246 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-repo-list';
+import {GrRepoList} from './gr-repo-list';
+import {page} from '../../../utils/page-wrapper-utils';
+import {
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  UrlEncodedRepoName,
+  ProjectInfoWithName,
+  RepoName,
+} from '../../../types/common';
+import {AppElementAdminParams} from '../../gr-app-types';
+import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {GerritView} from '../../../services/router/router-model';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrListView} from '../../shared/gr-list-view/gr-list-view';
+
+const basicFixture = fixtureFromElement('gr-repo-list');
+
+function createRepo(name: string, counter: number) {
+  return {
+    id: `${name}${counter}` as UrlEncodedRepoName,
+    name: `${name}` as RepoName,
+    state: 'ACTIVE' as ProjectState,
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://phabricator.example.org/r/project/${name}${counter}`,
+      },
+    ],
+  };
+}
+
+function createRepoList(name: string, n: number) {
+  const repos = [];
+  for (let i = 0; i < n; ++i) {
+    repos.push(createRepo(name, i));
+  }
+  return repos;
+}
+
+suite('gr-repo-list tests', () => {
+  let element: GrRepoList;
+  let repos: ProjectInfoWithName[];
+
+  setup(async () => {
+    sinon.stub(page, 'show');
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  suite('list with repos', () => {
+    setup(async () => {
+      repos = createRepoList('test', 26);
+      stubRestApi('getRepos').returns(Promise.resolve(repos));
+      await element._paramsChanged();
+      await element.updateComplete;
+    });
+
+    test('test for test repo in the list', async () => {
+      await element.updateComplete;
+      assert.equal(element.repos[0].id, 'test0');
+      assert.equal(element.repos[1].id, 'test1');
+      assert.equal(element.repos[2].id, 'test2');
+    });
+
+    test('shownRepos', () => {
+      assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+    });
+
+    test('maybeOpenCreateOverlay', () => {
+      const overlayOpen = sinon.stub(
+        queryAndAssert<GrOverlay>(element, '#createOverlay'),
+        'open'
+      );
+      element.maybeOpenCreateOverlay();
+      assert.isFalse(overlayOpen.called);
+      element.maybeOpenCreateOverlay(undefined);
+      assert.isFalse(overlayOpen.called);
+      const params: AppElementAdminParams = {
+        view: GerritView.ADMIN,
+        adminView: '',
+        openCreateModal: true,
+      };
+      element.maybeOpenCreateOverlay(params);
+      assert.isTrue(overlayOpen.called);
+    });
+  });
+
+  suite('list with less then 25 repos', () => {
+    setup(async () => {
+      repos = createRepoList('test', 25);
+      stubRestApi('getRepos').returns(Promise.resolve(repos));
+      await element._paramsChanged();
+      await element.updateComplete;
+    });
+
+    test('shownRepos', () => {
+      assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+    });
+  });
+
+  suite('filter', () => {
+    let reposFiltered: ProjectInfoWithName[];
+
+    setup(() => {
+      repos = createRepoList('test', 25);
+      reposFiltered = createRepoList('filter', 1);
+    });
+
+    test('_paramsChanged', async () => {
+      const repoStub = stubRestApi('getRepos');
+      repoStub.returns(Promise.resolve(repos));
+      element.params = {
+        view: GerritView.ADMIN,
+        adminView: '',
+        filter: 'test',
+        offset: 25,
+      } as AppElementAdminParams;
+      await element._paramsChanged();
+      assert.isTrue(repoStub.lastCall.calledWithExactly('test', 25, 25));
+    });
+
+    test('latest repos requested are always set', async () => {
+      const repoStub = stubRestApi('getRepos');
+      const promise = mockPromise<ProjectInfoWithName[]>();
+      repoStub.withArgs('filter', 25).returns(promise);
+
+      element.filter = 'test';
+      element.reposPerPage = 25;
+      element.offset = 0;
+
+      // Repos are not set because the element.filter differs.
+      const p = element.getRepos();
+      element.filter = 'filter';
+      promise.resolve(reposFiltered);
+      await p;
+      assert.deepEqual(element.repos, []);
+    });
+
+    test('filter is case insensitive', async () => {
+      const repoStub = stubRestApi('getRepos');
+      const repos = [createRepo('aSDf', 0)];
+      repoStub.withArgs('asdf', 25).returns(Promise.resolve(repos));
+
+      element.filter = 'asdf';
+      element.reposPerPage = 25;
+      element.offset = 0;
+
+      await element.getRepos();
+      assert.equal(element.repos.length, 1);
+    });
+  });
+
+  suite('loading', () => {
+    test('correct contents are displayed', async () => {
+      assert.isTrue(element.loading);
+      assert.equal(element.computeLoadingClass(element.loading), 'loading');
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<HTMLTableRowElement>(element, '#loading')
+        ).display,
+        'block'
+      );
+
+      element.loading = false;
+      element.repos = createRepoList('test', 25);
+
+      await element.updateComplete;
+      assert.equal(element.computeLoadingClass(element.loading), '');
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<HTMLTableRowElement>(element, '#loading')
+        ).display,
+        'none'
+      );
+    });
+  });
+
+  suite('create new', () => {
+    test('handleCreateClicked called when create-clicked fired', () => {
+      const handleCreateClickedStub = sinon.stub();
+      element.addEventListener('create-clicked', handleCreateClickedStub);
+      queryAndAssert<GrListView>(element, 'gr-list-view').dispatchEvent(
+        new CustomEvent('create-clicked', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCreateClickedStub.called);
+    });
+
+    test('handleCreateClicked opens modal', () => {
+      const openStub = sinon
+        .stub(queryAndAssert<GrOverlay>(element, '#createOverlay'), 'open')
+        .returns(Promise.resolve());
+      element.handleCreateClicked();
+      assert.isTrue(openStub.called);
+    });
+
+    test('handleCreateRepo called when confirm fired', () => {
+      const handleCreateRepoStub = sinon.stub();
+      element.addEventListener('confirm', handleCreateRepoStub);
+      queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
+        new CustomEvent('confirm', {
+          composed: true,
+          bubbles: false,
+        })
+      );
+      assert.isTrue(handleCreateRepoStub.called);
+    });
+
+    test('handleCloseCreate called when cancel fired', () => {
+      const handleCloseCreateStub = sinon.stub();
+      element.addEventListener('cancel', handleCloseCreateStub);
+      queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
+        new CustomEvent('cancel', {
+          composed: true,
+          bubbles: false,
+        })
+      );
+      assert.isTrue(handleCloseCreateStub.called);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index b10c17e..536ef92 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -99,7 +99,6 @@
   getVotingRange,
   StandardLabels,
 } from '../../../utils/label-util';
-import {CommentThread} from '../../../utils/comment-util';
 import {ShowAlertEventDetail} from '../../../types/events';
 import {
   ActionPriority,
@@ -448,9 +447,6 @@
   @property({type: String})
   _actionLoadingMessage = '';
 
-  @property({type: Array})
-  commentThreads: CommentThread[] = [];
-
   @property({
     type: Array,
     computed:
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
index d21c29f..17ca7cf 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
@@ -213,11 +213,9 @@
     <gr-confirm-submit-dialog
       id="confirmSubmitDialog"
       class="confirmDialog"
-      change="[[change]]"
       action="[[_revisionSubmitAction]]"
       on-cancel="_handleConfirmDialogCancel"
       on-confirm="_handleSubmitConfirm"
-      comment-threads="[[commentThreads]]"
       hidden=""
     ></gr-confirm-submit-dialog>
     <gr-dialog
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index e4b466b..c4ccb7d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators';
 import {subscribe} from '../../lit/subscription-controller';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {appContext} from '../../../services/app-context';
@@ -65,6 +65,11 @@
 import {modifierPressed} from '../../../utils/dom-util';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {account$} from '../../../services/user/user-model';
+import {
+  changeComments$,
+  threads$,
+} from '../../../services/comments/comments-model';
 
 export enum SummaryChipStyles {
   INFO = 'info',
@@ -378,31 +383,31 @@
 
 @customElement('gr-change-summary')
 export class GrChangeSummary extends LitElement {
-  @property({type: Object})
+  @state()
   changeComments?: ChangeComments;
 
-  @property({type: Array})
+  @state()
   commentThreads?: CommentThread[];
 
-  @property({type: Object})
+  @state()
   selfAccount?: AccountInfo;
 
-  @property()
+  @state()
   runs: CheckRun[] = [];
 
-  @property()
+  @state()
   showChecksSummary = false;
 
-  @property()
+  @state()
   someProvidersAreLoading = false;
 
-  @property()
+  @state()
   errorMessages: ErrorMessages = {};
 
-  @property()
+  @state()
   loginCallback?: () => void;
 
-  @property()
+  @state()
   actions: Action[] = [];
 
   private showAllChips = new Map<RunStatus | Category, boolean>();
@@ -421,6 +426,9 @@
     subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
     subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
     subscribe(this, topLevelActionsLatest$, x => (this.actions = x));
+    subscribe(this, changeComments$, x => (this.changeComments = x));
+    subscribe(this, threads$, x => (this.commentThreads = x));
+    subscribe(this, account$, x => (this.selfAccount = x));
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 8d64bc0..8a28cb1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -18,7 +18,6 @@
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/gr-paper-styles';
 import '../../../styles/shared-styles';
-import '../../diff/gr-comment-api/gr-comment-api';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-account-link/gr-account-link';
@@ -129,10 +128,7 @@
 import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
 import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
 import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
-import {
-  ChangeComments,
-  GrCommentApi,
-} from '../../diff/gr-comment-api/gr-comment-api';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
   assertIsDefined,
   hasOwnProperty,
@@ -230,7 +226,6 @@
 
 export interface GrChangeView {
   $: {
-    commentAPI: GrCommentApi;
     applyFixDialog: GrApplyFixDialog;
     fileList: GrFileList & Element;
     fileListHeader: GrFileListHeader;
@@ -2026,23 +2021,6 @@
       });
   }
 
-  /**
-   * Fetches a new changeComment object, and data for all types of comments
-   * (comments, robot comments, draft comments) is requested.
-   */
-  _reloadComments() {
-    // We are resetting all comment related properties, because we want to avoid
-    // a new change being loaded and then paired with outdated comments.
-    this._changeComments = undefined;
-    this._commentThreads = undefined;
-    this._draftCommentThreads = undefined;
-    this._robotCommentThreads = undefined;
-    if (!this._changeNum)
-      throw new Error('missing required changeNum property');
-
-    this.commentsService.loadAll(this._changeNum, this._patchRange?.patchNum);
-  }
-
   @observe('_changeComments')
   changeCommentsChanged(comments?: ChangeComments) {
     if (!comments) return;
@@ -2124,8 +2102,6 @@
     });
     allDataPromises.push(projectConfigLoaded);
 
-    this._reloadComments();
-
     let coreDataPromise;
 
     // If the patch number is specified
@@ -2230,13 +2206,20 @@
     assertIsDefined(this._changeNum, '_changeNum');
     if (!this._patchRange?.patchNum) throw new Error('missing patchNum');
     const promises = [this._getCommitInfo(), this.$.fileList.reload()];
-    if (patchNumChanged)
+    if (patchNumChanged) {
       promises.push(
-        this.$.commentAPI.reloadPortedComments(
+        this.commentsService.reloadPortedComments(
           this._changeNum,
           this._patchRange?.patchNum
         )
       );
+      promises.push(
+        this.commentsService.reloadPortedDrafts(
+          this._changeNum,
+          this._patchRange?.patchNum
+        )
+      );
+    }
     return Promise.all(promises);
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index 9341b18..9181ca0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -381,7 +381,6 @@
             on-stop-edit-tap="_handleStopEditTap"
             on-download-tap="_handleOpenDownloadDialog"
             on-included-tap="_handleOpenIncludedInDialog"
-            comment-threads="[[_commentThreads]]"
           ></gr-change-actions>
         </div>
         <!-- end commit actions -->
@@ -439,12 +438,7 @@
                 </gr-editable-content>
               </div>
               <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
-              <gr-change-summary
-                change-comments="[[_changeComments]]"
-                comment-threads="[[_commentThreads]]"
-                self-account="[[_account]]"
-              >
-              </gr-change-summary>
+              <gr-change-summary></gr-change-summary>
               <gr-endpoint-decorator name="commit-container">
                 <gr-endpoint-param name="change" value="[[_change]]">
                 </gr-endpoint-param>
@@ -528,7 +522,6 @@
           change="[[_change]]"
           change-num="[[_changeNum]]"
           revision-info="[[_revisionInfo]]"
-          change-comments="[[_changeComments]]"
           commit-info="[[_commitInfo]]"
           change-url="[[_computeChangeUrl(_change)]]"
           edit-mode="[[_editMode]]"
@@ -720,5 +713,4 @@
       </gr-reply-dialog>
     </template>
   </gr-overlay>
-  <gr-comment-api id="commentAPI"></gr-comment-api>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index ab17f47..72cd91e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -40,6 +40,7 @@
 import {
   mockPromise,
   queryAndAssert,
+  stubComments,
   stubRestApi,
   stubUsers,
   waitQueryAndAssert,
@@ -1346,10 +1347,8 @@
     sinon.stub(element, '_getCommitInfo');
     sinon.stub(element.$.fileList, 'reload');
     flush();
-    const reloadPortedCommentsStub = sinon.stub(
-      element.$.commentAPI,
-      'reloadPortedComments'
-    );
+    const reloadPortedCommentsStub = stubComments('reloadPortedComments');
+    const reloadPortedDraftsStub = stubComments('reloadPortedDrafts');
     sinon.stub(element.$.fileList, 'collapseAllDiffs');
 
     const value: AppElementChangeViewParams = {
@@ -1374,6 +1373,7 @@
     element.params = {...value};
     await flush();
     assert.isTrue(reloadPortedCommentsStub.calledOnce);
+    assert.isTrue(reloadPortedDraftsStub.calledOnce);
   });
 
   test('do not reload entire page when patchRange doesnt change', async () => {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index 9d371d3..a9b7b81 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -20,14 +20,18 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../gr-thread-list/gr-thread-list';
-import {ChangeInfo, ActionInfo} from '../../../types/common';
+import {ActionInfo} from '../../../types/common';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {pluralize} from '../../../utils/string-util';
 import {CommentThread, isUnresolved} from '../../../utils/comment-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {change$} from '../../../services/change/change-model';
+import {threads$} from '../../../services/comments/comments-model';
+import {ParsedChangeInfo} from '../../../types/types';
 
 @customElement('gr-confirm-submit-dialog')
 export class GrConfirmSubmitDialog extends LitElement {
@@ -47,16 +51,16 @@
    */
 
   @property({type: Object})
-  change?: ChangeInfo;
-
-  @property({type: Object})
   action?: ActionInfo;
 
-  @property({type: Array})
-  commentThreads?: CommentThread[] = [];
+  @state()
+  change?: ParsedChangeInfo;
 
-  @property({type: Boolean})
-  _initialised = false;
+  @state()
+  unresolvedThreads: CommentThread[] = [];
+
+  @state()
+  initialised = false;
 
   static override get styles() {
     return [
@@ -84,6 +88,16 @@
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(this, change$, x => (this.change = x));
+    subscribe(
+      this,
+      threads$,
+      x => (this.unresolvedThreads = x.filter(isUnresolved))
+    );
+  }
+
   private renderPrivate() {
     if (!this.change?.is_private) return '';
     return html`
@@ -106,11 +120,11 @@
           icon="gr-icons:warning"
           class="warningBeforeSubmit"
         ></iron-icon>
-        ${this._computeUnresolvedCommentsWarning(this.change)}
+        ${this.computeUnresolvedCommentsWarning()}
       </p>
       <gr-thread-list
         id="commentList"
-        .threads="${this._computeUnresolvedThreads(this.commentThreads)}"
+        .threads="${this.unresolvedThreads}"
         .change="${this.change}"
         .changeNum="${this.change?._number}"
         logged-in
@@ -121,7 +135,7 @@
   }
 
   private renderChangeEdit() {
-    if (!this._computeHasChangeEdit(this.change)) return '';
+    if (!this.computeHasChangeEdit()) return '';
     return html`
       <iron-icon
         icon="gr-icons:warning"
@@ -133,7 +147,7 @@
   }
 
   private renderInitialised() {
-    if (!this._initialised) return '';
+    if (!this.initialised) return '';
     return html`
       <div class="header" slot="header">${this.action?.label}</div>
       <div class="main" slot="main">
@@ -159,48 +173,43 @@
       id="dialog"
       confirm-label="Continue"
       confirm-on-enter=""
-      @cancel=${this._handleCancelTap}
-      @confirm=${this._handleConfirmTap}
+      @cancel=${this.handleCancelTap}
+      @confirm=${this.handleConfirmTap}
     >
       ${this.renderInitialised()}
     </gr-dialog>`;
   }
 
   init() {
-    this._initialised = true;
+    this.initialised = true;
   }
 
   resetFocus() {
     this.dialog?.resetFocus();
   }
 
-  _computeHasChangeEdit(change?: ChangeInfo) {
-    return (
-      !!change &&
-      !!change.revisions &&
-      Object.values(change.revisions).some(rev => rev._number === 'edit')
+  // Private method, but visible for testing.
+  computeHasChangeEdit() {
+    return Object.values(this.change?.revisions ?? {}).some(
+      rev => rev._number === 'edit'
     );
   }
 
-  _computeUnresolvedThreads(commentThreads?: CommentThread[]) {
-    if (!commentThreads) return [];
-    return commentThreads.filter(thread => isUnresolved(thread));
-  }
-
-  _computeUnresolvedCommentsWarning(change?: ChangeInfo) {
-    if (!change) return '';
-    const unresolvedCount = change.unresolved_comment_count;
+  // Private method, but visible for testing.
+  computeUnresolvedCommentsWarning() {
+    if (!this.change) return '';
+    const unresolvedCount = this.change.unresolved_comment_count;
     if (!unresolvedCount) throw new Error('unresolved comments undefined or 0');
     return `Heads Up! ${pluralize(unresolvedCount, 'unresolved comment')}.`;
   }
 
-  _handleConfirmTap(e: Event) {
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
index e1823b1..0426cb6 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
@@ -16,7 +16,10 @@
  */
 
 import '../../../test/common-test-setup-karma';
-import {createChange, createRevision} from '../../../test/test-data-generators';
+import {
+  createParsedChange,
+  createRevision,
+} from '../../../test/test-data-generators';
 import {queryAndAssert} from '../../../test/test-utils';
 import {PatchSetNum} from '../../../types/common';
 import {GrConfirmSubmitDialog} from './gr-confirm-submit-dialog';
@@ -28,13 +31,13 @@
 
   setup(() => {
     element = basicFixture.instantiate();
-    element._initialised = true;
+    element.initialised = true;
   });
 
   test('display', async () => {
     element.action = {label: 'my-label'};
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       subject: 'my-subject',
       revisions: {},
     };
@@ -47,23 +50,23 @@
     assert.notEqual(message.textContent!.indexOf('my-subject'), -1);
   });
 
-  test('_computeUnresolvedCommentsWarning', () => {
-    const change = {...createChange(), unresolved_comment_count: 1};
+  test('computeUnresolvedCommentsWarning', () => {
+    element.change = {...createParsedChange(), unresolved_comment_count: 1};
     assert.equal(
-      element._computeUnresolvedCommentsWarning(change),
+      element.computeUnresolvedCommentsWarning(),
       'Heads Up! 1 unresolved comment.'
     );
 
-    const change2 = {...createChange(), unresolved_comment_count: 2};
+    element.change = {...createParsedChange(), unresolved_comment_count: 2};
     assert.equal(
-      element._computeUnresolvedCommentsWarning(change2),
+      element.computeUnresolvedCommentsWarning(),
       'Heads Up! 2 unresolved comments.'
     );
   });
 
-  test('_computeHasChangeEdit', () => {
-    const change = {
-      ...createChange(),
+  test('computeHasChangeEdit', () => {
+    element.change = {
+      ...createParsedChange(),
       revisions: {
         d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
           ...createRevision(),
@@ -73,10 +76,10 @@
       unresolved_comment_count: 0,
     };
 
-    assert.isTrue(element._computeHasChangeEdit(change));
+    assert.isTrue(element.computeHasChangeEdit());
 
-    const change2 = {
-      ...createChange(),
+    element.change = {
+      ...createParsedChange(),
       revisions: {
         d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
           ...createRevision(),
@@ -84,6 +87,6 @@
         },
       },
     };
-    assert.isFalse(element._computeHasChangeEdit(change2));
+    assert.isFalse(element.computeHasChangeEdit());
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 1b44e35..920844b 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -39,7 +39,6 @@
   BasePatchSetNum,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fireEvent} from '../../../utils/event-util';
@@ -101,9 +100,6 @@
   changeUrl?: string;
 
   @property({type: Object})
-  changeComments?: ChangeComments;
-
-  @property({type: Object})
   commitInfo?: CommitInfo;
 
   @property({type: Boolean})
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
index 5a85531..fbba2fc 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -132,7 +132,6 @@
       <div class="patchInfoContent">
         <gr-patch-range-select
           id="rangeSelect"
-          change-comments="[[changeComments]]"
           change-num="[[changeNum]]"
           patch-num="[[patchNum]]"
           base-patch-num="[[basePatchNum]]"
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 95984b8..d02f09b 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -18,7 +18,6 @@
 import '../../../styles/shared-styles';
 import '../../diff/gr-diff-cursor/gr-diff-cursor';
 import '../../diff/gr-diff-host/gr-diff-host';
-import '../../diff/gr-comment-api/gr-comment-api';
 import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
 import '../../shared/gr-button/gr-button';
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 0db7690..ee65837 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -16,7 +16,6 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import '../../diff/gr-comment-api/gr-comment-api.js';
 import '../../shared/gr-date-formatter/gr-date-formatter.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-file-list.js';
@@ -49,7 +48,6 @@
     'gr-file-list-comment-api-mock', html`
     <gr-file-list id="fileList"
         change-comments="[[_changeComments]]"></gr-file-list>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
 `);
 
 const basicFixture = fixtureFromElement(commentApiMock.is);
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
index 0939daa..a3b8873 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -16,7 +16,6 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import '../../diff/gr-comment-api/gr-comment-api.js';
 import './gr-messages-list.js';
 import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
 import {TEST_ONLY} from './gr-messages-list.js';
@@ -30,7 +29,6 @@
      <gr-messages-list
          id="messagesList"
          change-comments="[[_changeComments]]"></gr-messages-list>
-     <gr-comment-api id="commentAPI"></gr-comment-api>
 `);
 
 const basicFixture = fixtureFromTemplate(html`
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 7e7e507..32c732e 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -14,16 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment-api_html';
-import {customElement, property} from '@polymer/decorators';
 import {
   CommentBasics,
   PatchRange,
   PatchSetNum,
   RobotCommentInfo,
   UrlEncodedCommentId,
-  NumericChangeId,
   PathToCommentsInfoMap,
   FileInfo,
   ParentPatchSetNum,
@@ -45,7 +41,6 @@
   addPath,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
-import {appContext} from '../../../services/app-context';
 import {CommentSide, Side} from '../../../constants/constants';
 import {pluralize} from '../../../utils/string-util';
 import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
@@ -611,38 +606,3 @@
     return createCommentThreads(comments);
   }
 }
-
-@customElement('gr-comment-api')
-export class GrCommentApi extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
-  _changeComments?: ChangeComments;
-
-  private readonly restApiService = appContext.restApiService;
-
-  private readonly commentsService = appContext.commentsService;
-
-  reloadPortedComments(changeNum: NumericChangeId, patchNum: PatchSetNum) {
-    if (!this._changeComments) {
-      this.commentsService.loadAll(changeNum);
-      return Promise.resolve();
-    }
-    return Promise.all([
-      this.restApiService.getPortedComments(changeNum, patchNum),
-      this.restApiService.getPortedDrafts(changeNum, patchNum),
-    ]).then(res => {
-      if (!this._changeComments) return;
-      this._changeComments =
-        this._changeComments.cloneWithUpdatedPortedComments(res[0], res[1]);
-    });
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-comment-api': GrCommentApi;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
index 3e292fe..a47db20 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -408,6 +408,7 @@
     range: Text | Element | Range
   ) {
     if (startLine > 1) {
+      actionBox.positionBelow = false;
       actionBox.placeAbove(range);
       return;
     }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 6facdca..8d75230 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -20,7 +20,6 @@
 
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {createDefaultDiffPrefs, Side} from '../../../constants/constants.js';
-import {_testOnly_resetState} from '../../../services/comments/comments-model.js';
 import {createChange, createComment, createCommentThread} from '../../../test/test-data-generators.js';
 import {addListenerForTest, mockPromise, stubRestApi} from '../../../test/test-utils.js';
 import {EditPatchSetNum, ParentPatchSetNum} from '../../../types/common.js';
@@ -43,7 +42,6 @@
     element.path = 'some/path';
     sinon.stub(element.reporting, 'time');
     sinon.stub(element.reporting, 'timeEnd');
-    _testOnly_resetState();
     await flush();
   });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 0ffe61f..64186f4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -65,7 +65,7 @@
   GrDropdownList,
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {ChangeComments, GrCommentApi} from '../gr-comment-api/gr-comment-api';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
 import {
   BasePatchSetNum,
@@ -134,7 +134,6 @@
 
 export interface GrDiffView {
   $: {
-    commentAPI: GrCommentApi;
     diffHost: GrDiffHost;
     reviewed: HTMLInputElement;
     dropdown: GrDropdownList;
@@ -348,8 +347,6 @@
 
   private readonly userService = appContext.userService;
 
-  private readonly commentsService = appContext.commentsService;
-
   private readonly shortcuts = appContext.shortcutsService;
 
   _throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
@@ -1074,8 +1071,6 @@
 
     if (!this._change) promises.push(this._getChangeDetail(this._changeNum));
 
-    if (!this._changeComments) this._loadComments(value.patchNum);
-
     promises.push(this._getChangeEdit());
 
     this.$.diffHost.cancel();
@@ -1464,11 +1459,6 @@
     return url;
   }
 
-  _loadComments(patchSet?: PatchSetNum) {
-    assertIsDefined(this._changeNum, '_changeNum');
-    return this.commentsService.loadAll(this._changeNum, patchSet);
-  }
-
   @observe(
     '_changeComments',
     '_files.changeFilesByPath',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index 16adb45..d87d192 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -426,5 +426,4 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-comment-api id="commentAPI"></gr-comment-api>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 46ec5d0..5c04ab0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -30,7 +30,7 @@
 import {EditPatchSetNum} from '../../../types/common.js';
 import {CursorMoveResult} from '../../../api/core.js';
 import {EventType} from '../../../types/events.js';
-import {_testOnly_resetState, _testOnly_setState} from '../../../services/browser/browser-model.js';
+import {_testOnly_setState} from '../../../services/browser/browser-model.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
@@ -69,8 +69,6 @@
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getPortedComments').returns(Promise.resolve({}));
 
-      _testOnly_resetState();
-
       element = basicFixture.instantiate();
       element._changeNum = '42';
       element._path = 'some/path.txt';
@@ -2040,7 +2038,6 @@
           Promise.resolve([]));
       element = basicFixture.instantiate();
       element._changeNum = '42';
-      return element._loadComments();
     });
 
     test('_getFiles add files with comments without changes', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 501f688..5d4405f 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -50,10 +50,16 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators';
+import {subscribe} from '../../lit/subscription-controller';
+import {changeComments$} from '../../../services/comments/comments-model';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
 
+function getShaForPatch(patch: PatchSet) {
+  return patch.sha.substring(0, 10);
+}
+
 export interface PatchRangeChangeDetail {
   patchNum?: PatchSetNum;
   basePatchNum?: BasePatchSetNum;
@@ -95,9 +101,6 @@
   changeNum?: string;
 
   @property({type: Object})
-  changeComments?: ChangeComments;
-
-  @property({type: Object})
   filesWeblinks?: FilesWebLinks;
 
   @property({type: String})
@@ -106,18 +109,28 @@
   @property({type: String})
   basePatchNum?: BasePatchSetNum;
 
+  /** Not used directly. Translated into `sortedRevisions` in willUpdate(). */
   @property({type: Object})
-  revisions?: RevisionInfo[];
+  revisions: (RevisionInfo | EditRevisionInfo)[] = [];
 
   @property({type: Object})
   revisionInfo?: RevisionInfoClass;
 
-  @property({type: Array})
+  /** Private internal state, derived from `revisions` in willUpdate(). */
   @state()
-  protected sortedRevisions?: RevisionInfo[];
+  private sortedRevisions: (RevisionInfo | EditRevisionInfo)[] = [];
+
+  /** Private internal state, visible for testing. */
+  @state()
+  changeComments?: ChangeComments;
 
   private readonly reporting: ReportingService = appContext.reportingService;
 
+  constructor() {
+    super();
+    subscribe(this, changeComments$, x => (this.changeComments = x));
+  }
+
   static override get styles() {
     return [
       a11yStyles,
@@ -152,20 +165,6 @@
     ];
   }
 
-  private renderWeblinks(fileLink?: GeneratedWebLink[]) {
-    if (!fileLink) return;
-
-    return html`<span class="filesWeblinks">
-      ${fileLink.map(
-        weblink => html`
-          <a target="_blank" rel="noopener" href="${weblink.url}">
-            ${weblink.name}
-          </a>
-        `
-      )}</span
-    > `;
-  }
-
   override render() {
     return html`
       <h3 class="assistive-tech-only">Patchset Range Selection</h3>
@@ -173,14 +172,8 @@
         <gr-dropdown-list
           id="basePatchDropdown"
           .value="${convertToString(this.basePatchNum)}"
-          .items="${this._computeBaseDropdownContent(
-            this.availablePatches,
-            this.patchNum,
-            this.sortedRevisions,
-            this.changeComments,
-            this.revisionInfo
-          )}"
-          @value-change=${this._handlePatchChange}
+          .items="${this.computeBaseDropdownContent()}"
+          @value-change=${this.handlePatchChange}
         >
         </gr-dropdown-list>
       </span>
@@ -190,13 +183,8 @@
         <gr-dropdown-list
           id="patchNumDropdown"
           .value="${convertToString(this.patchNum)}"
-          .items="${this._computePatchDropdownContent(
-            this.availablePatches,
-            this.basePatchNum,
-            this.sortedRevisions,
-            this.changeComments
-          )}"
-          @value-change=${this._handlePatchChange}
+          .items="${this.computePatchDropdownContent()}"
+          @value-change=${this.handlePatchChange}
         >
         </gr-dropdown-list>
         ${this.renderWeblinks(this.filesWeblinks?.meta_b)}
@@ -204,63 +192,54 @@
     `;
   }
 
-  override updated(changedProperties: PropertyValues) {
+  private renderWeblinks(fileLinks?: GeneratedWebLink[]) {
+    if (!fileLinks) return;
+    return html`<span class="filesWeblinks">
+      ${fileLinks.map(
+        weblink => html`
+          <a target="_blank" rel="noopener" href="${weblink.url}">
+            ${weblink.name}
+          </a>
+        `
+      )}</span
+    > `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('revisions')) {
-      this._updateSortedRevisions(this.revisions);
+      this.sortedRevisions = sortRevisions(Object.values(this.revisions || {}));
     }
   }
 
-  _updateSortedRevisions(revisions?: RevisionInfo[]) {
-    if (!revisions) return;
-    this.sortedRevisions = sortRevisions(Object.values(revisions));
-  }
-
-  _getShaForPatch(patch: PatchSet) {
-    return patch.sha.substring(0, 10);
-  }
-
-  _computeBaseDropdownContent(
-    availablePatches?: PatchSet[],
-    patchNum?: PatchSetNum,
-    sortedRevisions?: (RevisionInfo | EditRevisionInfo)[],
-    changeComments?: ChangeComments,
-    revisionInfo?: RevisionInfoClass
-  ): DropdownItem[] {
-    // Polymer 2: check for undefined
+  // Private method, but visible for testing.
+  computeBaseDropdownContent(): DropdownItem[] {
     if (
-      availablePatches === undefined ||
-      patchNum === undefined ||
-      sortedRevisions === undefined ||
-      changeComments === undefined ||
-      revisionInfo === undefined
+      this.availablePatches === undefined ||
+      this.patchNum === undefined ||
+      this.changeComments === undefined ||
+      this.revisionInfo === undefined
     ) {
       return [];
     }
 
-    const parentCounts = revisionInfo.getParentCountMap();
-    const currentParentCount = hasOwnProperty(parentCounts, patchNum)
-      ? parentCounts[patchNum as number]
+    const parentCounts = this.revisionInfo.getParentCountMap();
+    const currentParentCount = hasOwnProperty(parentCounts, this.patchNum)
+      ? parentCounts[this.patchNum as number]
       : 1;
-    const maxParents = revisionInfo.getMaxParents();
+    const maxParents = this.revisionInfo.getMaxParents();
     const isMerge = currentParentCount > 1;
 
     const dropdownContent: DropdownItem[] = [];
-    for (const basePatch of availablePatches) {
+    for (const basePatch of this.availablePatches) {
       const basePatchNum = basePatch.num;
-      const entry: DropdownItem = this._createDropdownEntry(
+      const entry: DropdownItem = this.createDropdownEntry(
         basePatchNum,
         'Patchset ',
-        sortedRevisions,
-        changeComments,
-        this._getShaForPatch(basePatch)
+        getShaForPatch(basePatch)
       );
       dropdownContent.push({
         ...entry,
-        disabled: this._computeLeftDisabled(
-          basePatch.num,
-          patchNum,
-          sortedRevisions
-        ),
+        disabled: this.computeLeftDisabled(basePatch.num, this.patchNum),
       });
     }
 
@@ -282,91 +261,61 @@
     return dropdownContent;
   }
 
-  _computeMobileText(
-    patchNum: PatchSetNum,
-    changeComments: ChangeComments,
-    revisions: (RevisionInfo | EditRevisionInfo)[]
-  ) {
+  private computeMobileText(patchNum: PatchSetNum) {
     return (
       `${patchNum}` +
-      `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
-      `${this._computePatchSetDescription(revisions, patchNum, true)}`
+      `${this.computePatchSetCommentsString(patchNum)}` +
+      `${this.computePatchSetDescription(patchNum, true)}`
     );
   }
 
-  _computePatchDropdownContent(
-    availablePatches?: PatchSet[],
-    basePatchNum?: BasePatchSetNum,
-    sortedRevisions?: (RevisionInfo | EditRevisionInfo)[],
-    changeComments?: ChangeComments
-  ): DropdownItem[] {
-    // Polymer 2: check for undefined
+  // Private method, but visible for testing.
+  computePatchDropdownContent(): DropdownItem[] {
     if (
-      availablePatches === undefined ||
-      basePatchNum === undefined ||
-      sortedRevisions === undefined ||
-      changeComments === undefined
+      this.availablePatches === undefined ||
+      this.basePatchNum === undefined ||
+      this.changeComments === undefined
     ) {
       return [];
     }
 
     const dropdownContent: DropdownItem[] = [];
-    for (const patch of availablePatches) {
+    for (const patch of this.availablePatches) {
       const patchNum = patch.num;
-      const entry = this._createDropdownEntry(
+      const entry = this.createDropdownEntry(
         patchNum,
         patchNum === 'edit' ? '' : 'Patchset ',
-        sortedRevisions,
-        changeComments,
-        this._getShaForPatch(patch)
+        getShaForPatch(patch)
       );
       dropdownContent.push({
         ...entry,
-        disabled: this._computeRightDisabled(
-          basePatchNum,
-          patchNum,
-          sortedRevisions
-        ),
+        disabled: this.computeRightDisabled(this.basePatchNum, patchNum),
       });
     }
     return dropdownContent;
   }
 
-  _computeText(
-    patchNum: PatchSetNum,
-    prefix: string,
-    changeComments: ChangeComments,
-    sha: string
-  ) {
+  private computeText(patchNum: PatchSetNum, prefix: string, sha: string) {
     return (
       `${prefix}${patchNum}` +
-      `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
+      `${this.computePatchSetCommentsString(patchNum)}` +
       ` | ${sha}`
     );
   }
 
-  _createDropdownEntry(
+  private createDropdownEntry(
     patchNum: PatchSetNum,
     prefix: string,
-    sortedRevisions: (RevisionInfo | EditRevisionInfo)[],
-    changeComments: ChangeComments,
     sha: string
   ) {
     const entry: DropdownItem = {
       triggerText: `${prefix}${patchNum}`,
-      text: this._computeText(patchNum, prefix, changeComments, sha),
-      mobileText: this._computeMobileText(
-        patchNum,
-        changeComments,
-        sortedRevisions
-      ),
-      bottomText: `${this._computePatchSetDescription(
-        sortedRevisions,
-        patchNum
-      )}`,
+      text: this.computeText(patchNum, prefix, sha),
+      mobileText: this.computeMobileText(patchNum),
+      bottomText: `${this.computePatchSetDescription(patchNum)}`,
       value: patchNum,
     };
-    const date = this._computePatchSetDate(sortedRevisions, patchNum);
+    const date = this.computePatchSetDate(patchNum);
     if (date) {
       entry.date = date;
     }
@@ -378,17 +327,18 @@
    * is sorted in reverse order (higher patchset nums first), invalid base
    * patch nums have an index greater than the index of patchNum.
    *
+   * Private method, but visible for testing.
+   *
    * @param basePatchNum The possible base patch num.
    * @param patchNum The current selected patch num.
    */
-  _computeLeftDisabled(
+  computeLeftDisabled(
     basePatchNum: PatchSetNum,
-    patchNum: PatchSetNum,
-    sortedRevisions: (RevisionInfo | EditRevisionInfo)[]
+    patchNum: PatchSetNum
   ): boolean {
     return (
-      findSortedIndex(basePatchNum, sortedRevisions) <=
-      findSortedIndex(patchNum, sortedRevisions)
+      findSortedIndex(basePatchNum, this.sortedRevisions) <=
+      findSortedIndex(patchNum, this.sortedRevisions)
     );
   }
 
@@ -403,13 +353,14 @@
    * If the current basePatchNum is a parent index, then only patches that have
    * at least that many parents are valid.
    *
+   * Private method, but visible for testing.
+   *
    * @param basePatchNum The current selected base patch num.
    * @param patchNum The possible patch num.
    */
-  _computeRightDisabled(
+  computeRightDisabled(
     basePatchNum: PatchSetNum,
-    patchNum: PatchSetNum,
-    sortedRevisions: (RevisionInfo | EditRevisionInfo)[]
+    patchNum: PatchSetNum
   ): boolean {
     if (basePatchNum === ParentPatchSetNum) {
       return false;
@@ -427,21 +378,17 @@
     }
 
     return (
-      findSortedIndex(basePatchNum, sortedRevisions) <=
-      findSortedIndex(patchNum, sortedRevisions)
+      findSortedIndex(basePatchNum, this.sortedRevisions) <=
+      findSortedIndex(patchNum, this.sortedRevisions)
     );
   }
 
   // TODO(dhruvsri): have ported comments contribute to this count
-  _computePatchSetCommentsString(
-    changeComments: ChangeComments,
-    patchNum: PatchSetNum
-  ) {
-    if (!changeComments) {
-      return;
-    }
+  // Private method, but visible for testing.
+  computePatchSetCommentsString(patchNum: PatchSetNum): string {
+    if (!this.changeComments) return '';
 
-    const commentThreadCount = changeComments.computeCommentThreadCount(
+    const commentThreadCount = this.changeComments.computeCommentThreadCount(
       {
         patchNum,
       },
@@ -449,7 +396,7 @@
     );
     const commentThreadString = pluralize(commentThreadCount, 'comment');
 
-    const unresolvedCount = changeComments.computeUnresolvedNum(
+    const unresolvedCount = this.changeComments.computeUnresolvedNum(
       {patchNum},
       true
     );
@@ -468,23 +415,19 @@
     );
   }
 
-  _computePatchSetDescription(
-    revisions: (RevisionInfo | EditRevisionInfo)[],
+  private computePatchSetDescription(
     patchNum: PatchSetNum,
     addFrontSpace?: boolean
   ) {
-    const rev = getRevisionByPatchNum(revisions, patchNum);
+    const rev = getRevisionByPatchNum(this.sortedRevisions, patchNum);
     return rev?.description
       ? (addFrontSpace ? ' ' : '') +
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH)
       : '';
   }
 
-  _computePatchSetDate(
-    revisions: (RevisionInfo | EditRevisionInfo)[],
-    patchNum: PatchSetNum
-  ): Timestamp | undefined {
-    const rev = getRevisionByPatchNum(revisions, patchNum);
+  private computePatchSetDate(patchNum: PatchSetNum): Timestamp | undefined {
+    const rev = getRevisionByPatchNum(this.sortedRevisions, patchNum);
     return rev ? rev.created : undefined;
   }
 
@@ -492,7 +435,7 @@
    * Catches value-change events from the patchset dropdowns and determines
    * whether or not a patch change event should be fired.
    */
-  _handlePatchChange(e: DropDownValueChangeEvent) {
+  private handlePatchChange(e: DropDownValueChangeEvent) {
     const detail: PatchRangeChangeDetail = {
       patchNum: this.patchNum,
       basePatchNum: this.basePatchNum,
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index a47b685..342fe3a 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -55,7 +55,7 @@
 suite('gr-patch-range-select tests', () => {
   let element: GrPatchRangeSelect;
 
-  function getInfo(revisions: RevisionInfo[]) {
+  function getInfo(revisions: (RevisionInfo | EditRevisionInfo)[]) {
     const revisionObj: Partial<RevIdToRevisionInfo> = {};
     for (let i = 0; i < revisions.length; i++) {
       revisionObj[i] = revisions[i];
@@ -78,97 +78,54 @@
     await element.updateComplete;
   });
 
-  test('enabled/disabled options', () => {
-    const patchRange = {
-      basePatchNum: 'PARENT' as PatchSetNum,
-      patchNum: 3 as PatchSetNum,
-    };
-    const sortedRevisions = [
+  test('enabled/disabled options', async () => {
+    element.revisions = [
       createRevision(3) as RevisionInfo,
       createEditRevision(2) as EditRevisionInfo,
       createRevision(2) as RevisionInfo,
       createRevision(1) as RevisionInfo,
     ];
+    await element.updateComplete;
+
+    const parent = 'PARENT' as PatchSetNum;
+    const edit = EditPatchSetNum;
+
     for (const patchNum of [1, 2, 3]) {
       assert.isFalse(
-        element._computeRightDisabled(
-          patchRange.basePatchNum,
-          patchNum as PatchSetNum,
-          sortedRevisions
-        )
+        element.computeRightDisabled(parent, patchNum as PatchSetNum)
       );
     }
     for (const basePatchNum of [1, 2]) {
-      assert.isFalse(
-        element._computeLeftDisabled(
-          basePatchNum as PatchSetNum,
-          patchRange.patchNum,
-          sortedRevisions
-        )
-      );
+      const base = basePatchNum as PatchSetNum;
+      assert.isFalse(element.computeLeftDisabled(base, 3 as PatchSetNum));
     }
     assert.isTrue(
-      element._computeLeftDisabled(3 as PatchSetNum, patchRange.patchNum, [])
+      element.computeLeftDisabled(3 as PatchSetNum, 3 as PatchSetNum)
     );
 
-    patchRange.basePatchNum = EditPatchSetNum;
     assert.isTrue(
-      element._computeLeftDisabled(
-        3 as PatchSetNum,
-        patchRange.patchNum,
-        sortedRevisions
-      )
+      element.computeLeftDisabled(3 as PatchSetNum, 3 as PatchSetNum)
     );
-    assert.isTrue(
-      element._computeRightDisabled(
-        patchRange.basePatchNum,
-        1 as PatchSetNum,
-        sortedRevisions
-      )
-    );
-    assert.isTrue(
-      element._computeRightDisabled(
-        patchRange.basePatchNum,
-        2 as PatchSetNum,
-        sortedRevisions
-      )
-    );
-    assert.isFalse(
-      element._computeRightDisabled(
-        patchRange.basePatchNum,
-        3 as PatchSetNum,
-        sortedRevisions
-      )
-    );
-    assert.isTrue(
-      element._computeRightDisabled(
-        patchRange.basePatchNum,
-        EditPatchSetNum,
-        sortedRevisions
-      )
-    );
+    assert.isTrue(element.computeRightDisabled(edit, 1 as PatchSetNum));
+    assert.isTrue(element.computeRightDisabled(edit, 2 as PatchSetNum));
+    assert.isFalse(element.computeRightDisabled(edit, 3 as PatchSetNum));
+    assert.isTrue(element.computeRightDisabled(edit, edit));
   });
 
-  test('_computeBaseDropdownContent', () => {
-    const availablePatches = [
+  test('computeBaseDropdownContent', async () => {
+    element.availablePatches = [
       {num: 'edit', sha: '1'} as PatchSet,
       {num: 3, sha: '2'} as PatchSet,
       {num: 2, sha: '3'} as PatchSet,
       {num: 1, sha: '4'} as PatchSet,
     ];
-    const revisions: RevisionInfo[] = [
+    element.revisions = [
       createRevision(2),
       createRevision(3),
       createRevision(1),
       createRevision(4),
     ];
-    element.revisionInfo = getInfo(revisions);
-    const sortedRevisions = [
-      createRevision(3) as RevisionInfo,
-      createEditRevision(2) as EditRevisionInfo,
-      createRevision(2) as RevisionInfo,
-      createRevision(1) as RevisionInfo,
-    ];
+    element.revisionInfo = getInfo(element.revisions);
     const expectedResult: DropdownItem[] = [
       {
         disabled: true,
@@ -210,19 +167,14 @@
         value: 'PARENT',
       } as DropdownItem,
     ];
-    assert.deepEqual(
-      element._computeBaseDropdownContent(
-        availablePatches,
-        1 as PatchSetNum,
-        sortedRevisions,
-        element.changeComments,
-        element.revisionInfo
-      ),
-      expectedResult
-    );
+    element.patchNum = 1 as PatchSetNum;
+    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    await element.updateComplete;
+
+    assert.deepEqual(element.computeBaseDropdownContent(), expectedResult);
   });
 
-  test('_computeBaseDropdownContent called when patchNum updates', async () => {
+  test('computeBaseDropdownContent called when patchNum updates', async () => {
     element.revisions = [
       createRevision(2),
       createRevision(3),
@@ -240,7 +192,7 @@
     element.basePatchNum = 'PARENT' as BasePatchSetNum;
     await element.updateComplete;
 
-    const baseDropDownStub = sinon.stub(element, '_computeBaseDropdownContent');
+    const baseDropDownStub = sinon.stub(element, 'computeBaseDropdownContent');
 
     // Should be recomputed for each available patch
     element.patchNum = 1 as PatchSetNum;
@@ -248,7 +200,7 @@
     assert.equal(baseDropDownStub.callCount, 1);
   });
 
-  test('_computeBaseDropdownContent called when changeComments update', async () => {
+  test('computeBaseDropdownContent called when changeComments update', async () => {
     element.revisions = [
       createRevision(2),
       createRevision(3),
@@ -266,14 +218,14 @@
     await element.updateComplete;
 
     // Should be recomputed for each available patch
-    const baseDropDownStub = sinon.stub(element, '_computeBaseDropdownContent');
+    const baseDropDownStub = sinon.stub(element, 'computeBaseDropdownContent');
     assert.equal(baseDropDownStub.callCount, 0);
     element.changeComments = new ChangeComments();
     await element.updateComplete;
     assert.equal(baseDropDownStub.callCount, 1);
   });
 
-  test('_computePatchDropdownContent called when basePatchNum updates', async () => {
+  test('computePatchDropdownContent called when basePatchNum updates', async () => {
     element.revisions = [
       createRevision(2),
       createRevision(3),
@@ -292,29 +244,27 @@
     await element.updateComplete;
 
     // Should be recomputed for each available patch
-    const baseDropDownStub = sinon.stub(
-      element,
-      '_computePatchDropdownContent'
-    );
+    const baseDropDownStub = sinon.stub(element, 'computePatchDropdownContent');
     element.basePatchNum = 1 as BasePatchSetNum;
     await element.updateComplete;
     assert.equal(baseDropDownStub.callCount, 1);
   });
 
-  test('_computePatchDropdownContent', () => {
-    const availablePatches: PatchSet[] = [
+  test('computePatchDropdownContent', async () => {
+    element.availablePatches = [
       {num: 'edit', sha: '1'} as PatchSet,
       {num: 3, sha: '2'} as PatchSet,
       {num: 2, sha: '3'} as PatchSet,
       {num: 1, sha: '4'} as PatchSet,
     ];
-    const basePatchNum = 1;
-    const sortedRevisions = [
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.revisions = [
       createRevision(3) as RevisionInfo,
       createEditRevision(2) as EditRevisionInfo,
       createRevision(2, 'description') as RevisionInfo,
       createRevision(1) as RevisionInfo,
     ];
+    await element.updateComplete;
 
     const expectedResult: DropdownItem[] = [
       {
@@ -354,15 +304,7 @@
       } as DropdownItem,
     ];
 
-    assert.deepEqual(
-      element._computePatchDropdownContent(
-        availablePatches,
-        basePatchNum as BasePatchSetNum,
-        sortedRevisions,
-        element.changeComments
-      ),
-      expectedResult
-    );
+    assert.deepEqual(element.computePatchDropdownContent(), expectedResult);
   });
 
   test('filesWeblinks', async () => {
@@ -391,7 +333,7 @@
     );
   });
 
-  test('_computePatchSetCommentsString', () => {
+  test('computePatchSetCommentsString', () => {
     // Test string with unresolved comments.
     const comments: PathToCommentsInfoMap = {
       foo: [
@@ -432,10 +374,7 @@
     element.changeComments = new ChangeComments(comments);
 
     assert.equal(
-      element._computePatchSetCommentsString(
-        element.changeComments,
-        1 as PatchSetNum
-      ),
+      element.computePatchSetCommentsString(1 as PatchSetNum),
       ' (3 comments, 1 unresolved)'
     );
 
@@ -443,23 +382,14 @@
     delete comments['foo'];
     element.changeComments = new ChangeComments(comments);
     assert.equal(
-      element._computePatchSetCommentsString(
-        element.changeComments,
-        1 as PatchSetNum
-      ),
+      element.computePatchSetCommentsString(1 as PatchSetNum),
       ' (2 comments)'
     );
 
     // Test string with no comments.
     delete comments['bar'];
     element.changeComments = new ChangeComments(comments);
-    assert.equal(
-      element._computePatchSetCommentsString(
-        element.changeComments,
-        1 as PatchSetNum
-      ),
-      ''
-    );
+    assert.equal(element.computePatchSetCommentsString(1 as PatchSetNum), '');
   });
 
   test('patch-range-change fires', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
index 551889f..0f64d9e 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -18,7 +18,6 @@
 import '../../shared/gr-tooltip/gr-tooltip';
 import {GrTooltip} from '../../shared/gr-tooltip/gr-tooltip';
 import {customElement, property} from '@polymer/decorators';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-selection-action-box_html';
 import {fireEvent} from '../../../utils/event-util';
@@ -56,8 +55,8 @@
     this.addEventListener('mousedown', e => this._handleMouseDown(e));
   }
 
-  placeAbove(el: Text | Element | Range) {
-    flush();
+  async placeAbove(el: Text | Element | Range) {
+    await this.$.tooltip.updateComplete;
     const rect = this._getTargetBoundingRect(el);
     const boxRect = this.$.tooltip.getBoundingClientRect();
     const parentRect = this._getParentBoundingClientRect();
@@ -70,8 +69,8 @@
     }px`;
   }
 
-  placeBelow(el: Text | Element | Range) {
-    flush();
+  async placeBelow(el: Text | Element | Range) {
+    await this.$.tooltip.updateComplete;
     const rect = this._getTargetBoundingRect(el);
     const boxRect = this.$.tooltip.getBoundingClientRect();
     const parentRect = this._getParentBoundingClientRect();
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
index 24d63b3..558742a 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
@@ -23,6 +23,9 @@
       font-family: var(--font-family);
       position: absolute;
       white-space: nowrap;
+      /* This prevents the mouse over the tooltip from interfering with the
+         selection. */
+      pointer-events: none;
     }
   </style>
   <gr-tooltip
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js
index 81cf0d6..c978c37 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js
@@ -83,35 +83,35 @@
           {width: 10, height: 10});
     });
 
-    test('placeAbove for Element argument', () => {
-      element.placeAbove(target);
+    test('placeAbove for Element argument', async () => {
+      await element.placeAbove(target);
       assert.equal(element.style.top, '25px');
       assert.equal(element.style.left, '72px');
     });
 
-    test('placeAbove for Text Node argument', () => {
-      element.placeAbove(target.firstChild);
+    test('placeAbove for Text Node argument', async () => {
+      await element.placeAbove(target.firstChild);
       assert.equal(element.style.top, '25px');
       assert.equal(element.style.left, '72px');
     });
 
-    test('placeBelow for Element argument', () => {
-      element.placeBelow(target);
+    test('placeBelow for Element argument', async () => {
+      await element.placeBelow(target);
       assert.equal(element.style.top, '45px');
       assert.equal(element.style.left, '72px');
     });
 
-    test('placeBelow for Text Node argument', () => {
-      element.placeBelow(target.firstChild);
+    test('placeBelow for Text Node argument', async () => {
+      await element.placeBelow(target.firstChild);
       assert.equal(element.style.top, '45px');
       assert.equal(element.style.left, '72px');
     });
 
-    test('uses document.createRange', () => {
+    test('uses document.createRange', async () => {
       sinon.spy(document, 'createRange');
       element._getTargetBoundingRect.restore();
       sinon.spy(element, '_getTargetBoundingRect');
-      element.placeAbove(target.firstChild);
+      await element.placeAbove(target.firstChild);
       assert.isTrue(document.createRange.called);
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 595de34..9e0027a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -50,7 +50,6 @@
   stubReporting,
   stubRestApi,
 } from '../../../test/test-utils';
-import {_testOnly_resetState} from '../../../services/comments/comments-model';
 import {SinonStub} from 'sinon';
 
 const basicFixture = fixtureFromElement('gr-comment-thread');
@@ -63,7 +62,6 @@
 
     setup(() => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      _testOnly_resetState();
       element = basicFixture.instantiate();
       element.patchNum = 3 as PatchSetNum;
       element.changeNum = 1 as NumericChangeId;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 00edc07..09ac95b 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -384,11 +384,7 @@
 
     test('delete comment', async () => {
       const stub = stubRestApi('deleteComment').returns(
-        Promise.resolve({
-          id: '1' as UrlEncodedCommentId,
-          updated: '1' as Timestamp,
-          ...createComment(),
-        })
+        Promise.resolve(createComment())
       );
       const openSpy = sinon.spy(element.confirmDeleteOverlay!, 'open');
       element.changeNum = 42 as NumericChangeId;
@@ -1183,6 +1179,7 @@
 
     test('draft prevent save when disabled', async () => {
       const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
+      sinon.stub(element, '_fireEdit');
       element.showActions = true;
       element.draft = true;
       await flush();
diff --git a/polygerrit-ui/app/services/browser/browser-model.ts b/polygerrit-ui/app/services/browser/browser-model.ts
index db790f6..8cb6575 100644
--- a/polygerrit-ui/app/services/browser/browser-model.ts
+++ b/polygerrit-ui/app/services/browser/browser-model.ts
@@ -33,11 +33,13 @@
 
 const initialState: BrowserState = {};
 
-// Mutable for testing
-let privateState$ = new BehaviorSubject(initialState);
+const privateState$ = new BehaviorSubject(initialState);
 
 export function _testOnly_resetState() {
-  privateState$ = new BehaviorSubject(initialState);
+  // We cannot assign a new subject to privateState$, because all the selectors
+  // have already subscribed to the original subject. So we have to emit the
+  // initial state on the existing subject.
+  privateState$.next({...initialState});
 }
 
 export function _testOnly_setState(state: BrowserState) {
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 962ef4d..7df0c22 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -40,6 +40,13 @@
 
 const privateState$ = new BehaviorSubject(initialState);
 
+export function _testOnly_resetState() {
+  // We cannot assign a new subject to privateState$, because all the selectors
+  // have already subscribed to the original subject. So we have to emit the
+  // initial state on the existing subject.
+  privateState$.next({...initialState});
+}
+
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const changeState$: Observable<ChangeState> = privateState$;
 
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 75c24b6..6435252 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -126,11 +126,13 @@
   pluginStateSelected: {},
 };
 
-// Mutable for testing
-let privateState$ = new BehaviorSubject(initialState);
+const privateState$ = new BehaviorSubject(initialState);
 
 export function _testOnly_resetState() {
-  privateState$ = new BehaviorSubject(initialState);
+  // We cannot assign a new subject to privateState$, because all the selectors
+  // have already subscribed to the original subject. So we have to emit the
+  // initial state on the existing subject.
+  privateState$.next({...initialState});
 }
 
 export function _testOnly_setState(state: ChecksState) {
diff --git a/polygerrit-ui/app/services/checks/checks-model_test.ts b/polygerrit-ui/app/services/checks/checks-model_test.ts
index dbd3f86..0be0451 100644
--- a/polygerrit-ui/app/services/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/services/checks/checks-model_test.ts
@@ -18,7 +18,6 @@
 import './checks-model';
 import {
   _testOnly_getState,
-  _testOnly_resetState,
   ChecksPatchset,
   updateStateSetLoading,
   updateStateSetProvider,
@@ -52,7 +51,6 @@
 
 suite('checks-model tests', () => {
   test('updateStateSetProvider', () => {
-    _testOnly_resetState();
     updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
     assert.deepEqual(current(), {
       pluginName: PLUGIN_NAME,
@@ -65,7 +63,6 @@
   });
 
   test('loading and first time load', () => {
-    _testOnly_resetState();
     updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
     assert.isFalse(current().loading);
     assert.isTrue(current().firstTimeLoad);
@@ -84,14 +81,12 @@
   });
 
   test('updateStateSetResults', () => {
-    _testOnly_resetState();
     updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
     assert.lengthOf(current().runs, 1);
     assert.lengthOf(current().runs[0].results!, 1);
   });
 
   test('updateStateUpdateResult', () => {
-    _testOnly_resetState();
     updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
     assert.equal(
       current().runs[0].results![0].summary,
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
index 850acbc..5b32465 100644
--- a/polygerrit-ui/app/services/comments/comments-model.ts
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -51,7 +51,10 @@
 const privateState$ = new BehaviorSubject(initialState);
 
 export function _testOnly_resetState() {
-  privateState$.next(initialState);
+  // We cannot assign a new subject to privateState$, because all the selectors
+  // have already subscribed to the original subject. So we have to emit the
+  // initial state on the existing subject.
+  privateState$.next({...initialState});
 }
 
 // Re-exporting as Observable so that you can only subscribe, but not emit.
@@ -65,11 +68,21 @@
   privateState$.next(state);
 }
 
+export const comments$ = commentState$.pipe(
+  map(commentState => commentState.comments),
+  distinctUntilChanged()
+);
+
 export const drafts$ = commentState$.pipe(
   map(commentState => commentState.drafts),
   distinctUntilChanged()
 );
 
+export const portedComments$ = commentState$.pipe(
+  map(commentState => commentState.portedComments),
+  distinctUntilChanged()
+);
+
 export const discardedDrafts$ = commentState$.pipe(
   map(commentState => commentState.discardedDrafts),
   distinctUntilChanged()
@@ -87,14 +100,22 @@
         commentState.portedComments,
         commentState.portedDrafts
       )
-  ),
-  distinctUntilChanged()
+  )
+);
+
+export const threads$ = changeComments$.pipe(
+  map(changeComments => changeComments.getAllThreadsForChange())
 );
 
 function publishState(state: CommentState) {
   privateState$.next(state);
 }
 
+/** Called when the change number changes. Wipes out all data from the state. */
+export function updateStateReset() {
+  publishState({...initialState});
+}
+
 export function updateStateComments(comments?: {
   [path: string]: CommentInfo[];
 }) {
diff --git a/polygerrit-ui/app/services/comments/comments-model_test.ts b/polygerrit-ui/app/services/comments/comments-model_test.ts
index e389254..30fc7cf 100644
--- a/polygerrit-ui/app/services/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/services/comments/comments-model_test.ts
@@ -22,13 +22,11 @@
 import {
   updateStateDeleteDraft,
   _testOnly_getState,
-  _testOnly_resetState,
   _testOnly_setState,
 } from './comments-model';
 
 suite('comments model tests', () => {
   test('updateStateDeleteDraft', () => {
-    _testOnly_resetState();
     const draft = createDraft();
     draft.id = '1' as UrlEncodedCommentId;
     _testOnly_setState({
diff --git a/polygerrit-ui/app/services/comments/comments-service.ts b/polygerrit-ui/app/services/comments/comments-service.ts
index 16ee2f7..5896b52 100644
--- a/polygerrit-ui/app/services/comments/comments-service.ts
+++ b/polygerrit-ui/app/services/comments/comments-service.ts
@@ -31,7 +31,10 @@
   updateStatePortedDrafts,
   updateStateUndoDiscardedDraft,
   discardedDrafts$,
+  updateStateReset,
 } from './comments-model';
+import {changeNum$, currentPatchNum$} from '../change/change-model';
+import {combineLatest} from 'rxjs';
 
 export class CommentsService {
   private discardedDrafts?: UIDraft[] = [];
@@ -40,31 +43,55 @@
     discardedDrafts$.subscribe(
       discardedDrafts => (this.discardedDrafts = discardedDrafts)
     );
+    changeNum$.subscribe(changeNum => {
+      updateStateReset();
+      if (!changeNum) return;
+      this.reloadComments(changeNum);
+      this.reloadRobotComments(changeNum);
+      this.reloadDrafts(changeNum);
+    });
+    combineLatest([changeNum$, currentPatchNum$]).subscribe(
+      ([changeNum, currentPatchNum]) => {
+        if (!changeNum || !currentPatchNum) return;
+        this.reloadPortedComments(changeNum, currentPatchNum);
+        this.reloadPortedDrafts(changeNum, currentPatchNum);
+      }
+    );
   }
 
-  /**
-   * Load all comments (with drafts and robot comments) for the given change
-   * number. The returned promise resolves when the comments have loaded, but
-   * does not yield the comment data.
-   */
-  // TODO(dhruvsri): listen to changeNum changes or reload event to update
-  // automatically
-  loadAll(changeNum: NumericChangeId, patchNum = CURRENT as RevisionId) {
-    const revision = patchNum;
-    this.restApiService
+  reloadComments(changeNum: NumericChangeId): Promise<void> {
+    return this.restApiService
       .getDiffComments(changeNum)
       .then(comments => updateStateComments(comments));
-    this.restApiService
+  }
+
+  reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
+    return this.restApiService
       .getDiffRobotComments(changeNum)
       .then(robotComments => updateStateRobotComments(robotComments));
-    this.restApiService
+  }
+
+  reloadDrafts(changeNum: NumericChangeId): Promise<void> {
+    return this.restApiService
       .getDiffDrafts(changeNum)
       .then(drafts => updateStateDrafts(drafts));
-    this.restApiService
-      .getPortedComments(changeNum, revision)
+  }
+
+  reloadPortedComments(
+    changeNum: NumericChangeId,
+    patchNum = CURRENT as RevisionId
+  ): Promise<void> {
+    return this.restApiService
+      .getPortedComments(changeNum, patchNum)
       .then(portedComments => updateStatePortedComments(portedComments));
-    this.restApiService
-      .getPortedDrafts(changeNum, revision)
+  }
+
+  reloadPortedDrafts(
+    changeNum: NumericChangeId,
+    patchNum = CURRENT as RevisionId
+  ): Promise<void> {
+    return this.restApiService
+      .getPortedDrafts(changeNum, patchNum)
       .then(portedDrafts => updateStatePortedDrafts(portedDrafts));
   }
 
diff --git a/polygerrit-ui/app/services/comments/comments-service_test.ts b/polygerrit-ui/app/services/comments/comments-service_test.ts
index 604b5c4..a35768e 100644
--- a/polygerrit-ui/app/services/comments/comments-service_test.ts
+++ b/polygerrit-ui/app/services/comments/comments-service_test.ts
@@ -18,109 +18,63 @@
 import '../../test/common-test-setup-karma';
 import {
   createComment,
-  createFixSuggestionInfo,
+  createParsedChange,
+  TEST_NUMERIC_CHANGE_ID,
 } from '../../test/test-data-generators';
-import {stubRestApi} from '../../test/test-utils';
-import {
-  NumericChangeId,
-  RobotId,
-  RobotRunId,
-  Timestamp,
-  UrlEncodedCommentId,
-} from '../../types/common';
+import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {appContext} from '../app-context';
 import {CommentsService} from './comments-service';
+import {updateState as updateChangeState} from '../change/change-model';
+import {
+  GerritView,
+  updateState as updateRouterState,
+} from '../router/router-model';
+import {comments$, portedComments$} from './comments-model';
+import {PathToCommentsInfoMap} from '../../types/common';
 
 suite('change service tests', () => {
-  let commentsService: CommentsService;
-
-  test('loads logged-out', () => {
-    const changeNum = 1234 as NumericChangeId;
-    commentsService = new CommentsService(appContext.restApiService);
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+  test('loads comments', async () => {
+    new CommentsService(appContext.restApiService);
     const diffCommentsSpy = stubRestApi('getDiffComments').returns(
-      Promise.resolve({
-        'foo.c': [
-          {
-            ...createComment(),
-            id: '123' as UrlEncodedCommentId,
-            message: 'Done',
-            updated: '2017-02-08 16:40:49' as Timestamp,
-          },
-        ],
-      })
+      Promise.resolve({'foo.c': [createComment()]})
     );
     const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
-      Promise.resolve({
-        'foo.c': [
-          {
-            ...createComment(),
-            id: '321' as UrlEncodedCommentId,
-            message: 'Done',
-            updated: '2017-02-08 16:40:49' as Timestamp,
-            robot_id: 'robot_1' as RobotId,
-            robot_run_id: 'run_1' as RobotRunId,
-            properties: {},
-            fix_suggestions: [
-              createFixSuggestionInfo('fix_1'),
-              createFixSuggestionInfo('fix_2'),
-            ],
-          },
-        ],
-      })
+      Promise.resolve({})
     );
     const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
       Promise.resolve({})
     );
-
-    commentsService.loadAll(changeNum);
-    assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
-    assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
-    assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
-  });
-
-  test('loads logged-in', () => {
-    const changeNum = 1234 as NumericChangeId;
-
-    stubRestApi('getLoggedIn').returns(Promise.resolve(true));
-    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
-      Promise.resolve({
-        'foo.c': [
-          {
-            ...createComment(),
-            id: '123' as UrlEncodedCommentId,
-            message: 'Done',
-            updated: '2017-02-08 16:40:49' as Timestamp,
-          },
-        ],
-      })
+    const portedCommentsSpy = stubRestApi('getPortedComments').returns(
+      Promise.resolve({'foo.c': [createComment()]})
     );
-    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
-      Promise.resolve({
-        'foo.c': [
-          {
-            ...createComment(),
-            id: '321' as UrlEncodedCommentId,
-            message: 'Done',
-            updated: '2017-02-08 16:40:49' as Timestamp,
-            robot_id: 'robot_1' as RobotId,
-            robot_run_id: 'run_1' as RobotRunId,
-            properties: {},
-            fix_suggestions: [
-              createFixSuggestionInfo('fix_1'),
-              createFixSuggestionInfo('fix_2'),
-            ],
-          },
-        ],
-      })
-    );
-    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+    const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
       Promise.resolve({})
     );
+    let comments: PathToCommentsInfoMap = {};
+    comments$.subscribe(c => (comments = c));
+    let portedComments: PathToCommentsInfoMap = {};
+    portedComments$.subscribe(c => (portedComments = c));
 
-    commentsService.loadAll(changeNum);
-    assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
-    assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
-    assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
+    updateRouterState(GerritView.CHANGE, TEST_NUMERIC_CHANGE_ID);
+    updateChangeState(createParsedChange());
+
+    await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
+    await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
+    await waitUntilCalled(diffDraftsSpy, 'diffDraftsSpy');
+    await waitUntilCalled(portedCommentsSpy, 'portedCommentsSpy');
+    await waitUntilCalled(portedDraftsSpy, 'portedDraftsSpy');
+    await waitUntil(
+      () => Object.keys(comments).length > 0,
+      'comment in model not set'
+    );
+    await waitUntil(
+      () => Object.keys(portedComments).length > 0,
+      'ported comment in model not set'
+    );
+
+    assert.equal(comments['foo.c'].length, 1);
+    assert.equal(comments['foo.c'][0].id, '12345');
+    assert.equal(portedComments['foo.c'].length, 1);
+    assert.equal(portedComments['foo.c'][0].id, '12345');
   });
 });
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index b3cdf9e..ae3d848 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -47,6 +47,13 @@
 
 const privateState$ = new BehaviorSubject<RouterState>(initialState);
 
+export function _testOnly_resetState() {
+  // We cannot assign a new subject to privateState$, because all the selectors
+  // have already subscribed to the original subject. So we have to emit the
+  // initial state on the existing subject.
+  privateState$.next({...initialState});
+}
+
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const routerState$: Observable<RouterState> = privateState$;
 
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
index 000887c..df307d6 100644
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -37,11 +37,13 @@
   diffPreferences: createDefaultDiffPrefs(),
 };
 
-// Mutable for testing
-let privateState$ = new BehaviorSubject(initialState);
+const privateState$ = new BehaviorSubject(initialState);
 
 export function _testOnly_resetState() {
-  privateState$ = new BehaviorSubject(initialState);
+  // We cannot assign a new subject to privateState$, because all the selectors
+  // have already subscribed to the original subject. So we have to emit the
+  // initial state on the existing subject.
+  privateState$.next({...initialState});
 }
 
 export function _testOnly_setState(state: UserState) {
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index bd5504a..05adb41 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -45,6 +45,12 @@
 import {updatePreferences} from '../services/user/user-model';
 import {createDefaultPreferences} from '../constants/constants';
 import {appContext} from '../services/app-context';
+import {_testOnly_resetState as resetBrowserState} from '../services/browser/browser-model';
+import {_testOnly_resetState as resetChangeState} from '../services/change/change-model';
+import {_testOnly_resetState as resetChecksState} from '../services/checks/checks-model';
+import {_testOnly_resetState as resetCommentsState} from '../services/comments/comments-model';
+import {_testOnly_resetState as resetRouterState} from '../services/router/router-model';
+import {_testOnly_resetState as resetUserState} from '../services/user/user-model';
 
 declare global {
   interface Window {
@@ -106,6 +112,14 @@
   // tests.
   initGlobalVariables();
   _testOnly_initGerritPluginApi();
+
+  resetBrowserState();
+  resetChangeState();
+  resetChecksState();
+  resetCommentsState();
+  resetRouterState();
+  resetUserState();
+
   const shortcuts = appContext.shortcutsService;
   assert.isTrue(shortcuts._testOnly_isEmpty());
   const selection = document.getSelection();
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index dd56ce2..91cd2f3 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -95,7 +95,12 @@
 import {AppElementChangeViewParams} from '../elements/gr-app-types';
 import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
 import {WebLinkInfo} from '../types/diff';
-import {createCommentThreads, UIComment, UIDraft} from '../utils/comment-util';
+import {
+  createCommentThreads,
+  UIComment,
+  UIDraft,
+  UIHuman,
+} from '../utils/comment-util';
 import {GerritView} from '../services/router/router-model';
 import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
@@ -488,7 +493,7 @@
   };
 }
 
-export function createComment(): UIComment {
+export function createComment(): UIHuman {
   return {
     patch_set: 1 as PatchSetNum,
     id: '12345' as UrlEncodedCommentId,
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 4a513f8..63c125e 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -19,7 +19,7 @@
 import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
 import {appContext} from '../services/app-context';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
-import {SinonSpy} from 'sinon';
+import {SinonSpy, SinonStub} from 'sinon';
 import {StorageService} from '../services/storage/gr-storage';
 import {AuthService} from '../services/gr-auth/gr-auth';
 import {ReportingService} from '../services/gr-reporting/gr-reporting';
@@ -185,6 +185,7 @@
 ): Promise<void> {
   const start = Date.now();
   let sleep = 0;
+  if (predicate()) return Promise.resolve();
   return new Promise((resolve, reject) => {
     const waiter = () => {
       if (predicate()) {
@@ -200,6 +201,10 @@
   });
 }
 
+export function waitUntilCalled(stub: SinonStub, name: string) {
+  return waitUntil(() => stub.called, `${name} was not called`);
+}
+
 /**
  * Promisify an event callback to simplify async...await tests.
  *
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 3ac7c7b..4406a73 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -206,6 +206,7 @@
   UrlEncodedRepoName,
   UserConfigInfo,
   VotingRangeInfo,
+  WebLinkInfo,
   isDetailedLabelInfo,
   isQuickLabelInfo,
 };
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index ee4ed8b..921850a 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -103,7 +103,7 @@
       return rev;
     }
   }
-  console.warn('no revision found');
+  if (revisions.length > 0) console.warn('no revision found');
   return;
 }