Merge "Migrate plugin endpoint components to lit" into stable-3.6
diff --git a/modules/jgit b/modules/jgit
index d73b7cd..e982de3 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit d73b7cdeb4eaf32e3d41c105b974e620b33a168e
+Subproject commit e982de3fcb9f3a2e5ec6ceaae44cbb344962ea01
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index 042ffc1..8d0eda8 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -14,10 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../../test/common-test-setup-karma';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response';
 import './gr-file-list';
 import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
 import {FilesExpandedState} from '../gr-file-list-constants';
@@ -47,6 +45,7 @@
 import {
   createChangeComments,
   createCommit,
+  createDiff,
   createParsedChange,
   createRevision,
 } from '../../../test/test-data-generators';
@@ -1754,7 +1753,7 @@
         syntax_highlighting: true,
         ignore_whitespace: 'IGNORE_NONE',
       };
-      diff.diff = getMockDiffResponse();
+      diff.diff = createDiff();
       await listenOnce(diff, 'render');
     }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index 6bf0780..205524f 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -19,89 +19,241 @@
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-info_html';
-import {customElement, property, observe} from '@polymer/decorators';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireEvent} from '../../../utils/event-util';
+import {LitElement, css, html, nothing, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {when} from 'lit/directives/when';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 
 @customElement('gr-account-info')
-export class GrAccountInfo extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountInfo extends LitElement {
   /**
    * Fired when account details are changed.
    *
    * @event account-detail-update
    */
 
-  @property({
-    type: Boolean,
-    notify: true,
-    computed: '_computeUsernameMutable(_serverConfig, _account.username)',
-  })
-  usernameMutable?: boolean;
+  // private but used in test
+  @state() usernameMutable?: boolean;
 
-  @property({
-    type: Boolean,
-    notify: true,
-    computed: '_computeNameMutable(_serverConfig)',
-  })
-  nameMutable?: boolean;
+  // private but used in test
+  @state() nameMutable?: boolean;
 
-  @property({
-    type: Boolean,
-    notify: true,
-    computed:
-      '_computeHasUnsavedChanges(_hasNameChange, ' +
-      '_hasUsernameChange, _hasStatusChange, _hasDisplayNameChange)',
-  })
-  hasUnsavedChanges?: boolean;
+  @property({type: Boolean}) hasUnsavedChanges = false;
 
-  @property({type: Boolean})
-  _hasNameChange?: boolean;
+  // private but used in test
+  @state() hasNameChange = false;
 
-  @property({type: Boolean})
-  _hasUsernameChange?: boolean;
+  // private but used in test
+  @state() hasUsernameChange = false;
 
-  @property({type: Boolean})
-  _hasDisplayNameChange?: boolean;
+  // private but used in test
+  @state() hasDisplayNameChange = false;
 
-  @property({type: Boolean})
-  _hasStatusChange?: boolean;
+  // private but used in test
+  @state() hasStatusChange = false;
 
-  @property({type: Boolean})
-  _loading = false;
+  // private but used in test
+  @state() loading = false;
 
-  @property({type: Boolean})
-  _saving = false;
+  @state() private saving = false;
 
-  @property({type: Object})
-  _account?: AccountDetailInfo;
+  // private but used in test
+  @state() account?: AccountDetailInfo;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  // private but used in test
+  @state() serverConfig?: ServerInfo;
 
-  @property({type: String, observer: '_usernameChanged'})
-  _username?: string;
+  // private but used in test
+  @state() username?: string;
 
-  @property({type: String})
-  _avatarChangeUrl = '';
+  @state() private avatarChangeUrl = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      gr-avatar {
+        height: 120px;
+        width: 120px;
+        margin-right: var(--spacing-xs);
+        vertical-align: -0.25em;
+      }
+      div section.hide {
+        display: none;
+      }
+    `,
+  ];
+
+  override render() {
+    if (!this.account || this.loading) return nothing;
+    return html`<div class="gr-form-styles">
+      <section>
+        <span class="title"></span>
+        <span class="value">
+          <gr-avatar .account=${this.account} imageSize="120"></gr-avatar>
+        </span>
+      </section>
+      ${when(
+        this.avatarChangeUrl,
+        () => html` <section>
+          <span class="title"></span>
+          <span class="value">
+            <a href=${this.avatarChangeUrl}> Change avatar </a>
+          </span>
+        </section>`
+      )}
+      <section>
+        <span class="title">ID</span>
+        <span class="value">${this.account._account_id}</span>
+      </section>
+      <section>
+        <span class="title">Email</span>
+        <span class="value">${this.account.email}</span>
+      </section>
+      <section>
+        <span class="title">Registered</span>
+        <span class="value">
+          <gr-date-formatter
+            withTooltip
+            .dateStr=${this.account.registered_on}
+          ></gr-date-formatter>
+        </span>
+      </section>
+      <section id="usernameSection">
+        <span class="title">Username</span>
+        ${when(
+          this.usernameMutable,
+          () => html`<span class="value">
+            <iron-input
+              @keydown=${this.handleKeydown}
+              .bindValue=${this.username}
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                if (!this.username || this.username === e.detail.value) return;
+                this.username = e.detail.value;
+                this.hasUsernameChange = true;
+              }}
+              id="usernameIronInput"
+            >
+              <input
+                id="usernameInput"
+                ?disabled=${this.saving}
+                @keydown=${this.handleKeydown}
+              />
+            </iron-input>
+          </span>`,
+          () => html`<span class="value">${this.username}</span>`
+        )}
+      </section>
+      <section id="nameSection">
+        <label class="title" for="nameInput">Full name</label>
+        ${when(
+          this.nameMutable,
+          () => html`<span class="value">
+            <iron-input
+              @keydown=${this.handleKeydown}
+              .bindValue=${this.account?.name}
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                const oldAccount = this.account;
+                if (!oldAccount || oldAccount.name === e.detail.value) return;
+                this.account = {...oldAccount, name: e.detail.value};
+                this.hasNameChange = true;
+              }}
+              id="nameIronInput"
+            >
+              <input
+                id="nameInput"
+                ?disabled=${this.saving}
+                @keydown=${this.handleKeydown}
+              />
+            </iron-input>
+          </span>`,
+          () => html` <span class="value">${this.account?.name}</span>`
+        )}
+      </section>
+      <section>
+        <label class="title" for="displayNameInput">Display name</label>
+        <span class="value">
+          <iron-input
+            @keydown=${this.handleKeydown}
+            .bindValue=${this.account.display_name}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              const oldAccount = this.account;
+              if (!oldAccount || oldAccount.display_name === e.detail.value) {
+                return;
+              }
+              this.account = {...oldAccount, display_name: e.detail.value};
+              this.hasDisplayNameChange = true;
+            }}
+          >
+            <input
+              id="displayNameInput"
+              ?disabled=${this.saving}
+              @keydown=${this.handleKeydown}
+            />
+          </iron-input>
+        </span>
+      </section>
+      <section>
+        <label class="title" for="statusInput">About me (e.g. employer)</label>
+        <span class="value">
+          <iron-input
+            id="statusIronInput"
+            @keydown=${this.handleKeydown}
+            .bindValue=${this.account?.status}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              const oldAccount = this.account;
+              if (!oldAccount || oldAccount.status === e.detail.value) return;
+              this.account = {...oldAccount, status: e.detail.value};
+              this.hasStatusChange = true;
+            }}
+          >
+            <input
+              id="statusInput"
+              ?disabled=${this.saving}
+              @keydown=${this.handleKeydown}
+            />
+          </iron-input>
+        </span>
+      </section>
+    </div>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('serverConfig')) {
+      this.usernameMutable = this.computeUsernameMutable();
+      this.nameMutable = this.computeNameMutable();
+    }
+    if (
+      changedProperties.has('hasNameChange') ||
+      changedProperties.has('hasUsernameChange') ||
+      changedProperties.has('hasStatusChange') ||
+      changedProperties.has('hasDisplayNameChange')
+    ) {
+      this.hasUnsavedChanges = this.computeHasUnsavedChanges();
+    }
+    if (changedProperties.has('hasUnsavedChanges')) {
+      fire(this, 'unsaved-changes-changed', {
+        value: this.hasUnsavedChanges,
+      });
+    }
+  }
+
   loadData() {
     const promises = [];
 
-    this._loading = true;
+    this.loading = true;
 
     promises.push(
       this.restApiService.getConfig().then(config => {
-        this._serverConfig = config;
+        this.serverConfig = config;
       })
     );
 
@@ -110,26 +262,26 @@
     promises.push(
       this.restApiService.getAccount().then(account => {
         if (!account) return;
-        this._hasNameChange = false;
-        this._hasUsernameChange = false;
-        this._hasDisplayNameChange = false;
-        this._hasStatusChange = false;
+        this.hasNameChange = false;
+        this.hasUsernameChange = false;
+        this.hasDisplayNameChange = false;
+        this.hasStatusChange = false;
         // Provide predefined value for username to trigger computation of
         // username mutability.
         account.username = account.username || '';
-        this._account = account;
-        this._username = account.username;
+        this.account = account;
+        this.username = account.username;
       })
     );
 
     promises.push(
       this.restApiService.getAvatarChangeUrl().then(url => {
-        this._avatarChangeUrl = url || '';
+        this.avatarChangeUrl = url || '';
       })
     );
 
     return Promise.all(promises).then(() => {
-      this._loading = false;
+      this.loading = false;
     });
   }
 
@@ -138,132 +290,89 @@
       return Promise.resolve();
     }
 
-    this._saving = true;
+    this.saving = true;
     // Set only the fields that have changed.
     // Must be done in sequence to avoid race conditions (@see Issue 5721)
-    return this._maybeSetName()
-      .then(() => this._maybeSetUsername())
-      .then(() => this._maybeSetDisplayName())
-      .then(() => this._maybeSetStatus())
+    return this.maybeSetName()
+      .then(() => this.maybeSetUsername())
+      .then(() => this.maybeSetDisplayName())
+      .then(() => this.maybeSetStatus())
       .then(() => {
-        this._hasNameChange = false;
-        this._hasDisplayNameChange = false;
-        this._hasStatusChange = false;
-        this._saving = false;
+        this.hasNameChange = false;
+        this.hasDisplayNameChange = false;
+        this.hasStatusChange = false;
+        this.saving = false;
         fireEvent(this, 'account-detail-update');
       });
   }
 
-  _maybeSetName() {
+  private maybeSetName() {
     // Note that we are intentionally not acting on this._account.name being the
     // empty string (which is falsy).
-    return this._hasNameChange && this.nameMutable && this._account?.name
-      ? this.restApiService.setAccountName(this._account.name)
+    return this.hasNameChange && this.nameMutable && this.account?.name
+      ? this.restApiService.setAccountName(this.account.name)
       : Promise.resolve();
   }
 
-  _maybeSetUsername() {
+  private maybeSetUsername() {
     // Note that we are intentionally not acting on this._username being the
     // empty string (which is falsy).
-    return this._hasUsernameChange && this.usernameMutable && this._username
-      ? this.restApiService.setAccountUsername(this._username)
+    return this.hasUsernameChange && this.usernameMutable && this.username
+      ? this.restApiService.setAccountUsername(this.username)
       : Promise.resolve();
   }
 
-  _maybeSetDisplayName() {
-    return this._hasDisplayNameChange &&
-      this._account?.display_name !== undefined
-      ? this.restApiService.setAccountDisplayName(this._account.display_name)
+  private maybeSetDisplayName() {
+    return this.hasDisplayNameChange && this.account?.display_name !== undefined
+      ? this.restApiService.setAccountDisplayName(this.account.display_name)
       : Promise.resolve();
   }
 
-  _maybeSetStatus() {
-    return this._hasStatusChange && this._account?.status !== undefined
-      ? this.restApiService.setAccountStatus(this._account.status)
+  private maybeSetStatus() {
+    return this.hasStatusChange && this.account?.status !== undefined
+      ? this.restApiService.setAccountStatus(this.account.status)
       : Promise.resolve();
   }
 
-  _computeHasUnsavedChanges(
-    nameChanged: boolean,
-    usernameChanged: boolean,
-    statusChanged: boolean,
-    displayNameChanged: boolean
-  ) {
+  private computeHasUnsavedChanges() {
     return (
-      nameChanged || usernameChanged || statusChanged || displayNameChanged
+      this.hasNameChange ||
+      this.hasUsernameChange ||
+      this.hasStatusChange ||
+      this.hasDisplayNameChange
     );
   }
 
-  _computeUsernameMutable(config: ServerInfo, username?: string) {
-    // Polymer 2: check for undefined
-    if ([config, username].includes(undefined)) {
-      return undefined;
-    }
-
+  private computeUsernameMutable() {
+    if (!this.serverConfig) return false;
     // Username may not be changed once it is set.
     return (
-      config.auth.editable_account_fields.includes(
+      this.serverConfig.auth.editable_account_fields.includes(
         EditableAccountField.USER_NAME
-      ) && !username
+      ) && !this.account?.username
     );
   }
 
-  _computeNameMutable(config: ServerInfo) {
-    return config.auth.editable_account_fields.includes(
+  private computeNameMutable() {
+    if (!this.serverConfig) return false;
+    return this.serverConfig.auth.editable_account_fields.includes(
       EditableAccountField.FULL_NAME
     );
   }
 
-  @observe('_account.status')
-  _statusChanged() {
-    if (this._loading) {
-      return;
-    }
-    this._hasStatusChange = true;
-  }
-
-  @observe('_account.display_name')
-  _displayNameChanged() {
-    if (this._loading) {
-      return;
-    }
-    this._hasDisplayNameChange = true;
-  }
-
-  _usernameChanged() {
-    if (this._loading || !this._account) {
-      return;
-    }
-    this._hasUsernameChange =
-      (this._account.username || '') !== (this._username || '');
-  }
-
-  @observe('_account.name')
-  _nameChanged() {
-    if (this._loading) {
-      return;
-    }
-    this._hasNameChange = true;
-  }
-
-  _handleKeydown(e: KeyboardEvent) {
+  private handleKeydown(e: KeyboardEvent) {
     if (e.keyCode === 13) {
       // Enter
       e.stopPropagation();
       this.save();
     }
   }
-
-  _hideAvatarChangeUrl(avatarChangeUrl: string) {
-    if (!avatarChangeUrl) {
-      return 'hide';
-    }
-
-    return '';
-  }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'unsaved-changes-changed': ValueChangedEvent<boolean>;
+  }
   interface HTMLElementTagNameMap {
     'gr-account-info': GrAccountInfo;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
deleted file mode 100644
index a6ea1f6..0000000
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-avatar {
-      height: 120px;
-      width: 120px;
-      margin-right: var(--spacing-xs);
-      vertical-align: -0.25em;
-    }
-    div section.hide {
-      display: none;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <section>
-      <span class="title"></span>
-      <span class="value">
-        <gr-avatar account="[[_account]]" imageSize="120"></gr-avatar>
-      </span>
-    </section>
-    <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
-      <span class="title"></span>
-      <span class="value">
-        <a href$="[[_avatarChangeUrl]]"> Change avatar </a>
-      </span>
-    </section>
-    <section>
-      <span class="title">ID</span>
-      <span class="value">[[_account._account_id]]</span>
-    </section>
-    <section>
-      <span class="title">Email</span>
-      <span class="value">[[_account.email]]</span>
-    </section>
-    <section>
-      <span class="title">Registered</span>
-      <span class="value">
-        <gr-date-formatter
-          withTooltip
-          date-str="[[_account.registered_on]]"
-        ></gr-date-formatter>
-      </span>
-    </section>
-    <section id="usernameSection">
-      <span class="title">Username</span>
-      <span hidden$="[[usernameMutable]]" class="value">[[_username]]</span>
-      <span hidden$="[[!usernameMutable]]" class="value">
-        <iron-input
-          on-keydown="_handleKeydown"
-          bind-value="{{_username}}"
-          id="usernameIronInput"
-        >
-          <input
-            id="usernameInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section id="nameSection">
-      <label class="title" for="nameInput">Full name</label>
-      <span hidden$="[[nameMutable]]" class="value">[[_account.name]]</span>
-      <span hidden$="[[!nameMutable]]" class="value">
-        <iron-input
-          on-keydown="_handleKeydown"
-          bind-value="{{_account.name}}"
-          id="nameIronInput"
-        >
-          <input
-            id="nameInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label class="title" for="displayNameInput">Display name</label>
-      <span class="value">
-        <iron-input
-          on-keydown="_handleKeydown"
-          bind-value="{{_account.display_name}}"
-        >
-          <input
-            id="displayNameInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label class="title" for="statusInput">About me (e.g. employer)</label>
-      <span class="value">
-        <iron-input
-          on-keydown="_handleKeydown"
-          bind-value="{{_account.status}}"
-        >
-          <input
-            id="statusInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-          />
-        </iron-input>
-      </span>
-    </section>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index a12d289..826046d 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -17,18 +17,20 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-account-info';
-import {SinonSpyMember, stubRestApi} from '../../../test/test-utils';
+import {query, queryAll, stubRestApi} from '../../../test/test-utils';
 import {GrAccountInfo} from './gr-account-info';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {
   createAccountDetailWithId,
   createAccountWithIdNameAndEmail,
+  createAuth,
   createPreferences,
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {IronInputElement} from '@polymer/iron-input';
 import {SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {EditableAccountField} from '../../../api/rest-api';
 
 const basicFixture = fixtureFromElement('gr-account-info');
 
@@ -38,13 +40,13 @@
   let config: ServerInfo;
 
   function queryIronInput(selector: string): IronInputElement {
-    const input = element.root?.querySelector<IronInputElement>(selector);
+    const input = query<IronInputElement>(element, selector);
     if (!input) assert.fail(`<iron-input> with id ${selector} not found.`);
     return input;
   }
 
   function valueOf(title: string): Element {
-    const sections = element.root?.querySelectorAll('section') ?? [];
+    const sections = queryAll<HTMLElement>(element, 'section') ?? [];
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
@@ -66,7 +68,7 @@
 
     element = basicFixture.instantiate();
     await element.loadData();
-    await flush();
+    await element.updateComplete;
   });
 
   test('renders', () => {
@@ -78,10 +80,6 @@
             <gr-avatar hidden="" imagesize="120"></gr-avatar>
           </span>
         </section>
-        <section class="hide">
-          <span class="title"></span>
-          <span class="value"><a href="">Change avatar</a></span>
-        </section>
         <section>
           <span class="title">ID</span>
           <span class="value">123</span>
@@ -99,20 +97,10 @@
         <section id="usernameSection">
           <span class="title">Username</span>
           <span class="value"></span>
-          <span class="value" hidden="true">
-            <iron-input id="usernameIronInput">
-              <input id="usernameInput" />
-            </iron-input>
-          </span>
         </section>
         <section id="nameSection">
           <label class="title" for="nameInput">Full name</label>
           <span class="value">User-123</span>
-          <span class="value" hidden="true">
-            <iron-input id="nameIronInput">
-              <input id="nameInput" />
-            </iron-input>
-          </span>
         </section>
         <section>
           <label class="title" for="displayNameInput">Display name</label>
@@ -127,7 +115,7 @@
             About me (e.g. employer)
           </label>
           <span class="value">
-            <iron-input>
+            <iron-input id="statusIronInput">
               <input id="statusInput" />
             </iron-input>
           </span>
@@ -137,7 +125,7 @@
   });
 
   test('basic account info render', () => {
-    assert.isFalse(element._loading);
+    assert.isFalse(element.loading);
 
     assert.equal(valueOf('ID').textContent, `${account._account_id}`);
     assert.equal(valueOf('Email').textContent, account.email);
@@ -145,55 +133,62 @@
   });
 
   test('full name render (immutable)', () => {
-    const section = element.$.nameSection;
+    const section = query<HTMLElement>(element, '#nameSection')!;
     const displaySpan = section.querySelectorAll('.value')[0];
     const inputSpan = section.querySelectorAll('.value')[1];
 
     assert.isFalse(element.nameMutable);
     assert.isFalse(displaySpan.hasAttribute('hidden'));
     assert.equal(displaySpan.textContent, account.name);
-    assert.isTrue(inputSpan.hasAttribute('hidden'));
+    assert.isUndefined(inputSpan);
   });
 
-  test('full name render (mutable)', () => {
-    element.set('_serverConfig', {
-      auth: {editable_account_fields: ['FULL_NAME']},
-    });
+  test('full name render (mutable)', async () => {
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        ...createAuth(),
+        editable_account_fields: [EditableAccountField.FULL_NAME],
+      },
+    };
 
-    const section = element.$.nameSection;
-    const displaySpan = section.querySelectorAll('.value')[0];
-    const inputSpan = section.querySelectorAll('.value')[1];
+    await element.updateComplete;
+    const section = query<HTMLElement>(element, '#nameSection')!;
+    const inputSpan = section.querySelectorAll('.value')[0];
 
     assert.isTrue(element.nameMutable);
-    assert.isTrue(displaySpan.hasAttribute('hidden'));
     assert.equal(queryIronInput('#nameIronInput').bindValue, account.name);
     assert.isFalse(inputSpan.hasAttribute('hidden'));
   });
 
   test('username render (immutable)', () => {
-    const section = element.$.usernameSection;
+    const section = query<HTMLElement>(element, '#usernameSection')!;
     const displaySpan = section.querySelectorAll('.value')[0];
     const inputSpan = section.querySelectorAll('.value')[1];
 
     assert.isFalse(element.usernameMutable);
     assert.isFalse(displaySpan.hasAttribute('hidden'));
     assert.equal(displaySpan.textContent, account.username);
-    assert.isTrue(inputSpan.hasAttribute('hidden'));
+    assert.isUndefined(inputSpan);
   });
 
-  test('username render (mutable)', () => {
-    element.set('_serverConfig', {
-      auth: {editable_account_fields: ['USER_NAME']},
-    });
-    element.set('_account.username', '');
-    element.set('_username', '');
+  test('username render (mutable)', async () => {
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        ...createAuth(),
+        editable_account_fields: [EditableAccountField.USER_NAME],
+      },
+    };
+    element.account!.username = '';
+    element.username = '';
 
-    const section = element.$.usernameSection;
-    const displaySpan = section.querySelectorAll('.value')[0];
-    const inputSpan = section.querySelectorAll('.value')[1];
+    await element.updateComplete;
+
+    const section = query<HTMLElement>(element, '#usernameSection')!;
+    const inputSpan = section.querySelectorAll('.value')[0];
 
     assert.isTrue(element.usernameMutable);
-    assert.isTrue(displaySpan.hasAttribute('hidden'));
     assert.equal(
       queryIronInput('#usernameIronInput').bindValue,
       account.username
@@ -202,21 +197,23 @@
   });
 
   suite('account info edit', () => {
-    let nameChangedSpy: SinonSpyMember<typeof element._nameChanged>;
-    let usernameChangedSpy: SinonSpyMember<typeof element._usernameChanged>;
-    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
     let nameStub: SinonStubbedMember<RestApiService['setAccountName']>;
     let usernameStub: SinonStubbedMember<RestApiService['setAccountUsername']>;
     let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
-    setup(() => {
-      nameChangedSpy = sinon.spy(element, '_nameChanged');
-      usernameChangedSpy = sinon.spy(element, '_usernameChanged');
-      statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig', {
-        auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']},
-      });
+    setup(async () => {
+      element.serverConfig = {
+        ...createServerInfo(),
+        auth: {
+          ...createAuth(),
+          editable_account_fields: [
+            EditableAccountField.FULL_NAME,
+            EditableAccountField.USER_NAME,
+          ],
+        },
+      };
 
+      await element.updateComplete;
       nameStub = stubRestApi('setAccountName').resolves();
       usernameStub = stubRestApi('setAccountUsername').resolves();
       statusStub = stubRestApi('setAccountStatus').resolves();
@@ -226,10 +223,11 @@
       assert.isTrue(element.nameMutable);
       assert.isFalse(element.hasUnsavedChanges);
 
-      element.set('_account.name', 'new name');
-
-      assert.isTrue(nameChangedSpy.called);
-      assert.isFalse(statusChangedSpy.called);
+      const statusInputEl = queryIronInput('#nameIronInput');
+      statusInputEl.bindValue = 'new name';
+      await element.updateComplete;
+      assert.isTrue(element.hasNameChange);
+      assert.isFalse(element.hasStatusChange);
       assert.isTrue(element.hasUnsavedChanges);
 
       await element.save();
@@ -241,14 +239,25 @@
     });
 
     test('username', async () => {
-      element.set('_account.username', '');
-      element._hasUsernameChange = false;
+      element.account!.username = '';
+      element.username = 't';
+      element.hasUsernameChange = false;
+      element.serverConfig = {
+        ...createServerInfo(),
+        auth: {
+          ...createAuth(),
+          editable_account_fields: [EditableAccountField.USER_NAME],
+        },
+      };
+      await element.updateComplete;
       assert.isTrue(element.usernameMutable);
 
-      element.set('_username', 'new username');
-
-      assert.isTrue(usernameChangedSpy.called);
-      assert.isFalse(statusChangedSpy.called);
+      const statusInputEl = queryIronInput('#usernameIronInput');
+      statusInputEl.bindValue = 'new username';
+      await element.updateComplete;
+      assert.isTrue(element.hasUsernameChange);
+      assert.isFalse(element.hasNameChange);
+      assert.isFalse(element.hasStatusChange);
       assert.isTrue(element.hasUnsavedChanges);
 
       await element.save();
@@ -262,10 +271,11 @@
     test('status', async () => {
       assert.isFalse(element.hasUnsavedChanges);
 
-      element.set('_account.status', 'new status');
-
-      assert.isFalse(nameChangedSpy.called);
-      assert.isTrue(statusChangedSpy.called);
+      const statusInputEl = queryIronInput('#statusIronInput');
+      statusInputEl.bindValue = 'new status';
+      await element.updateComplete;
+      assert.isFalse(element.hasNameChange);
+      assert.isTrue(element.hasStatusChange);
       assert.isTrue(element.hasUnsavedChanges);
 
       await element.save();
@@ -278,17 +288,18 @@
   });
 
   suite('edit name and status', () => {
-    let nameChangedSpy: SinonSpyMember<typeof element._nameChanged>;
-    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
     let nameStub: SinonStubbedMember<RestApiService['setAccountName']>;
     let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
-    setup(() => {
-      nameChangedSpy = sinon.spy(element, '_nameChanged');
-      statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig', {
-        auth: {editable_account_fields: ['FULL_NAME']},
-      });
+    setup(async () => {
+      element.serverConfig = {
+        ...createServerInfo(),
+        auth: {
+          ...createAuth(),
+          editable_account_fields: [EditableAccountField.FULL_NAME],
+        },
+      };
+      await element.updateComplete;
 
       nameStub = stubRestApi('setAccountName').resolves();
       statusStub = stubRestApi('setAccountStatus').resolves();
@@ -299,13 +310,15 @@
       assert.isTrue(element.nameMutable);
       assert.isFalse(element.hasUnsavedChanges);
 
-      element.set('_account.name', 'new name');
+      const inputEl = queryIronInput('#nameIronInput');
+      inputEl.bindValue = 'new name';
+      await element.updateComplete;
+      assert.isTrue(element.hasNameChange);
 
-      assert.isTrue(nameChangedSpy.called);
-
-      element.set('_account.status', 'new status');
-
-      assert.isTrue(statusChangedSpy.called);
+      const statusInputEl = queryIronInput('#statusIronInput');
+      statusInputEl.bindValue = 'new status';
+      await element.updateComplete;
+      assert.isTrue(element.hasStatusChange);
 
       assert.isTrue(element.hasUnsavedChanges);
 
@@ -320,18 +333,23 @@
   });
 
   suite('set status but read name', () => {
-    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
     let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
-    setup(() => {
-      statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig', {auth: {editable_account_fields: []}});
+    setup(async () => {
+      element.serverConfig = {
+        ...createServerInfo(),
+        auth: {
+          ...createAuth(),
+          editable_account_fields: [],
+        },
+      };
+      await element.updateComplete;
 
       statusStub = stubRestApi('setAccountStatus').resolves();
     });
 
     test('read full name but set status', async () => {
-      const section = element.$.nameSection;
+      const section = query<HTMLElement>(element, '#nameSection')!;
       const displaySpan = section.querySelectorAll('.value')[0];
       const inputSpan = section.querySelectorAll('.value')[1];
 
@@ -341,11 +359,12 @@
 
       assert.isFalse(displaySpan.hasAttribute('hidden'));
       assert.equal(displaySpan.textContent, account.name);
-      assert.isTrue(inputSpan.hasAttribute('hidden'));
+      assert.isUndefined(inputSpan);
 
-      element.set('_account.status', 'new status');
-
-      assert.isTrue(statusChangedSpy.called);
+      const inputEl = queryIronInput('#statusIronInput');
+      inputEl.bindValue = 'new status';
+      await element.updateComplete;
+      assert.isTrue(element.hasStatusChange);
 
       assert.isTrue(element.hasUnsavedChanges);
 
@@ -356,27 +375,27 @@
     });
   });
 
-  test('_usernameChanged compares usernames with loose equality', () => {
-    element._account = createAccountDetailWithId();
-    element._username = '';
-    element._hasUsernameChange = false;
-    element._loading = false;
-    // _usernameChanged is an observer, but call it here after setting
-    // _hasUsernameChange in the test to force recomputation.
-    element._usernameChanged();
-    flush();
+  test('_usernameChanged compares usernames with loose equality', async () => {
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        ...createAuth(),
+        editable_account_fields: [EditableAccountField.USER_NAME],
+      },
+    };
+    element.account = createAccountDetailWithId();
+    element.username = 't';
+    element.hasUsernameChange = false;
+    element.loading = false;
+    // usernameChanged is an observer, but call it here after setting
+    // hasUsernameChange in the test to force recomputation.
+    await element.updateComplete;
+    assert.isFalse(element.hasUsernameChange);
 
-    assert.isFalse(element._hasUsernameChange);
+    const inputEl = queryIronInput('#usernameIronInput');
+    inputEl.bindValue = 'test';
+    await element.updateComplete;
 
-    element.set('_username', 'test');
-    flush();
-
-    assert.isTrue(element._hasUsernameChange);
-  });
-
-  test('_hideAvatarChangeUrl', () => {
-    assert.equal(element._hideAvatarChangeUrl(''), 'hide');
-
-    assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
+    assert.isTrue(element.hasUsernameChange);
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 7a8a771..ca3f11d 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -16,12 +16,6 @@
  */
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-menu-page-styles';
-import '../../../styles/gr-page-nav-styles';
-import '../../../styles/gr-paper-styles';
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../gr-change-table-editor/gr-change-table-editor';
 import '../../shared/gr-button/gr-button';
@@ -39,10 +33,7 @@
 import '../gr-menu-editor/gr-menu-editor';
 import '../gr-ssh-editor/gr-ssh-editor';
 import '../gr-watched-projects-editor/gr-watched-projects-editor';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-settings-view_html';
 import {getDocsBaseUrl} from '../../../utils/url-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {AppElementParams} from '../../gr-app-types';
 import {GrAccountInfo} from '../gr-account-info/gr-account-info';
 import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
@@ -66,24 +57,16 @@
 } from '../../../constants/constants';
 import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
 import {windowLocationReload} from '../../../utils/dom-util';
-import {ValueChangedEvent} from '../../../types/events';
-
-const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
-  'changes_per_page',
-  'date_format',
-  'time_format',
-  'email_strategy',
-  'diff_view',
-  'publish_comments_on_push',
-  'disable_keyboard_shortcuts',
-  'disable_token_highlighting',
-  'work_in_progress_by_default',
-  'default_base_for_merges',
-  'signed_off_by',
-  'email_format',
-  'size_bar_in_change_table',
-  'relative_date_in_change_table',
-];
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {when} from 'lit/directives/when';
+import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
 
 const GERRIT_DOCS_BASE_URL =
   'https://gerrit-review.googlesource.com/' + 'Documentation';
@@ -98,39 +81,8 @@
   LocalPrefsToPrefs,
 }
 
-export interface GrSettingsView {
-  $: {
-    accountInfo: GrAccountInfo;
-    watchedProjectsEditor: GrWatchedProjectsEditor;
-    groupList: GrGroupList;
-    identities: GrIdentities;
-    diffPrefs: GrDiffPreferences;
-    sshEditor: GrSshEditor;
-    gpgEditor: GrGpgEditor;
-    emailEditor: GrEmailEditor;
-    insertSignedOff: HTMLInputElement;
-    workInProgressByDefault: HTMLInputElement;
-    showSizeBarsInFileList: HTMLInputElement;
-    publishCommentsOnPush: HTMLInputElement;
-    disableKeyboardShortcuts: HTMLInputElement;
-    disableTokenHighlighting: HTMLInputElement;
-    relativeDateInChangeTable: HTMLInputElement;
-    changesPerPageSelect: HTMLInputElement;
-    dateTimeFormatSelect: HTMLInputElement;
-    timeFormatSelect: HTMLInputElement;
-    emailNotificationsSelect: HTMLInputElement;
-    emailFormatSelect: HTMLInputElement;
-    defaultBaseForMergesSelect: HTMLInputElement;
-    diffViewSelect: HTMLInputElement;
-  };
-}
-
 @customElement('gr-settings-view')
-export class GrSettingsView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrSettingsView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
@@ -143,66 +95,106 @@
    * @event show-alert
    */
 
-  @property({type: Object})
-  prefs: PreferencesInput = {};
+  @query('#accountInfo', true) accountInfo!: GrAccountInfo;
 
-  @property({type: Object})
-  params?: AppElementParams;
+  @query('#watchedProjectsEditor', true)
+  watchedProjectsEditor!: GrWatchedProjectsEditor;
 
-  @property({type: Boolean})
-  _accountInfoChanged?: boolean;
+  @query('#groupList', true) groupList!: GrGroupList;
 
-  @property({type: Object})
-  _localPrefs: PreferencesInput = {};
+  @query('#identities', true) identities!: GrIdentities;
 
-  @property({type: Array})
-  _localChangeTableColumns: string[] = [];
+  @query('#diffPrefs') diffPrefs!: GrDiffPreferences;
 
-  @property({type: Boolean})
-  _loading = true;
+  @query('#sshEditor') sshEditor?: GrSshEditor;
 
-  @property({type: Boolean})
-  _changeTableChanged = false;
+  @query('#gpgEditor') gpgEditor?: GrGpgEditor;
 
-  @property({type: Boolean})
-  _prefsChanged = false;
+  @query('#emailEditor', true) emailEditor!: GrEmailEditor;
 
-  @property({type: Boolean})
-  _diffPrefsChanged = false;
+  @query('#insertSignedOff') insertSignedOff!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _watchedProjectsChanged = false;
+  @query('#workInProgressByDefault') workInProgressByDefault!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _keysChanged = false;
+  @query('#showSizeBarsInFileList') showSizeBarsInFileList!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _gpgKeysChanged = false;
+  @query('#publishCommentsOnPush') publishCommentsOnPush!: HTMLInputElement;
 
-  @property({type: String})
-  _newEmail?: string;
+  @query('#disableKeyboardShortcuts')
+  disableKeyboardShortcuts!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _addingEmail = false;
+  @query('#disableTokenHighlighting')
+  disableTokenHighlighting!: HTMLInputElement;
 
-  @property({type: String})
-  _lastSentVerificationEmail?: string | null = null;
+  @query('#relativeDateInChangeTable')
+  relativeDateInChangeTable!: HTMLInputElement;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  @query('#changesPerPageSelect') changesPerPageSelect!: HTMLInputElement;
 
-  @property({type: String})
-  _docsBaseUrl?: string | null;
+  @query('#dateTimeFormatSelect') dateTimeFormatSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _emailsChanged = false;
+  @query('#timeFormatSelect') timeFormatSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _showNumber?: boolean;
+  @query('#emailNotificationsSelect')
+  emailNotificationsSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _isDark = false;
+  @query('#emailFormatSelect') emailFormatSelect!: HTMLInputElement;
 
+  @query('#defaultBaseForMergesSelect')
+  defaultBaseForMergesSelect!: HTMLInputElement;
+
+  @query('#diffViewSelect') diffViewSelect!: HTMLInputElement;
+
+  @state() prefs: PreferencesInput = {};
+
+  @property({type: Object}) params?: AppElementParams;
+
+  @state() private accountInfoChanged = false;
+
+  @state() private localPrefs: PreferencesInput = {};
+
+  // private but used in test
+  @state() localChangeTableColumns: string[] = [];
+
+  @state() private loading = true;
+
+  @state() private changeTableChanged = false;
+
+  // private but used in test
+  @state() prefsChanged = false;
+
+  @state() private diffPrefsChanged = false;
+
+  @state() private watchedProjectsChanged = false;
+
+  @state() private keysChanged = false;
+
+  @state() private gpgKeysChanged = false;
+
+  // private but used in test
+  @state() newEmail?: string;
+
+  // private but used in test
+  @state() addingEmail = false;
+
+  // private but used in test
+  @state() lastSentVerificationEmail?: string | null = null;
+
+  // private but used in test
+  @state() serverConfig?: ServerInfo;
+
+  // private but used in test
+  @state() docsBaseUrl?: string | null;
+
+  @state() private emailsChanged = false;
+
+  // private but used in test
+  @state() showNumber?: boolean;
+
+  // private but used in test
+  @state() isDark = false;
+
+  // private but used in test
   public _testOnly_loadingPromise?: Promise<void>;
 
   private readonly restApiService = getAppContext().restApiService;
@@ -213,14 +205,16 @@
     // we need to manually calling scrollIntoView when hash changed
     window.addEventListener('location-change', this.handleLocationChange);
     fireTitleChange(this, 'Settings');
+  }
 
-    this._isDark = !!window.localStorage.getItem('dark-theme');
+  override firstUpdated() {
+    this.isDark = !!window.localStorage.getItem('dark-theme');
 
     const promises: Array<Promise<unknown>> = [
-      this.$.accountInfo.loadData(),
-      this.$.watchedProjectsEditor.loadData(),
-      this.$.groupList.loadData(),
-      this.$.identities.loadData(),
+      this.accountInfo.loadData(),
+      this.watchedProjectsEditor.loadData(),
+      this.groupList.loadData(),
+      this.identities.loadData(),
     ];
 
     // TODO(dhruvsri): move this to the service
@@ -230,9 +224,9 @@
           throw new Error('getPreferences returned undefined');
         }
         this.prefs = prefs;
-        this._showNumber = !!prefs.legacycid_in_change_table;
-        this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
-        this._localChangeTableColumns =
+        this.showNumber = !!prefs.legacycid_in_change_table;
+        this.copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
+        this.localChangeTableColumns =
           prefs.change_table.length === 0
             ? columnNames
             : prefs.change_table.map(column =>
@@ -243,24 +237,20 @@
 
     promises.push(
       this.restApiService.getConfig().then(config => {
-        this._serverConfig = config;
+        this.serverConfig = config;
         const configPromises: Array<Promise<void>> = [];
 
-        if (this._serverConfig && this._serverConfig.sshd) {
-          configPromises.push(this.$.sshEditor.loadData());
+        if (this.serverConfig?.sshd && this.sshEditor) {
+          configPromises.push(this.sshEditor.loadData());
         }
 
-        if (
-          this._serverConfig &&
-          this._serverConfig.receive &&
-          this._serverConfig.receive.enable_signed_push
-        ) {
-          configPromises.push(this.$.gpgEditor.loadData());
+        if (this.serverConfig?.receive?.enable_signed_push && this.gpgEditor) {
+          configPromises.push(this.gpgEditor.loadData());
         }
 
         configPromises.push(
           getDocsBaseUrl(config, this.restApiService).then(baseUrl => {
-            this._docsBaseUrl = baseUrl;
+            this.docsBaseUrl = baseUrl;
           })
         );
 
@@ -280,34 +270,728 @@
             if (message) {
               fireAlert(this, message);
             }
-            this.$.emailEditor.loadData();
+            this.emailEditor.loadData();
           })
       );
     } else {
-      promises.push(this.$.emailEditor.loadData());
+      promises.push(this.emailEditor.loadData());
     }
 
     this._testOnly_loadingPromise = Promise.all(promises).then(() => {
-      this._loading = false;
+      this.loading = false;
 
       // Handle anchor tag for initial load
       this.handleLocationChange();
     });
   }
 
+  static override styles = [
+    sharedStyles,
+    paperStyles,
+    fontStyles,
+    formStyles,
+    menuPageStyles,
+    pageNavStyles,
+    css`
+      :host {
+        color: var(--primary-text-color);
+      }
+      h2 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h2);
+        font-weight: var(--font-weight-h2);
+        line-height: var(--line-height-h2);
+      }
+      .newEmailInput {
+        width: 20em;
+      }
+      #email {
+        margin-bottom: var(--spacing-l);
+      }
+      .main section.darkToggle {
+        display: block;
+      }
+      .filters p,
+      .darkToggle p {
+        margin-bottom: var(--spacing-l);
+      }
+      .queryExample em {
+        color: violet;
+      }
+      .toggle {
+        align-items: center;
+        display: flex;
+        margin-bottom: var(--spacing-l);
+        margin-right: var(--spacing-l);
+      }
+    `,
+  ];
+
+  override render() {
+    const isLoading = this.loading || this.loading === undefined;
+    return html`<div class="loading" ?hidden=${!isLoading}>Loading...</div>
+      <div ?hidden=${isLoading}>
+        <gr-page-nav class="navStyles">
+          <ul>
+            <li><a href="#Profile">Profile</a></li>
+            <li><a href="#Preferences">Preferences</a></li>
+            <li><a href="#DiffPreferences">Diff Preferences</a></li>
+            <li><a href="#EditPreferences">Edit Preferences</a></li>
+            <li><a href="#Menu">Menu</a></li>
+            <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
+            <li><a href="#Notifications">Notifications</a></li>
+            <li><a href="#EmailAddresses">Email Addresses</a></li>
+            ${when(
+              this.showHttpAuth(),
+              () =>
+                html`<li><a href="#HTTPCredentials">HTTP Credentials</a></li>`
+            )}
+            ${when(
+              this.serverConfig?.sshd,
+              () => html`<li><a href="#SSHKeys"> SSH Keys </a></li>`
+            )}
+            ${when(
+              this.serverConfig?.receive?.enable_signed_push,
+              () => html`<li><a href="#GPGKeys"> GPG Keys </a></li>`
+            )}
+            <li><a href="#Groups">Groups</a></li>
+            <li><a href="#Identities">Identities</a></li>
+            ${when(
+              this.serverConfig?.auth.use_contributor_agreements,
+              () => html`<li><a href="#Agreements">Agreements</a></li>`
+            )}
+            <li><a href="#MailFilters">Mail Filters</a></li>
+            <gr-endpoint-decorator name="settings-menu-item">
+            </gr-endpoint-decorator>
+          </ul>
+        </gr-page-nav>
+        <div class="main gr-form-styles">
+          <h1 class="heading-1">User Settings</h1>
+          <h2 id="Theme">Theme</h2>
+          <section class="darkToggle">
+            <div class="toggle">
+              <paper-toggle-button
+                aria-labelledby="darkThemeToggleLabel"
+                ?checked=${this.isDark}
+                @change=${this.handleToggleDark}
+                @click=${this.onTapDarkToggle}
+              ></paper-toggle-button>
+              <div id="darkThemeToggleLabel">
+                Dark theme (the toggle reloads the page)
+              </div>
+            </div>
+          </section>
+          <h2
+            id="Profile"
+            class=${this.computeHeaderClass(this.accountInfoChanged)}
+          >
+            Profile
+          </h2>
+          <fieldset id="profile">
+            <gr-account-info
+              id="accountInfo"
+              ?hasUnsavedChanges=${this.accountInfoChanged}
+              @unsaved-changes-changed=${(e: ValueChangedEvent<boolean>) => {
+                this.accountInfoChanged = e.detail.value;
+              }}
+            ></gr-account-info>
+            <gr-button
+              @click=${() => {
+                this.accountInfo.save();
+              }}
+              ?disabled=${!this.accountInfoChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="Preferences"
+            class=${this.computeHeaderClass(this.prefsChanged)}
+          >
+            Preferences
+          </h2>
+          <fieldset id="preferences">
+            <section>
+              <label class="title" for="changesPerPageSelect"
+                >Changes per page</label
+              >
+              <span class="value">
+                <gr-select
+                  .bindValue=${this.convertToString(
+                    this.localPrefs.changes_per_page
+                  )}
+                  @change=${() => {
+                    this.localPrefs.changes_per_page = Number(
+                      this.changesPerPageSelect.value
+                    ) as 10 | 25 | 50 | 100;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="changesPerPageSelect">
+                    <option value="10">10 rows per page</option>
+                    <option value="25">25 rows per page</option>
+                    <option value="50">50 rows per page</option>
+                    <option value="100">100 rows per page</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="dateTimeFormatSelect"
+                >Date/time format</label
+              >
+              <span class="value">
+                <gr-select
+                  .bindValue=${this.convertToString(
+                    this.localPrefs.date_format
+                  )}
+                  @change=${() => {
+                    this.localPrefs.date_format = this.dateTimeFormatSelect
+                      .value as DateFormat;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="dateTimeFormatSelect">
+                    <option value="STD">Jun 3 ; Jun 3, 2016</option>
+                    <option value="US">06/03 ; 06/03/16</option>
+                    <option value="ISO">06-03 ; 2016-06-03</option>
+                    <option value="EURO">3. Jun ; 03.06.2016</option>
+                    <option value="UK">03/06 ; 03/06/2016</option>
+                  </select>
+                </gr-select>
+                <gr-select
+                  .bindValue=${this.convertToString(
+                    this.localPrefs.time_format
+                  )}
+                  aria-label="Time Format"
+                  @change=${() => {
+                    this.localPrefs.time_format = this.timeFormatSelect
+                      .value as TimeFormat;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="timeFormatSelect">
+                    <option value="HHMM_12">4:10 PM</option>
+                    <option value="HHMM_24">16:10</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="emailNotificationsSelect"
+                >Email notifications</label
+              >
+              <span class="value">
+                <gr-select
+                  .bindValue=${this.convertToString(
+                    this.localPrefs.email_strategy
+                  )}
+                  @change=${() => {
+                    this.localPrefs.email_strategy = this
+                      .emailNotificationsSelect.value as EmailStrategy;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="emailNotificationsSelect">
+                    <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+                    <option value="ENABLED">
+                      Only comments left by others
+                    </option>
+                    <option value="ATTENTION_SET_ONLY">
+                      Only when I am in the attention set
+                    </option>
+                    <option value="DISABLED">None</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section
+              ?hidden=${!this.convertToString(this.localPrefs.email_format)}
+            >
+              <label class="title" for="emailFormatSelect">Email format</label>
+              <span class="value">
+                <gr-select
+                  .bindValue=${this.convertToString(
+                    this.localPrefs.email_format
+                  )}
+                  @change=${() => {
+                    this.localPrefs.email_format = this.emailFormatSelect
+                      .value as EmailFormat;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="emailFormatSelect">
+                    <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+                    <option value="PLAINTEXT">Plaintext only</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section ?hidden=${!this.localPrefs.default_base_for_merges}>
+              <span class="title">Default Base For Merges</span>
+              <span class="value">
+                <gr-select
+                  .bindValue=${this.convertToString(
+                    this.localPrefs.default_base_for_merges
+                  )}
+                  @change=${() => {
+                    this.localPrefs.default_base_for_merges = this
+                      .defaultBaseForMergesSelect.value as DefaultBase;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="defaultBaseForMergesSelect">
+                    <option value="AUTO_MERGE">Auto Merge</option>
+                    <option value="FIRST_PARENT">First Parent</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="relativeDateInChangeTable"
+                >Show Relative Dates In Changes Table</label
+              >
+              <span class="value">
+                <input
+                  id="relativeDateInChangeTable"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.relative_date_in_change_table}
+                  @change=${() => {
+                    this.localPrefs.relative_date_in_change_table =
+                      this.relativeDateInChangeTable.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <section>
+              <span class="title">Diff view</span>
+              <span class="value">
+                <gr-select
+                  .bindValue=${this.convertToString(this.localPrefs.diff_view)}
+                  @change=${() => {
+                    this.localPrefs.diff_view = this.diffViewSelect
+                      .value as DiffViewMode;
+                    this.prefsChanged = true;
+                  }}
+                >
+                  <select id="diffViewSelect">
+                    <option value="SIDE_BY_SIDE">Side by side</option>
+                    <option value="UNIFIED_DIFF">Unified diff</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label for="showSizeBarsInFileList" class="title"
+                >Show size bars in file list</label
+              >
+              <span class="value">
+                <input
+                  id="showSizeBarsInFileList"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.size_bar_in_change_table}
+                  @change=${() => {
+                    this.localPrefs.size_bar_in_change_table =
+                      this.showSizeBarsInFileList.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <section>
+              <label for="publishCommentsOnPush" class="title"
+                >Publish comments on push</label
+              >
+              <span class="value">
+                <input
+                  id="publishCommentsOnPush"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.publish_comments_on_push}
+                  @change=${() => {
+                    this.localPrefs.publish_comments_on_push =
+                      this.publishCommentsOnPush.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <section>
+              <label for="workInProgressByDefault" class="title"
+                >Set new changes to "work in progress" by default</label
+              >
+              <span class="value">
+                <input
+                  id="workInProgressByDefault"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.work_in_progress_by_default}
+                  @change=${() => {
+                    this.localPrefs.work_in_progress_by_default =
+                      this.workInProgressByDefault.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <section>
+              <label for="disableKeyboardShortcuts" class="title"
+                >Disable all keyboard shortcuts</label
+              >
+              <span class="value">
+                <input
+                  id="disableKeyboardShortcuts"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.disable_keyboard_shortcuts}
+                  @change=${() => {
+                    this.localPrefs.disable_keyboard_shortcuts =
+                      this.disableKeyboardShortcuts.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <section>
+              <label for="disableTokenHighlighting" class="title"
+                >Disable token highlighting on hover</label
+              >
+              <span class="value">
+                <input
+                  id="disableTokenHighlighting"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.disable_token_highlighting}
+                  @change=${() => {
+                    this.localPrefs.disable_token_highlighting =
+                      this.disableTokenHighlighting.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <section>
+              <label for="insertSignedOff" class="title">
+                Insert Signed-off-by Footer For Inline Edit Changes
+              </label>
+              <span class="value">
+                <input
+                  id="insertSignedOff"
+                  type="checkbox"
+                  ?checked=${this.localPrefs.signed_off_by}
+                  @change=${() => {
+                    this.localPrefs.signed_off_by =
+                      this.insertSignedOff.checked;
+                    this.prefsChanged = true;
+                  }}
+                />
+              </span>
+            </section>
+            <gr-button
+              id="savePrefs"
+              @click=${this.handleSavePreferences}
+              ?disabled=${!this.prefsChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="DiffPreferences"
+            class=${this.computeHeaderClass(this.diffPrefsChanged)}
+          >
+            Diff Preferences
+          </h2>
+          <fieldset id="diffPreferences">
+            <gr-diff-preferences
+              id="diffPrefs"
+              @has-unsaved-changes-changed=${(
+                e: ValueChangedEvent<boolean>
+              ) => {
+                this.diffPrefsChanged = e.detail.value;
+              }}
+            ></gr-diff-preferences>
+            <gr-button
+              id="saveDiffPrefs"
+              @click=${() => {
+                this.diffPrefs.save();
+              }}
+              ?disabled=${!this.diffPrefsChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <gr-edit-preferences id="editPrefs"></gr-edit-preferences>
+          <gr-menu-editor></gr-menu-editor>
+          <h2
+            id="ChangeTableColumns"
+            class=${this.computeHeaderClass(this.changeTableChanged)}
+          >
+            Change Table Columns
+          </h2>
+          <fieldset id="changeTableColumns">
+            <gr-change-table-editor
+              .showNumber=${this.showNumber}
+              @show-number-changed=${(e: ValueChangedEvent<boolean>) => {
+                this.showNumber = e.detail.value;
+                this.changeTableChanged = true;
+              }}
+              .serverConfig=${this.serverConfig}
+              .defaultColumns=${this.localChangeTableColumns}
+              @displayed-columns-changed=${(e: ValueChangedEvent<string[]>) => {
+                this.localChangeTableColumns = e.detail.value;
+                this.changeTableChanged = true;
+              }}
+            >
+            </gr-change-table-editor>
+            <gr-button
+              id="saveChangeTable"
+              @click=${this.handleSaveChangeTable}
+              ?disabled=${!this.changeTableChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="Notifications"
+            class=${this.computeHeaderClass(this.watchedProjectsChanged)}
+          >
+            Notifications
+          </h2>
+          <fieldset id="watchedProjects">
+            <gr-watched-projects-editor
+              ?hasUnsavedChanges=${this.watchedProjectsChanged}
+              @has-unsaved-changes-changed=${(
+                e: ValueChangedEvent<boolean>
+              ) => {
+                this.watchedProjectsChanged = e.detail.value;
+              }}
+              id="watchedProjectsEditor"
+            ></gr-watched-projects-editor>
+            <gr-button
+              @click=${() => {
+                this.watchedProjectsEditor.save();
+              }}
+              ?disabled=${!this.watchedProjectsChanged}
+              id="_handleSaveWatchedProjects"
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="EmailAddresses"
+            class=${this.computeHeaderClass(this.emailsChanged)}
+          >
+            Email Addresses
+          </h2>
+          <fieldset id="email">
+            <gr-email-editor
+              id="emailEditor"
+              ?hasUnsavedChanges=${this.emailsChanged}
+              @has-unsaved-changes-changed=${(
+                e: ValueChangedEvent<boolean>
+              ) => {
+                this.emailsChanged = e.detail.value;
+              }}
+            ></gr-email-editor>
+            <gr-button
+              @click=${() => {
+                this.emailEditor.save();
+              }}
+              ?disabled=${!this.emailsChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <fieldset id="newEmail">
+            <section>
+              <span class="title">New email address</span>
+              <span class="value">
+                <iron-input
+                  class="newEmailInput"
+                  .bindValue=${this.newEmail}
+                  @bind-value-changed=${(e: BindValueChangeEvent) => {
+                    this.newEmail = e.detail.value;
+                  }}
+                  @keydown=${this.handleNewEmailKeydown}
+                >
+                  <input
+                    class="newEmailInput"
+                    type="text"
+                    ?disabled=${this.addingEmail}
+                    @keydown=${this.handleNewEmailKeydown}
+                    placeholder="email@example.com"
+                  />
+                </iron-input>
+              </span>
+            </section>
+            <section
+              id="verificationSentMessage"
+              ?hidden=${!this.lastSentVerificationEmail}
+            >
+              <p>
+                A verification email was sent to
+                <em>${this.lastSentVerificationEmail}</em>. Please check your
+                inbox.
+              </p>
+            </section>
+            <gr-button
+              ?disabled=${!this.computeAddEmailButtonEnabled()}
+              @click=${this.handleAddEmailButton}
+              >Send verification</gr-button
+            >
+          </fieldset>
+          ${when(
+            this.showHttpAuth(),
+            () => html` <div>
+              <h2 id="HTTPCredentials">HTTP Credentials</h2>
+              <fieldset>
+                <gr-http-password id="httpPass"></gr-http-password>
+              </fieldset>
+            </div>`
+          )}
+          ${when(
+            this.serverConfig?.sshd,
+            () => html`<h2
+                id="SSHKeys"
+                class=${this.computeHeaderClass(this.keysChanged)}
+              >
+                SSH keys
+              </h2>
+              <gr-ssh-editor
+                id="sshEditor"
+                ?hasUnsavedChanges=${this.keysChanged}
+                @has-unsaved-changes-changed=${(
+                  e: ValueChangedEvent<boolean>
+                ) => {
+                  this.keysChanged = e.detail.value;
+                }}
+              ></gr-ssh-editor>`
+          )}
+          ${when(
+            this.serverConfig?.receive?.enable_signed_push,
+            () => html`<div>
+              <h2
+                id="GPGKeys"
+                class=${this.computeHeaderClass(this.gpgKeysChanged)}
+              >
+                GPG keys
+              </h2>
+              <gr-gpg-editor
+                id="gpgEditor"
+                ?hasUnsavedChanges=${this.gpgKeysChanged}
+                @has-unsaved-changes-changed=${(
+                  e: ValueChangedEvent<boolean>
+                ) => {
+                  this.gpgKeysChanged = e.detail.value;
+                }}
+              ></gr-gpg-editor>
+            </div>`
+          )}
+          <h2 id="Groups">Groups</h2>
+          <fieldset>
+            <gr-group-list id="groupList"></gr-group-list>
+          </fieldset>
+          <h2 id="Identities">Identities</h2>
+          <fieldset>
+            <gr-identities
+              id="identities"
+              .serverConfig=${this.serverConfig}
+            ></gr-identities>
+          </fieldset>
+          ${when(
+            this.serverConfig?.auth.use_contributor_agreements,
+            () => html`<h2 id="Agreements">Agreements</h2>
+              <fieldset>
+                <gr-agreements-list id="agreementsList"></gr-agreements-list>
+              </fieldset>`
+          )}
+          <h2 id="MailFilters">Mail Filters</h2>
+          <fieldset class="filters">
+            <p>
+              Gerrit emails include metadata about the change to support writing
+              mail filters.
+            </p>
+            <p>
+              Here are some example Gmail queries that can be used for filters
+              or for searching through archived messages. View the
+              <a
+                href=${this.getFilterDocsLink(this.docsBaseUrl)}
+                target="_blank"
+                rel="nofollow"
+                >Gerrit documentation</a
+              >
+              for the complete set of footers.
+            </p>
+            <table>
+              <tbody>
+                <tr>
+                  <th>Name</th>
+                  <th>Query</th>
+                </tr>
+                <tr>
+                  <td>Changes requesting my review</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Reviewer: <em>Your Name</em>
+                      &lt;<em>your.email@example.com</em>&gt;"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes requesting my attention</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Attention: <em>Your Name</em>
+                      &lt;<em>your.email@example.com</em>&gt;"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes from a specific owner</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Owner: <em>Owner name</em>
+                      &lt;<em>owner.email@example.com</em>&gt;"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes targeting a specific branch</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Branch: <em>branch-name</em>"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes in a specific project</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Project: <em>project-name</em>"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific Change ID</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Id: <em>Change ID</em>"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific change number</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Number: <em>change number</em>"
+                    </code>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </fieldset>
+          <gr-endpoint-decorator name="settings-screen">
+          </gr-endpoint-decorator>
+        </div>
+      </div>`;
+  }
+
   override disconnectedCallback() {
     window.removeEventListener('location-change', this.handleLocationChange);
     super.disconnectedCallback();
   }
 
-  handleUnsavedChangesChanged(e: ValueChangedEvent) {
-    this._keysChanged = !!e.detail.value;
-  }
-
-  _handleGpgEditorHasSavedChanges(e: ValueChangedEvent<boolean>) {
-    this._gpgKeysChanged = e.detail.value;
-  }
-
   private readonly handleLocationChange = () => {
     // Handle anchor tag after dom attached
     const urlHash = window.location.hash;
@@ -321,176 +1005,82 @@
   };
 
   reloadAccountDetail() {
-    Promise.all([this.$.accountInfo.loadData(), this.$.emailEditor.loadData()]);
+    Promise.all([this.accountInfo.loadData(), this.emailEditor.loadData()]);
   }
 
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _copyPrefs(direction: CopyPrefsDirection) {
-    let to;
-    let from;
+  private copyPrefs(direction: CopyPrefsDirection) {
     if (direction === CopyPrefsDirection.LocalPrefsToPrefs) {
-      from = this._localPrefs;
-      to = 'prefs';
+      this.prefs = {
+        ...this.localPrefs,
+      };
     } else {
-      from = this.prefs;
-      to = '_localPrefs';
-    }
-    for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
-      this.set([to, PREFS_SECTION_FIELDS[i]], from[PREFS_SECTION_FIELDS[i]]);
+      this.localPrefs = {
+        ...this.prefs,
+      };
     }
   }
 
-  @observe('_localChangeTableColumns', '_showNumber')
-  _handleChangeTableChanged() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._changeTableChanged = true;
-  }
-
-  @observe('_localPrefs.*')
-  _handlePrefsChanged() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._prefsChanged = true;
-  }
-
-  _handleRelativeDateInChangeTable() {
-    this.set(
-      '_localPrefs.relative_date_in_change_table',
-      this.$.relativeDateInChangeTable.checked
-    );
-  }
-
-  _handleShowSizeBarsInFileListChanged() {
-    this.set(
-      '_localPrefs.size_bar_in_change_table',
-      this.$.showSizeBarsInFileList.checked
-    );
-  }
-
-  _handlePublishCommentsOnPushChanged() {
-    this.set(
-      '_localPrefs.publish_comments_on_push',
-      this.$.publishCommentsOnPush.checked
-    );
-  }
-
-  _handleDisableKeyboardShortcutsChanged() {
-    this.set(
-      '_localPrefs.disable_keyboard_shortcuts',
-      this.$.disableKeyboardShortcuts.checked
-    );
-  }
-
-  _handleDisableTokenHighlightingChanged() {
-    this.set(
-      '_localPrefs.disable_token_highlighting',
-      this.$.disableTokenHighlighting.checked
-    );
-  }
-
-  _handleWorkInProgressByDefault() {
-    this.set(
-      '_localPrefs.work_in_progress_by_default',
-      this.$.workInProgressByDefault.checked
-    );
-  }
-
-  _handleInsertSignedOff() {
-    this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
-  }
-
-  _handleSaveAccountInfo() {
-    this.$.accountInfo.save();
-  }
-
-  _handleSavePreferences() {
-    this._copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
+  // private but used in test
+  handleSavePreferences() {
+    this.copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
 
     return this.restApiService.savePreferences(this.prefs).then(() => {
-      this._prefsChanged = false;
+      this.prefsChanged = false;
     });
   }
 
-  _handleSaveChangeTable() {
-    this.set('prefs.change_table', this._localChangeTableColumns);
-    this.set('prefs.legacycid_in_change_table', this._showNumber);
+  // private but used in test
+  handleSaveChangeTable() {
+    this.prefs.change_table = this.localChangeTableColumns;
+    this.prefs.legacycid_in_change_table = this.showNumber;
     return this.restApiService.savePreferences(this.prefs).then(() => {
-      this._changeTableChanged = false;
+      this.changeTableChanged = false;
     });
   }
 
-  _handleSaveDiffPreferences() {
-    this.$.diffPrefs.save();
-  }
-
-  _handleSaveWatchedProjects() {
-    this.$.watchedProjectsEditor.save();
-  }
-
-  _computeHeaderClass(changed?: boolean) {
+  private computeHeaderClass(changed?: boolean) {
     return changed ? 'edited' : '';
   }
 
-  _handleSaveEmails() {
-    this.$.emailEditor.save();
-  }
-
-  _handleNewEmailKeydown(e: KeyboardEvent) {
+  // private but used in test
+  handleNewEmailKeydown(e: KeyboardEvent) {
     if (e.keyCode === 13) {
       // Enter
       e.stopPropagation();
-      this._handleAddEmailButton();
+      this.handleAddEmailButton();
     }
   }
 
-  _isNewEmailValid(newEmail?: string): newEmail is string {
+  // private but used in test
+  isNewEmailValid(newEmail?: string): newEmail is string {
     return !!newEmail && newEmail.includes('@');
   }
 
-  _computeAddEmailButtonEnabled(newEmail?: string, addingEmail?: boolean) {
-    return this._isNewEmailValid(newEmail) && !addingEmail;
+  // private but used in test
+  computeAddEmailButtonEnabled() {
+    return this.isNewEmailValid(this.newEmail) && !this.addingEmail;
   }
 
-  _handleAddEmailButton() {
-    if (!this._isNewEmailValid(this._newEmail)) return;
+  // private but used in test
+  handleAddEmailButton() {
+    if (!this.isNewEmailValid(this.newEmail)) return;
 
-    this._addingEmail = true;
-    this.restApiService.addAccountEmail(this._newEmail).then(response => {
-      this._addingEmail = false;
+    this.addingEmail = true;
+    this.restApiService.addAccountEmail(this.newEmail).then(response => {
+      this.addingEmail = false;
 
       // If it was unsuccessful.
       if (response.status < 200 || response.status >= 300) {
         return;
       }
 
-      this._lastSentVerificationEmail = this._newEmail;
-      this._newEmail = '';
+      this.lastSentVerificationEmail = this.newEmail;
+      this.newEmail = '';
     });
   }
 
-  _handleShowNumberChanged(e: ValueChangedEvent<boolean>) {
-    this._showNumber = e.detail.value;
-  }
-
-  _handleDisplayedColumnsChanged(e: ValueChangedEvent<string[]>) {
-    this._localChangeTableColumns = e.detail.value;
-  }
-
-  _handleHasEmailsChanged(e: ValueChangedEvent<boolean>) {
-    this._emailsChanged = e.detail.value;
-  }
-
-  _handleHasProjectsChanged(e: ValueChangedEvent<boolean>) {
-    this._watchedProjectsChanged = e.detail.value;
-  }
-
-  _getFilterDocsLink(docsBaseUrl?: string | null) {
+  // private but used in test
+  getFilterDocsLink(docsBaseUrl?: string | null) {
     let base = docsBaseUrl;
     if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
       base = GERRIT_DOCS_BASE_URL;
@@ -502,8 +1092,8 @@
     return base + GERRIT_DOCS_FILTER_PATH;
   }
 
-  _handleToggleDark() {
-    if (this._isDark) {
+  private handleToggleDark() {
+    if (this.isDark) {
       window.localStorage.removeItem('dark-theme');
     } else {
       window.localStorage.setItem('dark-theme', 'true');
@@ -511,14 +1101,16 @@
     this.reloadPage();
   }
 
+  // private but used in test
   reloadPage() {
     windowLocationReload();
   }
 
-  _showHttpAuth(config?: ServerInfo) {
-    if (config && config.auth && config.auth.git_basic_auth_policy) {
+  // private but used in test
+  showHttpAuth() {
+    if (this.serverConfig?.auth?.git_basic_auth_policy) {
       return HTTP_AUTH.includes(
-        config.auth.git_basic_auth_policy.toUpperCase()
+        this.serverConfig.auth.git_basic_auth_policy.toUpperCase()
       );
     }
 
@@ -528,57 +1120,17 @@
   /**
    * Work around a issue on iOS when clicking turns into double tap
    */
-  _onTapDarkToggle(e: Event) {
+  private onTapDarkToggle(e: Event) {
     e.preventDefault();
   }
 
-  _handleChangesPerPage() {
-    this.set(
-      '_localPrefs.changes_per_page',
-      Number(this.$.changesPerPageSelect.value)
-    );
-  }
-
-  _handleDateFormat() {
-    this.set('_localPrefs.date_format', this.$.dateTimeFormatSelect.value);
-  }
-
-  _handleTimeFormat() {
-    this.set('_localPrefs.time_format', this.$.timeFormatSelect.value);
-  }
-
-  _handleEmailStrategy() {
-    this.set(
-      '_localPrefs.email_strategy',
-      this.$.emailNotificationsSelect.value
-    );
-  }
-
-  _handleEmailFormat() {
-    this.set('_localPrefs.email_format', this.$.emailFormatSelect.value);
-  }
-
-  _handleDefaultBaseForMerges() {
-    this.set(
-      '_localPrefs.default_base_for_merges',
-      this.$.defaultBaseForMergesSelect.value
-    );
-  }
-
-  _handleDiffView() {
-    this.set(
-      '_localPrefs.diff_view',
-      this.$.diffViewSelect.value as DiffViewMode
-    );
-  }
-
   /**
    * bind-value has type string so we have to convert anything inputed
    * to string.
    *
    * This is so typescript template checker doesn't fail.
    */
-  _convertToString(
+  private convertToString(
     key?:
       | DateFormat
       | DefaultBase
@@ -590,10 +1142,6 @@
   ) {
     return key !== undefined ? String(key) : '';
   }
-
-  _handleHasUnsavedChangesChanged(e: ValueChangedEvent<boolean>) {
-    this._diffPrefsChanged = e.detail.value;
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
deleted file mode 100644
index 00f85d8..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ /dev/null
@@ -1,590 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-paper-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      color: var(--primary-text-color);
-    }
-    h2 {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h2);
-      font-weight: var(--font-weight-h2);
-      line-height: var(--line-height-h2);
-    }
-    .newEmailInput {
-      width: 20em;
-    }
-    #email {
-      margin-bottom: var(--spacing-l);
-    }
-    .main section.darkToggle {
-      display: block;
-    }
-    .filters p,
-    .darkToggle p {
-      margin-bottom: var(--spacing-l);
-    }
-    .queryExample em {
-      color: violet;
-    }
-    .toggle {
-      align-items: center;
-      display: flex;
-      margin-bottom: var(--spacing-l);
-      margin-right: var(--spacing-l);
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-page-nav-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-  <div hidden$="[[_loading]]" hidden="">
-    <gr-page-nav class="navStyles">
-      <ul>
-        <li><a href="#Profile">Profile</a></li>
-        <li><a href="#Preferences">Preferences</a></li>
-        <li><a href="#DiffPreferences">Diff Preferences</a></li>
-        <li><a href="#EditPreferences">Edit Preferences</a></li>
-        <li><a href="#Menu">Menu</a></li>
-        <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
-        <li><a href="#Notifications">Notifications</a></li>
-        <li><a href="#EmailAddresses">Email Addresses</a></li>
-        <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
-          <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
-        </template>
-        <li hidden$="[[!_serverConfig.sshd]]">
-          <a href="#SSHKeys"> SSH Keys </a>
-        </li>
-        <li hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-          <a href="#GPGKeys"> GPG Keys </a>
-        </li>
-        <li><a href="#Groups">Groups</a></li>
-        <li><a href="#Identities">Identities</a></li>
-        <template
-          is="dom-if"
-          if="[[_serverConfig.auth.use_contributor_agreements]]"
-        >
-          <li>
-            <a href="#Agreements">Agreements</a>
-          </li>
-        </template>
-        <li><a href="#MailFilters">Mail Filters</a></li>
-        <gr-endpoint-decorator name="settings-menu-item">
-        </gr-endpoint-decorator>
-      </ul>
-    </gr-page-nav>
-    <div class="main gr-form-styles">
-      <h1 class="heading-1">User Settings</h1>
-      <h2 id="Theme">Theme</h2>
-      <section class="darkToggle">
-        <div class="toggle">
-          <paper-toggle-button
-            aria-labelledby="darkThemeToggleLabel"
-            checked="[[_isDark]]"
-            on-change="_handleToggleDark"
-            on-click="_onTapDarkToggle"
-          ></paper-toggle-button>
-          <div id="darkThemeToggleLabel">
-            Dark theme (the toggle reloads the page)
-          </div>
-        </div>
-      </section>
-      <h2 id="Profile" class$="[[_computeHeaderClass(_accountInfoChanged)]]">
-        Profile
-      </h2>
-      <fieldset id="profile">
-        <gr-account-info
-          id="accountInfo"
-          has-unsaved-changes="{{_accountInfoChanged}}"
-        ></gr-account-info>
-        <gr-button
-          on-click="_handleSaveAccountInfo"
-          disabled="[[!_accountInfoChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="Preferences" class$="[[_computeHeaderClass(_prefsChanged)]]">
-        Preferences
-      </h2>
-      <fieldset id="preferences">
-        <section>
-          <label class="title" for="changesPerPageSelect"
-            >Changes per page</label
-          >
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.changes_per_page)]]"
-              on-change="_handleChangesPerPage"
-            >
-              <select id="changesPerPageSelect">
-                <option value="10">10 rows per page</option>
-                <option value="25">25 rows per page</option>
-                <option value="50">50 rows per page</option>
-                <option value="100">100 rows per page</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="dateTimeFormatSelect"
-            >Date/time format</label
-          >
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.date_format)]]"
-              on-change="_handleDateFormat"
-            >
-              <select id="dateTimeFormatSelect">
-                <option value="STD">Jun 3 ; Jun 3, 2016</option>
-                <option value="US">06/03 ; 06/03/16</option>
-                <option value="ISO">06-03 ; 2016-06-03</option>
-                <option value="EURO">3. Jun ; 03.06.2016</option>
-                <option value="UK">03/06 ; 03/06/2016</option>
-              </select>
-            </gr-select>
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.time_format)]]"
-              aria-label="Time Format"
-              on-change="_handleTimeFormat"
-            >
-              <select id="timeFormatSelect">
-                <option value="HHMM_12">4:10 PM</option>
-                <option value="HHMM_24">16:10</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="emailNotificationsSelect"
-            >Email notifications</label
-          >
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.email_strategy)]]"
-              on-change="_handleEmailStrategy"
-            >
-              <select id="emailNotificationsSelect">
-                <option value="CC_ON_OWN_COMMENTS">Every comment</option>
-                <option value="ENABLED">Only comments left by others</option>
-                <option value="ATTENTION_SET_ONLY">
-                  Only when I am in the attention set
-                </option>
-                <option value="DISABLED">None</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section hidden$="[[!_convertToString(_localPrefs.email_format)]]">
-          <label class="title" for="emailFormatSelect">Email format</label>
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.email_format)]]"
-              on-change="_handleEmailFormat"
-            >
-              <select id="emailFormatSelect">
-                <option value="HTML_PLAINTEXT">HTML and plaintext</option>
-                <option value="PLAINTEXT">Plaintext only</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section hidden$="[[!_localPrefs.default_base_for_merges]]">
-          <span class="title">Default Base For Merges</span>
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.default_base_for_merges)]]"
-              on-change="_handleDefaultBaseForMerges"
-            >
-              <select id="defaultBaseForMergesSelect">
-                <option value="AUTO_MERGE">Auto Merge</option>
-                <option value="FIRST_PARENT">First Parent</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="relativeDateInChangeTable"
-            >Show Relative Dates In Changes Table</label
-          >
-          <span class="value">
-            <input
-              id="relativeDateInChangeTable"
-              type="checkbox"
-              checked$="[[_localPrefs.relative_date_in_change_table]]"
-              on-change="_handleRelativeDateInChangeTable"
-            />
-          </span>
-        </section>
-        <section>
-          <span class="title">Diff view</span>
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.diff_view)]]"
-              on-change="_handleDiffView"
-            >
-              <select id="diffViewSelect">
-                <option value="SIDE_BY_SIDE">Side by side</option>
-                <option value="UNIFIED_DIFF">Unified diff</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label for="showSizeBarsInFileList" class="title"
-            >Show size bars in file list</label
-          >
-          <span class="value">
-            <input
-              id="showSizeBarsInFileList"
-              type="checkbox"
-              checked$="[[_localPrefs.size_bar_in_change_table]]"
-              on-change="_handleShowSizeBarsInFileListChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="publishCommentsOnPush" class="title"
-            >Publish comments on push</label
-          >
-          <span class="value">
-            <input
-              id="publishCommentsOnPush"
-              type="checkbox"
-              checked$="[[_localPrefs.publish_comments_on_push]]"
-              on-change="_handlePublishCommentsOnPushChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="workInProgressByDefault" class="title"
-            >Set new changes to "work in progress" by default</label
-          >
-          <span class="value">
-            <input
-              id="workInProgressByDefault"
-              type="checkbox"
-              checked$="[[_localPrefs.work_in_progress_by_default]]"
-              on-change="_handleWorkInProgressByDefault"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="disableKeyboardShortcuts" class="title"
-            >Disable all keyboard shortcuts</label
-          >
-          <span class="value">
-            <input
-              id="disableKeyboardShortcuts"
-              type="checkbox"
-              checked$="[[_localPrefs.disable_keyboard_shortcuts]]"
-              on-change="_handleDisableKeyboardShortcutsChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="disableTokenHighlighting" class="title"
-            >Disable token highlighting on hover</label
-          >
-          <span class="value">
-            <input
-              id="disableTokenHighlighting"
-              type="checkbox"
-              checked$="[[_localPrefs.disable_token_highlighting]]"
-              on-change="_handleDisableTokenHighlightingChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="insertSignedOff" class="title">
-            Insert Signed-off-by Footer For Inline Edit Changes
-          </label>
-          <span class="value">
-            <input
-              id="insertSignedOff"
-              type="checkbox"
-              checked$="[[_localPrefs.signed_off_by]]"
-              on-change="_handleInsertSignedOff"
-            />
-          </span>
-        </section>
-        <gr-button
-          id="savePrefs"
-          on-click="_handleSavePreferences"
-          disabled="[[!_prefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="DiffPreferences"
-        class$="[[_computeHeaderClass(_diffPrefsChanged)]]"
-      >
-        Diff Preferences
-      </h2>
-      <fieldset id="diffPreferences">
-        <gr-diff-preferences
-          id="diffPrefs"
-          on-has-unsaved-changes-changed="_handleHasUnsavedChangesChanged"
-        ></gr-diff-preferences>
-        <gr-button
-          id="saveDiffPrefs"
-          on-click="_handleSaveDiffPreferences"
-          disabled$="[[!_diffPrefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <gr-edit-preferences id="editPrefs"></gr-edit-preferences>
-      <gr-menu-editor></gr-menu-editor>
-      <h2
-        id="ChangeTableColumns"
-        class$="[[_computeHeaderClass(_changeTableChanged)]]"
-      >
-        Change Table Columns
-      </h2>
-      <fieldset id="changeTableColumns">
-        <gr-change-table-editor
-          show-number="[[_showNumber]]"
-          on-show-number-changed="_handleShowNumberChanged"
-          server-config="[[_serverConfig]]"
-          displayed-columns="[[_localChangeTableColumns]]"
-          on-displayed-columns-changed="_handleDisplayedColumnsChanged"
-        >
-        </gr-change-table-editor>
-        <gr-button
-          id="saveChangeTable"
-          on-click="_handleSaveChangeTable"
-          disabled="[[!_changeTableChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="Notifications"
-        class$="[[_computeHeaderClass(_watchedProjectsChanged)]]"
-      >
-        Notifications
-      </h2>
-      <fieldset id="watchedProjects">
-        <gr-watched-projects-editor
-          has-unsaved-changes="[[_watchedProjectsChanged]]"
-          on-has-unsaved-changes-changed="_handleHasProjectsChanged"
-          id="watchedProjectsEditor"
-        ></gr-watched-projects-editor>
-        <gr-button
-          on-click="_handleSaveWatchedProjects"
-          disabled$="[[!_watchedProjectsChanged]]"
-          id="_handleSaveWatchedProjects"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="EmailAddresses" class$="[[_computeHeaderClass(_emailsChanged)]]">
-        Email Addresses
-      </h2>
-      <fieldset id="email">
-        <gr-email-editor
-          id="emailEditor"
-          has-unsaved-changes="[[_emailsChanged]]"
-          on-has-unsaved-changes-changed="_handleHasEmailsChanged"
-        ></gr-email-editor>
-        <gr-button on-click="_handleSaveEmails" disabled$="[[!_emailsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <fieldset id="newEmail">
-        <section>
-          <span class="title">New email address</span>
-          <span class="value">
-            <iron-input
-              class="newEmailInput"
-              bind-value="{{_newEmail}}"
-              type="text"
-              on-keydown="_handleNewEmailKeydown"
-              placeholder="email@example.com"
-            >
-              <input
-                class="newEmailInput"
-                type="text"
-                disabled="[[_addingEmail]]"
-                on-keydown="_handleNewEmailKeydown"
-                placeholder="email@example.com"
-              />
-            </iron-input>
-          </span>
-        </section>
-        <section
-          id="verificationSentMessage"
-          hidden$="[[!_lastSentVerificationEmail]]"
-        >
-          <p>
-            A verification email was sent to
-            <em>[[_lastSentVerificationEmail]]</em>. Please check your inbox.
-          </p>
-        </section>
-        <gr-button
-          disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
-          on-click="_handleAddEmailButton"
-          >Send verification</gr-button
-        >
-      </fieldset>
-      <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
-        <div>
-          <h2 id="HTTPCredentials">HTTP Credentials</h2>
-          <fieldset>
-            <gr-http-password id="httpPass"></gr-http-password>
-          </fieldset>
-        </div>
-      </template>
-      <div hidden$="[[!_serverConfig.sshd]]">
-        <h2 id="SSHKeys" class$="[[_computeHeaderClass(_keysChanged)]]">
-          SSH keys
-        </h2>
-        <gr-ssh-editor
-          id="sshEditor"
-          has-unsaved-changes-changed="handleUnsavedChangesChanged"
-        ></gr-ssh-editor>
-      </div>
-      <div hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-        <h2 id="GPGKeys" class$="[[_computeHeaderClass(_gpgKeysChanged)]]">
-          GPG keys
-        </h2>
-        <gr-gpg-editor
-          id="gpgEditor"
-          has-unsaved-changes="[[_gpgKeysChanged]]"
-          on-has-unsaved-changes-changed="_handleGpgEditorHasSavedChanges"
-        ></gr-gpg-editor>
-      </div>
-      <h2 id="Groups">Groups</h2>
-      <fieldset>
-        <gr-group-list id="groupList"></gr-group-list>
-      </fieldset>
-      <h2 id="Identities">Identities</h2>
-      <fieldset>
-        <gr-identities
-          id="identities"
-          server-config="[[_serverConfig]]"
-        ></gr-identities>
-      </fieldset>
-      <template
-        is="dom-if"
-        if="[[_serverConfig.auth.use_contributor_agreements]]"
-      >
-        <h2 id="Agreements">Agreements</h2>
-        <fieldset>
-          <gr-agreements-list id="agreementsList"></gr-agreements-list>
-        </fieldset>
-      </template>
-      <h2 id="MailFilters">Mail Filters</h2>
-      <fieldset class="filters">
-        <p>
-          Gerrit emails include metadata about the change to support writing
-          mail filters.
-        </p>
-        <p>
-          Here are some example Gmail queries that can be used for filters or
-          for searching through archived messages. View the
-          <a
-            href$="[[_getFilterDocsLink(_docsBaseUrl)]]"
-            target="_blank"
-            rel="nofollow"
-            >Gerrit documentation</a
-          >
-          for the complete set of footers.
-        </p>
-        <table>
-          <tbody>
-            <tr>
-              <th>Name</th>
-              <th>Query</th>
-            </tr>
-            <tr>
-              <td>Changes requesting my review</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Reviewer: <em>Your Name</em>
-                  &lt;<em>your.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes requesting my attention</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Attention: <em>Your Name</em>
-                  &lt;<em>your.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes from a specific owner</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Owner: <em>Owner name</em>
-                  &lt;<em>owner.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes targeting a specific branch</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Branch: <em>branch-name</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes in a specific project</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Project: <em>project-name</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Messages related to a specific Change ID</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Change-Id: <em>Change ID</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Messages related to a specific change number</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Change-Number: <em>change number</em>"
-                </code>
-              </td>
-            </tr>
-          </tbody>
-        </table>
-      </fieldset>
-      <gr-endpoint-decorator name="settings-screen"> </gr-endpoint-decorator>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index 7f81f42..a514f00 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -19,7 +19,7 @@
 import './gr-settings-view';
 import {GrSettingsView} from './gr-settings-view';
 import {GerritView} from '../../../services/router/router-model';
-import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {
   AuthInfo,
   AccountDetailInfo,
@@ -56,7 +56,7 @@
   let config: ServerInfo;
 
   function valueOf(title: string, id: string) {
-    const sections = element.root?.querySelectorAll(`#${id} section`) ?? [];
+    const sections = queryAll(element, `#${id} section`);
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
@@ -122,10 +122,420 @@
     stubRestApi('getAccountEmails').returns(Promise.resolve(undefined));
     stubRestApi('getConfig').returns(Promise.resolve(config));
     element = basicFixture.instantiate();
+    await element.updateComplete;
 
     // Allow the element to render.
     if (element._testOnly_loadingPromise)
       await element._testOnly_loadingPromise;
+    await element.updateComplete;
+  });
+
+  test('renders', async () => {
+    sinon
+      .stub(element, 'getFilterDocsLink')
+      .returns('https://test.com/user-notify.html');
+    element.docsBaseUrl = 'https://test.com';
+    await element.updateComplete;
+    // this cannot be formatted with /* HTML */, because it breaks test
+    expect(element).shadowDom.to.equal(/* HTML*/ `<div
+        class="loading"
+        hidden=""
+      >
+        Loading...
+      </div>
+      <div>
+        <gr-page-nav class="navStyles">
+          <ul>
+            <li><a href="#Profile"> Profile </a></li>
+            <li><a href="#Preferences"> Preferences </a></li>
+            <li><a href="#DiffPreferences"> Diff Preferences </a></li>
+            <li><a href="#EditPreferences"> Edit Preferences </a></li>
+            <li><a href="#Menu"> Menu </a></li>
+            <li><a href="#ChangeTableColumns"> Change Table Columns </a></li>
+            <li><a href="#Notifications"> Notifications </a></li>
+            <li><a href="#EmailAddresses"> Email Addresses </a></li>
+            <li><a href="#Groups"> Groups </a></li>
+            <li><a href="#Identities"> Identities </a></li>
+            <li><a href="#MailFilters"> Mail Filters </a></li>
+            <gr-endpoint-decorator name="settings-menu-item">
+            </gr-endpoint-decorator>
+          </ul>
+        </gr-page-nav>
+        <div class="gr-form-styles main">
+          <h1 class="heading-1">User Settings</h1>
+          <h2 id="Theme">Theme</h2>
+          <section class="darkToggle">
+            <div class="toggle">
+              <paper-toggle-button
+                aria-disabled="false"
+                aria-labelledby="darkThemeToggleLabel"
+                aria-pressed="false"
+                role="button"
+                style="touch-action: none;"
+                tabindex="0"
+                toggles=""
+              >
+              </paper-toggle-button>
+              <div id="darkThemeToggleLabel">
+                Dark theme (the toggle reloads the page)
+              </div>
+            </div>
+          </section>
+          <h2 id="Profile">Profile</h2>
+          <fieldset id="profile">
+            <gr-account-info id="accountInfo"> </gr-account-info>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="Preferences">Preferences</h2>
+          <fieldset id="preferences">
+            <section>
+              <label class="title" for="changesPerPageSelect">
+                Changes per page
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="changesPerPageSelect">
+                    <option value="10">10 rows per page</option>
+                    <option value="25">25 rows per page</option>
+                    <option value="50">50 rows per page</option>
+                    <option value="100">100 rows per page</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="dateTimeFormatSelect">
+                Date/time format
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="dateTimeFormatSelect">
+                    <option value="STD">Jun 3 ; Jun 3, 2016</option>
+                    <option value="US">06/03 ; 06/03/16</option>
+                    <option value="ISO">06-03 ; 2016-06-03</option>
+                    <option value="EURO">3. Jun ; 03.06.2016</option>
+                    <option value="UK">03/06 ; 03/06/2016</option>
+                  </select>
+                </gr-select>
+                <gr-select aria-label="Time Format">
+                  <select id="timeFormatSelect">
+                    <option value="HHMM_12">4:10 PM</option>
+                    <option value="HHMM_24">16:10</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="emailNotificationsSelect">
+                Email notifications
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="emailNotificationsSelect">
+                    <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+                    <option value="ENABLED">
+                      Only comments left by others
+                    </option>
+                    <option value="ATTENTION_SET_ONLY">
+                      Only when I am in the attention set
+                    </option>
+                    <option value="DISABLED">None</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="emailFormatSelect">
+                Email format
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="emailFormatSelect">
+                    <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+                    <option value="PLAINTEXT">Plaintext only</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title"> Default Base For Merges </span>
+              <span class="value">
+                <gr-select>
+                  <select id="defaultBaseForMergesSelect">
+                    <option value="AUTO_MERGE">Auto Merge</option>
+                    <option value="FIRST_PARENT">First Parent</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="relativeDateInChangeTable">
+                Show Relative Dates In Changes Table
+              </label>
+              <span class="value">
+                <input id="relativeDateInChangeTable" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <span class="title"> Diff view </span>
+              <span class="value">
+                <gr-select>
+                  <select id="diffViewSelect">
+                    <option value="SIDE_BY_SIDE">Side by side</option>
+                    <option value="UNIFIED_DIFF">Unified diff</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showSizeBarsInFileList">
+                Show size bars in file list
+              </label>
+              <span class="value">
+                <input
+                  checked=""
+                  id="showSizeBarsInFileList"
+                  type="checkbox"
+                />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="publishCommentsOnPush">
+                Publish comments on push
+              </label>
+              <span class="value">
+                <input id="publishCommentsOnPush" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="workInProgressByDefault">
+                Set new changes to "work in progress" by default
+              </label>
+              <span class="value">
+                <input id="workInProgressByDefault" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="disableKeyboardShortcuts">
+                Disable all keyboard shortcuts
+              </label>
+              <span class="value">
+                <input id="disableKeyboardShortcuts" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="disableTokenHighlighting">
+                Disable token highlighting on hover
+              </label>
+              <span class="value">
+                <input id="disableTokenHighlighting" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="insertSignedOff">
+                Insert Signed-off-by Footer For Inline Edit Changes
+              </label>
+              <span class="value">
+                <input id="insertSignedOff" type="checkbox" />
+              </span>
+            </section>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="savePrefs"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="DiffPreferences">Diff Preferences</h2>
+          <fieldset id="diffPreferences">
+            <gr-diff-preferences id="diffPrefs"> </gr-diff-preferences>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="saveDiffPrefs"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <gr-edit-preferences id="editPrefs"> </gr-edit-preferences>
+          <gr-menu-editor> </gr-menu-editor>
+          <h2 id="ChangeTableColumns">Change Table Columns</h2>
+          <fieldset id="changeTableColumns">
+            <gr-change-table-editor> </gr-change-table-editor>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="saveChangeTable"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="Notifications">Notifications</h2>
+          <fieldset id="watchedProjects">
+            <gr-watched-projects-editor id="watchedProjectsEditor">
+            </gr-watched-projects-editor>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="_handleSaveWatchedProjects"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="EmailAddresses">Email Addresses</h2>
+          <fieldset id="email">
+            <gr-email-editor id="emailEditor"> </gr-email-editor>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <fieldset id="newEmail">
+            <section>
+              <span class="title"> New email address </span>
+              <span class="value">
+                <iron-input class="newEmailInput">
+                  <input
+                    class="newEmailInput"
+                    placeholder="email@example.com"
+                    type="text"
+                  />
+                </iron-input>
+              </span>
+            </section>
+            <section hidden="" id="verificationSentMessage">
+              <p>
+                A verification email was sent to <em>
+                </em>
+               . Please check your
+                inbox.
+              </p>
+            </section>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Send verification
+            </gr-button>
+          </fieldset> 
+          <h2 id="Groups">Groups</h2>
+          <fieldset><gr-group-list id="groupList"> </gr-group-list></fieldset>
+          <h2 id="Identities">Identities</h2>
+          <fieldset>
+            <gr-identities id="identities"> </gr-identities>
+          </fieldset>
+          <h2 id="MailFilters">Mail Filters</h2>
+          <fieldset class="filters">
+            <p>
+              Gerrit emails include metadata about the change to support writing
+              mail filters.
+            </p>
+            <p>
+              Here are some example Gmail queries that can be used for filters
+              or for searching through archived messages. View the
+              <a
+                href="https://test.com/user-notify.html"
+                rel="nofollow"
+                target="_blank"
+              >
+                Gerrit documentation
+              </a>
+              for the complete set of footers.
+            </p>
+            <table>
+              <tbody>
+                <tr>
+                  <th>Name</th>
+                  <th>Query</th>
+                </tr>
+                <tr>
+                  <td>Changes requesting my review</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Reviewer: <em> Your Name </em> <
+                      <em> your.email@example.com </em> >"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes requesting my attention</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Attention: <em> Your Name </em> <
+                      <em> your.email@example.com </em> >"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes from a specific owner</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Owner: <em> Owner name </em> <
+                      <em> owner.email@example.com </em> >"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes targeting a specific branch</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Branch: <em> branch-name </em> "
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes in a specific project</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Project: <em> project-name </em> "
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific Change ID</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Id: <em> Change ID </em> "
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific change number</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Number: <em> change number </em> "
+                    </code>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </fieldset>
+          <gr-endpoint-decorator name="settings-screen">
+          </gr-endpoint-decorator>
+        </div>
+      </div>`);
   });
 
   test('theme changing', async () => {
@@ -141,7 +551,7 @@
     assert.isTrue(window.localStorage.getItem('dark-theme') === 'true');
     assert.isTrue(reloadStub.calledOnce);
 
-    element._isDark = true;
+    element.isDark = true;
     await flush();
     MockInteractions.tap(themeToggle);
     assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
@@ -258,14 +668,14 @@
       false
     );
 
-    assert.isFalse(element._prefsChanged);
+    assert.isFalse(element.prefsChanged);
 
     const publishOnPush = valueOf('Publish comments on push', 'preferences')!
       .firstElementChild!;
 
     MockInteractions.tap(publishOnPush);
 
-    assert.isTrue(element._prefsChanged);
+    assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assertMenusEqual(prefs.my, preferences.my);
@@ -274,8 +684,8 @@
     });
 
     // Save the change.
-    await element._handleSavePreferences();
-    assert.isFalse(element._prefsChanged);
+    await element.handleSavePreferences();
+    assert.isFalse(element.prefsChanged);
   });
 
   test('publish comments on push', async () => {
@@ -285,7 +695,7 @@
     )!.firstElementChild!;
     MockInteractions.tap(publishCommentsOnPush);
 
-    assert.isTrue(element._prefsChanged);
+    assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assert.equal(prefs.publish_comments_on_push, true);
@@ -293,8 +703,8 @@
     });
 
     // Save the change.
-    await element._handleSavePreferences();
-    assert.isFalse(element._prefsChanged);
+    await element.handleSavePreferences();
+    assert.isFalse(element.prefsChanged);
   });
 
   test('set new changes work-in-progress', async () => {
@@ -304,7 +714,7 @@
     )!.firstElementChild!;
     MockInteractions.tap(newChangesWorkInProgress);
 
-    assert.isTrue(element._prefsChanged);
+    assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assert.equal(prefs.work_in_progress_by_default, true);
@@ -312,37 +722,40 @@
     });
 
     // Save the change.
-    await element._handleSavePreferences();
-    assert.isFalse(element._prefsChanged);
+    await element.handleSavePreferences();
+    assert.isFalse(element.prefsChanged);
   });
 
-  test('add email validation', () => {
-    assert.isFalse(element._isNewEmailValid('invalid email'));
-    assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
+  test('add email validation', async () => {
+    assert.isFalse(element.isNewEmailValid('invalid email'));
+    assert.isTrue(element.isNewEmailValid('vaguely@valid.email'));
 
-    assert.isFalse(
-      element._computeAddEmailButtonEnabled('invalid email', true)
-    );
-    assert.isFalse(
-      element._computeAddEmailButtonEnabled('vaguely@valid.email', true)
-    );
-    assert.isTrue(
-      element._computeAddEmailButtonEnabled('vaguely@valid.email', false)
-    );
+    element.newEmail = 'invalid email';
+    element.addingEmail = true;
+    await element.updateComplete;
+    assert.isFalse(element.computeAddEmailButtonEnabled());
+    element.newEmail = 'vaguely@valid.email';
+    element.addingEmail = true;
+    await element.updateComplete;
+    assert.isFalse(element.computeAddEmailButtonEnabled());
+    element.newEmail = 'vaguely@valid.email';
+    element.addingEmail = false;
+    await element.updateComplete;
+    assert.isTrue(element.computeAddEmailButtonEnabled());
   });
 
   test('add email does not save invalid', () => {
     const addEmailStub = stubAddAccountEmail(201);
 
-    assert.isFalse(element._addingEmail);
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'invalid email';
+    assert.isFalse(element.addingEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
+    element.newEmail = 'invalid email';
 
-    element._handleAddEmailButton();
+    element.handleAddEmailButton();
 
-    assert.isFalse(element._addingEmail);
+    assert.isFalse(element.addingEmail);
     assert.isFalse(addEmailStub.called);
-    assert.isNotOk(element._lastSentVerificationEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
 
     assert.isFalse(addEmailStub.called);
   });
@@ -350,62 +763,59 @@
   test('add email does save valid', async () => {
     const addEmailStub = stubAddAccountEmail(201);
 
-    assert.isFalse(element._addingEmail);
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'valid@email.com';
+    assert.isFalse(element.addingEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
+    element.newEmail = 'valid@email.com';
 
-    element._handleAddEmailButton();
+    element.handleAddEmailButton();
 
-    assert.isTrue(element._addingEmail);
+    assert.isTrue(element.addingEmail);
     assert.isTrue(addEmailStub.called);
 
     assert.isTrue(addEmailStub.called);
     await addEmailStub.lastCall.returnValue;
-    assert.isOk(element._lastSentVerificationEmail);
+    assert.isOk(element.lastSentVerificationEmail);
   });
 
   test('add email does not set last-email if error', async () => {
     const addEmailStub = stubAddAccountEmail(500);
 
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'valid@email.com';
+    assert.isNotOk(element.lastSentVerificationEmail);
+    element.newEmail = 'valid@email.com';
 
-    element._handleAddEmailButton();
+    element.handleAddEmailButton();
 
     assert.isTrue(addEmailStub.called);
     await addEmailStub.lastCall.returnValue;
-    assert.isNotOk(element._lastSentVerificationEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
   });
 
   test('emails are loaded without emailToken', () => {
-    const emailEditorLoadDataStub = sinon.stub(
-      element.$.emailEditor,
-      'loadData'
-    );
+    const emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
     element.params = {
       view: GerritView.SETTINGS,
     } as AppElementSettingsParam;
-    element.connectedCallback();
+    element.firstUpdated();
     assert.isTrue(emailEditorLoadDataStub.calledOnce);
   });
 
-  test('_handleSaveChangeTable', () => {
+  test('handleSaveChangeTable', () => {
     let newColumns = ['Owner', 'Project', 'Branch'];
-    element._localChangeTableColumns = newColumns.slice(0);
-    element._showNumber = false;
-    element._handleSaveChangeTable();
+    element.localChangeTableColumns = newColumns.slice(0);
+    element.showNumber = false;
+    element.handleSaveChangeTable();
     assert.deepEqual(element.prefs.change_table, newColumns);
     assert.isNotOk(element.prefs.legacycid_in_change_table);
 
     newColumns = ['Size'];
-    element._localChangeTableColumns = newColumns;
-    element._showNumber = true;
-    element._handleSaveChangeTable();
+    element.localChangeTableColumns = newColumns;
+    element.showNumber = true;
+    element.handleSaveChangeTable();
     assert.deepEqual(element.prefs.change_table, newColumns);
     assert.isTrue(element.prefs.legacycid_in_change_table);
   });
 
-  test('_showHttpAuth', () => {
+  test('showHttpAuth', async () => {
     const serverConfig: ServerInfo = {
       ...createServerInfo(),
       auth: {
@@ -413,41 +823,48 @@
       } as AuthInfo,
     };
 
-    assert.isTrue(element._showHttpAuth(serverConfig));
+    element.serverConfig = serverConfig;
+    await element.updateComplete;
+    assert.isTrue(element.showHttpAuth());
 
-    serverConfig.auth.git_basic_auth_policy = 'HTTP_LDAP';
-    assert.isTrue(element._showHttpAuth(serverConfig));
+    element.serverConfig.auth.git_basic_auth_policy = 'HTTP_LDAP';
+    await element.updateComplete;
+    assert.isTrue(element.showHttpAuth());
 
-    serverConfig.auth.git_basic_auth_policy = 'LDAP';
-    assert.isFalse(element._showHttpAuth(serverConfig));
+    element.serverConfig.auth.git_basic_auth_policy = 'LDAP';
+    await element.updateComplete;
+    assert.isFalse(element.showHttpAuth());
 
-    serverConfig.auth.git_basic_auth_policy = 'OAUTH';
-    assert.isFalse(element._showHttpAuth(serverConfig));
+    element.serverConfig.auth.git_basic_auth_policy = 'OAUTH';
+    await element.updateComplete;
+    assert.isFalse(element.showHttpAuth());
 
-    assert.isFalse(element._showHttpAuth(undefined));
+    element.serverConfig = undefined;
+    await element.updateComplete;
+    assert.isFalse(element.showHttpAuth());
   });
 
-  suite('_getFilterDocsLink', () => {
+  suite('getFilterDocsLink', () => {
     test('with http: docs base URL', () => {
       const base = 'http://example.com/';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(result, 'http://example.com/user-notify.html');
     });
 
     test('with http: docs base URL without slash', () => {
       const base = 'http://example.com';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(result, 'http://example.com/user-notify.html');
     });
 
     test('with https: docs base URL', () => {
       const base = 'https://example.com/';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(result, 'https://example.com/user-notify.html');
     });
 
     test('without docs base URL', () => {
-      const result = element._getFilterDocsLink(null);
+      const result = element.getFilterDocsLink(null);
       assert.equal(
         result,
         'https://gerrit-review.googlesource.com/' +
@@ -457,7 +874,7 @@
 
     test('ignores non HTTP links', () => {
       const base = 'javascript://alert("evil");';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(
         result,
         'https://gerrit-review.googlesource.com/' +
@@ -474,7 +891,7 @@
     let emailEditorLoadDataStub: sinon.SinonStub;
 
     setup(() => {
-      emailEditorLoadDataStub = sinon.stub(element.$.emailEditor, 'loadData');
+      emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
       confirmEmailStub = stubRestApi('confirmEmail').returns(
         new Promise(resolve => {
           resolveConfirm = resolve;
@@ -482,7 +899,7 @@
       );
 
       element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
-      element.connectedCallback();
+      element.firstUpdated();
     });
 
     test('it is used to confirm email via rest API', () => {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index aabdf57..e426e66 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -32,6 +32,7 @@
 import {CoverageRange, DiffLayer} from '../../../types/types';
 import {
   GrDiffProcessor,
+  GroupConsumer,
   KeyLocations,
 } from '../gr-diff-processor/gr-diff-processor';
 import {
@@ -47,7 +48,6 @@
   GrDiffGroupType,
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
-import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
@@ -58,12 +58,6 @@
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 const COMMIT_MSG_LINE_LENGTH = 72;
 
-export interface GrDiffBuilderElement {
-  $: {
-    processor: GrDiffProcessor;
-  };
-}
-
 export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
   return prefs.font_size * 4;
 }
@@ -94,7 +88,10 @@
 }
 
 @customElement('gr-diff-builder')
-export class GrDiffBuilderElement extends PolymerElement {
+export class GrDiffBuilderElement
+  extends PolymerElement
+  implements GroupConsumer
+{
   static get template() {
     return htmlTemplate;
   }
@@ -200,6 +197,8 @@
 
   private rangeLayer = new GrRangedCommentLayer();
 
+  private processor = new GrDiffProcessor();
+
   constructor() {
     super();
     afterNextRender(this, () => {
@@ -212,9 +211,11 @@
         }
       );
     });
+    this.processor.consumer = this;
   }
 
   override disconnectedCallback() {
+    this.processor.cancel();
     if (this._builder) {
       this._builder.clear();
     }
@@ -264,8 +265,8 @@
     }
     this._builder = this._getDiffBuilder();
 
-    this.$.processor.context = this.prefs.context;
-    this.$.processor.keyLocations = keyLocations;
+    this.processor.context = this.prefs.context;
+    this.processor.keyLocations = keyLocations;
 
     this._clearDiffContent();
     this._builder.addColumns(
@@ -277,7 +278,7 @@
 
     fireEvent(this, 'render-start');
     this._cancelableRenderPromise = util.makeCancelable(
-      this.$.processor.process(this.diff.content, isBinary).then(() => {
+      this.processor.process(this.diff.content, isBinary).then(() => {
         if (this.isImageDiff) {
           (this._builder as GrDiffBuilderImage).renderDiff();
         }
@@ -414,7 +415,7 @@
   }
 
   cancel() {
-    this.$.processor.cancel();
+    this.processor.cancel();
     if (this._cancelableRenderPromise) {
       this._cancelableRenderPromise.cancel();
       this._cancelableRenderPromise = null;
@@ -490,28 +491,20 @@
   }
 
   /**
-   * Forward groups added by the processor to the builder for rendering.
+   * Called when the processor starts converting the diff information from the
+   * server into chunks.
    */
-  @observe('_groups.splices')
-  _groupsChanged(changeRecord: PolymerSpliceChange<GrDiffGroup[]>) {
-    if (!changeRecord || !this._builder) return;
+  clearGroups() {
+    if (!this._builder) return;
+    this._builder.clearGroups();
+  }
 
-    // The processor either removes all groups or adds new ones to the end,
-    // so let's simplify the Polymer splices.
-    const isRemoval = changeRecord.indexSplices.find(
-      splice => splice.removed.length > 0
-    );
-    if (isRemoval) {
-      this._builder.clearGroups();
-      return;
-    }
-    for (const splice of changeRecord.indexSplices) {
-      const added = splice.object.slice(
-        splice.index,
-        splice.index + splice.addedCount
-      );
-      this._builder.addGroups(added);
-    }
+  /**
+   * Called when the processor is done converting a chunk of the diff.
+   */
+  addGroup(group: GrDiffGroup) {
+    if (!this._builder) return;
+    this._builder.addGroups([group]);
     fireEvent(this, 'render-progress');
   }
 
@@ -613,7 +606,7 @@
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
     this._builder?.updateRenderPrefs(renderPrefs);
-    this.$.processor.updateRenderPrefs(renderPrefs);
+    this.processor.updateRenderPrefs(renderPrefs);
   }
 }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
index 581f0fb..bd0e034 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
@@ -20,5 +20,4 @@
   <div class="contentWrapper">
     <slot></slot>
   </div>
-  <gr-diff-processor id="processor" groups="{{_groups}}"></gr-diff-processor>
 `;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
index 42d9edf..0ad21b0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import '../../../test/common-test-setup-karma.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import {createDiff} from '../../../test/test-data-generators.js';
 import './gr-diff-builder-element.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
@@ -554,7 +554,7 @@
     setup(() => {
       element = basicFixture.instantiate();
       element.viewMode = 'SIDE_BY_SIDE';
-      processStub = sinon.stub(element.$.processor, 'process')
+      processStub = sinon.stub(element.processor, 'process')
           .returns(Promise.resolve());
       keyLocations = {left: {}, right: {}};
       element.prefs = {
@@ -668,7 +668,7 @@
     });
 
     test('cancel cancels the processor', () => {
-      const processorCancelStub = sinon.stub(element.$.processor, 'cancel');
+      const processorCancelStub = sinon.stub(element.processor, 'cancel');
       element.cancel();
       assert.isTrue(processorCancelStub.called);
     });
@@ -775,7 +775,7 @@
 
     setup(async () => {
       element = mockDiffFixture.instantiate();
-      diff = getMockDiffResponse();
+      diff = createDiff();
       element.diff = diff;
 
       keyLocations = {left: {}, right: {}};
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 609b33e..c64f484 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -20,7 +20,7 @@
 import './gr-diff-cursor.js';
 import {fixture, html} from '@open-wc/testing-helpers';
 import {listenOnce, mockPromise} from '../../../test/test-utils.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import {createDiff} from '../../../test/test-data-generators.js';
 import {createDefaultDiffPrefs} from '../../../constants/constants.js';
 import {GrDiffCursor} from './gr-diff-cursor.js';
 
@@ -52,7 +52,7 @@
     };
     diffElement.addEventListener('render', setupDone);
 
-    diff = getMockDiffResponse();
+    diff = createDiff();
     diffElement.prefs = createDefaultDiffPrefs();
     diffElement.diff = diff;
     await promise;
@@ -468,7 +468,7 @@
       promise.resolve();
     }
     diffElement.addEventListener('render', renderHandler);
-    diffElement._diffChanged(getMockDiffResponse());
+    diffElement._diffChanged(createDiff());
     await promise;
   });
 
@@ -495,7 +495,7 @@
     cursor.initialLineNumber = 10;
     cursor.side = 'right';
 
-    diffElement._diffChanged(getMockDiffResponse());
+    diffElement._diffChanged(createDiff());
     await promise;
   });
 
@@ -548,7 +548,7 @@
         end_line: 6,
         end_character: 1,
       };
-      diffElement.$.highlights.selectedRange = {
+      diffElement.highlights.selectedRange = {
         side: 'right',
         range: someRange,
       };
@@ -661,8 +661,8 @@
       const diffRenderedPromises =
           diffElements.map(diffEl => listenOnce(diffEl, 'render'));
 
-      diffElements[0].diff = getMockDiffResponse();
-      diffElements[2].diff = getMockDiffResponse();
+      diffElements[0].diff = createDiff();
+      diffElements[2].diff = createDiff();
       await Promise.all([diffRenderedPromises[0], diffRenderedPromises[2]]);
 
       const lastLine = diffElements[0].diff.meta_b.lines;
@@ -683,7 +683,7 @@
       assert.equal(cursor.getTargetLineElement().textContent, lastLine);
 
       // Diff 1 finishing to load
-      diffElements[1].diff = getMockDiffResponse();
+      diffElements[1].diff = createDiff();
       await diffRenderedPromises[1];
 
       // Now we can go down
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 2ee6c9f..0714645 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -1,43 +1,25 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
 import '../gr-selection-action-box/gr-selection-action-box';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-highlight_html';
 import {GrAnnotation} from './gr-annotation';
 import {normalize} from './gr-range-normalizer';
 import {strToClassName} from '../../../utils/dom-util';
-import {customElement, property} from '@polymer/decorators';
 import {Side} from '../../../constants/constants';
 import {CommentRange} from '../../../types/common';
 import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {FILE} from '../gr-diff/gr-diff-line';
 import {
   getLineElByChild,
   getLineNumberByChild,
-  getRange,
-  getSide,
   getSideByLineEl,
   GrDiffThreadElement,
 } from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {queryAndAssert} from '../../../utils/common-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 
 interface SidedRange {
   side: Side;
@@ -56,51 +38,65 @@
   end: NormalizedPosition | null;
 }
 
-@customElement('gr-diff-highlight')
-export class GrDiffHighlight extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+/**
+ * The methods that we actually want to call on the builder. We don't want a
+ * fully blown dependency on GrDiffBuilderElement.
+ */
+export interface DiffBuilderInterface {
+  getContentTdByLineEl(lineEl?: Element): Element | null;
+}
 
-  @property({type: Array, notify: true})
-  commentRanges: SidedRange[] = [];
-
-  @property({type: Boolean})
-  loggedIn?: boolean;
-
-  @property({type: Object})
-  _cachedDiffBuilder?: GrDiffBuilderElement;
-
-  @property({type: Object, notify: true})
+/**
+ * Handles showing, positioning and interacting with <gr-selection-action-box>.
+ *
+ * Toggles a css class for highlighting comment ranges when the mouse leaves or
+ * enters a comment thread element.
+ */
+export class GrDiffHighlight {
   selectedRange?: SidedRange;
 
+  private diffBuilder?: DiffBuilderInterface;
+
+  private diffTable?: HTMLElement;
+
   private selectionChangeTask?: DelayedTask;
 
-  constructor() {
-    super();
-    this.addEventListener('comment-thread-mouseleave', e =>
-      this._handleCommentThreadMouseleave(e)
+  init(diffTable: HTMLElement, diffBuilder: DiffBuilderInterface) {
+    this.cleanup();
+
+    this.diffTable = diffTable;
+    this.diffBuilder = diffBuilder;
+
+    diffTable.addEventListener(
+      'comment-thread-mouseleave',
+      this.handleCommentThreadMouseleave
     );
-    this.addEventListener('comment-thread-mouseenter', e =>
-      this._handleCommentThreadMouseenter(e)
+    diffTable.addEventListener(
+      'comment-thread-mouseenter',
+      this.handleCommentThreadMouseenter
     );
-    this.addEventListener('create-comment-requested', e =>
-      this._handleRangeCommentRequest(e)
+    diffTable.addEventListener(
+      'create-comment-requested',
+      this.handleRangeCommentRequest
     );
   }
 
-  override disconnectedCallback() {
+  cleanup() {
     this.selectionChangeTask?.cancel();
-    super.disconnectedCallback();
-  }
-
-  get diffBuilder() {
-    if (!this._cachedDiffBuilder) {
-      this._cachedDiffBuilder = this.querySelector(
-        'gr-diff-builder'
-      ) as GrDiffBuilderElement;
+    if (this.diffTable) {
+      this.diffTable.removeEventListener(
+        'comment-thread-mouseleave',
+        this.handleCommentThreadMouseleave
+      );
+      this.diffTable.removeEventListener(
+        'comment-thread-mouseenter',
+        this.handleCommentThreadMouseenter
+      );
+      this.diffTable.removeEventListener(
+        'create-comment-requested',
+        this.handleRangeCommentRequest
+      );
     }
-    return this._cachedDiffBuilder;
   }
 
   /**
@@ -129,18 +125,17 @@
     // removed.
     // If you wait longer than 50 ms, then you don't properly catch a very
     // quick 'c' press after the selection change. If you wait less than 10
-    // ms, then you will have about 50 _handleSelection calls when doing a
+    // ms, then you will have about 50 handleSelection() calls when doing a
     // simple drag for select.
     this.selectionChangeTask = debounce(
       this.selectionChangeTask,
-      () => this._handleSelection(selection, isMouseUp),
+      () => this.handleSelection(selection, isMouseUp),
       10
     );
   }
 
-  _getThreadEl(e: Event): GrDiffThreadElement | null {
-    const path = (dom(e) as EventApi).path || [];
-    for (const pathEl of path) {
+  private getThreadEl(e: Event): GrDiffThreadElement | null {
+    for (const pathEl of e.composedPath()) {
       if (
         pathEl instanceof HTMLElement &&
         pathEl.classList.contains('comment-thread')
@@ -151,130 +146,74 @@
     return null;
   }
 
-  _toggleRangeElHighlight(
-    threadEl: GrDiffThreadElement,
+  private toggleRangeElHighlight(
+    threadEl: GrDiffThreadElement | null,
     highlightRange = false
   ) {
-    // We don't want to re-create the line just for highlighting the range which
-    // is creating annoying bugs: @see Issue 12934
-    // As gr-ranged-comment-layer now does not notify the layer re-render and
-    // lack of access to the thread or the lineEl from the ranged-comment-layer,
-    // need to update range class for styles here.
-    let curNode: HTMLElement | null = threadEl.assignedSlot;
-    while (curNode) {
-      if (curNode.nodeName === 'TABLE') break;
-      curNode = curNode.parentElement;
-    }
-    if (curNode?.querySelectorAll) {
-      if (highlightRange) {
-        const rangeNodes = curNode.querySelectorAll(
-          `.range.${strToClassName(threadEl.rootId)}`
-        );
-        rangeNodes.forEach(rangeNode => {
-          rangeNode.classList.add('rangeHoverHighlight');
-        });
-        const hintNode = threadEl.parentElement?.querySelector(
-          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
-        );
-        if (hintNode) {
-          hintNode.shadowRoot
-            ?.querySelectorAll('.rangeHighlight')
-            .forEach(highlightNode =>
-              highlightNode.classList.add('rangeHoverHighlight')
-            );
-        }
-      } else {
-        const rangeNodes = curNode.querySelectorAll(
-          `.rangeHoverHighlight.${strToClassName(threadEl.rootId)}`
-        );
-        rangeNodes.forEach(rangeNode => {
-          rangeNode.classList.remove('rangeHoverHighlight');
-        });
-        const hintNode = threadEl.parentElement?.querySelector(
-          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
-        );
-        if (hintNode) {
-          hintNode.shadowRoot
-            ?.querySelectorAll('.rangeHoverHighlight')
-            .forEach(highlightNode =>
-              highlightNode.classList.remove('rangeHoverHighlight')
-            );
-        }
-      }
-    }
-  }
-
-  _handleCommentThreadMouseenter(e: Event) {
-    const threadEl = this._getThreadEl(e)!;
-    const index = this._indexForThreadEl(threadEl);
-
-    if (index !== undefined) {
-      this.set(['commentRanges', index, 'hovering'], true);
-    }
-
-    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
-  }
-
-  _handleCommentThreadMouseleave(e: Event) {
-    const threadEl = this._getThreadEl(e)!;
-    const index = this._indexForThreadEl(threadEl);
-
-    if (index !== undefined) {
-      this.set(['commentRanges', index, 'hovering'], false);
-    }
-
-    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
-  }
-
-  _indexForThreadEl(threadEl: HTMLElement) {
-    const side = getSide(threadEl);
-    const range = getRange(threadEl);
-    if (!side || !range) return undefined;
-    return this._indexOfCommentRange(side, range);
-  }
-
-  _indexOfCommentRange(side: Side, range: CommentRange) {
-    function rangesEqual(a: CommentRange, b: CommentRange) {
-      if (!a && !b) {
-        return true;
-      }
-      if (!a || !b) {
-        return false;
-      }
-      return (
-        a.start_line === b.start_line &&
-        a.start_character === b.start_character &&
-        a.end_line === b.end_line &&
-        a.end_character === b.end_character
+    const rootId = threadEl?.rootId;
+    if (!rootId) return;
+    if (!this.diffTable) return;
+    if (highlightRange) {
+      const selector = `.range.${strToClassName(rootId)}`;
+      const rangeNodes = this.diffTable.querySelectorAll(selector);
+      rangeNodes.forEach(rangeNode => {
+        rangeNode.classList.add('rangeHoverHighlight');
+      });
+      const hintNode = this.diffTable.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
       );
+      hintNode?.shadowRoot
+        ?.querySelectorAll('.rangeHighlight')
+        .forEach(highlightNode =>
+          highlightNode.classList.add('rangeHoverHighlight')
+        );
+    } else {
+      const selector = `.rangeHoverHighlight.${strToClassName(rootId)}`;
+      const rangeNodes = this.diffTable.querySelectorAll(selector);
+      rangeNodes.forEach(rangeNode => {
+        rangeNode.classList.remove('rangeHoverHighlight');
+      });
+      const hintNode = this.diffTable.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
+      );
+      hintNode?.shadowRoot
+        ?.querySelectorAll('.rangeHoverHighlight')
+        .forEach(highlightNode =>
+          highlightNode.classList.remove('rangeHoverHighlight')
+        );
     }
-
-    return this.commentRanges.findIndex(
-      commentRange =>
-        commentRange.side === side && rangesEqual(commentRange.range, range)
-    );
   }
 
+  private handleCommentThreadMouseenter = (e: Event) => {
+    const threadEl = this.getThreadEl(e);
+    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+  };
+
+  private handleCommentThreadMouseleave = (e: Event) => {
+    const threadEl = this.getThreadEl(e);
+    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
+  };
+
   /**
    * Get current normalized selection.
    * Merges multiple ranges, accounts for triple click, accounts for
    * syntax highligh, convert native DOM Range objects to Gerrit concepts
    * (line, side, etc).
    */
-  _getNormalizedRange(selection: Selection | Range) {
+  private getNormalizedRange(selection: Selection | Range) {
     /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
        we can get is a single Range */
     if (selection instanceof Range) {
-      return this._normalizeRange(selection);
+      return this.normalizeRange(selection);
     }
     const rangeCount = selection.rangeCount;
     if (rangeCount === 0) {
       return null;
     } else if (rangeCount === 1) {
-      return this._normalizeRange(selection.getRangeAt(0));
+      return this.normalizeRange(selection.getRangeAt(0));
     } else {
-      const startRange = this._normalizeRange(selection.getRangeAt(0));
-      const endRange = this._normalizeRange(
+      const startRange = this.normalizeRange(selection.getRangeAt(0));
+      const endRange = this.normalizeRange(
         selection.getRangeAt(rangeCount - 1)
       );
       return {
@@ -289,15 +228,15 @@
    *
    * @return fixed normalized range
    */
-  _normalizeRange(domRange: Range): NormalizedRange {
+  private normalizeRange(domRange: Range): NormalizedRange {
     const range = normalize(domRange);
-    return this._fixTripleClickSelection(
+    return this.fixTripleClickSelection(
       {
-        start: this._normalizeSelectionSide(
+        start: this.normalizeSelectionSide(
           range.startContainer,
           range.startOffset
         ),
-        end: this._normalizeSelectionSide(range.endContainer, range.endOffset),
+        end: this.normalizeSelectionSide(range.endContainer, range.endOffset),
       },
       domRange
     );
@@ -313,7 +252,7 @@
    * @param domRange DOM Range object
    * @return fixed normalized range
    */
-  _fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
+  private fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
     if (!range.start) {
       // Selection outside of current diff.
       return range;
@@ -334,7 +273,7 @@
       end.column === 0 &&
       end.line === start.line + 1;
     const content = domRange.cloneContents().querySelector('.contentText');
-    const lineLength = (content && this._getLength(content)) || 0;
+    const lineLength = (content && this.getLength(content)) || 0;
     if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
       // Move the selection to the end of the previous line.
       range.end = {
@@ -355,12 +294,14 @@
    * @param node td.content child
    * @param offset offset within node
    */
-  _normalizeSelectionSide(
+  private normalizeSelectionSide(
     node: Node | null,
     offset: number
   ): NormalizedPosition | null {
     let column;
-    if (!node || !this.contains(node)) return null;
+    if (!this.diffTable) return null;
+    if (!this.diffBuilder) return null;
+    if (!node || !this.diffTable.contains(node)) return null;
     const lineEl = getLineElByChild(node);
     if (!lineEl) return null;
     const side = getSideByLineEl(lineEl);
@@ -376,10 +317,10 @@
     } else {
       const thread = contentTd.querySelector('.comment-thread');
       if (thread?.contains(node)) {
-        column = this._getLength(contentText);
+        column = this.getLength(contentText);
         node = contentText;
       } else {
-        column = this._convertOffsetToColumn(node, offset);
+        column = this.convertOffsetToColumn(node, offset);
       }
     }
 
@@ -398,7 +339,8 @@
    * collapsed section, so don't need to worry about this case for
    * positioning the tooltip.
    */
-  _positionActionBox(
+  // visible for testing
+  positionActionBox(
     actionBox: GrSelectionActionBox,
     startLine: number,
     range: Text | Element | Range
@@ -412,7 +354,7 @@
     actionBox.placeBelow(range);
   }
 
-  _isRangeValid(range: NormalizedRange | null) {
+  private isRangeValid(range: NormalizedRange | null) {
     if (!range || !range.start || !range.start.node || !range.end) {
       return false;
     }
@@ -425,15 +367,16 @@
     );
   }
 
-  _handleSelection(selection: Selection | Range, isMouseUp: boolean) {
+  // visible for testing
+  handleSelection(selection: Selection | Range, isMouseUp: boolean) {
     /* On Safari, the selection events may return a null range that should
        be ignored */
-    if (!selection) {
-      return;
-    }
-    const normalizedRange = this._getNormalizedRange(selection);
-    if (!this._isRangeValid(normalizedRange)) {
-      this._removeActionBox();
+    if (!selection) return;
+    if (!this.diffTable) return;
+
+    const normalizedRange = this.getNormalizedRange(selection);
+    if (!this.isRangeValid(normalizedRange)) {
+      this.removeActionBox();
       return;
     }
     /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
@@ -463,8 +406,8 @@
       // start.column with the content length), we just check if the selection
       // is empty to see that it's at the end of a line.
       const content = domRange.cloneContents().querySelector('.contentText');
-      if (isMouseUp && this._getLength(content) === 0) {
-        this._fireCreateRangeComment(start.side, {
+      if (isMouseUp && this.getLength(content) === 0) {
+        this.fireCreateRangeComment(start.side, {
           start_line: start.line,
           start_character: 0,
           end_line: start.line,
@@ -474,10 +417,10 @@
       return;
     }
 
-    let actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
+    let actionBox = this.diffTable.querySelector('gr-selection-action-box');
     if (!actionBox) {
       actionBox = document.createElement('gr-selection-action-box');
-      this.root!.insertBefore(actionBox, this.root!.firstElementChild);
+      this.diffTable.appendChild(actionBox);
     }
     this.selectedRange = {
       range: {
@@ -489,10 +432,10 @@
       side: start.side,
     };
     if (start.line === end.line) {
-      this._positionActionBox(actionBox, start.line, domRange);
+      this.positionActionBox(actionBox, start.line, domRange);
     } else if (start.node instanceof Text) {
       if (start.column) {
-        this._positionActionBox(
+        this.positionActionBox(
           actionBox,
           start.line,
           start.node.splitText(start.column)
@@ -505,44 +448,41 @@
       (start.node.firstChild instanceof Element ||
         start.node.firstChild instanceof Text)
     ) {
-      this._positionActionBox(actionBox, start.line, start.node.firstChild);
+      this.positionActionBox(actionBox, start.line, start.node.firstChild);
     } else if (start.node instanceof Element || start.node instanceof Text) {
-      this._positionActionBox(actionBox, start.line, start.node);
+      this.positionActionBox(actionBox, start.line, start.node);
     } else {
       console.warn('Failed to position comment action box.');
-      this._removeActionBox();
+      this.removeActionBox();
     }
   }
 
-  _fireCreateRangeComment(side: Side, range: CommentRange) {
-    this.dispatchEvent(
+  private fireCreateRangeComment(side: Side, range: CommentRange) {
+    this.diffTable?.dispatchEvent(
       new CustomEvent('create-range-comment', {
         detail: {side, range},
         composed: true,
         bubbles: true,
       })
     );
-    this._removeActionBox();
+    this.removeActionBox();
   }
 
-  _handleRangeCommentRequest(e: Event) {
+  private handleRangeCommentRequest = (e: Event) => {
     e.stopPropagation();
-    if (!this.selectedRange) {
-      throw Error('Selected Range is needed for new range comment!');
-    }
+    assertIsDefined(this.selectedRange, 'selectedRange');
     const {side, range} = this.selectedRange;
-    this._fireCreateRangeComment(side, range);
-  }
+    this.fireCreateRangeComment(side, range);
+  };
 
-  _removeActionBox() {
+  // visible for testing
+  removeActionBox() {
     this.selectedRange = undefined;
-    const actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
-    if (actionBox) {
-      this.root!.removeChild(actionBox);
-    }
+    const actionBox = this.diffTable?.querySelector('gr-selection-action-box');
+    if (actionBox) actionBox.remove();
   }
 
-  _convertOffsetToColumn(el: Node, offset: number) {
+  private convertOffsetToColumn(el: Node, offset: number) {
     if (el instanceof Element && el.classList.contains('content')) {
       return offset;
     }
@@ -552,7 +492,7 @@
     ) {
       if (el.previousSibling) {
         el = el.previousSibling;
-        offset += this._getLength(el);
+        offset += this.getLength(el);
       } else {
         el = el.parentElement!;
       }
@@ -566,18 +506,24 @@
    *
    * @param node this is sometimes passed as null.
    */
-  _getLength(node: Node | null): number {
+  // visible for testing
+  getLength(node: Node | null): number {
     if (node === null) return 0;
     if (node instanceof Element && node.classList.contains('content')) {
-      return this._getLength(queryAndAssert(node, '.contentText'));
+      return this.getLength(queryAndAssert(node, '.contentText'));
     } else {
       return GrAnnotation.getLength(node);
     }
   }
 }
 
+export interface CreateRangeCommentEventDetail {
+  side: Side;
+  range: CommentRange;
+}
+
 declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-highlight': GrDiffHighlight;
+  interface HTMLElementEventMap {
+    'create-range-comment': CustomEvent<CreateRangeCommentEventDetail>;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts
deleted file mode 100644
index 5a6cb1c..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      position: relative;
-    }
-    gr-selection-action-box {
-      /**
-         * Needs z-index to appear above wrapped content, since it's inserted
-         * into DOM before it.
-         */
-      z-index: 10;
-    }
-  </style>
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js
deleted file mode 100644
index 4c1295f..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js
+++ /dev/null
@@ -1,589 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-diff-highlight.js';
-import {_getTextOffset} from './gr-range-normalizer.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-// Splitting long lines in html into shorter rows breaks tests:
-// zero-length text nodes and new lines are not expected in some places
-/* eslint-disable max-len */
-const basicFixture = fixtureFromTemplate(html`
-<style>
-      .tab-indicator:before {
-        color: #C62828;
-        /* >> character */
-        content: '\\00BB';
-      }
-    </style>
-    <gr-diff-highlight>
-      <table id="diffTable">
-
-        <tbody class="section both">
-           <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div></td>
-            <td class="right lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-<tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="138"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="119"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="140"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
-                [Yet another random diff thread content here]
-            </div></td>
-            <td class="right lineNum" data-value="120"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="141"></td>
-            <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>complectitur<span class="tab-indicator" style="tab-size:8;">\u0009</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-            <td class="right lineNum" data-value="130"></td>
-            <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section contextControl">
-          <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
-            <td class="left contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-            <td class="right contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta total">
-          <tr class="diff-row side-by-side" left-type="blank" right-type="add">
-            <td class="left"></td>
-            <td class="blank"></td>
-            <td class="right lineNum" data-value="146"></td>
-            <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="165"></td>
-            <td class="content both"><div class="contentText"></div></td>
-            <td class="right lineNum" data-value="147"></td>
-            <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
-          </tr>
-        </tbody>
-
-      </table>
-    </gr-diff-highlight>
-`);
-/* eslint-enable max-len */
-
-suite('gr-diff-highlight', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate()[1];
-  });
-
-  suite('comment events', () => {
-    let builder;
-
-    setup(() => {
-      builder = {
-        getContentsByLineRange: sinon.stub().returns([]),
-        getLineElByChild: sinon.stub().returns({}),
-        getSideByLineEl: sinon.stub().returns('other-side'),
-      };
-      element._cachedDiffBuilder = builder;
-    });
-
-    test('comment-thread-mouseenter from line comments is ignored', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right'}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseenter', {bubbles: true, composed: true}));
-      assert.isFalse(element.set.called);
-    });
-
-    test('comment-thread-mouseenter from ranged comment causes set', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      threadEl.setAttribute('range', JSON.stringify({
-        start_line: 3,
-        start_character: 4,
-        end_line: 5,
-        end_character: 6,
-      }));
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right', range: {
-        start_line: 3,
-        start_character: 4,
-        end_line: 5,
-        end_character: 6,
-      }}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseenter', {bubbles: true, composed: true}));
-      assert.isTrue(element.set.called);
-      const args = element.set.lastCall.args;
-      assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
-      assert.deepEqual(args[1], true);
-    });
-
-    test('comment-thread-mouseleave from line comments is ignored', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right'}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseleave', {bubbles: true, composed: true}));
-      assert.isFalse(element.set.called);
-    });
-
-    test(`create-range-comment for range when create-comment-requested
-          is fired`, () => {
-      sinon.stub(element, '_removeActionBox');
-      element.selectedRange = {
-        side: 'left',
-        range: {
-          start_line: 7,
-          start_character: 11,
-          end_line: 24,
-          end_character: 42,
-        },
-      };
-      const requestEvent = new CustomEvent('create-comment-requested');
-      let createRangeEvent;
-      element.addEventListener('create-range-comment', e => {
-        createRangeEvent = e;
-      });
-      element.dispatchEvent(requestEvent);
-      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
-      assert.isTrue(element._removeActionBox.called);
-    });
-  });
-
-  suite('selection', () => {
-    let diff;
-    let builder;
-    let contentStubs;
-
-    const stubContent = (line, side, opt_child) => {
-      const contentTd = diff.querySelector(
-          `.${side}.lineNum[data-value="${line}"] ~ .content`);
-      const contentText = contentTd.querySelector('.contentText');
-      const lineEl = diff.querySelector(
-          `.${side}.lineNum[data-value="${line}"]`);
-      contentStubs.push({
-        lineEl,
-        contentTd,
-        contentText,
-      });
-      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
-      builder.getLineNumberByChild.withArgs(lineEl).returns(line);
-      builder.getContentTdByLine.withArgs(line, side).returns(contentTd);
-      builder.getSideByLineEl.withArgs(lineEl).returns(side);
-      return contentText;
-    };
-
-    const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
-      const selection = document.getSelection();
-      const range = document.createRange();
-      range.setStart(startNode, startOffset);
-      range.setEnd(endNode, endOffset);
-      selection.addRange(range);
-      element._handleSelection(selection);
-    };
-
-    const getLineElByChild = node => {
-      const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
-      return stubs && stubs.lineEl;
-    };
-
-    setup(() => {
-      contentStubs = [];
-      stub('gr-selection-action-box', 'placeAbove');
-      stub('gr-selection-action-box', 'placeBelow');
-      diff = element.querySelector('#diffTable');
-      builder = {
-        getContentTdByLine: sinon.stub(),
-        getContentTdByLineEl: sinon.stub(),
-        getLineElByChild,
-        getLineNumberByChild: sinon.stub(),
-        getSideByLineEl: sinon.stub(),
-      };
-      element._cachedDiffBuilder = builder;
-    });
-
-    teardown(() => {
-      contentStubs = null;
-      document.getSelection().removeAllRanges();
-    });
-
-    test('single first line', () => {
-      const content = stubContent(1, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(content.firstChild, 5, content.firstChild, 12);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      assert.isTrue(actionBox.positionBelow);
-    });
-
-    test('multiline starting on first line', () => {
-      const startContent = stubContent(1, 'right');
-      const endContent = stubContent(2, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(
-          startContent.firstChild, 10, endContent.lastChild, 7);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      assert.isTrue(actionBox.positionBelow);
-    });
-
-    test('single line', () => {
-      const content = stubContent(138, 'left');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(content.firstChild, 5, content.firstChild, 12);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 138,
-        start_character: 5,
-        end_line: 138,
-        end_character: 12,
-      });
-      assert.equal(side, 'left');
-      assert.notOk(actionBox.positionBelow);
-    });
-
-    test('multiline', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(
-          startContent.firstChild, 10, endContent.lastChild, 7);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-      assert.equal(side, 'right');
-      assert.notOk(actionBox.positionBelow);
-    });
-
-    test('multiple ranges aka firefox implementation', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-
-      const startRange = document.createRange();
-      startRange.setStart(startContent.firstChild, 10);
-      startRange.setEnd(startContent.firstChild, 11);
-
-      const endRange = document.createRange();
-      endRange.setStart(endContent.lastChild, 6);
-      endRange.setEnd(endContent.lastChild, 7);
-
-      const getRangeAtStub = sinon.stub();
-      getRangeAtStub
-          .onFirstCall().returns(startRange)
-          .onSecondCall()
-          .returns(endRange);
-      const selection = {
-        rangeCount: 2,
-        getRangeAt: getRangeAtStub,
-        removeAllRanges: sinon.stub(),
-      };
-      element._handleSelection(selection);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-    });
-
-    test('multiline grow end highlight over tabs', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 2,
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('collapsed', () => {
-      const content = stubContent(138, 'left');
-      emulateSelection(content.firstChild, 5, content.firstChild, 5);
-      assert.isOk(document.getSelection().getRangeAt(0).startContainer);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts inside hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelector('.foo');
-      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 8,
-        end_line: 140,
-        end_character: 23,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('ends inside hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelector('.bar');
-      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 18,
-        end_line: 140,
-        end_character: 27,
-      });
-    });
-
-    test('multiple hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelectorAll('hl')[4];
-      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 2,
-        end_line: 140,
-        end_character: 61,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts outside of diff', () => {
-      const contentText = stubContent(140, 'left');
-      const contentTd = contentText.parentElement;
-
-      emulateSelection(contentTd.parentElement, 0,
-          contentText.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('ends outside of diff', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(content.nextElementSibling.firstChild, 2,
-          content.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts and ends on different sides', () => {
-      const startContent = stubContent(140, 'left');
-      const endContent = stubContent(130, 'right');
-      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts in comment thread element', () => {
-      const startContent = stubContent(140, 'left');
-      const comment = startContent.parentElement.querySelector(
-          '.comment-thread');
-      const endContent = stubContent(141, 'left');
-      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 83,
-        end_line: 141,
-        end_character: 4,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('ends in comment thread element', () => {
-      const content = stubContent(140, 'left');
-      const comment = content.parentElement.querySelector(
-          '.comment-thread');
-      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 4,
-        end_line: 140,
-        end_character: 83,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts in context element', () => {
-      const contextControl =
-          diff.querySelector('.contextControl').querySelector('gr-button');
-      const content = stubContent(146, 'right');
-      emulateSelection(contextControl, 0, content.firstChild, 7);
-      // TODO (viktard): Select nearest line.
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('ends in context element', () => {
-      const contextControl =
-          diff.querySelector('.contextControl').querySelector('gr-button');
-      const content = stubContent(141, 'left');
-      emulateSelection(content.firstChild, 2, contextControl, 1);
-      // TODO (viktard): Select nearest line.
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('selection containing context element', () => {
-      const startContent = stubContent(130, 'right');
-      const endContent = stubContent(146, 'right');
-      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 130,
-        start_character: 3,
-        end_line: 146,
-        end_character: 14,
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('ends at a tab', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(
-          content.firstChild, 1, content.querySelector('span'), 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 1,
-        end_line: 140,
-        end_character: 51,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts at a tab', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(
-          content.querySelectorAll('hl')[3], 0,
-          content.querySelectorAll('span')[1].nextSibling, 1);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 51,
-        end_line: 140,
-        end_character: 71,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('properly accounts for syntax highlighting', () => {
-      const content = stubContent(140, 'left');
-      const spy = sinon.spy(element, '_normalizeRange');
-      emulateSelection(
-          content.querySelectorAll('hl')[3], 0,
-          content.querySelectorAll('span')[1], 0);
-      const spyCall = spy.getCall(0);
-      const range = document.getSelection().getRangeAt(0);
-      assert.notDeepEqual(spyCall.returnValue, range);
-    });
-
-    test('GrRangeNormalizer._getTextOffset computes text offset', () => {
-      let content = stubContent(140, 'left');
-      let child = content.lastChild.lastChild;
-      let result = _getTextOffset(content, child);
-      assert.equal(result, 75);
-      content = stubContent(146, 'right');
-      child = content.lastChild;
-      result = _getTextOffset(content, child);
-      assert.equal(result, 0);
-    });
-
-    test('_fixTripleClickSelection', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 0,
-        end_line: 119,
-        end_character: element._getLength(startContent),
-      });
-      assert.equal(side, 'right');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
new file mode 100644
index 0000000..b819754
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
@@ -0,0 +1,713 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-diff-highlight';
+import {_getTextOffset} from './gr-range-normalizer';
+import {fixture, fixtureCleanup, html} from '@open-wc/testing-helpers';
+import {
+  GrDiffHighlight,
+  DiffBuilderInterface,
+  CreateRangeCommentEventDetail,
+} from './gr-diff-highlight';
+import {Side} from '../../../api/diff';
+import {SinonStubbedMember} from 'sinon';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrDiffThreadElement} from '../gr-diff/gr-diff-utils';
+import {waitQueryAndAssert, waitUntil} from '../../../test/test-utils';
+import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
+
+// Splitting long lines in html into shorter rows breaks tests:
+// zero-length text nodes and new lines are not expected in some places
+/* eslint-disable max-len, lit/prefer-static-styles */
+/* prettier-ignore */
+const diffTable = html`
+  <table id="diffTable">
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="1"></td>
+        <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+        <td class="right lineNum" data-value="1"></td>
+        <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta">
+      <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+        <td class="left lineNum" data-value="2"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content remove"><div class="contentText">na💢ti <hl class="foo range generated_id314">te, inquit</hl>, sumus<hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>udiam, <hl>quid</hl> sit,<span class="tab-indicator" style="tab-size:8;"> </span>quod<hl>Epicurum</hl></div></td>
+        <td class="right lineNum" data-value="2"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>otiosum,<span class="tab-indicator" style="tab-size:8;"> </span> audiam,sit, quod</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="138"></td>
+        <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+        <td class="right lineNum" data-value="119"></td>
+        <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta">
+      <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+        <td class="left lineNum" data-value="140"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
+          [Yet another random diff thread content here]
+        </div></td>
+        <td class="right lineNum" data-value="120"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="141"></td>
+        <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>complectitur<span class="tab-indicator" style="tab-size:8;"></span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+        <td class="right lineNum" data-value="130"></td>
+        <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quodintellegam;</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section contextControl">
+      <tr
+        class="diff-row side-by-side"
+        left-type="contextControl"
+        right-type="contextControl"
+      >
+        <td class="left contextLineNum"></td>
+        <td>
+          <gr-button>+10↑</gr-button>
+          -
+          <gr-button>Show 21 common lines</gr-button>
+          -
+          <gr-button>+10↓</gr-button>
+        </td>
+        <td class="right contextLineNum"></td>
+        <td>
+          <gr-button>+10↑</gr-button>
+          -
+          <gr-button>Show 21 common lines</gr-button>
+          -
+          <gr-button>+10↓</gr-button>
+        </td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta total">
+      <tr class="diff-row side-by-side" left-type="blank" right-type="add">
+        <td class="left"></td>
+        <td class="blank"></td>
+        <td class="right lineNum" data-value="146"></td>
+        <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="165"></td>
+        <td class="content both"><div class="contentText"></div></td>
+        <td class="right lineNum" data-value="147"></td>
+        <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+      </tr>
+    </tbody>
+  </table>
+`;
+/* eslint-enable max-len */
+
+suite('gr-diff-highlight', () => {
+  suite('comment events', () => {
+    let threadEl: GrDiffThreadElement;
+    let hlRange: HTMLElement;
+    let element: GrDiffHighlight;
+    let diff: HTMLElement;
+    let builder: {
+      getContentTdByLineEl: SinonStubbedMember<
+        DiffBuilderInterface['getContentTdByLineEl']
+      >;
+    };
+
+    setup(async () => {
+      diff = await fixture<HTMLTableElement>(diffTable);
+      builder = {
+        getContentTdByLineEl: sinon.stub(),
+      };
+      element = new GrDiffHighlight();
+      element.init(diff, builder);
+      hlRange = queryAndAssert(diff, 'hl.range.generated_id314');
+
+      threadEl = document.createElement(
+        'div'
+      ) as unknown as GrDiffThreadElement;
+      threadEl.className = 'comment-thread';
+      threadEl.rootId = 'id314';
+      diff.appendChild(threadEl);
+    });
+
+    teardown(() => {
+      element.cleanup();
+      threadEl.remove();
+    });
+
+    test('comment-thread-mouseenter toggles rangeHoverHighlight class', async () => {
+      assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+      threadEl.dispatchEvent(
+        new CustomEvent('comment-thread-mouseenter', {
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await waitUntil(() => hlRange.classList.contains('rangeHoverHighlight'));
+      assert.isTrue(hlRange.classList.contains('rangeHoverHighlight'));
+    });
+
+    test('comment-thread-mouseleave toggles rangeHoverHighlight class', async () => {
+      hlRange.classList.add('rangeHoverHighlight');
+      threadEl.dispatchEvent(
+        new CustomEvent('comment-thread-mouseleave', {
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await waitUntil(() => !hlRange.classList.contains('rangeHoverHighlight'));
+      assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+    });
+
+    test(`create-range-comment for range when create-comment-requested
+          is fired`, () => {
+      const removeActionBoxStub = sinon.stub(element, 'removeActionBox');
+      element.selectedRange = {
+        side: Side.LEFT,
+        range: {
+          start_line: 7,
+          start_character: 11,
+          end_line: 24,
+          end_character: 42,
+        },
+      };
+      const requestEvent = new CustomEvent('create-comment-requested');
+      let createRangeEvent: CustomEvent<CreateRangeCommentEventDetail>;
+      diff.addEventListener('create-range-comment', e => {
+        createRangeEvent = e;
+      });
+      diff.dispatchEvent(requestEvent);
+      if (!createRangeEvent!) assert.fail('event not set');
+      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
+      assert.isTrue(removeActionBoxStub.called);
+    });
+  });
+
+  suite('selection', () => {
+    let element: GrDiffHighlight;
+    let diff: HTMLElement;
+    let builder: {
+      getContentTdByLineEl: SinonStubbedMember<
+        DiffBuilderInterface['getContentTdByLineEl']
+      >;
+    };
+    let contentStubs;
+
+    setup(async () => {
+      diff = await fixture<HTMLTableElement>(diffTable);
+      builder = {
+        getContentTdByLineEl: sinon.stub(),
+      };
+      element = new GrDiffHighlight();
+      element.init(diff, builder);
+      contentStubs = [];
+      stub('gr-selection-action-box', 'placeAbove');
+      stub('gr-selection-action-box', 'placeBelow');
+    });
+
+    teardown(() => {
+      fixtureCleanup();
+      element.cleanup();
+      contentStubs = null;
+      document.getSelection()!.removeAllRanges();
+    });
+
+    const stubContent = (line: number, side: Side) => {
+      const contentTd = diff.querySelector(
+        `.${side}.lineNum[data-value="${line}"] ~ .content`
+      );
+      if (!contentTd) assert.fail('content td not found');
+      const contentText = contentTd.querySelector('.contentText');
+      const lineEl =
+        diff.querySelector(`.${side}.lineNum[data-value="${line}"]`) ??
+        undefined;
+      contentStubs.push({
+        lineEl,
+        contentTd,
+        contentText,
+      });
+      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
+      return contentText;
+    };
+
+    const emulateSelection = (
+      startNode: Node,
+      startOffset: number,
+      endNode: Node,
+      endOffset: number
+    ) => {
+      const selection = document.getSelection();
+      if (!selection) assert.fail('no selection');
+      selection.removeAllRanges();
+      const range = document.createRange();
+      range.setStart(startNode, startOffset);
+      range.setEnd(endNode, endOffset);
+      selection.addRange(range);
+      element.handleSelection(selection, false);
+    };
+
+    test('single first line', () => {
+      const content = stubContent(1, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('multiline starting on first line', () => {
+      const startContent = stubContent(1, Side.RIGHT);
+      const endContent = stubContent(2, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('single line', async () => {
+      const content = stubContent(138, Side.LEFT);
+      sinon.spy(element, 'positionActionBox');
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = await waitQueryAndAssert<GrSelectionActionBox>(
+        diff,
+        'gr-selection-action-box'
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 138,
+        start_character: 5,
+        end_line: 138,
+        end_character: 12,
+      });
+      assert.equal(side, Side.LEFT);
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiline', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+      assert.equal(side, Side.RIGHT);
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiple ranges aka firefox implementation', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content');
+      }
+
+      const startRange = document.createRange();
+      startRange.setStart(startContent.firstChild, 10);
+      startRange.setEnd(startContent.firstChild, 11);
+
+      const endRange = document.createRange();
+      endRange.setStart(endContent.lastChild, 6);
+      endRange.setEnd(endContent.lastChild, 7);
+
+      const getRangeAtStub = sinon.stub();
+      getRangeAtStub
+        .onFirstCall()
+        .returns(startRange)
+        .onSecondCall()
+        .returns(endRange);
+      const selection = {
+        rangeCount: 2,
+        getRangeAt: getRangeAtStub,
+        removeAllRanges: sinon.stub(),
+      } as unknown as Selection;
+      element.handleSelection(selection, false);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+    });
+
+    test('multiline grow end highlight over tabs', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 2,
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+
+    test('collapsed', () => {
+      const content = stubContent(138, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content not found');
+      }
+      emulateSelection(content.firstChild, 5, content.firstChild, 5);
+      const sel = document.getSelection();
+      if (!sel) assert.fail('no selection');
+      assert.isOk(sel.getRangeAt(0).startContainer);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts inside hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) {
+        assert.fail('content not found');
+      }
+      const hl = content.querySelector('.foo');
+      if (!hl?.firstChild) {
+        assert.fail('first child of hl element not found');
+      }
+      if (!hl?.nextSibling) {
+        assert.fail('next sibling of hl element not found');
+      }
+      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 8,
+        end_line: 140,
+        end_character: 23,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('ends inside hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      const hl = content.querySelector('.bar');
+      if (!hl) assert.fail('hl inside content not found');
+      if (!hl.previousSibling) assert.fail('previous sibling not found');
+      if (!hl.firstChild) assert.fail('first child not found');
+      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 18,
+        end_line: 140,
+        end_character: 27,
+      });
+    });
+
+    test('multiple hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('first child not found');
+      const hl = content.querySelectorAll('hl')[4];
+      if (!hl) assert.fail('hl not found');
+      if (!hl.firstChild) assert.fail('first child of hl not found');
+      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 2,
+        end_line: 140,
+        end_character: 61,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts outside of diff', () => {
+      const contentText = stubContent(140, Side.LEFT);
+      if (!contentText) assert.fail('content not found');
+      if (!contentText.firstChild) assert.fail('child not found');
+      const contentTd = contentText.parentElement;
+      if (!contentTd) assert.fail('content td not found');
+      if (!contentTd.parentElement) assert.fail('parent of td not found');
+
+      emulateSelection(contentTd.parentElement, 0, contentText.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends outside of diff', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('child not found');
+      if (!content.nextElementSibling) assert.fail('sibling not found');
+      if (!content.nextElementSibling.firstChild) {
+        assert.fail('sibling child not found');
+      }
+      emulateSelection(
+        content.nextElementSibling.firstChild,
+        2,
+        content.firstChild,
+        2
+      );
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts and ends on different sides', () => {
+      const startContent = stubContent(140, Side.LEFT);
+      const endContent = stubContent(130, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts in comment thread element', () => {
+      const startContent = stubContent(140, Side.LEFT);
+      if (!startContent?.parentElement) {
+        assert.fail('parent el of start content not found');
+      }
+      const comment =
+        startContent.parentElement.querySelector('.comment-thread');
+      if (!comment?.firstChild) {
+        assert.fail('first child of comment not found');
+      }
+      const endContent = stubContent(141, Side.LEFT);
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 83,
+        end_line: 141,
+        end_character: 4,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('ends in comment thread element', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content not found');
+      }
+      if (!content?.parentElement) {
+        assert.fail('parent element of content not found');
+      }
+      const comment = content.parentElement.querySelector('.comment-thread');
+      if (!comment?.firstChild) {
+        assert.fail('first child of comment element not found');
+      }
+      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 4,
+        end_line: 140,
+        end_character: 83,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts in context element', () => {
+      const contextControl = diff
+        .querySelector('.contextControl')!
+        .querySelector('gr-button');
+      if (!contextControl) assert.fail('context control not found');
+      const content = stubContent(146, Side.RIGHT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('content child not found');
+      emulateSelection(contextControl, 0, content.firstChild, 7);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends in context element', () => {
+      const contextControl = diff
+        .querySelector('.contextControl')!
+        .querySelector('gr-button');
+      if (!contextControl) {
+        assert.fail('context control element not found');
+      }
+      const content = stubContent(141, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content element not found');
+      }
+      emulateSelection(content.firstChild, 2, contextControl, 1);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('selection containing context element', () => {
+      const startContent = stubContent(130, Side.RIGHT);
+      const endContent = stubContent(146, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 130,
+        start_character: 3,
+        end_line: 146,
+        end_character: 14,
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+
+    test('ends at a tab', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content element not found');
+      }
+      const span = content.querySelector('span');
+      if (!span) assert.fail('span element not found');
+      emulateSelection(content.firstChild, 1, span, 0);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 1,
+        end_line: 140,
+        end_character: 51,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts at a tab', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      emulateSelection(
+        content.querySelectorAll('hl')[3],
+        0,
+        content.querySelectorAll('span')[1].nextSibling!,
+        1
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 71,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('properly accounts for syntax highlighting', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      emulateSelection(
+        content.querySelectorAll('hl')[3],
+        0,
+        content.querySelectorAll('span')[1],
+        0
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 69,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('GrRangeNormalizer.getTextOffset computes text offset', () => {
+      let content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      if (!content.lastChild) assert.fail('last child of content not found');
+      let child = content.lastChild.lastChild;
+      if (!child) assert.fail('last child of last child of content not found');
+      let result = _getTextOffset(content, child);
+      assert.equal(result, 75);
+      content = stubContent(146, Side.RIGHT);
+      if (!content) assert.fail('content element not found');
+      child = content.lastChild;
+      if (!child) assert.fail('child element not found');
+      result = _getTextOffset(content, child);
+      assert.equal(result, 0);
+    });
+
+    test('fixTripleClickSelection', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent) assert.fail('end content not found');
+      if (!endContent.firstChild) assert.fail('first child not found');
+      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 0,
+        end_line: 119,
+        end_character: element.getLength(startContent),
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 605d493..4a1e5be 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -3,7 +3,6 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {
   GrDiffLine,
   GrDiffLineType,
@@ -17,11 +16,11 @@
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
 import {CancelablePromise, util} from '../../../scripts/util';
-import {customElement, property} from '@polymer/decorators';
 import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {RenderPreferences} from '../../../api/diff';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const WHOLE_FILE = -1;
 
@@ -57,6 +56,12 @@
   return asyncThreshold * 2;
 }
 
+/** Interface for listening to the output of the processor. */
+export interface GroupConsumer {
+  addGroup(group: GrDiffGroup): void;
+  clearGroups(): void;
+}
+
 /**
  * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
  *
@@ -82,19 +87,10 @@
  *    that the part that is within the context or has comments is shown, while
  *    the rest is not.
  */
-@customElement('gr-diff-processor')
-export class GrDiffProcessor extends PolymerElement {
+export class GrDiffProcessor {
   context = 3;
 
-  /**
-   * The builder elements watches this (two-way data binding and @observe) and
-   * thus passes each added group on to the renderer (i.e. gr-diff-builder).
-   * You must only add to this array and not modify it later (only when
-   * resetting). The source of truth is then held by gr-diff-builder, which also
-   * reflects expanding and collapsing of groups.
-   */
-  @property({type: Array, notify: true})
-  groups: GrDiffGroup[] = [];
+  consumer?: GroupConsumer;
 
   keyLocations: KeyLocations = {left: {}, right: {}};
 
@@ -109,18 +105,6 @@
 
   private resetIsScrollingTask?: DelayedTask;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    window.addEventListener('scroll', this.handleWindowScroll);
-  }
-
-  override disconnectedCallback() {
-    this.resetIsScrollingTask?.cancel();
-    this.cancel();
-    window.removeEventListener('scroll', this.handleWindowScroll);
-    super.disconnectedCallback();
-  }
-
   private readonly handleWindowScroll = () => {
     this.isScrolling = true;
     this.resetIsScrollingTask = debounce(
@@ -141,10 +125,12 @@
     // Cancel any still running process() calls, because they append to the
     // same groups field.
     this.cancel();
+    window.addEventListener('scroll', this.handleWindowScroll);
 
-    this.groups = [];
-    this.push('groups', this.makeGroup('LOST'));
-    this.push('groups', this.makeGroup(FILE));
+    assertIsDefined(this.consumer, 'consumer');
+    this.consumer.clearGroups();
+    this.consumer.addGroup(this.makeGroup('LOST'));
+    this.consumer.addGroup(this.makeGroup(FILE));
 
     // If it's a binary diff, we won't be rendering hunks of text differences
     // so finish processing.
@@ -178,7 +164,8 @@
           // Process the next chunk and incorporate the result.
           const stateUpdate = this.processNext(state, chunks);
           for (const group of stateUpdate.groups) {
-            this.push('groups', group);
+            assertIsDefined(this.consumer, 'consumer');
+            this.consumer.addGroup(group);
             currentBatch += group.lines.length;
           }
           state.lineNums.left += stateUpdate.lineDelta.left;
@@ -199,6 +186,7 @@
     );
     return this.processPromise.finally(() => {
       this.processPromise = null;
+      window.removeEventListener('scroll', this.handleWindowScroll);
     });
   }
 
@@ -213,6 +201,7 @@
     if (this.processPromise) {
       this.processPromise.cancel();
     }
+    window.removeEventListener('scroll', this.handleWindowScroll);
   }
 
   /**
@@ -746,9 +735,3 @@
     }
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-processor': GrDiffProcessor;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index ba59af0..60a1cba 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -10,8 +10,6 @@
 import {GrDiffProcessor, State} from './gr-diff-processor';
 import {DiffContent} from '../../../types/diff';
 
-const basicFixture = fixtureFromElement('gr-diff-processor');
-
 suite('gr-diff-processor tests', () => {
   const WHOLE_FILE = -1;
   const loremIpsum =
@@ -22,13 +20,22 @@
     'fugit assum per.';
 
   let element: GrDiffProcessor;
+  let groups: GrDiffGroup[];
 
   setup(() => {});
 
   suite('not logged in', () => {
     setup(() => {
-      element = basicFixture.instantiate();
-
+      groups = [];
+      element = new GrDiffProcessor();
+      element.consumer = {
+        addGroup(group: GrDiffGroup) {
+          groups.push(group);
+        },
+        clearGroups() {
+          groups = [];
+        },
+      };
       element.context = 4;
     });
 
@@ -51,7 +58,6 @@
       ];
 
       return element.process(content, false).then(() => {
-        const groups = element.groups;
         groups.shift(); // remove portedThreadsWithoutRangeGroup
         assert.equal(groups.length, 4);
 
@@ -113,7 +119,6 @@
       const content = [{b: ['foo']}];
 
       return element.process(content, false).then(() => {
-        const groups = element.groups;
         groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         assert.equal(groups[0].type, GrDiffGroupType.BOTH);
@@ -137,7 +142,6 @@
         ];
 
         return element.process(content, false).then(() => {
-          const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -168,7 +172,6 @@
 
         await element.process(content, false);
 
-        const groups = element.groups;
         groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         // group[0] is the file group
@@ -214,7 +217,6 @@
         ];
 
         return element.process(content, false).then(() => {
-          const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -239,7 +241,6 @@
         ];
 
         return element.process(content, false).then(() => {
-          const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -268,7 +269,6 @@
         ];
 
         return element.process(content, false).then(() => {
-          const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -306,7 +306,6 @@
         ];
 
         return element.process(content, false).then(() => {
-          const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -395,7 +394,6 @@
         ];
 
         return element.process(content, false).then(() => {
-          const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -431,7 +429,6 @@
         ];
 
         return element.process(content, false).then(() => {
-          const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -458,7 +455,6 @@
 
       await element.process(content, false);
 
-      const groups = element.groups;
       groups.shift(); // remove portedThreadsWithoutRangeGroup
 
       // group[0] is the file group
@@ -734,21 +730,21 @@
       element.isScrolling = true;
       element.process(content, false);
       // Just the files group - no more processing during scrolling.
-      assert.equal(element.groups.length, 2);
+      assert.equal(groups.length, 2);
 
       element.isScrolling = false;
       element.process(content, false);
       // More groups have been processed. How many does not matter here.
-      assert.isAtLeast(element.groups.length, 3);
+      assert.isAtLeast(groups.length, 3);
     });
 
     test('image diffs', () => {
       const content = Array(200).fill({ab: ['', '']});
       element.process(content, true);
-      assert.equal(element.groups.length, 2);
+      assert.equal(groups.length, 2);
 
       // Image diffs don't process content, just the 'FILE' line.
-      assert.equal(element.groups[0].lines.length, 1);
+      assert.equal(groups[0].lines.length, 1);
     });
 
     suite('processNext', () => {
@@ -1074,11 +1070,4 @@
       });
     });
   });
-
-  test('detaching cancels', () => {
-    element = basicFixture.instantiate();
-    const cancelStub = sinon.stub(element, 'cancel');
-    element.disconnectedCallback();
-    assert(cancelStub.called);
-  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
index 2665ef0..4bb8cc3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
@@ -1,39 +1,23 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
-import {addListener} from '@polymer/polymer/lib/utils/gestures';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-selection_html';
 import {
   normalize,
   NormalizedRange,
 } from '../gr-diff-highlight/gr-range-normalizer';
 import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {DiffInfo} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {
   getLineElByChild,
   getSide,
   getSideByLineEl,
   isThreadEl,
 } from '../gr-diff/gr-diff-utils';
+import {assertIsDefined} from '../../../utils/common-util';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -55,49 +39,35 @@
   return {left: null, right: null};
 }
 
-@customElement('gr-diff-selection')
-export class GrDiffSelection extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
+export class GrDiffSelection {
+  // visible for testing
   diff?: DiffInfo;
 
-  @property({type: Object})
-  _cachedDiffBuilder?: GrDiffBuilderElement;
+  // visible for testing
+  diffTable?: HTMLElement;
 
-  @property({type: Object})
-  _linesCache: LinesCache = {left: null, right: null};
+  // visible for testing
+  linesCache: LinesCache = getNewCache();
 
-  constructor() {
-    super();
-    this.addEventListener('copy', e => this._handleCopy(e));
-    addListener(this, 'down', e => this._handleDown(e));
+  init(diff: DiffInfo, diffTable: HTMLElement) {
+    this.cleanup();
+    this.diff = diff;
+    this.diffTable = diffTable;
+    this.diffTable.classList.add(SelectionClass.RIGHT);
+    this.diffTable.addEventListener('copy', this.handleCopy);
+    this.diffTable.addEventListener('mousedown', this.handleDown);
+    this.linesCache = getNewCache();
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.classList.add(SelectionClass.RIGHT);
+  cleanup() {
+    if (!this.diffTable) return;
+    this.diffTable.removeEventListener('copy', this.handleCopy);
+    this.diffTable.removeEventListener('mousedown', this.handleDown);
   }
 
-  get diffBuilder() {
-    if (!this._cachedDiffBuilder) {
-      this._cachedDiffBuilder = this.querySelector(
-        'gr-diff-builder'
-      ) as GrDiffBuilderElement;
-    }
-    return this._cachedDiffBuilder;
-  }
-
-  @observe('diff')
-  _diffChanged() {
-    this._linesCache = getNewCache();
-  }
-
-  _handleDownOnRangeComment(node: Element) {
+  handleDownOnRangeComment(node: Element) {
     if (isThreadEl(node)) {
-      this._setClasses([
+      this.setClasses([
         SelectionClass.COMMENT,
         getSide(node) === Side.LEFT
           ? SelectionClass.LEFT
@@ -108,14 +78,13 @@
     return false;
   }
 
-  _handleDown(e: Event) {
+  handleDown = (e: Event) => {
     const target = e.target;
     if (!(target instanceof Element)) return;
-    // Handle the down event on comment thread in Polymer 2
-    const handled = this._handleDownOnRangeComment(target);
+    const handled = this.handleDownOnRangeComment(target);
     if (handled) return;
     const lineEl = getLineElByChild(target);
-    const blameSelected = this._elementDescendedFromClass(target, 'blame');
+    const blameSelected = descendedFromClass(target, 'blame', this.diffTable);
     if (!lineEl && !blameSelected) {
       return;
     }
@@ -125,9 +94,10 @@
     if (blameSelected) {
       targetClasses.push(SelectionClass.BLAME);
     } else if (lineEl) {
-      const commentSelected = this._elementDescendedFromClass(
+      const commentSelected = descendedFromClass(
         target,
-        'gr-comment'
+        'gr-comment',
+        this.diffTable
       );
       const side = getSideByLineEl(lineEl);
 
@@ -140,60 +110,50 @@
       }
     }
 
-    this._setClasses(targetClasses);
-  }
+    this.setClasses(targetClasses);
+  };
 
   /**
    * Set the provided list of classes on the element, to the exclusion of all
    * other SelectionClass values.
    */
-  _setClasses(targetClasses: string[]) {
+  setClasses(targetClasses: string[]) {
+    if (!this.diffTable) return;
     // Remove any selection classes that do not belong.
     for (const className of Object.values(SelectionClass)) {
       if (!targetClasses.includes(className)) {
-        this.classList.remove(className);
+        this.diffTable.classList.remove(className);
       }
     }
     // Add new selection classes iff they are not already present.
-    for (const _class of targetClasses) {
-      if (!this.classList.contains(_class)) {
-        this.classList.add(_class);
+    for (const targetClass of targetClasses) {
+      if (!this.diffTable.classList.contains(targetClass)) {
+        this.diffTable.classList.add(targetClass);
       }
     }
   }
 
-  _getCopyEventTarget(e: Event) {
-    return (dom(e) as EventApi).rootTarget;
-  }
-
-  /**
-   * Utility function to determine whether an element is a descendant of
-   * another element with the particular className.
-   */
-  _elementDescendedFromClass(element: Element, className: string) {
-    return descendedFromClass(element, className, this.diffBuilder.diffElement);
-  }
-
-  _handleCopy(e: ClipboardEvent) {
+  handleCopy = (e: ClipboardEvent) => {
     let commentSelected = false;
-    const target = this._getCopyEventTarget(e);
+    const target = e.composedPath()[0];
     if (!(target instanceof Element)) return;
     if (target instanceof HTMLTextAreaElement) return;
-    if (!this._elementDescendedFromClass(target, 'diff-row')) return;
-    if (this.classList.contains(SelectionClass.COMMENT)) {
+    if (!descendedFromClass(target, 'diff-row', this.diffTable)) return;
+    if (!this.diffTable) return;
+    if (this.diffTable.classList.contains(SelectionClass.COMMENT)) {
       commentSelected = true;
     }
     const lineEl = getLineElByChild(target);
     if (!lineEl) return;
     const side = getSideByLineEl(lineEl);
-    const text = this._getSelectedText(side, commentSelected);
+    const text = this.getSelectedText(side, commentSelected);
     if (text && e.clipboardData) {
       e.clipboardData.setData('Text', text);
       e.preventDefault();
     }
-  }
+  };
 
-  _getSelection() {
+  getSelection() {
     const diffHosts = querySelectorAll(document.body, 'gr-diff');
     if (!diffHosts.length) return document.getSelection();
 
@@ -219,13 +179,13 @@
    * @param commentSelected Whether or not a comment is selected.
    * @return The selected text.
    */
-  _getSelectedText(side: Side, commentSelected: boolean) {
-    const sel = this._getSelection();
+  getSelectedText(side: Side, commentSelected: boolean) {
+    const sel = this.getSelection();
     if (!sel || sel.rangeCount !== 1) {
       return ''; // No multi-select support yet.
     }
     if (commentSelected) {
-      return this._getCommentLines(sel, side);
+      return this.getCommentLines(sel, side);
     }
     const range = normalize(sel.getRangeAt(0));
     const startLineEl = getLineElByChild(range.startContainer);
@@ -250,7 +210,7 @@
       if (endLineDataValue) endLineNum = Number(endLineDataValue);
     }
 
-    return this._getRangeFromDiff(
+    return this.getRangeFromDiff(
       startLineNum,
       range.startOffset,
       endLineNum,
@@ -262,7 +222,7 @@
   /**
    * Query the diff object for the selected lines.
    */
-  _getRangeFromDiff(
+  getRangeFromDiff(
     startLineNum: number,
     startOffset: number,
     endLineNum: number | undefined,
@@ -274,7 +234,7 @@
       startLineNum -= skipChunk.skip!;
       if (endLineNum) endLineNum -= skipChunk.skip!;
     }
-    const lines = this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
+    const lines = this.getDiffLines(side).slice(startLineNum - 1, endLineNum);
     if (lines.length) {
       lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
       lines[0] = lines[0].substring(startOffset);
@@ -288,9 +248,9 @@
    * @param side The side that is currently selected.
    * @return An array of strings indexed by line number.
    */
-  _getDiffLines(side: Side): string[] {
-    if (this._linesCache[side]) {
-      return this._linesCache[side]!;
+  getDiffLines(side: Side): string[] {
+    if (this.linesCache[side]) {
+      return this.linesCache[side]!;
     }
     if (!this.diff) return [];
     let lines: string[] = [];
@@ -303,7 +263,7 @@
         lines = lines.concat(chunk.b);
       }
     }
-    this._linesCache[side] = lines;
+    this.linesCache[side] = lines;
     return lines;
   }
 
@@ -315,11 +275,11 @@
    * @param side The side that is currently selected.
    * @return The selected comment text.
    */
-  _getCommentLines(sel: Selection, side: Side) {
+  getCommentLines(sel: Selection, side: Side) {
     const range = normalize(sel.getRangeAt(0));
     const content = [];
-    // Query the diffElement for comments.
-    const messages = this.diffBuilder.diffElement.querySelectorAll(
+    assertIsDefined(this.diffTable, 'diffTable');
+    const messages = this.diffTable.querySelectorAll(
       `.side-by-side [data-side="${side}"] .message *, .unified .message *`
     );
 
@@ -339,9 +299,9 @@
 
         if (
           el.id === 'output' &&
-          !this._elementDescendedFromClass(el, 'collapsed')
+          !descendedFromClass(el, 'collapsed', this.diffTable)
         ) {
-          content.push(this._getTextContentForRange(el, sel, range));
+          content.push(this.getTextContentForRange(el, sel, range));
         }
       }
     }
@@ -359,7 +319,7 @@
    * @param range The normalized selection range.
    * @return The text within the selection.
    */
-  _getTextContentForRange(
+  getTextContentForRange(
     domNode: Node,
     sel: Selection,
     range: NormalizedRange
@@ -379,15 +339,9 @@
       }
     } else {
       for (const childNode of domNode.childNodes) {
-        text += this._getTextContentForRange(childNode, sel, range);
+        text += this.getTextContentForRange(childNode, sel, range);
       }
     }
     return text;
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-selection': GrDiffSelection;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts
deleted file mode 100644
index bd0e034..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.js b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.js
deleted file mode 100644
index 15454f9..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.js
+++ /dev/null
@@ -1,389 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../test/common-test-setup-karma.js';
-import './gr-diff-selection.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-// Splitting long lines in html into shorter rows breaks tests:
-// zero-length text nodes and new lines are not expected in some places
-/* eslint-disable max-len */
-const basicFixture = fixtureFromTemplate(html`
-<gr-diff-selection>
-      <table id="diffTable" class="side-by-side">
-        <tr class="diff-row">
-          <td class="blame" data-line-number="1"></td>
-          <td class="lineNum left" data-value="1">1</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ba ba</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is a comment</span>
-                </div>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="1">1</td>
-          <td class="content">
-            <div class="contentText" data-side="right">some other text</div>
-          </td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="2"></td>
-          <td class="lineNum left" data-value="2">2</td>
-          <td class="content">
-            <div class="contentText" data-side="left">zin</div>
-          </td>
-          <td class="lineNum right" data-value="2">2</td>
-          <td class="content">
-            <div class="contentText" data-side="right">more more more</div>
-            <div data-side="right">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is a comment on the right</span>
-                </div>
-              </div>
-            </div>
-          </td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="3"></td>
-          <td class="lineNum left" data-value="3">3</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ga ga</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is <a>a</a> different comment 💩 unicode is fun</span>
-                </div>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="3">3</td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="4"></td>
-          <td class="lineNum left" data-value="4">4</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ga ga</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <textarea data-side="right">test for textarea copying</textarea>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="4">4</td>
-        </tr>
-        <tr class="not-diff-row">
-          <td class="other">
-            <div class="contentText" data-side="right">some other text</div>
-          </td>
-        </tr>
-      </table>
-    </gr-diff-selection>
-`);
-/* eslint-enable max-len */
-
-suite('gr-diff-selection', () => {
-  let element;
-
-  const emulateCopyOn = function(target) {
-    const fakeEvent = {
-      target,
-      preventDefault: sinon.stub(),
-      clipboardData: {
-        setData: sinon.stub(),
-      },
-    };
-    element._getCopyEventTarget.returns(target);
-    element._handleCopy(fakeEvent);
-    return fakeEvent;
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-
-    sinon.stub(element, '_getCopyEventTarget');
-    element._cachedDiffBuilder = {
-      getLineElByChild: sinon.stub().returns({}),
-      getSideByLineEl: sinon.stub(),
-      diffElement: element.querySelector('#diffTable'),
-    };
-    element.diff = {
-      content: [
-        {
-          a: ['ba ba'],
-          b: ['some other text'],
-        },
-        {
-          a: ['zin'],
-          b: ['more more more'],
-        },
-        {
-          a: ['ga ga'],
-          b: ['some other text'],
-        },
-      ],
-    };
-  });
-
-  test('applies selected-left on left side click', () => {
-    element.classList.add('selected-right');
-    const lineNumberEl = element.querySelector('.lineNum.left');
-    MockInteractions.down(lineNumberEl);
-    assert.isTrue(
-        element.classList.contains('selected-left'), 'adds selected-left');
-    assert.isFalse(
-        element.classList.contains('selected-right'),
-        'removes selected-right');
-  });
-
-  test('applies selected-right on right side click', () => {
-    element.classList.add('selected-left');
-    const lineNumberEl = element.querySelector('.lineNum.right');
-    MockInteractions.down(lineNumberEl);
-    assert.isTrue(
-        element.classList.contains('selected-right'), 'adds selected-right');
-    assert.isFalse(
-        element.classList.contains('selected-left'), 'removes selected-left');
-  });
-
-  test('applies selected-blame on blame click', () => {
-    element.classList.add('selected-left');
-    element.diffBuilder.getLineElByChild.returns(null);
-    sinon.stub(element, '_elementDescendedFromClass').callsFake(
-        (el, className) => className === 'blame');
-    MockInteractions.down(element);
-    assert.isTrue(
-        element.classList.contains('selected-blame'), 'adds selected-right');
-    assert.isFalse(
-        element.classList.contains('selected-left'), 'removes selected-left');
-  });
-
-  test('ignores copy for non-content Element', () => {
-    sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('.not-diff-row'));
-    assert.isFalse(element._getSelectedText.called);
-  });
-
-  test('asks for text for left side Elements', () => {
-    element._cachedDiffBuilder.getSideByLineEl.returns('left');
-    sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('div.contentText'));
-    assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
-  });
-
-  test('reacts to copy for content Elements', () => {
-    sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('div.contentText'));
-    assert.isTrue(element._getSelectedText.called);
-  });
-
-  test('copy event is prevented for content Elements', () => {
-    sinon.stub(element, '_getSelectedText');
-    element._cachedDiffBuilder.getSideByLineEl.returns('left');
-    element._getSelectedText.returns('test');
-    const event = emulateCopyOn(element.querySelector('div.contentText'));
-    assert.isTrue(event.preventDefault.called);
-  });
-
-  test('inserts text into clipboard on copy', () => {
-    sinon.stub(element, '_getSelectedText').returns('the text');
-    const event = emulateCopyOn(element.querySelector('div.contentText'));
-    assert.deepEqual(
-        ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
-  });
-
-  test('_setClasses adds given SelectionClass values, removes others', () => {
-    element.classList.add('selected-right');
-    element._setClasses(['selected-comment', 'selected-left']);
-    assert.isTrue(element.classList.contains('selected-comment'));
-    assert.isTrue(element.classList.contains('selected-left'));
-    assert.isFalse(element.classList.contains('selected-right'));
-    assert.isFalse(element.classList.contains('selected-blame'));
-
-    element._setClasses(['selected-blame']);
-    assert.isFalse(element.classList.contains('selected-comment'));
-    assert.isFalse(element.classList.contains('selected-left'));
-    assert.isFalse(element.classList.contains('selected-right'));
-    assert.isTrue(element.classList.contains('selected-blame'));
-  });
-
-  test('_setClasses removes before it ads', () => {
-    element.classList.add('selected-right');
-    const addStub = sinon.stub(element.classList, 'add');
-    const removeStub = sinon.stub(element.classList, 'remove').callsFake(
-        () => {
-          assert.isFalse(addStub.called);
-        });
-    element._setClasses(['selected-comment', 'selected-left']);
-    assert.isTrue(addStub.called);
-    assert.isTrue(removeStub.called);
-  });
-
-  test('copies content correctly', () => {
-    // Fetch the line number.
-    element._cachedDiffBuilder.getLineElByChild = function(child) {
-      while (!child.classList.contains('content') && child.parentElement) {
-        child = child.parentElement;
-      }
-      return child.previousElementSibling;
-    };
-
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(element.querySelector('div.contentText').firstChild, 3);
-    range.setEnd(
-        element.querySelectorAll('div.contentText')[4].firstChild, 2);
-    selection.addRange(range);
-    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-  });
-
-  test('copies comments', () => {
-    element.classList.add('selected-left');
-    element.classList.add('selected-comment');
-    element.classList.remove('selected-right');
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(
-        element.querySelector('.gr-formatted-text *').firstChild, 3);
-    range.setEnd(
-        element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2], 7);
-    selection.addRange(range);
-    assert.equal('s is a comment\nThis is a differ',
-        element._getSelectedText('left', true));
-  });
-
-  test('respects astral chars in comments', () => {
-    element.classList.add('selected-left');
-    element.classList.add('selected-comment');
-    element.classList.remove('selected-right');
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    const nodes = element.querySelectorAll('.gr-formatted-text *');
-    range.setStart(nodes[2].childNodes[2], 13);
-    range.setEnd(nodes[2].childNodes[2], 23);
-    selection.addRange(range);
-    assert.equal('mment 💩 u',
-        element._getSelectedText('left', true));
-  });
-
-  test('defers to default behavior for textarea', () => {
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-    const selectedTextSpy = sinon.spy(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('textarea'));
-    assert.isFalse(selectedTextSpy.called);
-  });
-
-  test('regression test for 4794', () => {
-    element._cachedDiffBuilder.getLineElByChild = function(child) {
-      while (!child.classList.contains('content') && child.parentElement) {
-        child = child.parentElement;
-      }
-      return child.previousElementSibling;
-    };
-
-    element.classList.add('selected-right');
-    element.classList.remove('selected-left');
-
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(
-        element.querySelectorAll('div.contentText')[1].firstChild, 4);
-    range.setEnd(
-        element.querySelectorAll('div.contentText')[1].firstChild, 10);
-    selection.addRange(range);
-    assert.equal(element._getSelectedText('right'), ' other');
-  });
-
-  test('copies to end of side (issue 7895)', () => {
-    element._cachedDiffBuilder.getLineElByChild = function(child) {
-      // Return null for the end container.
-      if (child.textContent === 'ga ga') { return null; }
-      while (!child.classList.contains('content') && child.parentElement) {
-        child = child.parentElement;
-      }
-      return child.previousElementSibling;
-    };
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(element.querySelector('div.contentText').firstChild, 3);
-    range.setEnd(
-        element.querySelectorAll('div.contentText')[4].firstChild, 2);
-    selection.addRange(range);
-    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-  });
-
-  suite('_getTextContentForRange', () => {
-    let selection;
-    let range;
-    let nodes;
-
-    setup(() => {
-      element.classList.add('selected-left');
-      element.classList.add('selected-comment');
-      element.classList.remove('selected-right');
-      selection = document.getSelection();
-      selection.removeAllRanges();
-      range = document.createRange();
-      nodes = element.querySelectorAll('.gr-formatted-text *');
-    });
-
-    test('multi level element contained in range', () => {
-      range.setStart(nodes[2].childNodes[0], 1);
-      range.setEnd(nodes[2].childNodes[2], 7);
-      selection.addRange(range);
-      assert.equal(element._getTextContentForRange(element, selection, range),
-          'his is a differ');
-    });
-
-    test('multi level element as startContainer of range', () => {
-      range.setStart(nodes[2].childNodes[1], 0);
-      range.setEnd(nodes[2].childNodes[2], 7);
-      selection.addRange(range);
-      assert.equal(element._getTextContentForRange(element, selection, range),
-          'a differ');
-    });
-
-    test('startContainer === endContainer', () => {
-      range.setStart(nodes[0].firstChild, 2);
-      range.setEnd(nodes[0].firstChild, 12);
-      selection.addRange(range);
-      assert.equal(element._getTextContentForRange(element, selection, range),
-          'is is a co');
-    });
-  });
-
-  test('cache is reset when diff changes', () => {
-    element._linesCache = {left: 'test', right: 'test'};
-    element.diff = {};
-    flush();
-    assert.deepEqual(element._linesCache, {left: null, right: null});
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
new file mode 100644
index 0000000..b44114a
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -0,0 +1,390 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-diff-selection';
+import {GrDiffSelection} from './gr-diff-selection';
+import {createDiff} from '../../../test/test-data-generators';
+import {DiffInfo, Side} from '../../../api/diff';
+import {GrFormattedText} from '../../../elements/shared/gr-formatted-text/gr-formatted-text';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {mouseDown} from '../../../test/test-utils';
+
+const diffTableTemplate = html`
+  <table id="diffTable" class="side-by-side">
+    <tr class="diff-row">
+      <td class="blame" data-line-number="1"></td>
+      <td class="lineNum left" data-value="1">1</td>
+      <td class="content">
+        <div class="contentText" data-side="left">ba ba</div>
+        <div data-side="left">
+          <div class="comment-thread">
+            <div class="gr-formatted-text message">
+              <span id="output" class="gr-linked-text">This is a comment</span>
+            </div>
+          </div>
+        </div>
+      </td>
+      <td class="lineNum right" data-value="1">1</td>
+      <td class="content">
+        <div class="contentText" data-side="right">some other text</div>
+      </td>
+    </tr>
+    <tr class="diff-row">
+      <td class="blame" data-line-number="2"></td>
+      <td class="lineNum left" data-value="2">2</td>
+      <td class="content">
+        <div class="contentText" data-side="left">zin</div>
+      </td>
+      <td class="lineNum right" data-value="2">2</td>
+      <td class="content">
+        <div class="contentText" data-side="right">more more more</div>
+        <div data-side="right">
+          <div class="comment-thread">
+            <div class="gr-formatted-text message">
+              <span id="output" class="gr-linked-text"
+                >This is a comment on the right</span
+              >
+            </div>
+          </div>
+        </div>
+      </td>
+    </tr>
+    <tr class="diff-row">
+      <td class="blame" data-line-number="3"></td>
+      <td class="lineNum left" data-value="3">3</td>
+      <td class="content">
+        <div class="contentText" data-side="left">ga ga</div>
+        <div data-side="left">
+          <div class="comment-thread">
+            <div class="gr-formatted-text message">
+              <span id="output" class="gr-linked-text"
+                >This is <a>a</a> different comment 💩 unicode is fun</span
+              >
+            </div>
+          </div>
+        </div>
+      </td>
+      <td class="lineNum right" data-value="3">3</td>
+    </tr>
+    <tr class="diff-row">
+      <td class="blame" data-line-number="4"></td>
+      <td class="lineNum left" data-value="4">4</td>
+      <td class="content">
+        <div class="contentText" data-side="left">ga ga</div>
+        <div data-side="left">
+          <div class="comment-thread">
+            <textarea data-side="right">test for textarea copying</textarea>
+          </div>
+        </div>
+      </td>
+      <td class="lineNum right" data-value="4">4</td>
+    </tr>
+    <tr class="not-diff-row">
+      <td class="other">
+        <div class="contentText" data-side="right">some other text</div>
+      </td>
+    </tr>
+  </table>
+`;
+
+suite('gr-diff-selection', () => {
+  let element: GrDiffSelection;
+  let diffTable: HTMLTableElement;
+
+  const emulateCopyOn = function (target: HTMLElement | null) {
+    const fakeEvent = {
+      target,
+      preventDefault: sinon.stub(),
+      composedPath() {
+        return [target];
+      },
+      clipboardData: {
+        setData: sinon.stub(),
+      },
+    };
+    element.handleCopy(fakeEvent as unknown as ClipboardEvent);
+    return fakeEvent;
+  };
+
+  setup(async () => {
+    element = new GrDiffSelection();
+    diffTable = await fixture<HTMLTableElement>(diffTableTemplate);
+
+    const diff: DiffInfo = {
+      ...createDiff(),
+      content: [
+        {
+          a: ['ba ba'],
+          b: ['some other text'],
+        },
+        {
+          a: ['zin'],
+          b: ['more more more'],
+        },
+        {
+          a: ['ga ga'],
+          b: ['some other text'],
+        },
+      ],
+    };
+    element.init(diff, diffTable);
+  });
+
+  test('applies selected-left on left side click', () => {
+    element.diffTable!.classList.add('selected-right');
+    const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.left');
+    if (!lineNumberEl) assert.fail('line number element missing');
+    mouseDown(lineNumberEl);
+    assert.isTrue(
+      element.diffTable!.classList.contains('selected-left'),
+      'adds selected-left'
+    );
+    assert.isFalse(
+      element.diffTable!.classList.contains('selected-right'),
+      'removes selected-right'
+    );
+  });
+
+  test('applies selected-right on right side click', () => {
+    element.diffTable!.classList.add('selected-left');
+    const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.right');
+    if (!lineNumberEl) assert.fail('line number element missing');
+    mouseDown(lineNumberEl);
+    assert.isTrue(
+      element.diffTable!.classList.contains('selected-right'),
+      'adds selected-right'
+    );
+    assert.isFalse(
+      element.diffTable!.classList.contains('selected-left'),
+      'removes selected-left'
+    );
+  });
+
+  test('applies selected-blame on blame click', () => {
+    element.diffTable!.classList.add('selected-left');
+    const blameDiv = document.createElement('div');
+    blameDiv.classList.add('blame');
+    element.diffTable!.appendChild(blameDiv);
+    mouseDown(blameDiv);
+    assert.isTrue(
+      element.diffTable!.classList.contains('selected-blame'),
+      'adds selected-right'
+    );
+    assert.isFalse(
+      element.diffTable!.classList.contains('selected-left'),
+      'removes selected-left'
+    );
+  });
+
+  test('ignores copy for non-content Element', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('.not-diff-row'));
+    assert.isFalse(getSelectedTextStub.called);
+  });
+
+  test('asks for text for left side Elements', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.deepEqual([Side.LEFT, false], getSelectedTextStub.lastCall.args);
+  });
+
+  test('reacts to copy for content Elements', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.isTrue(getSelectedTextStub.called);
+  });
+
+  test('copy event is prevented for content Elements', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    getSelectedTextStub.returns('test');
+    const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.isTrue(event.preventDefault.called);
+  });
+
+  test('inserts text into clipboard on copy', () => {
+    sinon.stub(element, 'getSelectedText').returns('the text');
+    const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.deepEqual(
+      ['Text', 'the text'],
+      event.clipboardData.setData.lastCall.args
+    );
+  });
+
+  test('setClasses adds given SelectionClass values, removes others', () => {
+    element.diffTable!.classList.add('selected-right');
+    element.setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(element.diffTable!.classList.contains('selected-comment'));
+    assert.isTrue(element.diffTable!.classList.contains('selected-left'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-right'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-blame'));
+
+    element.setClasses(['selected-blame']);
+    assert.isFalse(element.diffTable!.classList.contains('selected-comment'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-left'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-right'));
+    assert.isTrue(element.diffTable!.classList.contains('selected-blame'));
+  });
+
+  test('setClasses removes before it ads', () => {
+    element.diffTable!.classList.add('selected-right');
+    const addStub = sinon.stub(element.diffTable!.classList, 'add');
+    const removeStub = sinon
+      .stub(element.diffTable!.classList, 'remove')
+      .callsFake(() => {
+        assert.isFalse(addStub.called);
+      });
+    element.setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(addStub.called);
+    assert.isTrue(removeStub.called);
+  });
+
+  test('copies content correctly', () => {
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.remove('selected-right');
+
+    const selection = document.getSelection();
+    if (selection === null) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
+    range.setEnd(
+      diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
+      2
+    );
+    selection.addRange(range);
+    assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
+  });
+
+  test('copies comments', () => {
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.add('selected-comment');
+    element.diffTable!.classList.remove('selected-right');
+    const selection = document.getSelection();
+    if (selection === null) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(
+      diffTable.querySelector('.gr-formatted-text *')!.firstChild!,
+      3
+    );
+    range.setEnd(
+      diffTable.querySelectorAll('.gr-formatted-text *')[2].childNodes[2],
+      7
+    );
+    selection.addRange(range);
+    assert.equal(
+      's is a comment\nThis is a differ',
+      element.getSelectedText(Side.LEFT, true)
+    );
+  });
+
+  test('respects astral chars in comments', () => {
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.add('selected-comment');
+    element.diffTable!.classList.remove('selected-right');
+    const selection = document.getSelection();
+    if (selection === null) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    const nodes = diffTable.querySelectorAll('.gr-formatted-text *');
+    range.setStart(nodes[2].childNodes[2], 13);
+    range.setEnd(nodes[2].childNodes[2], 23);
+    selection.addRange(range);
+    assert.equal('mment 💩 u', element.getSelectedText(Side.LEFT, true));
+  });
+
+  test('defers to default behavior for textarea', () => {
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.remove('selected-right');
+    const selectedTextSpy = sinon.spy(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('textarea'));
+    assert.isFalse(selectedTextSpy.called);
+  });
+
+  test('regression test for 4794', () => {
+    element.diffTable!.classList.add('selected-right');
+    element.diffTable!.classList.remove('selected-left');
+
+    const selection = document.getSelection();
+    if (!selection) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(
+      diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
+      4
+    );
+    range.setEnd(
+      diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
+      10
+    );
+    selection.addRange(range);
+    assert.equal(element.getSelectedText(Side.RIGHT, false), ' other');
+  });
+
+  test('copies to end of side (issue 7895)', () => {
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.remove('selected-right');
+    const selection = document.getSelection();
+    if (selection === null) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
+    range.setEnd(
+      diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
+      2
+    );
+    selection.addRange(range);
+    assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
+  });
+
+  suite('getTextContentForRange', () => {
+    let selection: Selection;
+    let range: Range;
+    let nodes: NodeListOf<GrFormattedText>;
+
+    setup(() => {
+      element.diffTable!.classList.add('selected-left');
+      element.diffTable!.classList.add('selected-comment');
+      element.diffTable!.classList.remove('selected-right');
+      const s = document.getSelection();
+      if (s === null) assert.fail('no selection');
+      selection = s;
+      selection.removeAllRanges();
+      range = document.createRange();
+      nodes = diffTable.querySelectorAll('.gr-formatted-text *');
+    });
+
+    test('multi level element contained in range', () => {
+      range.setStart(nodes[2].childNodes[0], 1);
+      range.setEnd(nodes[2].childNodes[2], 7);
+      selection.addRange(range);
+      assert.equal(
+        element.getTextContentForRange(diffTable, selection, range),
+        'his is a differ'
+      );
+    });
+
+    test('multi level element as startContainer of range', () => {
+      range.setStart(nodes[2].childNodes[1], 0);
+      range.setEnd(nodes[2].childNodes[2], 7);
+      selection.addRange(range);
+      assert.equal(
+        element.getTextContentForRange(diffTable, selection, range),
+        'a differ'
+      );
+    });
+
+    test('startContainer === endContainer', () => {
+      range.setStart(nodes[0].firstChild!, 2);
+      range.setEnd(nodes[0].firstChild!, 12);
+      selection.addRange(range);
+      assert.equal(
+        element.getTextContentForRange(diffTable, selection, range),
+        'is is a co'
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index a38ec91..34c2a33 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -80,6 +80,7 @@
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -99,7 +100,6 @@
 
 export interface GrDiff {
   $: {
-    highlights: GrDiffHighlight;
     diffBuilder: GrDiffBuilderElement;
     diffTable: HTMLTableElement;
   };
@@ -294,6 +294,10 @@
 
   private renderDiffTableTask?: DelayedTask;
 
+  private diffSelection = new GrDiffSelection();
+
+  private highlights = new GrDiffHighlight();
+
   constructor() {
     super();
     this._setLoading(true);
@@ -315,6 +319,8 @@
     this.renderDiffTableTask?.cancel();
     this._unobserveIncrementalNodes();
     this._unobserveNodes();
+    this.diffSelection.cleanup();
+    this.highlights.cleanup();
     super.disconnectedCallback();
   }
 
@@ -357,7 +363,7 @@
     // and pass the shadow DOM selection into gr-diff-highlight, where the
     // corresponding range is determined and normalized.
     const selection = this._getShadowOrDocumentSelection();
-    this.$.highlights.handleSelectionChange(selection, false);
+    this.highlights.handleSelectionChange(selection, false);
   };
 
   private readonly handleMouseUp = () => {
@@ -365,7 +371,7 @@
     // mouse-up if there's a selection that just covers a line change. We
     // can't do that on selection change since the user may still be dragging.
     const selection = this._getShadowOrDocumentSelection();
-    this.$.highlights.handleSelectionChange(selection, true);
+    this.highlights.handleSelectionChange(selection, true);
   };
 
   /** Gets the current selection, preferring the shadow DOM selection. */
@@ -404,7 +410,7 @@
       const range = getRange(threadEl);
       if (!range) return undefined;
 
-      return {side, range, hovering: false, rootId: threadEl.rootId};
+      return {side, range, rootId: threadEl.rootId};
     }
 
     // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
@@ -430,7 +436,6 @@
       this.push('_commentRanges', {
         side: Side.RIGHT,
         range: this.highlightRange,
-        hovering: true,
         rootId: '',
       });
     }
@@ -498,7 +503,7 @@
   }
 
   isRangeSelected() {
-    return !!this.$.highlights.selectedRange;
+    return !!this.highlights.selectedRange;
   }
 
   toggleLeftDiff() {
@@ -590,7 +595,7 @@
     if (!this.isRangeSelected()) {
       throw Error('Selection is needed for new range comment');
     }
-    const selectedRange = this.$.highlights.selectedRange;
+    const selectedRange = this.highlights.selectedRange;
     if (!selectedRange) throw Error('selected range not set');
     const {side, range} = selectedRange;
     this._createCommentForSelection(side, range);
@@ -813,6 +818,10 @@
       this._diffLength = this.getDiffLength(newValue);
       this._debounceRenderDiffTable();
     }
+    if (this.diff) {
+      this.diffSelection.init(this.diff, this.$.diffTable);
+      this.highlights.init(this.$.diffTable, this.$.diffBuilder);
+    }
   }
 
   /**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
index e05e85a..6d36b89 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
@@ -487,6 +487,10 @@
       color: var(--link-color);
       padding: var(--spacing-m) 0 var(--spacing-m) 48px;
     }
+    #diffTable {
+      /* for gr-selection-action-box positioning */
+      position: relative;
+    }
     #diffTable:focus {
       outline: none;
     }
@@ -670,6 +674,14 @@
     .token-highlight {
       background-color: var(--token-highlighting-color, #fffd54);
     }
+
+    gr-selection-action-box {
+      /**
+       * Needs z-index to appear above wrapped content, since it's inserted
+       * into DOM before it.
+       */
+      z-index: 10;
+    }
   </style>
   <style include="gr-syntax-theme">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
@@ -686,44 +698,36 @@
     class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
     on-click="_handleTap"
   >
-    <gr-diff-selection diff="[[diff]]">
-      <gr-diff-highlight
-        id="highlights"
-        logged-in="[[loggedIn]]"
-        comment-ranges="{{_commentRanges}}"
-      >
-        <gr-diff-builder
-          id="diffBuilder"
-          comment-ranges="[[_commentRanges]]"
-          coverage-ranges="[[coverageRanges]]"
-          diff="[[diff]]"
-          path="[[path]]"
-          view-mode="[[viewMode]]"
-          is-image-diff="[[isImageDiff]]"
-          base-image="[[baseImage]]"
-          layers="[[layers]]"
-          revision-image="[[revisionImage]]"
-          use-new-image-diff-ui="[[useNewImageDiffUi]]"
-        >
-          <table
-            id="diffTable"
-            class$="[[_diffTableClass]]"
-            role="presentation"
-            contenteditable$="[[isContentEditable]]"
-          ></table>
+    <gr-diff-builder
+      id="diffBuilder"
+      comment-ranges="[[_commentRanges]]"
+      coverage-ranges="[[coverageRanges]]"
+      diff="[[diff]]"
+      path="[[path]]"
+      view-mode="[[viewMode]]"
+      is-image-diff="[[isImageDiff]]"
+      base-image="[[baseImage]]"
+      layers="[[layers]]"
+      revision-image="[[revisionImage]]"
+      use-new-image-diff-ui="[[useNewImageDiffUi]]"
+    >
+      <table
+        id="diffTable"
+        class$="[[_diffTableClass]]"
+        role="presentation"
+        contenteditable$="[[isContentEditable]]"
+      ></table>
 
-          <template
-            is="dom-if"
-            if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
-          >
-            <div class="whitespace-change-only-message">
-              This file only contains whitespace changes. Modify the whitespace
-              setting to see the changes.
-            </div>
-          </template>
-        </gr-diff-builder>
-      </gr-diff-highlight>
-    </gr-diff-selection>
+      <template
+        is="dom-if"
+        if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
+      >
+        <div class="whitespace-change-only-message">
+          This file only contains whitespace changes. Modify the whitespace
+          setting to see the changes.
+        </div>
+      </template>
+    </gr-diff-builder>
   </div>
   <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
     [[_newlineWarning]]
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
index 714005e..c8d8a2f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
@@ -14,9 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../../test/common-test-setup-karma.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import {createDiff} from '../../../test/test-data-generators.js';
 import './gr-diff.js';
 import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
 import {getComputedStyleValue} from '../../../utils/dom-util.js';
@@ -51,21 +50,21 @@
 
     setup(() => {
       element = basicFixture.instantiate();
-      sinon.stub(element.$.highlights, 'handleSelectionChange');
+      sinon.stub(element.highlights, 'handleSelectionChange');
     });
 
     test('enabled if logged in', async () => {
       element.loggedIn = true;
       emulateSelection();
       await flush();
-      assert.isTrue(element.$.highlights.handleSelectionChange.called);
+      assert.isTrue(element.highlights.handleSelectionChange.called);
     });
 
     test('ignored if logged out', async () => {
       element.loggedIn = false;
       emulateSelection();
       await flush();
-      assert.isFalse(element.$.highlights.handleSelectionChange.called);
+      assert.isFalse(element.highlights.handleSelectionChange.called);
     });
   });
 
@@ -191,7 +190,7 @@
       element.changeNum = 123;
       element.patchRange = {basePatchNum: 1, patchNum: 2};
       element.path = 'file.txt';
-      element.$.diffBuilder.diff = getMockDiffResponse();
+      element.$.diffBuilder.diff = createDiff();
       element.$.diffBuilder.prefs = {...MINIMAL_PREFS};
       element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder();
 
@@ -526,7 +525,7 @@
 
     suite('getCursorStops', () => {
       function setupDiff() {
-        element.diff = getMockDiffResponse();
+        element.diff = createDiff();
         element.prefs = {
           context: 10,
           tab_size: 8,
@@ -807,7 +806,7 @@
             return Promise.resolve({});
           });
       sinon.stub(element, 'getDiffLength').returns(10000);
-      element.diff = getMockDiffResponse();
+      element.diff = createDiff();
       element.noRenderOnPrefsChange = true;
     });
 
@@ -1217,7 +1216,7 @@
   });
 
   test('getDiffLength', () => {
-    const diff = getMockDiffResponse();
+    const diff = createDiff();
     assert.equal(element.getDiffLength(diff), 52);
   });
 
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 6c8a5e9..70cec64 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -20,7 +20,6 @@
 export interface CommentRangeLayer {
   side: Side;
   range: CommentRange;
-  hovering: boolean;
   // New drafts don't have a rootId.
   rootId?: string;
 }
@@ -40,7 +39,6 @@
  * highlights.
  */
 interface CommentRangeLineLayer {
-  hovering: boolean;
   longRange: boolean;
   id: string;
   // start char (0-based)
@@ -59,7 +57,7 @@
 
 const RANGE_BASE_ONLY = 'style-scope gr-diff range';
 const RANGE_HIGHLIGHT = 'style-scope gr-diff range rangeHighlight';
-const HOVER_HIGHLIGHT = 'style-scope gr-diff range rangeHoverHighlight';
+// Note that there is also `rangeHoverHighlight` being set by GrDiffHighlight.
 
 export class GrRangedCommentLayer implements DiffLayer {
   private knownRanges: CommentRangeLayer[] = [];
@@ -95,11 +93,8 @@
         el,
         range.start,
         range.end - range.start,
-        (range.hovering
-          ? HOVER_HIGHLIGHT
-          : range.longRange
-          ? RANGE_BASE_ONLY
-          : RANGE_HIGHLIGHT) + ` ${strToClassName(range.id)}`
+        (range.longRange ? RANGE_BASE_ONLY : RANGE_HIGHLIGHT) +
+          ` ${strToClassName(range.id)}`
       );
     }
   }
@@ -139,17 +134,15 @@
   }
 
   private addRange(commentRange: CommentRangeLayer) {
-    const {side, range, hovering} = commentRange;
+    const {side, range} = commentRange;
     const longRange = isLongCommentRange(range);
     this.updateRangesMap({
       side,
       range,
-      hovering,
-      operation: (forLine, startChar, endChar, hovering) => {
+      operation: (forLine, startChar, endChar) => {
         forLine.push({
           start: startChar,
           end: endChar,
-          hovering,
           id: id(commentRange),
           longRange,
         });
@@ -158,11 +151,10 @@
   }
 
   private removeRange(commentRange: CommentRangeLayer) {
-    const {side, range, hovering} = commentRange;
+    const {side, range} = commentRange;
     this.updateRangesMap({
       side,
       range,
-      hovering,
       operation: forLine => {
         const index = forLine.findIndex(
           lineRange => id(commentRange) === lineRange.id
@@ -175,21 +167,19 @@
   private updateRangesMap(options: {
     side: Side;
     range: CommentRange;
-    hovering: boolean;
     operation: (
       forLine: CommentRangeLineLayer[],
       start: number,
-      end: number,
-      hovering: boolean
+      end: number
     ) => void;
   }) {
-    const {side, range, hovering, operation} = options;
+    const {side, range, operation} = options;
     const forSide = this.rangesMap[side] || (this.rangesMap[side] = {});
     for (let line = range.start_line; line <= range.end_line; line++) {
       const forLine = forSide[line] || (forSide[line] = []);
       const start = line === range.start_line ? range.start_character : 0;
       const end = line === range.end_line ? range.end_character : -1;
-      operation(forLine, start, end, hovering);
+      operation(forLine, start, end);
     }
     this.notifyUpdateRange(range.start_line, range.end_line, side);
   }
@@ -199,25 +189,20 @@
     const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
     if (lineNum === 'FILE' || lineNum === 'LOST') return [];
     const ranges: CommentRangeLineLayer[] = this.rangesMap[side][lineNum] || [];
-    return (
-      ranges
-        .map(range => {
-          // Make a copy, so that the normalization below does not mess with
-          // our map.
-          range = {...range};
-          range.end = range.end === -1 ? line.text.length : range.end;
+    return ranges.map(range => {
+      // Make a copy, so that the normalization below does not mess with
+      // our map.
+      range = {...range};
+      range.end = range.end === -1 ? line.text.length : range.end;
 
-          // Normalize invalid ranges where the start is after the end but the
-          // start still makes sense. Set the end to the end of the line.
-          // @see Issue 5744
-          if (range.start >= range.end && range.start < line.text.length) {
-            range.end = line.text.length;
-          }
+      // Normalize invalid ranges where the start is after the end but the
+      // start still makes sense. Set the end to the end of the line.
+      // @see Issue 5744
+      if (range.start >= range.end && range.start < line.text.length) {
+        range.end = line.text.length;
+      }
 
-          return range;
-        })
-        // Sort the ranges so that hovering highlights are on top.
-        .sort((a, b) => (a.hovering && !b.hovering ? 1 : 0))
-    );
+      return range;
+    });
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index 4e35645..15d14e3 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -24,7 +24,6 @@
     start_line: 36,
   },
   rootId: 'a',
-  hovering: false,
 };
 
 const rangeB: CommentRangeLayer = {
@@ -36,7 +35,6 @@
     start_line: 10,
   },
   rootId: 'b',
-  hovering: false,
 };
 
 const rangeC: CommentRangeLayer = {
@@ -47,7 +45,6 @@
     start_character: 5,
     start_line: 100,
   },
-  hovering: false,
 };
 
 const rangeD: CommentRangeLayer = {
@@ -59,7 +56,6 @@
     start_line: 55,
   },
   rootId: 'd',
-  hovering: false,
 };
 
 const rangeE: CommentRangeLayer = {
@@ -70,7 +66,6 @@
     start_character: 1,
     start_line: 60,
   },
-  hovering: false,
 };
 
 suite('gr-ranged-comment-layer', () => {
diff --git a/polygerrit-ui/app/test/mocks/diff-response.ts b/polygerrit-ui/app/test/mocks/diff-response.ts
deleted file mode 100644
index 46d30b6..0000000
--- a/polygerrit-ui/app/test/mocks/diff-response.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {DiffInfo} from '../../types/diff';
-
-export function getMockDiffResponse(): DiffInfo {
-  // Return new response, so tests can't affect each other - if a test somehow
-  // modifies it, the future calls return original value
-  // Do not put it to a const outside of a method
-  return {
-    meta_a: {
-      name: 'lorem-ipsum.txt',
-      content_type: 'text/plain',
-      lines: 45,
-    },
-    meta_b: {
-      name: 'lorem-ipsum.txt',
-      content_type: 'text/plain',
-      lines: 48,
-    },
-    intraline_status: 'OK',
-    change_type: 'MODIFIED',
-    diff_header: [
-      'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-      'index b2adcf4..554ae49 100644',
-      '--- a/lorem-ipsum.txt',
-      '+++ b/lorem-ipsum.txt',
-    ],
-    content: [
-      {
-        ab: [
-          'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
-            'nulla phasellus.',
-          'Mattis lectus.',
-          'Sodales duis.',
-          'Orci a faucibus.',
-        ],
-      },
-      {
-        b: [
-          'Nullam neque, ligula ac, id blandit.',
-          'Sagittis tincidunt torquent, tempor nunc amet.',
-          'At rhoncus id.',
-        ],
-      },
-      {
-        ab: [
-          'Sem nascetur, erat ut, non in.',
-          'A donec, venenatis pellentesque dis.',
-          'Mauris mauris.',
-          'Quisque nisl duis, facilisis viverra.',
-          'Justo purus, semper eget et.',
-        ],
-      },
-      {
-        a: [
-          'Est amet, vestibulum pellentesque.',
-          'Erat ligula.',
-          'Justo eros.',
-          'Fringilla quisque.',
-        ],
-      },
-      {
-        ab: [
-          'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          'Eros suspendisse.',
-        ],
-      },
-      {
-        a: ['Rhoncus tempor, ultricies aliquam ipsum.'],
-        b: ['Rhoncus tempor, ultricies praesent ipsum.'],
-        edit_a: [[26, 7]],
-        edit_b: [[26, 8]],
-      },
-      {
-        ab: [
-          'Sollicitudin duis.',
-          'Blandit blandit, ante nisl fusce.',
-          'Felis ac at, tellus consectetuer.',
-          'Sociis ligula sapien, egestas leo.',
-          'Cum pulvinar, sed mauris, cursus neque velit.',
-          'Augue porta lobortis.',
-          'Nibh lorem, amet fermentum turpis, vel pulvinar diam.',
-          'Id quam ipsum, id urna et, massa suspendisse.',
-          'Ac nec, nibh praesent.',
-          'Rutrum vestibulum.',
-          'Est tellus, bibendum habitasse.',
-          'Justo facilisis, vel nulla.',
-          'Donec eu, vulputate neque aliquam, nulla dui.',
-          'Risus adipiscing in.',
-          'Lacus arcu arcu.',
-          'Urna velit.',
-          'Urna a dolor.',
-          'Lectus magna augue, convallis mattis tortor, sed tellus ' +
-            'consequat.',
-          'Etiam dui, blandit wisi.',
-          'Mi nec.',
-          'Vitae eget vestibulum.',
-          'Ullamcorper nunc ante, nec imperdiet felis, consectetur in.',
-          'Ac eget.',
-          'Vel fringilla, interdum pellentesque placerat, proin ante.',
-        ],
-      },
-      {
-        b: [
-          'Eu congue risus.',
-          'Enim ac, quis elementum.',
-          'Non et elit.',
-          'Etiam aliquam, diam vel nunc.',
-        ],
-      },
-      {
-        ab: [
-          'Nec at.',
-          'Arcu mauris, venenatis lacus fermentum, praesent duis.',
-          'Pellentesque amet et, tellus duis.',
-          'Ipsum arcu vitae, justo elit, sed libero tellus.',
-          'Metus rutrum euismod, vivamus sodales, vel arcu nisl.',
-        ],
-      },
-    ],
-  };
-}
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 04b6c93..2fffc9a 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -33,6 +33,7 @@
   CommentInfo,
   CommentLinkInfo,
   CommentLinks,
+  CommentRange,
   CommitId,
   CommitInfo,
   ConfigInfo,
@@ -122,6 +123,7 @@
 } from '../api/rest-api';
 import {RunResult} from '../models/checks/checks-model';
 import {Category, RunStatus} from '../api/checks';
+import {DiffInfo} from '../api/diff';
 
 const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN';
 export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
@@ -467,6 +469,122 @@
   };
 }
 
+export function createDiff(): DiffInfo {
+  return {
+    meta_a: {
+      name: 'lorem-ipsum.txt',
+      content_type: 'text/plain',
+      lines: 45,
+    },
+    meta_b: {
+      name: 'lorem-ipsum.txt',
+      content_type: 'text/plain',
+      lines: 48,
+    },
+    intraline_status: 'OK',
+    change_type: 'MODIFIED',
+    diff_header: [
+      'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+      'index b2adcf4..554ae49 100644',
+      '--- a/lorem-ipsum.txt',
+      '+++ b/lorem-ipsum.txt',
+    ],
+    content: [
+      {
+        ab: [
+          'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
+            'nulla phasellus.',
+          'Mattis lectus.',
+          'Sodales duis.',
+          'Orci a faucibus.',
+        ],
+      },
+      {
+        b: [
+          'Nullam neque, ligula ac, id blandit.',
+          'Sagittis tincidunt torquent, tempor nunc amet.',
+          'At rhoncus id.',
+        ],
+      },
+      {
+        ab: [
+          'Sem nascetur, erat ut, non in.',
+          'A donec, venenatis pellentesque dis.',
+          'Mauris mauris.',
+          'Quisque nisl duis, facilisis viverra.',
+          'Justo purus, semper eget et.',
+        ],
+      },
+      {
+        a: [
+          'Est amet, vestibulum pellentesque.',
+          'Erat ligula.',
+          'Justo eros.',
+          'Fringilla quisque.',
+        ],
+      },
+      {
+        ab: [
+          'Arcu eget, rhoncus amet cursus, ipsum elementum.',
+          'Eros suspendisse.',
+        ],
+      },
+      {
+        a: ['Rhoncus tempor, ultricies aliquam ipsum.'],
+        b: ['Rhoncus tempor, ultricies praesent ipsum.'],
+        edit_a: [[26, 7]],
+        edit_b: [[26, 8]],
+      },
+      {
+        ab: [
+          'Sollicitudin duis.',
+          'Blandit blandit, ante nisl fusce.',
+          'Felis ac at, tellus consectetuer.',
+          'Sociis ligula sapien, egestas leo.',
+          'Cum pulvinar, sed mauris, cursus neque velit.',
+          'Augue porta lobortis.',
+          'Nibh lorem, amet fermentum turpis, vel pulvinar diam.',
+          'Id quam ipsum, id urna et, massa suspendisse.',
+          'Ac nec, nibh praesent.',
+          'Rutrum vestibulum.',
+          'Est tellus, bibendum habitasse.',
+          'Justo facilisis, vel nulla.',
+          'Donec eu, vulputate neque aliquam, nulla dui.',
+          'Risus adipiscing in.',
+          'Lacus arcu arcu.',
+          'Urna velit.',
+          'Urna a dolor.',
+          'Lectus magna augue, convallis mattis tortor, sed tellus ' +
+            'consequat.',
+          'Etiam dui, blandit wisi.',
+          'Mi nec.',
+          'Vitae eget vestibulum.',
+          'Ullamcorper nunc ante, nec imperdiet felis, consectetur in.',
+          'Ac eget.',
+          'Vel fringilla, interdum pellentesque placerat, proin ante.',
+        ],
+      },
+      {
+        b: [
+          'Eu congue risus.',
+          'Enim ac, quis elementum.',
+          'Non et elit.',
+          'Etiam aliquam, diam vel nunc.',
+        ],
+      },
+      {
+        ab: [
+          'Nec at.',
+          'Arcu mauris, venenatis lacus fermentum, praesent duis.',
+          'Pellentesque amet et, tellus duis.',
+          'Ipsum arcu vitae, justo elit, sed libero tellus.',
+          'Metus rutrum euismod, vivamus sodales, vel arcu nisl.',
+        ],
+      },
+    ],
+  };
+}
+
 export function createMergeable(): MergeableInfo {
   return {
     submit_type: SubmitType.MERGE_IF_NECESSARY,
@@ -535,6 +653,15 @@
   };
 }
 
+export function createRange(): CommentRange {
+  return {
+    start_line: 1,
+    start_character: 0,
+    end_line: 1,
+    end_character: 1,
+  };
+}
+
 export function createComment(
   extra: Partial<CommentInfo | DraftInfo> = {}
 ): CommentInfo {
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 0c63de0..985bec1 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -296,6 +296,19 @@
   element.dispatchEvent(new KeyboardEvent('keydown', eventOptions));
 }
 
+export function mouseDown(element: HTMLElement) {
+  const rect = element.getBoundingClientRect();
+  const eventOptions = {
+    bubbles: true,
+    composed: true,
+    clientX: (rect.left + rect.right) / 2,
+    clientY: (rect.top + rect.bottom) / 2,
+    screenX: (rect.left + rect.right) / 2,
+    screenY: (rect.top + rect.bottom) / 2,
+  };
+  element.dispatchEvent(new MouseEvent('mousedown', eventOptions));
+}
+
 export function assertFails(promise: Promise<unknown>, error?: unknown) {
   promise
     .then((_v: unknown) => {