Merge "Some 'project' to 'repo' renaming"
diff --git a/WORKSPACE b/WORKSPACE
index da15966..ab4ab55 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -65,8 +65,8 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "f10a3a12894fc3c9bf578ee5a5691769f6805c4be84359681a785a0c12e8d2b6",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.5.3/rules_nodejs-5.5.3.tar.gz"],
+    sha256 = "c29944ba9b0b430aadcaf3bf2570fece6fc5ebfb76df145c6cdad40d65c20811",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.7.0/rules_nodejs-5.7.0.tar.gz"],
 )
 
 load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
@@ -136,7 +136,7 @@
 load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
 
 node_repositories(
-    node_version = "16.16.0",
+    node_version = "17.9.1",
     yarn_version = "1.22.19",
 )
 
diff --git a/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java b/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
index 2f47107..e74af5b 100644
--- a/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
+++ b/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
@@ -16,6 +16,7 @@
 
 import static java.time.format.DateTimeFormatter.ISO_INSTANT;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.gson.TypeAdapter;
 import com.google.gson.stream.JsonReader;
 import com.google.gson.stream.JsonWriter;
@@ -27,7 +28,7 @@
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
 import java.time.format.FormatStyle;
-import java.time.temporal.TemporalAccessor;
+import java.util.Locale;
 
 /**
  * Adapter that reads/writes {@link Timestamp}s as ISO 8601 instant in UTC.
@@ -49,6 +50,16 @@
   private static final DateTimeFormatter FALLBACK =
       DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
 
+  /**
+   * Fixed format to parse date/time in the "Feb 7, 2017 2:20:30 AM" format
+   *
+   * <p>Some old comments (created in Jan-Feb 2017) can be stored in legacy format, which can't be
+   * parsed with {@link #FALLBACK} formatter if the system/default locale has been changed. We will
+   * try to parse with a fixed format if {@link #FALLBACK} doesn't work.
+   */
+  private static final DateTimeFormatter FIXED_FORMAT_FALLBACK =
+      DateTimeFormatter.ofPattern("MMM d, yyyy h:mm:ss a").withLocale(Locale.US);
+
   @Override
   public void write(JsonWriter out, Timestamp ts) throws IOException {
     Timestamp truncated = new Timestamp(ts.getTime() / 1000 * 1000);
@@ -58,12 +69,26 @@
   @Override
   public Timestamp read(JsonReader in) throws IOException {
     String str = in.nextString();
-    TemporalAccessor ta;
     try {
-      ta = ISO_INSTANT.parse(str);
+      return Timestamp.from(Instant.from(ISO_INSTANT.parse(str)));
     } catch (DateTimeParseException e) {
-      ta = LocalDateTime.from(FALLBACK.parse(str)).atZone(ZoneId.systemDefault());
+      try {
+        return parseDateTimeWithDefaultLocaleFormat(str);
+      } catch (DateTimeParseException e2) {
+        return parseDateTimeWithFixedFormat(str);
+      }
     }
-    return Timestamp.from(Instant.from(ta));
+  }
+
+  public static Timestamp parseDateTimeWithDefaultLocaleFormat(String str) {
+    return Timestamp.from(
+        Instant.from(LocalDateTime.from(FALLBACK.parse(str)).atZone(ZoneId.systemDefault())));
+  }
+
+  @VisibleForTesting
+  public static Timestamp parseDateTimeWithFixedFormat(String str) {
+    return Timestamp.from(
+        Instant.from(
+            LocalDateTime.from(FIXED_FORMAT_FALLBACK.parse(str)).atZone(ZoneId.systemDefault())));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
index 347d547..7aadc08 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
@@ -18,10 +18,12 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
@@ -312,13 +314,14 @@
     gApi.projects().name(project.get()).label(labelName).create(input);
   }
 
-  private void createSubmitRequirement(String name, String submitExpression, boolean canOverride)
-      throws Exception {
+  @CanIgnoreReturnValue
+  private SubmitRequirementApi createSubmitRequirement(
+      String name, String submitExpression, boolean canOverride) throws Exception {
     SubmitRequirementInput input = new SubmitRequirementInput();
     input.name = name;
     input.submittabilityExpression = submitExpression;
     input.allowOverrideInChildProjects = canOverride;
-    gApi.projects().name(project.get()).submitRequirement(name).create(input);
+    return gApi.projects().name(project.get()).submitRequirement(name).create(input);
   }
 
   private void assertLabelFunction(String labelName, String function) throws Exception {
diff --git a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
index 2191f00..5a89584 100644
--- a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -123,6 +123,18 @@
   }
 
   @Test
+  public void fixedFallbackFormatCanParseOutputOfLegacyAdapter() {
+    assertThat(CommentTimestampAdapter.parseDateTimeWithFixedFormat("Feb 7, 2017 2:20:30 AM"))
+        .isEqualTo(Timestamp.from(ZonedDateTime.parse("2017-02-07T10:20:30Z").toInstant()));
+    assertThat(CommentTimestampAdapter.parseDateTimeWithFixedFormat("Feb 17, 2017 10:20:30 AM"))
+        .isEqualTo(Timestamp.from(ZonedDateTime.parse("2017-02-17T18:20:30Z").toInstant()));
+    assertThat(CommentTimestampAdapter.parseDateTimeWithFixedFormat("Feb 17, 2017 02:20:30 PM"))
+        .isEqualTo(Timestamp.from(ZonedDateTime.parse("2017-02-17T22:20:30Z").toInstant()));
+    assertThat(CommentTimestampAdapter.parseDateTimeWithFixedFormat("Feb 07, 2017 10:20:30 PM"))
+        .isEqualTo(Timestamp.from(ZonedDateTime.parse("2017-02-08T06:20:30Z").toInstant()));
+  }
+
+  @Test
   public void newAdapterDisagreesWithLegacyAdapterDuringDstTransition() {
     String duringJson = legacyGson.toJson(new Timestamp(MID_DST_MS));
     Timestamp duringTs = legacyGson.fromJson(duringJson, Timestamp.class);
diff --git a/plugins/replication b/plugins/replication
index ced31c0..8f465cd 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit ced31c0c7d56cbb3e10a8da35a8ddd5db1bba550
+Subproject commit 8f465cd3f1f3039a937bd3d863c192e33d8347a2
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
index 6673cdf..56b5aee 100644
--- a/polygerrit-ui/FE_Style_Guide.md
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -167,83 +167,3 @@
 the element's class constructor.
 
 Do not use appContext anywhere except the constructor of the class.
-
-**Note for legacy elements:** If a polymer element extends a LegacyElementMixin and overrides the `created()` method,
-move all code from this method to a constructor right after the call to a `super()`
-([example](#assign-dependencies-legacy-element-example)). The `created()`
-method is [deprecated](https://polymer-library.polymer-project.org/2.0/docs/about_20#lifecycle-changes) and is called
-when a super (i.e. base) class constructor is called. If you are unsure about moving the code from the `created` method
-to the class constructor, consult with the source code:
-[`LegacyElementMixin._initializeProperties`](https://github.com/Polymer/polymer/blob/v3.4.0/lib/legacy/legacy-element-mixin.js#L318)
-and
-[`PropertiesChanged.constructor`](https://github.com/Polymer/polymer/blob/v3.4.0/lib/mixins/properties-changed.js#L177)
-
-
-
-**Good:**
-```Javascript
-import {appContext} from `.../services/app-context.js`;
-
-export class MyCustomElement extends ...{
-    constructor() {
-        super(); //This is mandatory to call parent constructor
-        this._userModel = appContext.userModel;
-    }
-    //...
-    _getUserName() {
-        return this._userModel.activeUserName();
-    }
-}
-```
-
-**Bad:**
-```Javascript
-import {appContext} from `.../services/app-context.js`;
-
-export class MyCustomElement extends ...{
-    created() {
-        // Incorrect: assign all dependencies in the constructor
-        this._userModel = appContext.userModel;
-    }
-    //...
-    _getUserName() {
-        // Incorrect: use appContext outside of a constructor
-        return appContext.userModel.activeUserName();
-    }
-}
-```
-
-<a name="assign-dependencies-legacy-element-example"></a>
-**Legacy element:**
-
-Before:
-```Javascript
-export class MyCustomElement extends ...LegacyElementMixin(...) {
-    constructor() {
-        super();
-        someAction();
-    }
-    created() {
-        super();
-        createdAction1();
-        createdAction2();
-    }
-}
-```
-
-After:
-```Javascript
-export class MyCustomElement extends ...LegacyElementMixin(...) {
-    constructor() {
-        super();
-        // Assign services here
-        this._userModel = appContext.userModel;
-        // Code from the created method - put it before existing actions in constructor
-        createdAction1();
-        createdAction2();
-        // Original constructor code
-        someAction();
-    }
-    // created method is removed
-}
-```
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index b9c065f..ef70a75 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -22,19 +22,16 @@
 
 // Note: for new events, naming convention should be: `a-b`
 export enum EventType {
-  HISTORY = 'history',
   LABEL_CHANGE = 'labelchange',
   SHOW_CHANGE = 'showchange',
   SUBMIT_CHANGE = 'submitchange',
   SHOW_REVISION_ACTIONS = 'show-revision-actions',
   COMMIT_MSG_EDIT = 'commitmsgedit',
-  COMMENT = 'comment',
   REVERT = 'revert',
   REVERT_SUBMISSION = 'revert_submission',
   POST_REVERT = 'postrevert',
   ANNOTATE_DIFF = 'annotatediff',
   ADMIN_MENU_LINKS = 'admin-menu-links',
-  HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
 }
 
 export declare interface PluginApi {
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0e00d07..ae4aad9 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -14,6 +14,8 @@
   PLUGINS_INSTALLED = 'Plugins installed',
   PLUGINS_FAILED = 'Some plugins failed to load',
   USER_REFERRED_FROM = 'User referred from',
+  NOTIFICATION_PERMISSION = 'Notification Permission',
+  SERVICE_WORKER_UPDATE = 'Service worker update',
 }
 
 export enum Execution {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 088002c..85be2d5 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -34,7 +34,10 @@
 } from '../../../types/common';
 import {GroupNameChangedDetail} from '../gr-group/gr-group';
 import {getAppContext} from '../../../services/app-context';
-import {GerritView} from '../../../services/router/router-model';
+import {
+  GerritView,
+  routerModelToken,
+} from '../../../services/router/router-model';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -120,7 +123,7 @@
 
   private readonly getRepoViewModel = resolve(this, repoViewModelToken);
 
-  private readonly routerModel = getAppContext().routerModel;
+  private readonly getRouterModel = resolve(this, routerModelToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
@@ -152,7 +155,7 @@
     );
     subscribe(
       this,
-      () => this.routerModel.routerView$,
+      () => this.getRouterModel().routerView$,
       view => {
         this.view = view;
         if (this.needsReload()) this.reload();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index b7b0c6b..8d6d89a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -40,6 +40,8 @@
 import {customElement, property, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {createSearchUrl} from '../../../models/views/search';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
 
 const STATES = {
   active: {value: RepoState.ACTIVE, label: 'Active'},
@@ -110,7 +112,7 @@
 
   @state() private pluginConfigChanged = false;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -118,7 +120,7 @@
     super();
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       prefs => {
         if (prefs?.download_scheme) {
           // Note (issue 5180): normalize the download scheme with lower-case.
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
index 5728529..714b588 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -39,12 +39,13 @@
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {Interaction} from '../../../constants/reporting';
 import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
 
 @customElement('gr-change-list-bulk-vote-flow')
 export class GrChangeListBulkVoteFlow extends LitElement {
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly reportingService = getAppContext().reportingService;
 
@@ -141,7 +142,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       account => (this.account = account)
     );
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index 436e435..cb6dd7d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -44,6 +44,7 @@
 import {fireAlert, fireReload} from '../../../utils/event-util';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {Interaction} from '../../../constants/reporting';
+import {userModelToken} from '../../../models/user/user-model';
 
 @customElement('gr-change-list-reviewer-flow')
 export class GrChangeListReviewerFlow extends LitElement {
@@ -95,9 +96,11 @@
 
   private readonly reportingService = getAppContext().reportingService;
 
-  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
-  private getConfigModel = resolve(this, configModelToken);
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private restApiService = getAppContext().restApiService;
 
@@ -169,12 +172,12 @@
     );
     subscribe(
       this,
-      () => getAppContext().userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       isLoggedIn => (this.isLoggedIn = isLoggedIn)
     );
     subscribe(
       this,
-      () => getAppContext().userModel.account$,
+      () => this.getUserModel().account$,
       account => (this.account = account)
     );
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 8000c22..cd5cb96 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -27,6 +27,7 @@
 } from '../../../models/views/search';
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
 
 const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
@@ -76,7 +77,7 @@
 
   private reporting = getAppContext().reportingService;
 
-  private userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getViewModel = resolve(this, searchViewModelToken);
 
@@ -117,22 +118,22 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       x => (this.account = x)
     );
     subscribe(
       this,
-      () => this.userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       x => (this.loggedIn = x)
     );
     subscribe(
       this,
-      () => this.userModel.preferenceChangesPerPage$,
+      () => this.getUserModel().preferenceChangesPerPage$,
       x => (this.changesPerPage = x)
     );
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       x => (this.preferences = x)
     );
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 6fc2374..a2a4061 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -57,6 +57,7 @@
   UserDashboard,
   YOUR_TURN,
 } from '../../../utils/dashboard-util';
+import {userModelToken} from '../../../models/user/user-model';
 
 const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
 
@@ -107,7 +108,7 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getViewModel = resolve(this, dashboardViewModelToken);
 
@@ -119,7 +120,7 @@
     super();
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       x => (this.account = x)
     );
     subscribe(
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 917b7ca..834f2b0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -107,6 +107,7 @@
 import {rootUrl} from '../../../utils/url-util';
 import {createSearchUrl} from '../../../models/views/search';
 import {createChangeUrl} from '../../../models/views/change';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -540,7 +541,7 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly storage = getAppContext().storageService;
+  private readonly getStorage = resolve(this, storageServiceToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
@@ -1720,7 +1721,7 @@
 
     // We need to make sure that all cached version of a change
     // edit are deleted.
-    this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
+    this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
 
     this.fireAction(
       '/edit',
@@ -2077,7 +2078,7 @@
 
     // We need to make sure that all cached version of a change
     // edit are deleted.
-    this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
+    this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
 
     this.fireAction(
       '/edit:publish',
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 1b98c5d..a5431ed 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -22,7 +22,6 @@
   query,
   queryAll,
   queryAndAssert,
-  spyStorage,
   stubReporting,
   stubRestApi,
 } from '../../../test/test-utils';
@@ -48,7 +47,6 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {UIActionInfo} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {getAppContext} from '../../../services/app-context';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
 import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
@@ -60,6 +58,7 @@
 import {GrConfirmRevertDialog} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
 import {EventType} from '../../../types/events';
 import {testResolver} from '../../../test/common-test-setup';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 
 // TODO(dhruvsri): remove use of _populateRevertMessage as it's private
 suite('gr-change-actions tests', () => {
@@ -813,7 +812,7 @@
         element.editPatchsetLoaded = true;
         await element.updateComplete;
 
-        const storage = getAppContext().storageService;
+        const storage = testResolver(storageServiceToken);
         storage.setEditableContentItem(
           'c42_ps2_index.php',
           '<?php\necho 42_ps_2'
@@ -836,7 +835,8 @@
         assert.isOk(storage.getEditableContentItem('c42_ps2_index.php')!);
         assert.isNotOk(storage.getEditableContentItem('c50_psedit_index.php')!);
 
-        const eraseEditableContentItemsForChangeEditSpy = spyStorage(
+        const eraseEditableContentItemsForChangeEditSpy = sinon.spy(
+          storage,
           'eraseEditableContentItemsForChangeEdit'
         );
         sinon.stub(element, 'fireAction');
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index ed1f46d..84bdffb 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -57,6 +57,7 @@
 import {when} from 'lit/directives/when.js';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {combineLatest} from 'rxjs';
+import {userModelToken} from '../../../models/user/user-model';
 
 function handleSpaceOrEnter(e: KeyboardEvent, handler: () => void) {
   if (modifierPressed(e)) return;
@@ -109,11 +110,9 @@
 
   private readonly showAllChips = new Map<RunStatus | Category, boolean>();
 
-  // private but used in tests
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  // private but used in tests
-  readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
@@ -172,7 +171,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       x => (this.selfAccount = x)
     );
     if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
@@ -180,7 +179,7 @@
         this,
         () =>
           combineLatest([
-            this.userModel.account$,
+            this.getUserModel().account$,
             this.getCommentsModel().threads$,
           ]),
         ([selfAccount, threads]) => {
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
index 9584637..05036ab 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
@@ -16,11 +16,22 @@
 } from '../../../test/test-data-generators';
 import {stubFlags} from '../../../test/test-utils';
 import {Timestamp} from '../../../api/rest-api';
+import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
 
 suite('gr-change-summary test', () => {
   let element: GrChangeSummary;
+  let commentsModel: CommentsModel;
+  let userModel: UserModel;
+
   setup(async () => {
     element = await fixture(html`<gr-change-summary></gr-change-summary>`);
+    commentsModel = testResolver(commentsModelToken);
+    userModel = testResolver(userModelToken);
   });
 
   test('is defined', () => {
@@ -29,7 +40,7 @@
   });
 
   test('renders', async () => {
-    element.getCommentsModel().setState({
+    commentsModel.setState({
       drafts: {
         a: [createDraft(), createDraft(), createDraft()],
       },
@@ -112,7 +123,7 @@
     element = await fixture(html`<gr-change-summary></gr-change-summary>`);
     await element.updateComplete;
 
-    element.getCommentsModel().setState({
+    commentsModel.setState({
       drafts: {
         a: [
           {
@@ -139,7 +150,7 @@
       },
       discardedDrafts: [],
     });
-    element.userModel.setAccount({
+    userModel.setAccount({
       ...createAccountWithEmail('abc@def.com'),
       registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
     });
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 535ba6f..6a7aeeb 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -135,7 +135,10 @@
   fireReload,
   fireTitleChange,
 } from '../../../utils/event-util';
-import {GerritView} from '../../../services/router/router-model';
+import {
+  GerritView,
+  routerModelToken,
+} from '../../../services/router/router-model';
 import {
   debounce,
   DelayedTask,
@@ -181,6 +184,7 @@
 } from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {createEditUrl} from '../../../models/views/edit';
+import {userModelToken} from '../../../models/user/user-model';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -535,16 +539,13 @@
 
   private readonly flagsService = getAppContext().flagsService;
 
-  // Private but used in tests.
-  readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  // Private but used in tests.
-  readonly getChangeModel = resolve(this, changeModelToken);
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly routerModel = getAppContext().routerModel;
+  private readonly getRouterModel = resolve(this, routerModelToken);
 
-  // Private but used in tests.
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
@@ -717,14 +718,14 @@
     );
     subscribe(
       this,
-      () => this.routerModel.routerView$,
+      () => this.getRouterModel().routerView$,
       view => {
         this.isViewCurrent = view === GerritView.CHANGE;
       }
     );
     subscribe(
       this,
-      () => this.routerModel.routerPatchNum$,
+      () => this.getRouterModel().routerPatchNum$,
       patchNum => {
         this.routerPatchNum = patchNum;
       }
@@ -738,7 +739,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.preferenceDiffViewMode$,
+      () => this.getUserModel().preferenceDiffViewMode$,
       diffViewMode => {
         this.diffViewMode = diffViewMode;
       }
@@ -768,14 +769,14 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       account => {
         this.account = account;
       }
     );
     subscribe(
       this,
-      () => this.userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       loggedIn => {
         this.loggedIn = loggedIn;
       }
@@ -1726,9 +1727,9 @@
   // Private but used in tests.
   handleToggleDiffMode() {
     if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) {
-      this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+      this.getUserModel().updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.userModel.updatePreferences({
+      this.getUserModel().updatePreferences({
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       });
     }
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 77f080d..2491610 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -28,7 +28,6 @@
   queryAndAssert,
   stubFlags,
   stubRestApi,
-  stubUsers,
   waitEventLoop,
   waitQueryAndAssert,
   waitUntil,
@@ -86,7 +85,11 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {LoadingStatus} from '../../../models/change/change-model';
+import {
+  ChangeModel,
+  changeModelToken,
+  LoadingStatus,
+} from '../../../models/change/change-model';
 import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
@@ -101,10 +104,18 @@
 import {ChangeViewState} from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
 
 suite('gr-change-view tests', () => {
   let element: GrChangeView;
   let setUrlStub: sinon.SinonStub;
+  let userModel: UserModel;
+  let changeModel: ChangeModel;
+  let commentsModel: CommentsModel;
 
   const ROBOT_COMMENTS_LIMIT = 10;
 
@@ -374,6 +385,9 @@
       assertIsDefined(element.actions);
       sinon.stub(element.actions, 'reload').returns(Promise.resolve());
     });
+    userModel = testResolver(userModelToken);
+    commentsModel = testResolver(commentsModelToken);
+    changeModel = testResolver(changeModelToken);
   });
 
   teardown(async () => {
@@ -805,7 +819,7 @@
     });
 
     test('A fires an error event when not logged in', async () => {
-      element.userModel.setAccount(undefined);
+      userModel.setAccount(undefined);
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
       pressKey(element, 'a');
@@ -978,14 +992,14 @@
     });
 
     test('m should toggle diff mode', async () => {
-      const updatePreferencesStub = stubUsers('updatePreferences');
+      const updatePreferencesStub = sinon.stub(userModel, 'updatePreferences');
       await element.updateComplete;
 
       const prefs = {
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       };
-      element.userModel.setPreferences(prefs);
+      userModel.setPreferences(prefs);
       element.handleToggleDiffMode();
       assert.isTrue(
         updatePreferencesStub.calledWith({diff_view: DiffViewMode.UNIFIED})
@@ -995,7 +1009,7 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.UNIFIED,
       };
-      element.userModel.setPreferences(newPrefs);
+      userModel.setPreferences(newPrefs);
       await element.updateComplete;
       element.handleToggleDiffMode();
       assert.isTrue(
@@ -1586,11 +1600,11 @@
     sinon.stub(element, 'loadAndSetCommitInfo');
     await element.updateComplete;
     const reloadPortedCommentsStub = sinon.stub(
-      element.getCommentsModel(),
+      commentsModel,
       'reloadPortedComments'
     );
     const reloadPortedDraftsStub = sinon.stub(
-      element.getCommentsModel(),
+      commentsModel,
       'reloadPortedDrafts'
     );
     sinon.stub(element.fileList, 'collapseAllDiffs');
@@ -1683,7 +1697,7 @@
     );
 
     element.viewState = createChangeViewState();
-    element.getChangeModel().setState({
+    changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1776,7 +1790,7 @@
 
   test('topic is coalesced to null', async () => {
     sinon.stub(element, 'changeChanged');
-    element.getChangeModel().setState({
+    changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1791,7 +1805,7 @@
   });
 
   test('commit sha is populated from getChangeDetail', async () => {
-    element.getChangeModel().setState({
+    changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -2161,7 +2175,7 @@
   test('selectedRevision updates when patchNum is changed', async () => {
     const revision1: RevisionInfo = createRevision(1);
     const revision2: RevisionInfo = createRevision(2);
-    element.getChangeModel().setState({
+    changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -2174,7 +2188,7 @@
         current_revision: 'bbb' as CommitId,
       },
     });
-    element.userModel.setPreferences(createPreferences());
+    userModel.setPreferences(createPreferences());
 
     element.patchRange = {patchNum: 2 as RevisionPatchSetNum};
     await element.performPostChangeLoadTasks();
@@ -2189,7 +2203,7 @@
     const revision1 = createRevision(1);
     const revision2 = createRevision(2);
     const revision3 = createEditRevision();
-    element.getChangeModel().setState({
+    changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -2463,7 +2477,7 @@
         changeNum: TEST_NUMERIC_CHANGE_ID,
         repo: TEST_PROJECT_NAME,
       };
-      element.getChangeModel().setState({
+      changeModel.setState({
         loadingStatus: LoadingStatus.LOADED,
         change: {
           ...createChangeViewChange(),
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 832738b..9bac7c2 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -38,10 +38,10 @@
   shortcutsServiceToken,
 } from '../../../services/shortcuts/shortcuts-service';
 import {resolve} from '../../../models/dependency';
-import {getAppContext} from '../../../services/app-context';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
 import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
 
 @customElement('gr-file-list-header')
 export class GrFileListHeader extends LitElement {
@@ -123,7 +123,7 @@
   // 'hide diffs' buttons still be functional.
   private readonly maxFilesForBulkActions = 225;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
@@ -131,7 +131,7 @@
     super();
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         if (!diffPreferences) return;
         this.diffPrefs = diffPreferences;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index d21f5a9..ccc885e 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -76,6 +76,7 @@
 import {createDiffUrl} from '../../../models/views/diff';
 import {createEditUrl} from '../../../models/views/edit';
 import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -285,7 +286,7 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
@@ -766,7 +767,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         this.diffPrefs = diffPreferences;
       }
@@ -775,7 +776,7 @@
       this,
       () =>
         select(
-          this.userModel.preferences$,
+          this.getUserModel().preferences$,
           prefs => !!prefs?.size_bar_in_change_table
         ),
       sizeBarInChangeTable => {
@@ -784,7 +785,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       loggedIn => {
         this.loggedIn = loggedIn;
       }
@@ -2595,7 +2596,7 @@
   }
 
   private handleReloadingDiffPreference() {
-    this.userModel.getDiffPreferences();
+    this.getUserModel().getDiffPreferences();
   }
 
   private getOldPath(file: NormalizedFileInfo) {
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 5417127..e7a54fb 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -322,8 +322,7 @@
   @state()
   private combinedMessages: CombinedMessage[] = [];
 
-  // Private but used in tests.
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly changeModel = resolve(this, changeModelToken);
 
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index 2e62718..158ad8d 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -32,6 +32,8 @@
 import {fixture, assert} from '@open-wc/testing';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {PaperToggleButtonElement} from '@polymer/paper-toggle-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {commentsModelToken} from '../../../models/comments/comments-model';
 
 const author = {
   _account_id: 42 as AccountId,
@@ -136,7 +138,9 @@
       element = await fixture<GrMessagesList>(
         html`<gr-messages-list></gr-messages-list>`
       );
-      await element.getCommentsModel().reloadComments(0 as NumericChangeId);
+      await testResolver(commentsModelToken).reloadComments(
+        0 as NumericChangeId
+      );
       element.messages = messages;
       await element.updateComplete;
     });
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 9d6ec08..ec5760b 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -129,8 +129,10 @@
   GrComment,
 } from '../../shared/gr-comment/gr-comment';
 import {ShortcutController} from '../../lit/shortcut-controller';
-import {Key, Modifier} from '../../../utils/dom-util';
+import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
+import {userModelToken} from '../../../models/user/user-model';
+import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
 
 export enum FocusTarget {
   ANY = 'any',
@@ -216,8 +218,7 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  // Private but used in tests.
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   // TODO: update type to only ParsedChangeInfo
   @property({type: Object})
@@ -241,6 +242,8 @@
   @property({type: Object})
   projectConfig?: ConfigInfo;
 
+  @query('#patchsetLevelComment') patchsetLevelGrComment?: GrComment;
+
   @query('#reviewers') reviewersList?: GrAccountList;
 
   @query('#ccs') ccsList?: GrAccountList;
@@ -393,7 +396,9 @@
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
-  private readonly accountsModel = getAppContext().accountsModel;
+  private readonly getAccountsModel = resolve(this, accountsModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private latestPatchNum?: PatchSetNumber;
 
@@ -629,7 +634,7 @@
 
     subscribe(
       this,
-      () => getAppContext().userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       isLoggedIn => (this.isLoggedIn = isLoggedIn)
     );
     subscribe(
@@ -1415,7 +1420,9 @@
 
     const newAttentionSetUsers = (
       await Promise.all(
-        newAttentionSetAdditions.map(a => this.accountsModel.fillDetails(a))
+        newAttentionSetAdditions.map(a =>
+          this.getAccountsModel().fillDetails(a)
+        )
       )
     ).filter(isDefined);
 
@@ -1446,11 +1453,7 @@
       reviewInput.remove_from_attention_set
     );
 
-    const patchsetLevelComment = queryAndAssert<GrComment>(
-      this,
-      '#patchsetLevelComment'
-    );
-    await patchsetLevelComment.save();
+    await this.patchsetLevelGrComment?.save();
 
     assertIsDefined(this.change, 'change');
     reviewInput.reviewers = this.computeReviewers();
@@ -1494,13 +1497,17 @@
     if (!section || section === FocusTarget.ANY) {
       section = this.chooseFocusTarget();
     }
-    if (section === FocusTarget.REVIEWERS) {
-      const reviewerEntry = this.reviewersList?.focusStart;
-      setTimeout(() => reviewerEntry?.focus());
-    } else if (section === FocusTarget.CCS) {
-      const ccEntry = this.ccsList?.focusStart;
-      setTimeout(() => ccEntry?.focus());
-    }
+    whenVisible(this, () => {
+      if (section === FocusTarget.REVIEWERS) {
+        const reviewerEntry = this.reviewersList?.focusStart;
+        reviewerEntry?.focus();
+      } else if (section === FocusTarget.CCS) {
+        const ccEntry = this.ccsList?.focusStart;
+        ccEntry?.focus();
+      } else {
+        this.patchsetLevelGrComment?.focus();
+      }
+    });
   }
 
   chooseFocusTarget() {
@@ -1860,11 +1867,7 @@
         bubbles: false,
       })
     );
-    const patchsetLevelComment = queryAndAssert<GrComment>(
-      this,
-      '#patchsetLevelComment'
-    );
-    await patchsetLevelComment.save();
+    await this.patchsetLevelGrComment?.save();
     this.rebuildReviewerArrays();
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index acd1755..ef781ba 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -15,6 +15,7 @@
   queryAndAssert,
   stubFlags,
   stubRestApi,
+  waitUntilVisible,
 } from '../../../test/test-utils';
 import {ChangeStatus, ReviewerState} from '../../../constants/constants';
 import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
@@ -62,6 +63,11 @@
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {Key, Modifier} from '../../../utils/dom-util';
 import {GrComment} from '../../shared/gr-comment/gr-comment';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
 
 function cloneableResponse(status: number, text: string) {
   return {
@@ -87,6 +93,7 @@
   let element: GrReplyDialog;
   let changeNum: NumericChangeId;
   let patchNum: PatchSetNum;
+  let commentsModel: CommentsModel;
 
   let lastId = 1;
   const makeAccount = function () {
@@ -147,6 +154,7 @@
     element.draftCommentThreads = [];
 
     await element.updateComplete;
+    commentsModel = testResolver(commentsModelToken);
   });
 
   function stubSaveReview(
@@ -1444,14 +1452,10 @@
 
   test('focusOn', async () => {
     await element.updateComplete;
-    const clock = sinon.useFakeTimers();
     const chooseFocusTargetSpy = sinon.spy(element, 'chooseFocusTarget');
     element.focusOn();
-    // element.focus() is called after a setTimeout(). The focusOn() method
-    // does not trigger any changes in the element hence element.updateComplete
-    // resolves immediately and cannot be used here, hence tick the clock here
-    // explicitly instead
-    clock.tick(1);
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 1);
     assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
     assert.equal(
@@ -1460,7 +1464,8 @@
     );
 
     element.focusOn(element.FocusTarget.ANY);
-    clock.tick(1);
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
     assert.equal(
@@ -1469,7 +1474,8 @@
     );
 
     element.focusOn(element.FocusTarget.BODY);
-    clock.tick(1);
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
     assert.equal(
@@ -1478,23 +1484,21 @@
     );
 
     element.focusOn(element.FocusTarget.REVIEWERS);
-    clock.tick(1);
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 2);
-    assert.equal(
-      element?.shadowRoot?.activeElement?.tagName,
-      'GR-ACCOUNT-LIST'
+    await waitUntil(
+      () => element?.shadowRoot?.activeElement?.tagName === 'GR-ACCOUNT-LIST'
     );
     assert.equal(element?.shadowRoot?.activeElement?.id, 'reviewers');
 
     element.focusOn(element.FocusTarget.CCS);
-    clock.tick(1);
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(
       element?.shadowRoot?.activeElement?.tagName,
       'GR-ACCOUNT-LIST'
     );
-    assert.equal(element?.shadowRoot?.activeElement?.id, 'ccs');
-    clock.restore();
+    await waitUntil(() => element?.shadowRoot?.activeElement?.id === 'ccs');
   });
 
   test('chooseFocusTarget', () => {
@@ -2381,7 +2385,7 @@
 
     test('replies to patchset level comments are not filtered out', async () => {
       const draft = {...createDraft(), in_reply_to: '1' as UrlEncodedCommentId};
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         drafts: {
           'abc.txt': [draft],
         },
@@ -2417,7 +2421,7 @@
         ...createDraft(),
         message: 'hey @abcd@def take a look at this',
       };
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {
@@ -2452,7 +2456,7 @@
         message: 'hey @abcd@def.com take a look at this',
         unresolved: true,
       };
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {
@@ -2492,7 +2496,7 @@
         message: 'hey @abcd@def.com take a look at this',
         unresolved: true,
       };
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {
@@ -2545,7 +2549,7 @@
       };
       stubRestApi('getAccountDetails').returns(Promise.resolve(account));
 
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {
@@ -2582,7 +2586,7 @@
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       };
       stubRestApi('getAccountDetails').returns(Promise.resolve(account));
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 27b5097..414fed9 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -44,6 +44,7 @@
 import {Interaction} from '../../../constants/reporting';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {HtmlPatched} from '../../../utils/lit-util';
+import {userModelToken} from '../../../models/user/user-model';
 
 enum SortDropdownState {
   TIMESTAMP = 'Latest timestamp',
@@ -205,7 +206,7 @@
 
   private readonly flagsService = getAppContext().flagsService;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly patched = new HtmlPatched(key => {
     this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
@@ -228,7 +229,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       x => (this.account = x)
     );
     // for COMMENTS_AUTOCLOSE logging purposes only
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index dcc9a99..6c6d6a0 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -29,6 +29,7 @@
 import {fireEvent} from '../../../utils/event-util';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -142,9 +143,9 @@
 
   private readonly jsAPI = getAppContext().jsApiService;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly configModel = resolve(this, configModelToken);
+  private readonly getConfigModel = resolve(this, configModelToken);
 
   private subscriptions: Subscription[] = [];
 
@@ -153,8 +154,8 @@
     this.loadAccount();
 
     this.subscriptions.push(
-      this.userModel.preferences$
-        .pipe(
+      this.getUserModel()
+        .preferences$.pipe(
           map(preferences => preferences?.my ?? []),
           distinctUntilChanged()
         )
@@ -163,7 +164,7 @@
         })
     );
     this.subscriptions.push(
-      this.configModel().serverConfig$.subscribe(config => {
+      this.getConfigModel().serverConfig$.subscribe(config => {
         if (!config) return;
         this.serverConfig = config;
         this.retrieveFeedbackURL(config);
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index a145b96..44a5616 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -343,6 +343,7 @@
     for (const subscription of this.subscriptions) {
       subscription.unsubscribe();
     }
+    this.subscriptions = [];
   }
 
   start() {
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 17d7516..8e77f96 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -34,6 +34,7 @@
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {userModelToken} from '../../../models/user/user-model';
 
 interface FilePreview {
   filepath: string;
@@ -89,7 +90,7 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
@@ -97,7 +98,7 @@
     super();
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       preferences => {
         if (!preferences?.disable_token_highlighting) {
           this.layers = [new TokenHighlightLayer(this)];
@@ -106,7 +107,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         if (!diffPreferences) return;
         this.diffPrefs = diffPreferences;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index baf89c0..d3428de 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -84,7 +84,10 @@
 import {deepEqual} from '../../../utils/deep-util';
 import {Category} from '../../../api/checks';
 import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
-import {CODE_MAX_LINES} from '../../../services/highlight/highlight-service';
+import {
+  CODE_MAX_LINES,
+  highlightServiceToken,
+} from '../../../services/highlight/highlight-service';
 import {html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {ValueChangedEvent} from '../../../types/events';
@@ -95,6 +98,7 @@
 } from '../../../utils/async-util';
 import {subscribe} from '../../lit/subscription-controller';
 import {GeneratedWebLink} from '../../../utils/weblink-util';
+import {userModelToken} from '../../../models/user/user-model';
 
 const EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -330,7 +334,7 @@
   private readonly restApiService = getAppContext().restApiService;
 
   // visible for testing
-  readonly userModel = getAppContext().userModel;
+  readonly getUserModel = resolve(this, userModelToken);
 
   // visible for testing
   readonly jsAPI = getAppContext().jsApiService;
@@ -345,7 +349,10 @@
 
   constructor() {
     super();
-    this.syntaxLayer = new GrSyntaxLayerWorker();
+    this.syntaxLayer = new GrSyntaxLayerWorker(
+      resolve(this, highlightServiceToken),
+      () => getAppContext().reportingService
+    );
     this.renderPrefs = {
       ...this.renderPrefs,
       use_lit_components: this.flags.isEnabled(
@@ -372,7 +379,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       loggedIn => (this.loggedIn = loggedIn)
     );
     subscribe(
@@ -384,7 +391,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         this.prefs = diffPreferences;
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index 598819b..4163989 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -53,11 +53,14 @@
 import {GrAnnotationActionsInterface} from '../../shared/gr-js-api-interface/gr-annotation-actions-js-api';
 import {fixture, html, assert} from '@open-wc/testing';
 import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {userModelToken, UserModel} from '../../../models/user/user-model';
 
 suite('gr-diff-host tests', () => {
   let element: GrDiffHost;
   let account = createAccountDetailWithId(1);
   let getDiffRestApiStub: SinonStub;
+  let userModel: UserModel;
 
   setup(async () => {
     stubRestApi('getAccount').callsFake(() => Promise.resolve(account));
@@ -70,6 +73,7 @@
     // Fall back in case a test forgets to set one up
     getDiffRestApiStub.returns(Promise.resolve(createDiff()));
     await element.updateComplete;
+    userModel = testResolver(userModelToken);
   });
 
   suite('plugin layers', () => {
@@ -591,7 +595,7 @@
   });
 
   test('cannot create comments when not logged in', () => {
-    element.userModel.setAccount(undefined);
+    userModel.setAccount(undefined);
     element.patchRange = createPatchRange();
     const showAuthRequireSpy = sinon.spy();
     element.addEventListener('show-auth-required', showAuthRequireSpy);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 75117f9..17ea2e5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -85,7 +85,10 @@
   ValueChangedEvent,
 } from '../../../types/events';
 import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
-import {GerritView} from '../../../services/router/router-model';
+import {
+  GerritView,
+  routerModelToken,
+} from '../../../services/router/router-model';
 import {assertIsDefined} from '../../../utils/common-util';
 import {Key, toggleClass} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
@@ -100,7 +103,6 @@
 import {LoadingStatus} from '../../../models/change/change-model';
 import {DisplayLine} from '../../../api/diff';
 import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
-import {browserModelToken} from '../../../models/browser/browser-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {resolve} from '../../../models/dependency';
@@ -122,6 +124,7 @@
 import {createChangeUrl} from '../../../models/views/change';
 import {createEditUrl} from '../../../models/views/edit';
 import {GeneratedWebLink} from '../../../utils/weblink-util';
+import {userModelToken} from '../../../models/user/user-model';
 
 const LOADING_BLAME = 'Loading blame...';
 const LOADED_BLAME = 'Blame loaded';
@@ -288,20 +291,13 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  // Private but used in tests.
-  readonly routerModel = getAppContext().routerModel;
+  private readonly getRouterModel = resolve(this, routerModelToken);
 
-  // Private but used in tests.
-  readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  // Private but used in tests.
-  readonly getChangeModel = resolve(this, changeModelToken);
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  // Private but used in tests.
-  readonly getBrowserModel = resolve(this, browserModelToken);
-
-  // Private but used in tests.
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
@@ -396,7 +392,7 @@
   private setupSubscriptions() {
     subscribe(
       this,
-      () => this.userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       loggedIn => {
         this.loggedIn = loggedIn;
       }
@@ -417,14 +413,14 @@
     );
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       preferences => {
         this.userPrefs = preferences;
       }
     );
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         this.prefs = diffPreferences;
       }
@@ -478,8 +474,8 @@
           switchMap(() =>
             combineLatest([
               this.getChangeModel().patchNum$,
-              this.routerModel.routerView$,
-              this.userModel.diffPreferences$,
+              this.getRouterModel().routerView$,
+              this.getUserModel().diffPreferences$,
               this.getChangeModel().reviewedFiles$,
             ]).pipe(
               filter(
@@ -1315,9 +1311,9 @@
   handleToggleDiffMode() {
     if (!this.userPrefs) return;
     if (this.userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
-      this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+      this.getUserModel().updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.userModel.updatePreferences({
+      this.getUserModel().updatePreferences({
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       });
     }
@@ -2266,7 +2262,7 @@
   }
 
   private handleReloadingDiffPreference() {
-    this.userModel.getDiffPreferences();
+    this.getUserModel().getDiffPreferences();
   }
 
   private computeCanEdit() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index a611361..a6c1308 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -20,12 +20,14 @@
   queryAndAssert,
   stubReporting,
   stubRestApi,
-  stubUsers,
   waitEventLoop,
   waitUntil,
 } from '../../../test/test-utils';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
-import {GerritView} from '../../../services/router/router-model';
+import {
+  GerritView,
+  routerModelToken,
+} from '../../../services/router/router-model';
 import {
   createRevisions,
   createComment as createCommentGeneric,
@@ -60,7 +62,11 @@
 import {Files, GrDiffView} from './gr-diff-view';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {SinonFakeTimers, SinonStub, SinonSpy} from 'sinon';
-import {LoadingStatus} from '../../../models/change/change-model';
+import {
+  changeModelToken,
+  ChangeModel,
+  LoadingStatus,
+} from '../../../models/change/change-model';
 import {CommentMap} from '../../../utils/comment-util';
 import {ParsedChangeInfo} from '../../../types/types';
 import {assertIsDefined} from '../../../utils/common-util';
@@ -70,6 +76,15 @@
 import {Key} from '../../../utils/dom-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {
+  commentsModelToken,
+  CommentsModel,
+} from '../../../models/comments/comments-model';
+import {
+  BrowserModel,
+  browserModelToken,
+} from '../../../models/browser/browser-model';
 
 function createComment(
   id: string,
@@ -93,6 +108,10 @@
     let diffCommentsStub;
     let getDiffRestApiStub: SinonStub;
     let setUrlStub: SinonStub;
+    let changeModel: ChangeModel;
+    let commentsModel: CommentsModel;
+    let browserModel: BrowserModel;
+    let userModel: UserModel;
 
     function getFilesFromFileList(fileList: string[]): Files {
       const changeFilesByPath = fileList.reduce((files, path) => {
@@ -140,8 +159,12 @@
         ],
       });
       await element.updateComplete;
+      commentsModel = testResolver(commentsModelToken);
+      changeModel = testResolver(changeModelToken);
+      browserModel = testResolver(browserModelToken);
+      userModel = testResolver(userModelToken);
 
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {},
@@ -192,7 +215,7 @@
         assertIsDefined(element.diffHost);
         sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
         viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-        element.getChangeModel().setState({
+        changeModel.setState({
           change: {
             ...createParsedChange(),
             revisions: createRevisions(11),
@@ -202,7 +225,7 @@
       });
 
       test('comment url resolves to comment.patch_set vs latest', () => {
-        element.getCommentsModel().setState({
+        commentsModel.setState({
           comments: {
             '/COMMIT_MSG': [
               createComment('c1', 10, 2, '/COMMIT_MSG'),
@@ -265,7 +288,7 @@
     });
 
     test('unchanged diff X vs latest from comment links navigates to base vs X', async () => {
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {
           '/COMMIT_MSG': [
             createComment('c1', 10, 2, '/COMMIT_MSG'),
@@ -284,7 +307,7 @@
       sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
       sinon.stub(element, 'isFileUnchanged').returns(true);
       const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-      element.getChangeModel().setState({
+      changeModel.setState({
         change: {
           ...createParsedChange(),
           revisions: createRevisions(11),
@@ -311,7 +334,7 @@
     });
 
     test('unchanged diff Base vs latest from comment does not navigate', async () => {
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {
           '/COMMIT_MSG': [
             createComment('c1', 10, 2, '/COMMIT_MSG'),
@@ -330,7 +353,7 @@
       sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
       sinon.stub(element, 'isFileUnchanged').returns(true);
       const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-      element.getChangeModel().setState({
+      changeModel.setState({
         change: {
           ...createParsedChange(),
           revisions: createRevisions(11),
@@ -385,7 +408,7 @@
     });
 
     test('diff toast to go to latest is shown and not base', async () => {
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {
           '/COMMIT_MSG': [
             createComment('c1', 10, 2, '/COMMIT_MSG'),
@@ -405,7 +428,7 @@
       sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
       const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
       element.change = undefined;
-      element.getChangeModel().setState({
+      changeModel.setState({
         change: {
           ...createParsedChange(),
           revisions: createRevisions(11),
@@ -439,7 +462,7 @@
     test('renders', async () => {
       clock = sinon.useFakeTimers();
       element.changeNum = 42 as NumericChangeId;
-      element.getBrowserModel().setScreenWidth(0);
+      browserModel.setScreenWidth(0);
       element.patchRange = {
         basePatchNum: PARENT,
         patchNum: 10 as RevisionPatchSetNum,
@@ -623,7 +646,7 @@
     test('keyboard shortcuts', async () => {
       clock = sinon.useFakeTimers();
       element.changeNum = 42 as NumericChangeId;
-      element.getBrowserModel().setScreenWidth(0);
+      browserModel.setScreenWidth(0);
       element.patchRange = {
         basePatchNum: PARENT,
         patchNum: 10 as RevisionPatchSetNum,
@@ -1529,7 +1552,7 @@
         'automatically called',
       async () => {
         const setReviewedFileStatusStub = sinon
-          .stub(element.getChangeModel(), 'setReviewedFilesStatus')
+          .stub(changeModel, 'setReviewedFilesStatus')
           .callsFake(() => Promise.resolve());
 
         const setReviewedStatusStub = sinon.spy(element, 'setReviewedStatus');
@@ -1541,15 +1564,15 @@
           ...createDefaultDiffPrefs(),
           manual_review: true,
         };
-        element.userModel.setDiffPreferences(diffPreferences);
-        element.getChangeModel().setState({
+        userModel.setDiffPreferences(diffPreferences);
+        changeModel.setState({
           change: createParsedChange(),
           diffPath: '/COMMIT_MSG',
           reviewedFiles: [],
           loadingStatus: LoadingStatus.LOADED,
         });
 
-        element.routerModel.setState({
+        testResolver(routerModelToken).setState({
           changeNum: TEST_NUMERIC_CHANGE_ID,
           view: GerritView.DIFF,
           patchNum: 2 as RevisionPatchSetNum,
@@ -1564,7 +1587,7 @@
         assert.isFalse(setReviewedFileStatusStub.called);
 
         // if prefs are updated then the reviewed status should not be set again
-        element.userModel.setDiffPreferences(createDefaultDiffPrefs());
+        userModel.setDiffPreferences(createDefaultDiffPrefs());
 
         await element.updateComplete;
         assert.isFalse(setReviewedFileStatusStub.called);
@@ -1573,7 +1596,7 @@
 
     test('_prefs.manual_review false means set reviewed is called', async () => {
       const setReviewedFileStatusStub = sinon
-        .stub(element.getChangeModel(), 'setReviewedFilesStatus')
+        .stub(changeModel, 'setReviewedFilesStatus')
         .callsFake(() => Promise.resolve());
 
       assertIsDefined(element.diffHost);
@@ -1583,15 +1606,15 @@
         ...createDefaultDiffPrefs(),
         manual_review: false,
       };
-      element.userModel.setDiffPreferences(diffPreferences);
-      element.getChangeModel().setState({
+      userModel.setDiffPreferences(diffPreferences);
+      changeModel.setState({
         change: createParsedChange(),
         diffPath: '/COMMIT_MSG',
         reviewedFiles: [],
         loadingStatus: LoadingStatus.LOADED,
       });
 
-      element.routerModel.setState({
+      testResolver(routerModelToken).setState({
         changeNum: TEST_NUMERIC_CHANGE_ID,
         view: GerritView.DIFF,
         patchNum: 22 as RevisionPatchSetNum,
@@ -1607,7 +1630,7 @@
     });
 
     test('file review status', async () => {
-      element.getChangeModel().setState({
+      changeModel.setState({
         change: createParsedChange(),
         diffPath: '/COMMIT_MSG',
         reviewedFiles: [],
@@ -1615,14 +1638,14 @@
       });
       element.loggedIn = true;
       const saveReviewedStub = sinon
-        .stub(element.getChangeModel(), 'setReviewedFilesStatus')
+        .stub(changeModel, 'setReviewedFilesStatus')
         .callsFake(() => Promise.resolve());
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload');
 
-      element.userModel.setDiffPreferences(createDefaultDiffPrefs());
+      userModel.setDiffPreferences(createDefaultDiffPrefs());
 
-      element.routerModel.setState({
+      testResolver(routerModelToken).setState({
         changeNum: TEST_NUMERIC_CHANGE_ID,
         view: GerritView.DIFF,
         patchNum: 2 as RevisionPatchSetNum,
@@ -1635,7 +1658,7 @@
 
       await waitUntil(() => saveReviewedStub.called);
 
-      element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', true);
+      changeModel.updateStateFileReviewed('/COMMIT_MSG', true);
       await element.updateComplete;
 
       const reviewedStatusCheckBox = queryAndAssert<HTMLInputElement>(
@@ -1660,7 +1683,7 @@
         false,
       ]);
 
-      element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', false);
+      changeModel.updateStateFileReviewed('/COMMIT_MSG', false);
       await element.updateComplete;
 
       reviewedStatusCheckBox.click();
@@ -1688,7 +1711,7 @@
 
     test('file review status with edit loaded', async () => {
       const saveReviewedStub = sinon.stub(
-        element.getChangeModel(),
+        changeModel,
         'setReviewedFilesStatus'
       );
 
@@ -1730,9 +1753,9 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       };
-      element.getBrowserModel().setScreenWidth(0);
+      browserModel.setScreenWidth(0);
 
-      const userStub = stubUsers('updatePreferences');
+      const userStub = sinon.stub(userModel, 'updatePreferences');
 
       await element.updateComplete;
       // The mode selected in the view state reflects the selected option.
@@ -1926,7 +1949,7 @@
     });
 
     test('handleToggleDiffMode', () => {
-      const userStub = stubUsers('updatePreferences');
+      const userStub = sinon.stub(userModel, 'updatePreferences');
       element.userPrefs = {
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.SIDE_BY_SIDE,
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index a15a575..8e346c3 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -33,6 +33,8 @@
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {editViewModelToken, EditViewState} from '../../../models/views/edit';
 import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -85,11 +87,11 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly storage = getAppContext().storageService;
-
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getStorage = resolve(this, storageServiceToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
@@ -109,7 +111,7 @@
     });
     subscribe(
       this,
-      () => this.userModel.editPreferences$,
+      () => this.getUserModel().editPreferences$,
       editPreferences => (this.editPrefs = editPreferences)
     );
     subscribe(
@@ -379,7 +381,9 @@
     assertIsDefined(patchNum, 'patchset number');
     assertIsDefined(path, 'path');
 
-    const storedContent = this.storage.getEditableContentItem(this.storageKey);
+    const storedContent = this.getStorage().getEditableContentItem(
+      this.storageKey
+    );
 
     return this.restApiService
       .getFileContent(changeNum, path, patchNum)
@@ -418,7 +422,7 @@
 
     this.saving = true;
     this.showAlert(SAVING_MESSAGE);
-    this.storage.eraseEditableContentItem(this.storageKey);
+    this.getStorage().eraseEditableContentItem(this.storageKey);
     if (!this.newContent)
       return Promise.reject(new Error('new content undefined'));
     return this.restApiService
@@ -499,9 +503,9 @@
         const content = e.detail.value;
         if (content) {
           this.newContent = e.detail.value;
-          this.storage.setEditableContentItem(this.storageKey, content);
+          this.getStorage().setEditableContentItem(this.storageKey, content);
         } else {
-          this.storage.eraseEditableContentItem(this.storageKey);
+          this.getStorage().eraseEditableContentItem(this.storageKey);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index 52581ed..d428e18 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -13,7 +13,6 @@
   pressKey,
   query,
   stubRestApi,
-  stubStorage,
 } from '../../../test/test-utils';
 import {
   EDIT,
@@ -32,6 +31,8 @@
 import {EventType} from '../../../types/events';
 import {Modifier} from '../../../utils/dom-util';
 import {testResolver} from '../../../test/common-test-setup';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {StorageService} from '../../../services/storage/gr-storage';
 
 suite('gr-editor-view tests', () => {
   let element: GrEditorView;
@@ -40,6 +41,7 @@
   let saveFileStub: sinon.SinonStub;
   let changeDetailStub: sinon.SinonStub;
   let navigateStub: sinon.SinonStub;
+  let storageService: StorageService;
 
   setup(async () => {
     element = await fixture(html`<gr-editor-view></gr-editor-view>`);
@@ -53,6 +55,7 @@
     };
     element.latestPatchsetNumber = 1 as PatchSetNumber;
     await element.updateComplete;
+    storageService = testResolver(storageServiceToken);
   });
 
   test('render', () => {
@@ -178,7 +181,7 @@
   });
 
   test('reacts to content-change event', async () => {
-    const storageStub = stubStorage('setEditableContentItem');
+    const storageStub = sinon.stub(storageService, 'setEditableContentItem');
     element.newContent = 'test';
     await element.updateComplete;
     query<GrEndpointDecorator>(element, '#editorEndpoint')!.dispatchEvent(
@@ -219,7 +222,7 @@
 
     test('file modification and save, !ok response', async () => {
       const saveSpy = sinon.spy(element, 'saveEdit');
-      const eraseStub = stubStorage('eraseEditableContentItem');
+      const eraseStub = sinon.stub(storageService, 'eraseEditableContentItem');
       const alertStub = sinon.stub(element, 'showAlert');
       saveFileStub.returns(Promise.resolve({ok: false}));
       element.newContent = newText;
@@ -355,7 +358,7 @@
       element.newContent = 'initial';
       element.content = 'initial';
       element.type = 'initial';
-      stubStorage('getEditableContentItem').returns(null);
+      sinon.stub(storageService, 'getEditableContentItem').returns(null);
     });
 
     test('res.ok', () => {
@@ -512,7 +515,7 @@
 
   suite('gr-storage caching', () => {
     test('local edit exists', () => {
-      stubStorage('getEditableContentItem').returns({
+      sinon.stub(storageService, 'getEditableContentItem').returns({
         message: 'pending edit',
         updated: 0,
       });
@@ -544,7 +547,7 @@
     });
 
     test('local edit exists, is same as remote edit', () => {
-      stubStorage('getEditableContentItem').returns({
+      sinon.stub(storageService, 'getEditableContentItem').returns({
         message: 'pending edit',
         updated: 0,
       });
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index c05e4c4..646727f 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -53,7 +53,7 @@
   RpcLogEvent,
   TitleChangeEventDetail,
 } from '../types/events';
-import {GerritView} from '../services/router/router-model';
+import {GerritView, routerModelToken} from '../services/router/router-model';
 import {Execution, LifeCycle} from '../constants/reporting';
 import {fireIronAnnounce} from '../utils/event-util';
 import {resolve} from '../models/dependency';
@@ -73,6 +73,7 @@
 import {createSearchUrl, SearchViewState} from '../models/views/search';
 import {createSettingsUrl} from '../models/views/settings';
 import {createDashboardUrl} from '../models/views/dashboard';
+import {userModelToken} from '../models/user/user-model';
 
 interface ErrorInfo {
   text: string;
@@ -161,9 +162,9 @@
 
   private readonly shortcuts = new ShortcutController(this);
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly routerModel = getAppContext().routerModel;
+  private readonly getRouterModel = resolve(this, routerModelToken);
 
   constructor() {
     super();
@@ -210,7 +211,7 @@
 
     subscribe(
       this,
-      () => this.userModel.preferenceTheme$,
+      () => this.getUserModel().preferenceTheme$,
       theme => {
         this.theme = theme;
         this.applyTheme();
@@ -218,7 +219,7 @@
     );
     subscribe(
       this,
-      () => this.routerModel.routerView$,
+      () => this.getRouterModel().routerView$,
       view => {
         this.view = view;
         if (view) this.errorView?.classList.remove('show');
@@ -257,14 +258,14 @@
     // TODO(milutin): Remove saving preferences after while. This code is
     // for migration.
     if (window.localStorage.getItem('dark-theme')) {
-      this.userModel.updatePreferences({theme: AppTheme.DARK});
+      this.getUserModel().updatePreferences({theme: AppTheme.DARK});
       window.localStorage.removeItem('dark-theme');
       this.reporting.reportExecution(
         Execution.REACHABLE_CODE,
         'Dark theme was migrated from localstorage'
       );
     } else if (window.localStorage.getItem('light-theme')) {
-      this.userModel.updatePreferences({theme: AppTheme.LIGHT});
+      this.getUserModel().updatePreferences({theme: AppTheme.LIGHT});
       window.localStorage.removeItem('light-theme');
       this.reporting.reportExecution(
         Execution.REACHABLE_CODE,
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 0cec8dd..721b46d 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -24,12 +24,18 @@
 import {initGlobalVariables} from './gr-app-global-var-init';
 import './gr-app-element';
 import {Finalizable} from '../services/registry';
-import {provide} from '../models/dependency';
+import {
+  DependencyError,
+  DependencyToken,
+  provide,
+  Provider,
+} from '../models/dependency';
 import {installPolymerResin} from '../scripts/polymer-resin-install';
 
 import {
   createAppContext,
   createAppDependencies,
+  Creator,
 } from '../services/app-context-init';
 import {
   initVisibilityReporter,
@@ -41,6 +47,7 @@
 import {html, LitElement} from 'lit';
 import {customElement} from 'lit/decorators.js';
 import {ServiceWorkerInstaller} from '../services/service-worker-installer';
+import {userModelToken} from '../models/user/user-model';
 
 const appContext = createAppContext();
 injectAppContext(appContext);
@@ -60,15 +67,48 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    const dependencies = createAppDependencies(appContext);
-    for (const [token, service] of dependencies) {
-      this.finalizables.push(service);
-      provide(this, token, () => service);
+    const dependencies = new Map<DependencyToken<unknown>, Provider<unknown>>();
+
+    const injectDependency = <T>(
+      token: DependencyToken<T>,
+      creator: Creator<T>
+    ) => {
+      let service: (T & Finalizable) | undefined = undefined;
+      dependencies.set(token, () => {
+        if (service) return service;
+        service = creator();
+        this.finalizables.push(service);
+        return service;
+      });
+    };
+
+    const resolver = <T>(token: DependencyToken<T>): T => {
+      const provider = dependencies.get(token);
+      if (provider) {
+        return provider() as T;
+      } else {
+        throw new DependencyError(
+          token,
+          'Forgot to set up dependency for gr-app'
+        );
+      }
+    };
+
+    for (const [token, creator] of createAppDependencies(
+      appContext,
+      resolver
+    )) {
+      injectDependency(token, creator);
     }
+    for (const [token, provider] of dependencies) {
+      provide(this, token, provider);
+    }
+    // TODO(milutin): Move inside app dependencies.
     if (!this.serviceWorkerInstaller) {
       this.serviceWorkerInstaller = new ServiceWorkerInstaller(
         appContext.flagsService,
-        appContext.userModel
+        appContext.reportingService,
+        resolver(userModelToken)
       );
     }
   }
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index a87973b..730548c 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -16,7 +16,9 @@
   createServerInfo,
 } from '../test/test-data-generators';
 import {GrAppElement} from './gr-app-element';
-import {GrRouter} from './core/gr-router/gr-router';
+import {GrRouter, routerToken} from './core/gr-router/gr-router';
+import {resolve} from '../models/dependency';
+import {removeRequestDependencyListener} from '../test/common-test-setup';
 
 suite('gr-app tests', () => {
   let grApp: GrApp;
@@ -34,9 +36,14 @@
     stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
     stubRestApi('getVersion').returns(Promise.resolve('42'));
     stubRestApi('probePath').returns(Promise.resolve(false));
-
     grApp = await fixture<GrApp>(html`<gr-app id="app"></gr-app>`);
-    await grApp.updateComplete;
+  });
+
+  test('models resolve', () => {
+    // Verify that models resolve on grApp without falling back
+    // to the ones instantiated by the test-setup.
+    removeRequestDependencyListener();
+    assert.ok(resolve(grApp, routerToken)());
   });
 
   test('reporting', () => {
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
index b0993b9..2add2bb 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
@@ -16,8 +16,7 @@
   @state()
   config?: ServerInfo;
 
-  // visible for testing
-  readonly getConfigModel = resolve(this, configModelToken);
+  private readonly getConfigModel = resolve(this, configModelToken);
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
index bb89d12..a58314e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
@@ -10,10 +10,16 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import {SinonStub} from 'sinon';
 import {createServerInfo} from '../../../test/test-data-generators';
+import {
+  ConfigModel,
+  configModelToken,
+} from '../../../models/config/config-model';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-plugin-host tests', () => {
   let element: GrPluginHost;
   let loadPluginsStub: SinonStub;
+  let configModel: ConfigModel;
 
   setup(async () => {
     loadPluginsStub = sinon.stub(getPluginLoader(), 'loadPlugins');
@@ -21,13 +27,14 @@
       <gr-plugin-host></gr-plugin-host>
     `);
     await element.updateComplete;
+    configModel = testResolver(configModelToken);
 
     sinon.stub(document.body, 'appendChild');
   });
 
   test('load plugins should be called', async () => {
     loadPluginsStub.reset();
-    element.getConfigModel().updateServerConfig({
+    configModel.updateServerConfig({
       ...createServerInfo(),
       plugin: {
         has_avatars: false,
@@ -46,7 +53,7 @@
 
   test('theme plugins should be loaded if enabled', async () => {
     loadPluginsStub.reset();
-    element.getConfigModel().updateServerConfig({
+    configModel.updateServerConfig({
       ...createServerInfo(),
       default_theme: 'gerrit-theme.js',
       plugin: {
@@ -69,7 +76,7 @@
     loadPluginsStub.reset();
     const config = createServerInfo();
     config.gerrit.instance_id = 'test-id';
-    element.getConfigModel().updateServerConfig(config);
+    configModel.updateServerConfig(config);
     assert.isTrue(loadPluginsStub.calledOnce);
     assert.isTrue(loadPluginsStub.calledWith([], 'test-id'));
   });
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 99b47da..f63f34e 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
@@ -6,9 +6,11 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-avatar/gr-avatar';
 import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-hovercard-account/gr-hovercard-account-contents';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
@@ -76,6 +78,15 @@
       div section.hide {
         display: none;
       }
+      gr-hovercard-account-contents {
+        display: block;
+        max-width: 600px;
+        margin-top: var(--spacing-m);
+        background: var(--dialog-background-color);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        box-shadow: var(--elevation-level-5);
+      }
     `,
   ];
 
@@ -211,9 +222,20 @@
         </span>
       </section>
       <section>
-        <span class="title">Account chip preview</span>
+        <span class="title">
+          <gr-tooltip-content
+            title="This is how you appear to others"
+            has-tooltip
+            show-icon
+          >
+            Account preview
+          </gr-tooltip-content>
+        </span>
         <span class="value">
           <gr-account-chip .account=${this.account}></gr-account-chip>
+          <gr-hovercard-account-contents
+            .account=${this.account}
+          ></gr-hovercard-account-contents>
         </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 bcd3964..f7e5dee 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
@@ -110,8 +110,19 @@
             </span>
           </section>
           <section>
-            <span class="title">Account chip preview</span>
-            <span class="value"><gr-account-chip></gr-account-chip></span>
+            <span class="title">
+              <gr-tooltip-content
+                has-tooltip=""
+                show-icon=""
+                title="This is how you appear to others"
+              >
+                Account preview
+              </gr-tooltip-content>
+            </span>
+            <span class="value"
+              ><gr-account-chip></gr-account-chip>
+              <gr-hovercard-account-contents></gr-hovercard-account-contents>
+            </span>
           </section>
         </div>
       `
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
index f554ff0..183425d 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -7,7 +7,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
 import {EditPreferencesInfo} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -15,6 +14,8 @@
 import {customElement, query, state} from 'lit/decorators.js';
 import {convertToString} from '../../../utils/string-util';
 import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
 
 @customElement('gr-edit-preferences')
 export class GrEditPreferences extends LitElement {
@@ -46,13 +47,13 @@
 
   @state() private originalEditPrefs?: EditPreferencesInfo;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   constructor() {
     super();
     subscribe(
       this,
-      () => this.userModel.editPreferences$,
+      () => this.getUserModel().editPreferences$,
       editPreferences => {
         this.originalEditPrefs = editPreferences;
         this.editPrefs = {...editPreferences};
@@ -307,7 +308,7 @@
 
   async save() {
     if (!this.editPrefs) return;
-    await this.userModel.updateEditPreference(this.editPrefs);
+    await this.getUserModel().updateEditPreference(this.editPrefs);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index 460cc7c..9c23857 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -12,12 +12,13 @@
 import {state, customElement} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {subscribe} from '../../lit/subscription-controller';
-import {getAppContext} from '../../../services/app-context';
 import {deepEqual} from '../../../utils/deep-util';
 import {createDefaultPreferences} from '../../../constants/constants';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {classMap} from 'lit/directives/class-map.js';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
 
 @customElement('gr-menu-editor')
 export class GrMenuEditor extends LitElement {
@@ -33,13 +34,13 @@
   @state()
   newUrl = '';
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   constructor() {
     super();
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       prefs => {
         this.originalPrefs = prefs;
         this.menuItems = [...prefs.my];
@@ -196,7 +197,7 @@
   }
 
   private handleSave() {
-    this.userModel.updatePreferences({
+    this.getUserModel().updatePreferences({
       ...this.originalPrefs,
       my: this.menuItems,
     });
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 ff2904a..92b1f86 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
@@ -63,6 +63,7 @@
 import {resolve} from '../../../models/dependency';
 import {settingsViewModelToken} from '../../../models/views/settings';
 import {areNotificationsEnabled} from '../../../utils/worker-util';
+import {userModelToken} from '../../../models/user/user-model';
 
 const GERRIT_DOCS_BASE_URL =
   'https://gerrit-review.googlesource.com/' + 'Documentation';
@@ -201,7 +202,7 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   // private but used in test
   readonly flagsService = getAppContext().flagsService;
@@ -220,14 +221,14 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       acc => {
         this.account = acc;
       }
     );
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       prefs => {
         if (!prefs) {
           throw new Error('getPreferences returned undefined');
@@ -1148,7 +1149,7 @@
 
   // private but used in test
   handleSavePreferences() {
-    return this.userModel.updatePreferences(this.localPrefs);
+    return this.getUserModel().updatePreferences(this.localPrefs);
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index aa5fd58e..bb0200a 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -22,6 +22,8 @@
 import {getRemovedByIconClickReason} from '../../../utils/attention-set-util';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {createSearchUrl} from '../../../models/views/search';
+import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {resolve} from '../../../models/dependency';
 
 @customElement('gr-account-label')
 export class GrAccountLabel extends LitElement {
@@ -97,7 +99,7 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly accountsModel = getAppContext().accountsModel;
+  private readonly getAccountsModel = resolve(this, accountsModelToken);
 
   static override get styles() {
     return [
@@ -190,7 +192,7 @@
 
   override async updated() {
     assertIsDefined(this.account, 'account');
-    const account = await this.accountsModel.fillDetails(this.account);
+    const account = await this.getAccountsModel().fillDetails(this.account);
     if (account) this.account = account;
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index d02801a..df41b1f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -74,6 +74,8 @@
 import {HtmlPatched} from '../../../utils/lit-util';
 import {createDiffUrl} from '../../../models/views/diff';
 import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
 
 declare global {
   interface HTMLElementEventMap {
@@ -247,18 +249,20 @@
   @state()
   saving = false;
 
-  // Private but used in tests.
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly reporting = getAppContext().reportingService;
 
   private readonly shortcuts = new ShortcutController(this);
 
-  private readonly syntaxLayer = new GrSyntaxLayerWorker();
+  private readonly syntaxLayer = new GrSyntaxLayerWorker(
+    resolve(this, highlightServiceToken),
+    () => getAppContext().reportingService
+  );
 
   // for COMMENTS_AUTOCLOSE logging purposes only
   readonly uid = performance.now().toString(36) + Math.random().toString(36);
@@ -281,7 +285,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       x => (this.account = x)
     );
     subscribe(
@@ -291,12 +295,12 @@
     );
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       x => this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
     );
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       prefs => {
         const layers: DiffLayer[] = [this.syntaxLayer];
         if (!prefs.disable_token_highlighting) {
@@ -307,7 +311,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       prefs => {
         this.prefs = {
           ...prefs,
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 10e54c7..97eafc9 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -30,6 +30,8 @@
 import {GrButton} from '../gr-button/gr-button';
 import {SpecialFilePath} from '../../../constants/constants';
 import {GrIcon} from '../gr-icon/gr-icon';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {testResolver} from '../../../test/common-test-setup';
 
 const c1 = {
   author: {name: 'Kermit'},
@@ -311,7 +313,7 @@
     setup(async () => {
       savePromise = mockPromise<DraftInfo>();
       stub = sinon
-        .stub(element.getCommentsModel(), 'saveDraft')
+        .stub(testResolver(commentsModelToken), 'saveDraft')
         .returns(savePromise);
 
       element.thread = createThread(c1, {...c2, unresolved: true});
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 00c2d12..af7c64f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -64,6 +64,7 @@
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {isBase64FileContent} from '../../../api/rest-api';
 import {createDiffUrl} from '../../../models/views/diff';
+import {userModelToken} from '../../../models/user/user-model';
 
 const UNSAVED_MESSAGE = 'Unable to save draft';
 
@@ -229,10 +230,9 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  // Private but used in tests.
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly shortcuts = new ShortcutController(this);
 
@@ -282,12 +282,12 @@
     }
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       x => (this.account = x)
     );
     subscribe(
       this,
-      () => this.userModel.isAdmin$,
+      () => this.getUserModel().isAdmin$,
       x => (this.isAdmin = x)
     );
 
@@ -811,7 +811,8 @@
     // fixed. Currently diff line doesn't match commit message line, because
     // of metadata in diff, which aren't in content api request.
     if (this.comment.path === SpecialFilePath.COMMIT_MESSAGE) return nothing;
-    if (this.isOwner) return nothing;
+    // TODO(milutin): disable user suggestions for owners, after user study.
+    // if (this.isOwner) return nothing;
     return html`<gr-button
       link
       class="action suggestEdit"
@@ -976,7 +977,7 @@
 
   override updated(changed: PropertyValues) {
     if (changed.has('editing')) {
-      if (this.editing) {
+      if (this.editing && !this.permanentEditingMode) {
         whenVisible(this, () => this.textarea?.putCursorAtEnd());
       }
     }
@@ -1088,6 +1089,10 @@
     return !this.messageText?.trimEnd();
   }
 
+  override focus() {
+    this.textarea?.focus();
+  }
+
   private handleEsc() {
     // vim users don't like ESC to cancel/discard, so only do this when the
     // comment text is empty.
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 1b48658..4e8c70f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -47,9 +47,15 @@
 import {SinonStub} from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
 
 suite('gr-comment tests', () => {
   let element: GrComment;
+  let commentsModel: CommentsModel;
   const account = {
     email: 'dhruvsri@google.com' as EmailAddress,
     name: 'Dhruv Srivastava',
@@ -77,6 +83,7 @@
         .comment=${comment}
       ></gr-comment>`
     );
+    commentsModel = testResolver(commentsModelToken);
   });
 
   suite('DOM rendering', () => {
@@ -548,9 +555,7 @@
 
     test('save', async () => {
       const savePromise = mockPromise<DraftInfo>();
-      const stub = sinon
-        .stub(element.getCommentsModel(), 'saveDraft')
-        .returns(savePromise);
+      const stub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
 
       element.comment = createDraft();
       element.editing = true;
@@ -595,7 +600,7 @@
 
     test('save failed', async () => {
       sinon
-        .stub(element.getCommentsModel(), 'saveDraft')
+        .stub(commentsModel, 'saveDraft')
         .returns(Promise.reject(new Error('saving failed')));
 
       element.comment = createDraft();
@@ -615,7 +620,7 @@
     test('discard', async () => {
       const discardPromise = mockPromise<void>();
       const stub = sinon
-        .stub(element.getCommentsModel(), 'discardDraft')
+        .stub(commentsModel, 'discardDraft')
         .returns(discardPromise);
 
       element.comment = createDraft();
@@ -638,7 +643,7 @@
     });
 
     test('resolved comment state indicated by checkbox', async () => {
-      const saveStub = sinon.stub(element.getCommentsModel(), 'saveDraft');
+      const saveStub = sinon.stub(commentsModel, 'saveDraft');
       element.comment = {
         ...createComment(),
         __draft: true,
@@ -662,11 +667,8 @@
     });
 
     test('saving empty text calls discard()', async () => {
-      const saveStub = sinon.stub(element.getCommentsModel(), 'saveDraft');
-      const discardStub = sinon.stub(
-        element.getCommentsModel(),
-        'discardDraft'
-      );
+      const saveStub = sinon.stub(commentsModel, 'saveDraft');
+      const discardStub = sinon.stub(commentsModel, 'discardDraft');
       element.comment = createDraft();
       element.editing = true;
       await element.updateComplete;
@@ -740,9 +742,7 @@
     setup(async () => {
       clock = sinon.useFakeTimers();
       savePromise = mockPromise<DraftInfo>();
-      saveStub = sinon
-        .stub(element.getCommentsModel(), 'saveDraft')
-        .returns(savePromise);
+      saveStub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
 
       element.comment = createUnsaved();
       element.editing = true;
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 15d7072..019bec1 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -8,7 +8,6 @@
 import '../gr-button/gr-button';
 import '../gr-select/gr-select';
 import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
-import {getAppContext} from '../../../services/app-context';
 import {subscribe} from '../../lit/subscription-controller';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -18,6 +17,8 @@
 import {fire} from '../../../utils/event-util';
 import {ValueChangedEvent} from '../../../types/events';
 import {GrSelect} from '../gr-select/gr-select';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
 
 @customElement('gr-diff-preferences')
 export class GrDiffPreferences extends LitElement {
@@ -51,13 +52,13 @@
 
   @state() private originalDiffPrefs?: DiffPreferencesInfo;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   constructor() {
     super();
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         if (!diffPreferences) return;
         this.originalDiffPrefs = diffPreferences;
@@ -314,7 +315,7 @@
 
   async save() {
     if (!this.diffPrefs) return;
-    await this.userModel.updateDiffPreference(this.diffPrefs);
+    await this.getUserModel().updateDiffPreference(this.diffPrefs);
     fire(this, 'has-unsaved-changes-changed', {
       value: this.hasUnsavedChanges(),
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 2d93227..886894e 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -16,6 +16,8 @@
 import {customElement, property, state} from 'lit/decorators.js';
 import {fire} from '../../../utils/event-util';
 import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
 
 declare global {
   interface HTMLElementEventMap {
@@ -53,7 +55,7 @@
   private readonly restApiService = getAppContext().restApiService;
 
   // Private but used in tests.
-  readonly userModel = getAppContext().userModel;
+  readonly getUserModel = resolve(this, userModelToken);
 
   private subscriptions: Subscription[] = [];
 
@@ -63,7 +65,7 @@
       this.loggedIn = loggedIn;
     });
     this.subscriptions.push(
-      this.userModel.preferences$.subscribe(prefs => {
+      this.getUserModel().preferences$.subscribe(prefs => {
         if (prefs?.download_scheme) {
           // Note (issue 5180): normalize the download scheme with lower-case.
           this.selectedScheme = prefs.download_scheme.toLowerCase();
@@ -194,7 +196,7 @@
       this.selectedScheme = scheme;
       fire(this, 'selected-scheme-changed', {value: scheme});
       if (this.loggedIn) {
-        this.userModel.updatePreferences({
+        this.getUserModel().updatePreferences({
           download_scheme: this.selectedScheme,
         });
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index 695c674..b1d4e36 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -18,6 +18,8 @@
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {fixture, html, assert} from '@open-wc/testing';
 import {PaperTabElement} from '@polymer/paper-tabs/paper-tab';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-download-commands', () => {
   let element: GrDownloadCommands;
@@ -170,11 +172,16 @@
     });
   });
   suite('authenticated', () => {
-    test('loads scheme from preferences', async () => {
-      const element: GrDownloadCommands = await fixture(
+    let element: GrDownloadCommands;
+    let userModel: UserModel;
+    setup(async () => {
+      userModel = testResolver(userModelToken);
+      element = await fixture(
         html`<gr-download-commands></gr-download-commands>`
       );
-      element.userModel.setPreferences({
+    });
+    test('loads scheme from preferences', async () => {
+      userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'repo',
       });
@@ -182,10 +189,7 @@
     });
 
     test('normalize scheme from preferences', async () => {
-      const element: GrDownloadCommands = await fixture(
-        html`<gr-download-commands></gr-download-commands>`
-      );
-      element.userModel.setPreferences({
+      userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'REPO',
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index d6a4d94..e176598 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -26,6 +26,8 @@
 import {classMap} from 'lit/directives/class-map.js';
 import {when} from 'lit/directives/when.js';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {resolve} from '../../../models/dependency';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -90,7 +92,7 @@
 
   @state() newContent = '';
 
-  private readonly storage = getAppContext().storageService;
+  private readonly getStorage = resolve(this, storageServiceToken);
 
   private readonly reporting = getAppContext().reportingService;
 
@@ -321,14 +323,14 @@
       this.storeTask,
       () => {
         if (this.newContent.length) {
-          this.storage.setEditableContentItem(storageKey, this.newContent);
+          this.getStorage().setEditableContentItem(storageKey, this.newContent);
         } else {
           // This does not really happen, because we don't clear newContent
           // after saving (see below). So this only occurs when the user clears
           // all the content in the editable textarea. But GrStorage cleans
           // up itself after one day, so we are not so concerned about leaving
           // some garbage behind.
-          this.storage.eraseEditableContentItem(storageKey);
+          this.getStorage().eraseEditableContentItem(storageKey);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
@@ -358,7 +360,7 @@
 
     let content;
     if (this.storageKey) {
-      const storedContent = this.storage.getEditableContentItem(
+      const storedContent = this.getStorage().getEditableContentItem(
         this.storageKey
       );
       if (storedContent?.message) {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index fec347c6..b4f25ce 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -6,17 +6,22 @@
 import '../../../test/common-test-setup';
 import './gr-editable-content';
 import {GrEditableContent} from './gr-editable-content';
-import {query, queryAndAssert, stubStorage} from '../../../test/test-utils';
+import {query, queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
 import {EventType} from '../../../types/events';
+import {StorageService} from '../../../services/storage/gr-storage';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-editable-content tests', () => {
   let element: GrEditableContent;
+  let storageService: StorageService;
 
   setup(async () => {
     element = await fixture(html`<gr-editable-content></gr-editable-content>`);
     await element.updateComplete;
+    storageService = testResolver(storageServiceToken);
   });
 
   test('renders', () => {
@@ -177,7 +182,7 @@
     });
 
     test('editing toggled to true, has stored data', async () => {
-      stubStorage('getEditableContentItem').returns({
+      sinon.stub(storageService, 'getEditableContentItem').returns({
         message: 'stored content',
         updated: 0,
       });
@@ -189,7 +194,7 @@
     });
 
     test('editing toggled to true, has no stored data', async () => {
-      stubStorage('getEditableContentItem').returns(null);
+      sinon.stub(storageService, 'getEditableContentItem').returns(null);
       element.editing = true;
 
       await element.updateComplete;
@@ -199,8 +204,8 @@
     });
 
     test('edits are cached', async () => {
-      const storeStub = stubStorage('setEditableContentItem');
-      const eraseStub = stubStorage('eraseEditableContentItem');
+      const storeStub = sinon.stub(storageService, 'setEditableContentItem');
+      const eraseStub = sinon.stub(storageService, 'eraseEditableContentItem');
       element.editing = true;
 
       // Needed because editingChanged resets newContent
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
new file mode 100644
index 0000000..dc08648
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
@@ -0,0 +1,566 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-avatar/gr-avatar';
+import '../gr-button/gr-button';
+import '../gr-icon/gr-icon';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {getAppContext} from '../../../services/app-context';
+import {
+  accountKey,
+  computeVoteableText,
+  isAccountEmailOnly,
+  isSelf,
+} from '../../../utils/account-util';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+  AccountInfo,
+  ChangeInfo,
+  ServerInfo,
+  ReviewInput,
+} from '../../../types/common';
+import {
+  canHaveAttention,
+  getAddedByReason,
+  getLastUpdate,
+  getReason,
+  getRemovedByReason,
+  hasAttention,
+} from '../../../utils/attention-set-util';
+import {ReviewerState} from '../../../constants/constants';
+import {CURRENT} from '../../../utils/patch-set-util';
+import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css, html, LitElement, nothing} from 'lit';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {EventType} from '../../../types/events';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {createSearchUrl} from '../../../models/views/search';
+import {createDashboardUrl} from '../../../models/views/dashboard';
+import {fire, fireEvent} from '../../../utils/event-util';
+import {userModelToken} from '../../../models/user/user-model';
+
+@customElement('gr-hovercard-account-contents')
+export class GrHovercardAccountContents extends LitElement {
+  @property({type: Object})
+  account!: AccountInfo;
+
+  @state()
+  selfAccount?: AccountInfo;
+
+  /**
+   * Optional ChangeInfo object, typically comes from the change page or
+   * from a row in a list of search results. This is needed for some change
+   * related features like adding the user as a reviewer.
+   */
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  /**
+   * Should attention set related features be shown in the component? Note
+   * that the information whether the user is in the attention set or not is
+   * part of the ChangeInfo object in the change property.
+   */
+  @property({type: Boolean})
+  highlightAttention = false;
+
+  @state()
+  serverConfig?: ServerInfo;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.selfAccount = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      css`
+        .top,
+        .attention,
+        .status,
+        .voteable {
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .links {
+          padding: var(--spacing-m) 0px var(--spacing-l) var(--spacing-xxl);
+        }
+        .top {
+          display: flex;
+          padding-top: var(--spacing-xl);
+          min-width: 300px;
+        }
+        gr-avatar {
+          height: 48px;
+          width: 48px;
+          margin-right: var(--spacing-l);
+        }
+        .title,
+        .email {
+          color: var(--deemphasized-text-color);
+        }
+        .action {
+          border-top: 1px solid var(--border-color);
+          padding: var(--spacing-s) var(--spacing-l);
+          --gr-button-padding: var(--spacing-s) var(--spacing-m);
+        }
+        .attention {
+          background-color: var(--emphasis-color);
+        }
+        .attention a {
+          text-decoration: none;
+        }
+        .status gr-icon {
+          font-size: 14px;
+          position: relative;
+          top: 2px;
+        }
+        gr-icon.attentionIcon {
+          transform: scaleX(0.8);
+        }
+        gr-icon.linkIcon {
+          font-size: var(--line-height-normal, 20px);
+          color: var(--deemphasized-text-color);
+          padding-right: 12px;
+        }
+        .links a {
+          color: var(--link-color);
+          padding: 0px 4px;
+        }
+        .reason {
+          padding-top: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="top">
+        <div class="avatar">
+          <gr-avatar .account=${this.account} .imageSize=${56}></gr-avatar>
+        </div>
+        <div class="account">
+          <h3 class="name heading-3">${this.account.name}</h3>
+          <div class="email">${this.account.email}</div>
+        </div>
+      </div>
+      ${this.renderAccountStatusPlugins()} ${this.renderAccountStatus()}
+      ${this.renderLinks()} ${this.renderChangeRelatedInfoAndActions()}
+    `;
+  }
+
+  private renderChangeRelatedInfoAndActions() {
+    if (this.change === undefined) {
+      return nothing;
+    }
+    const voteableText = computeVoteableText(this.change, this.account);
+    return html`
+      ${voteableText
+        ? html`
+            <div class="voteable">
+              <span class="title">Voteable:</span>
+              <span class="value">${voteableText}</span>
+            </div>
+          `
+        : ''}
+      ${this.renderNeedsAttention()} ${this.renderAddToAttention()}
+      ${this.renderRemoveFromAttention()} ${this.renderReviewerOrCcActions()}
+    `;
+  }
+
+  private renderReviewerOrCcActions() {
+    // `selfAccount` is required so that logged out users can't perform actions.
+    if (!this.selfAccount || !isRemovableReviewer(this.change, this.account))
+      return nothing;
+    return html`
+      <div class="action">
+        <gr-button
+          class="removeReviewerOrCC"
+          link
+          no-uppercase
+          @click=${this.handleRemoveReviewerOrCC}
+        >
+          Remove ${this.computeReviewerOrCCText()}
+        </gr-button>
+      </div>
+      <div class="action">
+        <gr-button
+          class="changeReviewerOrCC"
+          link
+          no-uppercase
+          @click=${this.handleChangeReviewerOrCCStatus}
+        >
+          ${this.computeChangeReviewerOrCCText()}
+        </gr-button>
+      </div>
+    `;
+  }
+
+  private renderAccountStatusPlugins() {
+    return html`
+      <gr-endpoint-decorator name="hovercard-status">
+        <gr-endpoint-param
+          name="account"
+          .value=${this.account}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private renderLinks() {
+    if (!this.account || isAccountEmailOnly(this.account)) return nothing;
+    return html` <div class="links">
+      <gr-icon icon="link" class="linkIcon"></gr-icon>
+      <a
+        href=${ifDefined(this.computeOwnerChangesLink())}
+        @click=${() => {
+          fireEvent(this, 'link-clicked');
+        }}
+        @enter=${() => {
+          fireEvent(this, 'link-clicked');
+        }}
+      >
+        Changes
+      </a>
+      ·
+      <a
+        href=${ifDefined(this.computeOwnerDashboardLink())}
+        @click=${() => {
+          fireEvent(this, 'link-clicked');
+        }}
+        @enter=${() => {
+          fireEvent(this, 'link-clicked');
+        }}
+      >
+        Dashboard
+      </a>
+    </div>`;
+  }
+
+  private renderAccountStatus() {
+    if (!this.account.status) return nothing;
+    return html`
+      <div class="status">
+        <span class="title">About me:</span>
+        <span class="value">${this.account.status}</span>
+      </div>
+    `;
+  }
+
+  private renderNeedsAttention() {
+    if (!(this.isAttentionEnabled && this.hasUserAttention)) return nothing;
+    const lastUpdate = getLastUpdate(this.account, this.change);
+    return html`
+      <div class="attention">
+        <div>
+          <gr-icon
+            icon="label_important"
+            filled
+            small
+            class="attentionIcon"
+          ></gr-icon>
+          <span> ${this.computePronoun()} turn to take action. </span>
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+            target="_blank"
+          >
+            <gr-icon icon="help" title="read documentation"></gr-icon>
+          </a>
+        </div>
+        <div class="reason">
+          <span class="title">Reason:</span>
+          <span class="value">
+            ${getReason(this.serverConfig, this.account, this.change)}
+          </span>
+          ${lastUpdate
+            ? html` (
+                <gr-date-formatter
+                  withTooltip
+                  .dateStr=${lastUpdate}
+                ></gr-date-formatter>
+                )`
+            : ''}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderAddToAttention() {
+    if (!this.computeShowActionAddToAttentionSet()) return nothing;
+    return html`
+      <div class="action">
+        <gr-button
+          class="addToAttentionSet"
+          link
+          no-uppercase
+          @click=${this.handleClickAddToAttentionSet}
+        >
+          Add to attention set
+        </gr-button>
+      </div>
+    `;
+  }
+
+  private renderRemoveFromAttention() {
+    if (!this.computeShowActionRemoveFromAttentionSet()) return nothing;
+    return html`
+      <div class="action">
+        <gr-button
+          class="removeFromAttentionSet"
+          link
+          no-uppercase
+          @click=${this.handleClickRemoveFromAttentionSet}
+        >
+          Remove from attention set
+        </gr-button>
+      </div>
+    `;
+  }
+
+  // private but used by tests
+  computePronoun() {
+    if (!this.account || !this.selfAccount) return '';
+    return isSelf(this.account, this.selfAccount) ? 'Your' : 'Their';
+  }
+
+  computeOwnerChangesLink() {
+    if (!this.account) return undefined;
+    return createSearchUrl({
+      owner:
+        this.account.email ||
+        this.account.username ||
+        this.account.name ||
+        `${this.account._account_id}`,
+    });
+  }
+
+  computeOwnerDashboardLink() {
+    if (!this.account) return undefined;
+    if (this.account._account_id)
+      return createDashboardUrl({user: `${this.account._account_id}`});
+    if (this.account.email)
+      return createDashboardUrl({user: this.account.email});
+    return undefined;
+  }
+
+  get isAttentionEnabled() {
+    return (
+      !!this.highlightAttention &&
+      !!this.change &&
+      canHaveAttention(this.account)
+    );
+  }
+
+  get hasUserAttention() {
+    return hasAttention(this.account, this.change);
+  }
+
+  private getReviewerState(change: ChangeInfo) {
+    if (
+      change.reviewers[ReviewerState.REVIEWER]?.some(
+        (reviewer: AccountInfo) =>
+          reviewer._account_id === this.account._account_id
+      )
+    ) {
+      return ReviewerState.REVIEWER;
+    }
+    return ReviewerState.CC;
+  }
+
+  private computeReviewerOrCCText() {
+    if (!this.change || !this.account) return '';
+    return this.getReviewerState(this.change) === ReviewerState.REVIEWER
+      ? 'Reviewer'
+      : 'CC';
+  }
+
+  private computeChangeReviewerOrCCText() {
+    if (!this.change || !this.account) return '';
+    return this.getReviewerState(this.change) === ReviewerState.REVIEWER
+      ? 'Move Reviewer to CC'
+      : 'Move CC to Reviewer';
+  }
+
+  private handleChangeReviewerOrCCStatus() {
+    assertIsDefined(this.change, 'change');
+    // accountKey() throws an error if _account_id & email is not found, which
+    // we want to check before showing reloading toast
+    const _accountKey = accountKey(this.account);
+    fire(this, EventType.SHOW_ALERT, {
+      message: 'Reloading page...',
+    });
+    const reviewInput: Partial<ReviewInput> = {};
+    reviewInput.reviewers = [
+      {
+        reviewer: _accountKey,
+        state:
+          this.getReviewerState(this.change) === ReviewerState.CC
+            ? ReviewerState.REVIEWER
+            : ReviewerState.CC,
+      },
+    ];
+
+    this.restApiService
+      .saveChangeReview(this.change._number, CURRENT, reviewInput)
+      .then(response => {
+        if (!response || !response.ok) {
+          throw new Error(
+            'something went wrong when toggling' +
+              this.getReviewerState(this.change!)
+          );
+        }
+        fire(this, 'reload', {clearPatchset: true});
+      });
+  }
+
+  private handleRemoveReviewerOrCC() {
+    if (!this.change || !(this.account?._account_id || this.account?.email))
+      throw new Error('Missing change or account.');
+    fire(this, EventType.SHOW_ALERT, {
+      message: 'Reloading page...',
+    });
+    this.restApiService
+      .removeChangeReviewer(
+        this.change._number,
+        (this.account?._account_id || this.account?.email)!
+      )
+      .then((response: Response | undefined) => {
+        if (!response || !response.ok) {
+          throw new Error('something went wrong when removing user');
+        }
+        fire(this, 'reload', {clearPatchset: true});
+        return response;
+      });
+  }
+
+  private computeShowActionAddToAttentionSet() {
+    const involvedOrSelf =
+      isInvolved(this.change, this.selfAccount) ||
+      isSelf(this.account, this.selfAccount);
+    return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention;
+  }
+
+  private computeShowActionRemoveFromAttentionSet() {
+    const involvedOrSelf =
+      isInvolved(this.change, this.selfAccount) ||
+      isSelf(this.account, this.selfAccount);
+    return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention;
+  }
+
+  private handleClickAddToAttentionSet() {
+    if (!this.change || !this.account._account_id) return;
+    fire(this, EventType.SHOW_ALERT, {
+      message: 'Reloading page...',
+      dismissOnNavigation: true,
+    });
+
+    // We are deliberately updating the UI before making the API call. It is a
+    // risk that we are taking to achieve a better UX for 99.9% of the cases.
+    const reason = getAddedByReason(this.selfAccount, this.serverConfig);
+
+    if (!this.change.attention_set) this.change.attention_set = {};
+    this.change.attention_set[this.account._account_id] = {
+      account: this.account,
+      reason,
+      reason_account: this.selfAccount,
+    };
+    fireEvent(this, 'attention-set-updated');
+
+    this.reporting.reportInteraction(
+      'attention-hovercard-add',
+      this.reportingDetails()
+    );
+    this.restApiService
+      .addToAttentionSet(this.change._number, this.account._account_id, reason)
+      .then(() => {
+        fireEvent(this, 'hide-alert');
+      });
+    fireEvent(this, 'action-taken');
+  }
+
+  private handleClickRemoveFromAttentionSet() {
+    if (!this.change || !this.account._account_id) return;
+    fire(this, EventType.SHOW_ALERT, {
+      message: 'Saving attention set update ...',
+      dismissOnNavigation: true,
+    });
+
+    // We are deliberately updating the UI before making the API call. It is a
+    // risk that we are taking to achieve a better UX for 99.9% of the cases.
+
+    const reason = getRemovedByReason(this.selfAccount, this.serverConfig);
+    if (this.change.attention_set)
+      delete this.change.attention_set[this.account._account_id];
+    fireEvent(this, 'attention-set-updated');
+
+    this.reporting.reportInteraction(
+      'attention-hovercard-remove',
+      this.reportingDetails()
+    );
+    this.restApiService
+      .removeFromAttentionSet(
+        this.change._number,
+        this.account._account_id,
+        reason
+      )
+      .then(() => {
+        fireEvent(this, 'hide-alert');
+      });
+    fireEvent(this, 'action-taken');
+  }
+
+  private reportingDetails() {
+    const targetId = this.account._account_id;
+    const ownerId =
+      (this.change && this.change.owner && this.change.owner._account_id) || -1;
+    const selfId = (this.selfAccount && this.selfAccount._account_id) || -1;
+    const reviewers =
+      this.change && this.change.reviewers && this.change.reviewers.REVIEWER
+        ? [...this.change.reviewers.REVIEWER]
+        : [];
+    const reviewerIds = reviewers
+      .map(r => r._account_id)
+      .filter(rId => rId !== ownerId);
+    return {
+      actionByOwner: selfId === ownerId,
+      actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId),
+      targetIsOwner: targetId === ownerId,
+      targetIsReviewer: reviewerIds.includes(targetId),
+      targetIsSelf: targetId === selfId,
+    };
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-hovercard-account-contents': GrHovercardAccountContents;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
new file mode 100644
index 0000000..b217562
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
@@ -0,0 +1,386 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-hovercard-account-contents';
+import {GrHovercardAccountContents} from './gr-hovercard-account-contents';
+import {
+  mockPromise,
+  query,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  AccountDetailInfo,
+  AccountId,
+  EmailAddress,
+  ReviewerState,
+} from '../../../api/rest-api';
+import {
+  createAccountDetailWithId,
+  createChange,
+  createDetailedLabelInfo,
+} from '../../../test/test-data-generators';
+import {GrButton} from '../gr-button/gr-button';
+import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {userModelToken} from '../../../models/user/user-model';
+
+suite('gr-hovercard-account-contents tests', () => {
+  let element: GrHovercardAccountContents;
+
+  const ACCOUNT: AccountDetailInfo = {
+    ...createAccountDetailWithId(31),
+    email: 'kermit@gmail.com' as EmailAddress,
+    username: 'kermit',
+    name: 'Kermit The Frog',
+    status: 'I am a frog',
+    _account_id: 31415926535 as AccountId,
+  };
+
+  setup(async () => {
+    const change = {
+      ...createChange(),
+      attention_set: {},
+      reviewers: {},
+      owner: {...ACCOUNT},
+    };
+    element = await fixture(
+      html`<gr-hovercard-account-contents .account=${ACCOUNT} .change=${change}>
+      </gr-hovercard-account-contents>`
+    );
+    testResolver(userModelToken).setAccount({...ACCOUNT});
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="top">
+          <div class="avatar">
+            <gr-avatar hidden=""></gr-avatar>
+          </div>
+          <div class="account">
+            <h3 class="heading-3 name">Kermit The Frog</h3>
+            <div class="email">kermit@gmail.com</div>
+          </div>
+        </div>
+        <gr-endpoint-decorator name="hovercard-status">
+          <gr-endpoint-param name="account"></gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <div class="status">
+          <span class="title">About me:</span>
+          <span class="value">I am a frog</span>
+        </div>
+        <div class="links">
+          <gr-icon icon="link" class="linkIcon"></gr-icon>
+          <a href="/q/owner:kermit%2540gmail.com">Changes</a>
+          ·
+          <a href="/dashboard/31415926535">Dashboard</a>
+        </div>
+      `
+    );
+  });
+
+  test('renders without change data', async () => {
+    const elementWithoutChange = await fixture(
+      html`<gr-hovercard-account-contents
+        .account=${ACCOUNT}
+      ></gr-hovercard-account-contents>`
+    );
+    assert.shadowDom.equal(
+      elementWithoutChange,
+      /* HTML */ `
+        <div class="top">
+          <div class="avatar">
+            <gr-avatar hidden=""></gr-avatar>
+          </div>
+          <div class="account">
+            <h3 class="heading-3 name">Kermit The Frog</h3>
+            <div class="email">kermit@gmail.com</div>
+          </div>
+        </div>
+        <gr-endpoint-decorator name="hovercard-status">
+          <gr-endpoint-param name="account"> </gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <div class="status">
+          <span class="title"> About me: </span>
+          <span class="value"> I am a frog </span>
+        </div>
+        <div class="links">
+          <gr-icon class="linkIcon" icon="link"> </gr-icon>
+          <a href="/q/owner:kermit%2540gmail.com"> Changes </a>
+          ·
+          <a href="/dashboard/31415926535"> Dashboard </a>
+        </div>
+      `
+    );
+  });
+
+  test('account name is shown', () => {
+    const name = queryAndAssert<HTMLHeadingElement>(element, '.name');
+    assert.equal(name.innerText, 'Kermit The Frog');
+  });
+
+  test('computePronoun', async () => {
+    element.account = createAccountDetailWithId(1);
+    element.selfAccount = createAccountDetailWithId(1);
+    await element.updateComplete;
+    assert.equal(element.computePronoun(), 'Your');
+    element.account = createAccountDetailWithId(2);
+    await element.updateComplete;
+    assert.equal(element.computePronoun(), 'Their');
+  });
+
+  test('account status is not shown if the property is not set', async () => {
+    element.account = {...ACCOUNT, status: undefined};
+    await element.updateComplete;
+    assert.isUndefined(query(element, '.status'));
+  });
+
+  test('account status is displayed', () => {
+    const status = queryAndAssert<HTMLSpanElement>(element, '.status .value');
+    assert.equal(status.innerText, 'I am a frog');
+  });
+
+  test('voteable div is not shown if the property is not set', () => {
+    assert.isUndefined(query(element, '.voteable'));
+  });
+
+  test('voteable div is displayed', async () => {
+    element.change = {
+      ...createChange(),
+      labels: {
+        Foo: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 2, min: 0},
+            },
+          ],
+        },
+        Bar: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createAccountDetailWithId(1),
+              permitted_voting_range: {max: 1, min: 0},
+            },
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 1, min: 0},
+            },
+          ],
+        },
+        FooBar: {
+          ...createDetailedLabelInfo(),
+          all: [{_account_id: 7 as AccountId, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+    element.account = createAccountDetailWithId(1);
+
+    await element.updateComplete;
+    const voteableEl = queryAndAssert<HTMLSpanElement>(
+      element,
+      '.voteable .value'
+    );
+    assert.equal(voteableEl.innerText, 'Bar: +1');
+  });
+
+  test('remove reviewer', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    await element.updateComplete;
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element.addEventListener('reload', reloadListener);
+    const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Remove Reviewer');
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    await element.updateComplete;
+    const saveReviewStub = stubRestApi('saveChangeReview').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element.addEventListener('reload', reloadListener);
+
+    const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
+
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move Reviewer to CC');
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    await element.updateComplete;
+    const saveReviewStub = stubRestApi('saveChangeReview').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element.addEventListener('reload', reloadListener);
+
+    const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move CC to Reviewer');
+
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('remove cc', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    await element.updateComplete;
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element.addEventListener('reload', reloadListener);
+
+    const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
+
+    assert.equal(button.innerText, 'Remove CC');
+    assert.isOk(button);
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('add to attention set', async () => {
+    const apiPromise = mockPromise<Response>();
+    const apiSpy = stubRestApi('addToAttentionSet').returns(apiPromise);
+    element.highlightAttention = true;
+    await element.updateComplete;
+    const showAlertListener = sinon.spy();
+    const hideAlertListener = sinon.spy();
+    const updatedListener = sinon.spy();
+    element.addEventListener(EventType.SHOW_ALERT, showAlertListener);
+    element.addEventListener('hide-alert', hideAlertListener);
+    element.addEventListener('attention-set-updated', updatedListener);
+
+    const button = queryAndAssert<GrButton>(element, '.addToAttentionSet');
+    assert.isOk(button);
+    button.click();
+
+    assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 1);
+    const attention_set_info = Object.values(
+      element.change?.attention_set ?? {}
+    )[0];
+    assert.equal(
+      attention_set_info.reason,
+      `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+        ' using the hovercard menu'
+    );
+    assert.equal(
+      attention_set_info.reason_account?._account_id,
+      ACCOUNT._account_id
+    );
+    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+    assert.isTrue(updatedListener.called, 'updatedListener was called');
+
+    apiPromise.resolve({...new Response(), ok: true});
+    await element.updateComplete;
+    assert.isTrue(apiSpy.calledOnce);
+    assert.equal(
+      apiSpy.lastCall.args[2],
+      `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+        ' using the hovercard menu'
+    );
+    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+  });
+
+  test('remove from attention set', async () => {
+    const apiPromise = mockPromise<Response>();
+    const apiSpy = stubRestApi('removeFromAttentionSet').returns(apiPromise);
+    element.highlightAttention = true;
+    element.change = {
+      ...createChange(),
+      attention_set: {
+        '31415926535': {account: ACCOUNT, reason: 'a good reason'},
+      },
+      reviewers: {},
+      owner: {...ACCOUNT},
+    };
+    await element.updateComplete;
+    const showAlertListener = sinon.spy();
+    const hideAlertListener = sinon.spy();
+    const updatedListener = sinon.spy();
+    element.addEventListener(EventType.SHOW_ALERT, showAlertListener);
+    element.addEventListener('hide-alert', hideAlertListener);
+    element.addEventListener('attention-set-updated', updatedListener);
+
+    const button = queryAndAssert<GrButton>(element, '.removeFromAttentionSet');
+    assert.isOk(button);
+    button.click();
+
+    assert.isDefined(element.change?.attention_set);
+    assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 0);
+    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+    assert.isTrue(updatedListener.called, 'updatedListener was called');
+
+    apiPromise.resolve({...new Response(), ok: true});
+    await element.updateComplete;
+
+    assert.isTrue(apiSpy.calledOnce);
+    assert.equal(
+      apiSpy.lastCall.args[2],
+      `Removed by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+        ' using the hovercard menu'
+    );
+    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 9647141..543f5bc 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -8,42 +8,12 @@
 import '../gr-icon/gr-icon';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import {getAppContext} from '../../../services/app-context';
-import {
-  accountKey,
-  computeVoteableText,
-  isAccountEmailOnly,
-  isSelf,
-} from '../../../utils/account-util';
-import {customElement, property, state} from 'lit/decorators.js';
-import {
-  AccountInfo,
-  ChangeInfo,
-  ServerInfo,
-  ReviewInput,
-} from '../../../types/common';
-import {
-  canHaveAttention,
-  getAddedByReason,
-  getLastUpdate,
-  getReason,
-  getRemovedByReason,
-  hasAttention,
-} from '../../../utils/attention-set-util';
-import {ReviewerState} from '../../../constants/constants';
-import {CURRENT} from '../../../utils/patch-set-util';
-import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
-import {assertIsDefined} from '../../../utils/common-util';
-import {fontStyles} from '../../../styles/gr-font-styles';
-import {css, html, LitElement, nothing} from 'lit';
-import {ifDefined} from 'lit/directives/if-defined.js';
+import {customElement, property} from 'lit/decorators.js';
+import {AccountInfo, ChangeInfo} from '../../../types/common';
+import {html, LitElement} from 'lit';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
-import {EventType} from '../../../types/events';
-import {subscribe} from '../../lit/subscription-controller';
-import {resolve} from '../../../models/dependency';
-import {configModelToken} from '../../../models/config/config-model';
-import {createSearchUrl} from '../../../models/views/search';
-import {createDashboardUrl} from '../../../models/views/dashboard';
+import {when} from 'lit/directives/when.js';
+import './gr-hovercard-account-contents';
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = HovercardMixin(LitElement);
@@ -53,9 +23,6 @@
   @property({type: Object})
   account!: AccountInfo;
 
-  @state()
-  selfAccount?: AccountInfo;
-
   /**
    * Optional ChangeInfo object, typically comes from the change page or
    * from a row in a list of search results. This is needed for some change
@@ -72,498 +39,30 @@
   @property({type: Boolean})
   highlightAttention = false;
 
-  @state()
-  serverConfig?: ServerInfo;
-
-  private readonly restApiService = getAppContext().restApiService;
-
-  private readonly reporting = getAppContext().reportingService;
-
-  // private but used in tests
-  readonly userModel = getAppContext().userModel;
-
-  private readonly getConfigModel = resolve(this, configModelToken);
-
-  constructor() {
-    super();
-    subscribe(
-      this,
-      () => this.userModel.account$,
-      x => (this.selfAccount = x)
-    );
-    subscribe(
-      this,
-      () => this.getConfigModel().serverConfig$,
-      config => {
-        this.serverConfig = config;
-      }
-    );
-  }
-
-  static override get styles() {
-    return [
-      fontStyles,
-      base.styles || [],
-      css`
-        .top,
-        .attention,
-        .status,
-        .voteable {
-          padding: var(--spacing-s) var(--spacing-l);
-        }
-        .links {
-          padding: var(--spacing-m) 0px var(--spacing-l) var(--spacing-xxl);
-        }
-        .top {
-          display: flex;
-          padding-top: var(--spacing-xl);
-          min-width: 300px;
-        }
-        gr-avatar {
-          height: 48px;
-          width: 48px;
-          margin-right: var(--spacing-l);
-        }
-        .title,
-        .email {
-          color: var(--deemphasized-text-color);
-        }
-        .action {
-          border-top: 1px solid var(--border-color);
-          padding: var(--spacing-s) var(--spacing-l);
-          --gr-button-padding: var(--spacing-s) var(--spacing-m);
-        }
-        .attention {
-          background-color: var(--emphasis-color);
-        }
-        .attention a {
-          text-decoration: none;
-        }
-        .status gr-icon {
-          font-size: 14px;
-          position: relative;
-          top: 2px;
-        }
-        gr-icon.attentionIcon {
-          transform: scaleX(0.8);
-        }
-        gr-icon.linkIcon {
-          font-size: var(--line-height-normal, 20px);
-          color: var(--deemphasized-text-color);
-          padding-right: 12px;
-        }
-        .links a {
-          color: var(--link-color);
-          padding: 0px 4px;
-        }
-        .reason {
-          padding-top: var(--spacing-s);
-        }
-      `,
-    ];
-  }
-
   override render() {
     return html`
       <div id="container" role="tooltip" tabindex="-1">
-        ${this.renderContent()}
+        ${when(
+          this._isShowing,
+          () =>
+            html`<gr-hovercard-account-contents
+              .account=${this.account}
+              .change=${this.change}
+              .highlightAttention=${this.highlightAttention}
+              @link-clicked=${this.forceHide}
+              @action-taken=${this.mouseHide}
+              @attention-set-updated=${this.redirectEventToTarget}
+              @hide-alert=${this.redirectEventToTarget}
+              @show-alert=${this.redirectEventToTarget}
+              @reload=${this.redirectEventToTarget}
+            ></gr-hovercard-account-contents>`
+        )}
       </div>
     `;
   }
 
-  private renderContent() {
-    if (!this._isShowing) return;
-    return html`
-      <div class="top">
-        <div class="avatar">
-          <gr-avatar .account=${this.account} imageSize="56"></gr-avatar>
-        </div>
-        <div class="account">
-          <h3 class="name heading-3">${this.account.name}</h3>
-          <div class="email">${this.account.email}</div>
-        </div>
-      </div>
-      ${this.renderAccountStatusPlugins()} ${this.renderAccountStatus()}
-      ${this.renderLinks()} ${this.renderChangeRelatedInfoAndActions()}
-    `;
-  }
-
-  private renderChangeRelatedInfoAndActions() {
-    if (this.change === undefined) {
-      return;
-    }
-    const voteableText = computeVoteableText(this.change, this.account);
-    return html`
-      ${voteableText
-        ? html`
-            <div class="voteable">
-              <span class="title">Voteable:</span>
-              <span class="value">${voteableText}</span>
-            </div>
-          `
-        : ''}
-      ${this.renderNeedsAttention()} ${this.renderAddToAttention()}
-      ${this.renderRemoveFromAttention()} ${this.renderReviewerOrCcActions()}
-    `;
-  }
-
-  private renderReviewerOrCcActions() {
-    if (!this.selfAccount || !isRemovableReviewer(this.change, this.account))
-      return;
-    return html`
-      <div class="action">
-        <gr-button
-          class="removeReviewerOrCC"
-          link=""
-          no-uppercase
-          @click=${this.handleRemoveReviewerOrCC}
-        >
-          Remove ${this.computeReviewerOrCCText()}
-        </gr-button>
-      </div>
-      <div class="action">
-        <gr-button
-          class="changeReviewerOrCC"
-          link=""
-          no-uppercase
-          @click=${this.handleChangeReviewerOrCCStatus}
-        >
-          ${this.computeChangeReviewerOrCCText()}
-        </gr-button>
-      </div>
-    `;
-  }
-
-  private renderAccountStatusPlugins() {
-    return html`
-      <gr-endpoint-decorator name="hovercard-status">
-        <gr-endpoint-param
-          name="account"
-          .value=${this.account}
-        ></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    `;
-  }
-
-  private renderLinks() {
-    if (!this.account || isAccountEmailOnly(this.account)) return nothing;
-    return html` <div class="links">
-      <gr-icon icon="link" class="linkIcon"></gr-icon
-      ><a
-        href=${ifDefined(this.computeOwnerChangesLink())}
-        @click=${() => {
-          this.forceHide();
-          return true;
-        }}
-        @enter=${() => {
-          this.forceHide();
-          return true;
-        }}
-        >Changes</a
-      >·<a
-        href=${ifDefined(this.computeOwnerDashboardLink())}
-        @click=${() => {
-          this.forceHide();
-          return true;
-        }}
-        @enter=${() => {
-          this.forceHide();
-          return true;
-        }}
-        >Dashboard</a
-      >
-    </div>`;
-  }
-
-  private renderAccountStatus() {
-    if (!this.account.status) return;
-    return html`
-      <div class="status">
-        <span class="title">About me:</span>
-        <span class="value">${this.account.status}</span>
-      </div>
-    `;
-  }
-
-  private renderNeedsAttention() {
-    if (!(this.isAttentionEnabled && this.hasUserAttention)) return;
-    const lastUpdate = getLastUpdate(this.account, this.change);
-    return html`
-      <div class="attention">
-        <div>
-          <gr-icon
-            icon="label_important"
-            filled
-            small
-            class="attentionIcon"
-          ></gr-icon>
-          <span> ${this.computePronoun()} turn to take action. </span>
-          <a
-            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
-            target="_blank"
-          >
-            <gr-icon icon="help" title="read documentation"></gr-icon>
-          </a>
-        </div>
-        <div class="reason">
-          <span class="title">Reason:</span>
-          <span class="value">
-            ${getReason(this.serverConfig, this.account, this.change)}
-          </span>
-          ${lastUpdate
-            ? html` (<gr-date-formatter
-                  withTooltip
-                  .dateStr=${lastUpdate}
-                ></gr-date-formatter
-                >)`
-            : ''}
-        </div>
-      </div>
-    `;
-  }
-
-  private renderAddToAttention() {
-    if (!this.computeShowActionAddToAttentionSet()) return;
-    return html`
-      <div class="action">
-        <gr-button
-          class="addToAttentionSet"
-          link=""
-          no-uppercase
-          @click=${this.handleClickAddToAttentionSet}
-        >
-          Add to attention set
-        </gr-button>
-      </div>
-    `;
-  }
-
-  private renderRemoveFromAttention() {
-    if (!this.computeShowActionRemoveFromAttentionSet()) return;
-    return html`
-      <div class="action">
-        <gr-button
-          class="removeFromAttentionSet"
-          link=""
-          no-uppercase
-          @click=${this.handleClickRemoveFromAttentionSet}
-        >
-          Remove from attention set
-        </gr-button>
-      </div>
-    `;
-  }
-
-  // private but used by tests
-  computePronoun() {
-    if (!this.account || !this.selfAccount) return '';
-    return isSelf(this.account, this.selfAccount) ? 'Your' : 'Their';
-  }
-
-  computeOwnerChangesLink() {
-    if (!this.account) return undefined;
-    return createSearchUrl({
-      owner:
-        this.account.email ||
-        this.account.username ||
-        this.account.name ||
-        `${this.account._account_id}`,
-    });
-  }
-
-  computeOwnerDashboardLink() {
-    if (!this.account) return undefined;
-    if (this.account._account_id)
-      return createDashboardUrl({user: `${this.account._account_id}`});
-    if (this.account.email)
-      return createDashboardUrl({user: this.account.email});
-    return undefined;
-  }
-
-  get isAttentionEnabled() {
-    return (
-      !!this.highlightAttention &&
-      !!this.change &&
-      canHaveAttention(this.account)
-    );
-  }
-
-  get hasUserAttention() {
-    return hasAttention(this.account, this.change);
-  }
-
-  private getReviewerState() {
-    if (
-      this.change!.reviewers[ReviewerState.REVIEWER]?.some(
-        (reviewer: AccountInfo) =>
-          reviewer._account_id === this.account._account_id
-      )
-    ) {
-      return ReviewerState.REVIEWER;
-    }
-    return ReviewerState.CC;
-  }
-
-  private computeReviewerOrCCText() {
-    if (!this.change || !this.account) return '';
-    return this.getReviewerState() === ReviewerState.REVIEWER
-      ? 'Reviewer'
-      : 'CC';
-  }
-
-  private computeChangeReviewerOrCCText() {
-    if (!this.change || !this.account) return '';
-    return this.getReviewerState() === ReviewerState.REVIEWER
-      ? 'Move Reviewer to CC'
-      : 'Move CC to Reviewer';
-  }
-
-  private handleChangeReviewerOrCCStatus() {
-    assertIsDefined(this.change, 'change');
-    // accountKey() throws an error if _account_id & email is not found, which
-    // we want to check before showing reloading toast
-    const _accountKey = accountKey(this.account);
-    this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
-      message: 'Reloading page...',
-    });
-    const reviewInput: Partial<ReviewInput> = {};
-    reviewInput.reviewers = [
-      {
-        reviewer: _accountKey,
-        state:
-          this.getReviewerState() === ReviewerState.CC
-            ? ReviewerState.REVIEWER
-            : ReviewerState.CC,
-      },
-    ];
-
-    this.restApiService
-      .saveChangeReview(this.change._number, CURRENT, reviewInput)
-      .then(response => {
-        if (!response || !response.ok) {
-          throw new Error(
-            'something went wrong when toggling' + this.getReviewerState()
-          );
-        }
-        this.dispatchEventThroughTarget('reload', {clearPatchset: true});
-      });
-  }
-
-  private handleRemoveReviewerOrCC() {
-    if (!this.change || !(this.account?._account_id || this.account?.email))
-      throw new Error('Missing change or account.');
-    this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
-      message: 'Reloading page...',
-    });
-    this.restApiService
-      .removeChangeReviewer(
-        this.change._number,
-        (this.account?._account_id || this.account?.email)!
-      )
-      .then((response: Response | undefined) => {
-        if (!response || !response.ok) {
-          throw new Error('something went wrong when removing user');
-        }
-        this.dispatchEventThroughTarget('reload', {clearPatchset: true});
-        return response;
-      });
-  }
-
-  private computeShowActionAddToAttentionSet() {
-    const involvedOrSelf =
-      isInvolved(this.change, this.selfAccount) ||
-      isSelf(this.account, this.selfAccount);
-    return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention;
-  }
-
-  private computeShowActionRemoveFromAttentionSet() {
-    const involvedOrSelf =
-      isInvolved(this.change, this.selfAccount) ||
-      isSelf(this.account, this.selfAccount);
-    return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention;
-  }
-
-  private handleClickAddToAttentionSet(e: MouseEvent) {
-    if (!this.change || !this.account._account_id) return;
-    this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
-      message: 'Saving attention set update ...',
-      dismissOnNavigation: true,
-    });
-
-    // We are deliberately updating the UI before making the API call. It is a
-    // risk that we are taking to achieve a better UX for 99.9% of the cases.
-    const reason = getAddedByReason(this.selfAccount, this.serverConfig);
-
-    if (!this.change.attention_set) this.change.attention_set = {};
-    this.change.attention_set[this.account._account_id] = {
-      account: this.account,
-      reason,
-      reason_account: this.selfAccount,
-    };
-    this.dispatchEventThroughTarget('attention-set-updated');
-
-    this.reporting.reportInteraction(
-      'attention-hovercard-add',
-      this.reportingDetails()
-    );
-    this.restApiService
-      .addToAttentionSet(this.change._number, this.account._account_id, reason)
-      .then(() => {
-        this.dispatchEventThroughTarget('hide-alert');
-      });
-    this.mouseHide(e);
-  }
-
-  private handleClickRemoveFromAttentionSet(e: MouseEvent) {
-    if (!this.change || !this.account._account_id) return;
-    this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
-      message: 'Saving attention set update ...',
-      dismissOnNavigation: true,
-    });
-
-    // We are deliberately updating the UI before making the API call. It is a
-    // risk that we are taking to achieve a better UX for 99.9% of the cases.
-
-    const reason = getRemovedByReason(this.selfAccount, this.serverConfig);
-    if (this.change.attention_set)
-      delete this.change.attention_set[this.account._account_id];
-    this.dispatchEventThroughTarget('attention-set-updated');
-
-    this.reporting.reportInteraction(
-      'attention-hovercard-remove',
-      this.reportingDetails()
-    );
-    this.restApiService
-      .removeFromAttentionSet(
-        this.change._number,
-        this.account._account_id,
-        reason
-      )
-      .then(() => {
-        this.dispatchEventThroughTarget('hide-alert');
-      });
-    this.mouseHide(e);
-  }
-
-  private reportingDetails() {
-    const targetId = this.account._account_id;
-    const ownerId =
-      (this.change && this.change.owner && this.change.owner._account_id) || -1;
-    const selfId = (this.selfAccount && this.selfAccount._account_id) || -1;
-    const reviewers =
-      this.change && this.change.reviewers && this.change.reviewers.REVIEWER
-        ? [...this.change.reviewers.REVIEWER]
-        : [];
-    const reviewerIds = reviewers
-      .map(r => r._account_id)
-      .filter(rId => rId !== ownerId);
-    return {
-      actionByOwner: selfId === ownerId,
-      actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId),
-      targetIsOwner: targetId === ownerId,
-      targetIsReviewer: reviewerIds.includes(targetId),
-      targetIsSelf: targetId === selfId,
-    };
+  private redirectEventToTarget(e: CustomEvent<unknown>) {
+    this.dispatchEventThroughTarget(e.type, e.detail);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
index 89bc043..40e4c75 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
@@ -8,56 +8,35 @@
 import {html} from 'lit';
 import './gr-hovercard-account';
 import {GrHovercardAccount} from './gr-hovercard-account';
-import {
-  mockPromise,
-  query,
-  queryAndAssert,
-  stubRestApi,
-} from '../../../test/test-utils';
-import {
-  AccountDetailInfo,
-  AccountId,
-  EmailAddress,
-  ReviewerState,
-} from '../../../api/rest-api';
+import {queryAndAssert} from '../../../test/test-utils';
 import {
   createAccountDetailWithId,
   createChange,
-  createDetailedLabelInfo,
 } from '../../../test/test-data-generators';
 import {GrButton} from '../gr-button/gr-button';
-import {EventType} from '../../../types/events';
+import {GrHovercardAccountContents} from './gr-hovercard-account-contents';
+import {userModelToken} from '../../../models/user/user-model';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-hovercard-account tests', () => {
   let element: GrHovercardAccount;
-
-  const ACCOUNT: AccountDetailInfo = {
-    ...createAccountDetailWithId(31),
-    email: 'kermit@gmail.com' as EmailAddress,
-    username: 'kermit',
-    name: 'Kermit The Frog',
-    status: 'I am a frog',
-    _account_id: 31415926535 as AccountId,
-  };
+  let contents: GrHovercardAccountContents;
 
   setup(async () => {
-    const change = {
-      ...createChange(),
-      attention_set: {},
-      reviewers: {},
-      owner: {...ACCOUNT},
-    };
+    const account = createAccountDetailWithId(31);
     element = await fixture<GrHovercardAccount>(
       html`<gr-hovercard-account
         class="hovered"
-        .account=${ACCOUNT}
-        .change=${change}
+        .account=${account}
+        .change=${createChange()}
+        .highlightAttention=${true}
       >
       </gr-hovercard-account>`
     );
     await element.show({});
-    element.userModel.setAccount({...ACCOUNT});
+    testResolver(userModelToken).setAccount({...account});
     await element.updateComplete;
+    contents = queryAndAssert(element, 'gr-hovercard-account-contents');
   });
 
   teardown(async () => {
@@ -70,337 +49,29 @@
       element,
       /* HTML */ `
         <div id="container" role="tooltip" tabindex="-1">
-          <div class="top">
-            <div class="avatar">
-              <gr-avatar hidden="" imagesize="56"></gr-avatar>
-            </div>
-            <div class="account">
-              <h3 class="heading-3 name">Kermit The Frog</h3>
-              <div class="email">kermit@gmail.com</div>
-            </div>
-          </div>
-          <gr-endpoint-decorator name="hovercard-status">
-            <gr-endpoint-param name="account"></gr-endpoint-param>
-          </gr-endpoint-decorator>
-          <div class="status">
-            <span class="title">About me:</span>
-            <span class="value">I am a frog</span>
-          </div>
-          <div class="links">
-            <gr-icon icon="link" class="linkIcon"></gr-icon>
-            <a href="/q/owner:kermit%2540gmail.com">Changes</a>
-            ·
-            <a href="/dashboard/31415926535">Dashboard</a>
-          </div>
+          <gr-hovercard-account-contents></gr-hovercard-account-contents>
         </div>
       `
     );
   });
 
-  test('renders without change data', async () => {
-    const elementWithoutChange = await fixture<GrHovercardAccount>(
-      html`<gr-hovercard-account class="hovered" .account=${ACCOUNT}>
-      </gr-hovercard-account>`
-    );
-    await elementWithoutChange.show({});
-    assert.shadowDom.equal(
-      elementWithoutChange,
-      /* HTML */ `
-        <div id="container" role="tooltip" tabindex="-1">
-          <div class="top">
-            <div class="avatar">
-              <gr-avatar hidden="" imagesize="56"> </gr-avatar>
-            </div>
-            <div class="account">
-              <h3 class="heading-3 name">Kermit The Frog</h3>
-              <div class="email">kermit@gmail.com</div>
-            </div>
-          </div>
-          <gr-endpoint-decorator name="hovercard-status">
-            <gr-endpoint-param name="account"> </gr-endpoint-param>
-          </gr-endpoint-decorator>
-          <div class="status">
-            <span class="title"> About me: </span>
-            <span class="value"> I am a frog </span>
-          </div>
-          <div class="links">
-            <gr-icon class="linkIcon" icon="link"> </gr-icon>
-            <a href="/q/owner:kermit%2540gmail.com"> Changes </a>
-            ·
-            <a href="/dashboard/31415926535"> Dashboard </a>
-          </div>
-        </div>
-      `
-    );
-    elementWithoutChange.mouseHide(new MouseEvent('click'));
-    await elementWithoutChange.updateComplete;
+  test('hides when links are clicked', () => {
+    const changesLink = queryAndAssert<HTMLAnchorElement>(contents, 'a');
+    // Actually redirecting will break the test, replace URL with no-op
+    changesLink.href = 'javascript:';
+
+    assert.isTrue(element._isShowing);
+
+    changesLink.click();
+
+    assert.isFalse(element._isShowing);
   });
 
-  test('account name is shown', () => {
-    const name = queryAndAssert<HTMLHeadingElement>(element, '.name');
-    assert.equal(name.innerText, 'Kermit The Frog');
-  });
+  test('hides when actions are performed', () => {
+    assert.isTrue(element._isShowing);
 
-  test('computePronoun', async () => {
-    element.account = createAccountDetailWithId(1);
-    element.selfAccount = createAccountDetailWithId(1);
-    await element.updateComplete;
-    assert.equal(element.computePronoun(), 'Your');
-    element.account = createAccountDetailWithId(2);
-    await element.updateComplete;
-    assert.equal(element.computePronoun(), 'Their');
-  });
+    queryAndAssert<GrButton>(contents, 'gr-button.addToAttentionSet').click();
 
-  test('account status is not shown if the property is not set', async () => {
-    element.account = {...ACCOUNT, status: undefined};
-    await element.updateComplete;
-    assert.isUndefined(query(element, '.status'));
-  });
-
-  test('account status is displayed', () => {
-    const status = queryAndAssert<HTMLSpanElement>(element, '.status .value');
-    assert.equal(status.innerText, 'I am a frog');
-  });
-
-  test('voteable div is not shown if the property is not set', () => {
-    assert.isUndefined(query(element, '.voteable'));
-  });
-
-  test('voteable div is displayed', async () => {
-    element.change = {
-      ...createChange(),
-      labels: {
-        Foo: {
-          ...createDetailedLabelInfo(),
-          all: [
-            {
-              _account_id: 7 as AccountId,
-              permitted_voting_range: {max: 2, min: 0},
-            },
-          ],
-        },
-        Bar: {
-          ...createDetailedLabelInfo(),
-          all: [
-            {
-              ...createAccountDetailWithId(1),
-              permitted_voting_range: {max: 1, min: 0},
-            },
-            {
-              _account_id: 7 as AccountId,
-              permitted_voting_range: {max: 1, min: 0},
-            },
-          ],
-        },
-        FooBar: {
-          ...createDetailedLabelInfo(),
-          all: [{_account_id: 7 as AccountId, value: 0}],
-        },
-      },
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-        FooBar: ['-1', ' 0'],
-      },
-    };
-    element.account = createAccountDetailWithId(1);
-
-    await element.updateComplete;
-    const voteableEl = queryAndAssert<HTMLSpanElement>(
-      element,
-      '.voteable .value'
-    );
-    assert.equal(voteableEl.innerText, 'Bar: +1');
-  });
-
-  test('remove reviewer', async () => {
-    element.change = {
-      ...createChange(),
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [ACCOUNT],
-      },
-    };
-    await element.updateComplete;
-    stubRestApi('removeChangeReviewer').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    const reloadListener = sinon.spy();
-    element._target?.addEventListener('reload', reloadListener);
-    const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
-    assert.isOk(button);
-    assert.equal(button.innerText, 'Remove Reviewer');
-    button.click();
-    await element.updateComplete;
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('move reviewer to cc', async () => {
-    element.change = {
-      ...createChange(),
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [ACCOUNT],
-      },
-    };
-    await element.updateComplete;
-    const saveReviewStub = stubRestApi('saveChangeReview').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    stubRestApi('removeChangeReviewer').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    const reloadListener = sinon.spy();
-    element._target?.addEventListener('reload', reloadListener);
-
-    const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
-
-    assert.isOk(button);
-    assert.equal(button.innerText, 'Move Reviewer to CC');
-    button.click();
-    await element.updateComplete;
-    assert.isTrue(saveReviewStub.called);
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('move reviewer to cc', async () => {
-    element.change = {
-      ...createChange(),
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [],
-      },
-    };
-    await element.updateComplete;
-    const saveReviewStub = stubRestApi('saveChangeReview').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    stubRestApi('removeChangeReviewer').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    const reloadListener = sinon.spy();
-    element._target?.addEventListener('reload', reloadListener);
-
-    const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
-    assert.isOk(button);
-    assert.equal(button.innerText, 'Move CC to Reviewer');
-
-    button.click();
-    await element.updateComplete;
-    assert.isTrue(saveReviewStub.called);
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('remove cc', async () => {
-    element.change = {
-      ...createChange(),
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [],
-      },
-    };
-    await element.updateComplete;
-    stubRestApi('removeChangeReviewer').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    const reloadListener = sinon.spy();
-    element._target?.addEventListener('reload', reloadListener);
-
-    const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
-
-    assert.equal(button.innerText, 'Remove CC');
-    assert.isOk(button);
-    button.click();
-    await element.updateComplete;
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('add to attention set', async () => {
-    const apiPromise = mockPromise<Response>();
-    const apiSpy = stubRestApi('addToAttentionSet').returns(apiPromise);
-    element.highlightAttention = true;
-    element._target = document.createElement('div');
-    await element.updateComplete;
-    const showAlertListener = sinon.spy();
-    const hideAlertListener = sinon.spy();
-    const updatedListener = sinon.spy();
-    element._target.addEventListener(EventType.SHOW_ALERT, showAlertListener);
-    element._target.addEventListener('hide-alert', hideAlertListener);
-    element._target.addEventListener('attention-set-updated', updatedListener);
-
-    const button = queryAndAssert<GrButton>(element, '.addToAttentionSet');
-    assert.isOk(button);
-    assert.isTrue(element._isShowing, 'hovercard is showing');
-    button.click();
-
-    assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 1);
-    const attention_set_info = Object.values(
-      element.change?.attention_set ?? {}
-    )[0];
-    assert.equal(
-      attention_set_info.reason,
-      `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
-        ' using the hovercard menu'
-    );
-    assert.equal(
-      attention_set_info.reason_account?._account_id,
-      ACCOUNT._account_id
-    );
-    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
-    assert.isTrue(updatedListener.called, 'updatedListener was called');
-    assert.isFalse(element._isShowing, 'hovercard is hidden');
-
-    apiPromise.resolve({...new Response(), ok: true});
-    await element.updateComplete;
-    assert.isTrue(apiSpy.calledOnce);
-    assert.equal(
-      apiSpy.lastCall.args[2],
-      `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
-        ' using the hovercard menu'
-    );
-    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
-  });
-
-  test('remove from attention set', async () => {
-    const apiPromise = mockPromise<Response>();
-    const apiSpy = stubRestApi('removeFromAttentionSet').returns(apiPromise);
-    element.highlightAttention = true;
-    element.change = {
-      ...createChange(),
-      attention_set: {
-        '31415926535': {account: ACCOUNT, reason: 'a good reason'},
-      },
-      reviewers: {},
-      owner: {...ACCOUNT},
-    };
-    element._target = document.createElement('div');
-    await element.updateComplete;
-    const showAlertListener = sinon.spy();
-    const hideAlertListener = sinon.spy();
-    const updatedListener = sinon.spy();
-    element._target.addEventListener(EventType.SHOW_ALERT, showAlertListener);
-    element._target.addEventListener('hide-alert', hideAlertListener);
-    element._target.addEventListener('attention-set-updated', updatedListener);
-
-    const button = queryAndAssert<GrButton>(element, '.removeFromAttentionSet');
-    assert.isOk(button);
-    assert.isTrue(element._isShowing, 'hovercard is showing');
-    button.click();
-
-    assert.isDefined(element.change?.attention_set);
-    assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 0);
-    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
-    assert.isTrue(updatedListener.called, 'updatedListener was called');
-    assert.isFalse(element._isShowing, 'hovercard is hidden');
-
-    apiPromise.resolve({...new Response(), ok: true});
-    await element.updateComplete;
-
-    assert.isTrue(apiSpy.calledOnce);
-    assert.equal(
-      apiSpy.lastCall.args[2],
-      `Removed by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
-        ' using the hovercard menu'
-    );
-    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+    assert.isFalse(element._isShowing);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 353fc2a..6142aa2 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -20,7 +20,7 @@
   ShowRevisionActionsDetail,
 } from './gr-js-api-types';
 import {EventType, TargetElement} from '../../../api/plugin';
-import {DiffLayer, HighlightJS, ParsedChangeInfo} from '../../../types/types';
+import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {MenuLink} from '../../../api/admin';
 import {Finalizable} from '../../../services/registry';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
@@ -39,24 +39,15 @@
       .awaitPluginsLoaded()
       .then(() => {
         switch (type) {
-          case EventType.HISTORY:
-            this._handleHistory(detail);
-            break;
           case EventType.SHOW_CHANGE:
             this._handleShowChange(detail);
             break;
-          case EventType.COMMENT:
-            this._handleComment(detail);
-            break;
           case EventType.LABEL_CHANGE:
             this._handleLabelChange(detail);
             break;
           case EventType.SHOW_REVISION_ACTIONS:
             this._handleShowRevisionActions(detail);
             break;
-          case EventType.HIGHLIGHTJS_LOADED:
-            this._handleHighlightjsLoaded(detail);
-            break;
           default:
             console.warn(
               'handleEvent called with unsupported event type:',
@@ -107,21 +98,6 @@
     }
   }
 
-  // TODO(TS): The HISTORY event and its handler seem unused.
-  _handleHistory(detail: {path: string}) {
-    for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
-      try {
-        cb(detail.path);
-      } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('handleHistory callback error'),
-          err
-        );
-      }
-    }
-  }
-
   _handleShowChange(detail: ShowChangeDetail) {
     // Note (issue 8221) Shallow clone the change object and add a mergeable
     // getter with deprecation warning. This makes the change detail appear as
@@ -198,21 +174,6 @@
     }
   }
 
-  // TODO(TS): The COMMENT event and its handler seem unused.
-  _handleComment(detail: {node: Node}) {
-    for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
-      try {
-        cb(detail.node);
-      } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('comment callback error'),
-          err
-        );
-      }
-    }
-  }
-
   _handleLabelChange(detail: {change: ChangeInfo}) {
     for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
       try {
@@ -227,20 +188,6 @@
     }
   }
 
-  _handleHighlightjsLoaded(detail: {hljs: HighlightJS}) {
-    for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
-      try {
-        cb(detail.hljs);
-      } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('HighlightjsLoaded callback error'),
-          err
-        );
-      }
-    }
-  }
-
   modifyRevertMsg(change: ChangeInfo, revertMsg: string, origMsg: string) {
     for (const cb of this._getEventCallbacks(EventType.REVERT)) {
       try {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index 4fc403d..7c09bad 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -69,17 +69,6 @@
     });
   });
 
-  test('history event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    plugin.on(EventType.HISTORY, throwErrFn);
-    plugin.on(EventType.HISTORY, resolve);
-    element.handleEvent(EventType.HISTORY, {path: '/path/to/awesomesauce'});
-    const path = await promise;
-    assert.equal(path, '/path/to/awesomesauce');
-    assert.isTrue(errorStub.calledOnce);
-  });
-
   test('showchange event', async () => {
     let resolve;
     const promise = new Promise(r => resolve = r);
@@ -141,19 +130,6 @@
     assert.isTrue(spy.called);
   });
 
-  test('comment event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testCommentNode = {foo: 'bar'};
-    plugin.on(EventType.COMMENT, throwErrFn);
-    plugin.on(EventType.COMMENT, resolve);
-    element.handleEvent(EventType.COMMENT, {node: testCommentNode});
-
-    const commentNode = await promise;
-    assert.deepEqual(commentNode, testCommentNode);
-    assert.isTrue(errorStub.calledOnce);
-  });
-
   test('revert event', () => {
     function appendToRevertMsg(c, revertMsg, originalMsg) {
       return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
@@ -243,19 +219,6 @@
     assert.isTrue(errorStub.calledTwice);
   });
 
-  test('highlightjs-loaded event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testHljs = {_number: 42};
-    plugin.on(EventType.HIGHLIGHTJS_LOADED, throwErrFn);
-    plugin.on(EventType.HIGHLIGHTJS_LOADED, resolve);
-    element.handleEvent(EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
-
-    const hljs = await promise;
-    assert.deepEqual(hljs, testHljs);
-    assert.isTrue(errorStub.calledOnce);
-  });
-
   test('getLoggedIn', () => {
     // fake fetch for authCheck
     sinon.stub(window, 'fetch').callsFake(() => Promise.resolve({status: 204}));
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 9c54349..1fbe5b2 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -102,7 +102,7 @@
 
   get(key: '/accounts/self/emails'): EmailInfo[] | null;
 
-  get(key: '/accounts/self/detail'): AccountDetailInfo[] | null;
+  get(key: '/accounts/self/detail'): AccountDetailInfo | null;
 
   get(key: string): ParsedJSON | null;
 
@@ -112,7 +112,7 @@
 
   set(key: '/accounts/self/emails', value: EmailInfo[]): void;
 
-  set(key: '/accounts/self/detail', value: AccountDetailInfo[]): void;
+  set(key: '/accounts/self/detail', value: AccountDetailInfo): void;
 
   set(key: string, value: ParsedJSON | null): void;
 
@@ -275,16 +275,14 @@
    * by this method, it should be called immediately after the request
    * finishes.
    *
+   * Private, but used in tests.
+   *
    * @param startTime the time that the request was started.
    * @param status the HTTP status of the response. The status value
    *     is used here rather than the response object so there is no way this
    *     method can read the body stream.
    */
-  private _logCall(
-    req: FetchRequest,
-    startTime: number,
-    status: number | null
-  ) {
+  _logCall(req: FetchRequest, startTime: number, status: number | null) {
     const method =
       req.fetchOptions && req.fetchOptions.method
         ? req.fetchOptions.method
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index d285dd0..b38d71b 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -302,6 +302,10 @@
     return this.textarea!.textarea;
   }
 
+  override focus() {
+    this.textarea?.textarea.focus();
+  }
+
   putCursorAtEnd() {
     const textarea = this.getNativeTextarea();
     // Put the cursor at the end always.
@@ -341,7 +345,7 @@
     e.preventDefault();
     e.stopPropagation();
     this.getVisibleDropdown().cursorUp();
-    this.textarea!.textarea.focus();
+    this.focus();
   }
 
   private handleDownKey(e: KeyboardEvent) {
@@ -351,7 +355,7 @@
     e.preventDefault();
     e.stopPropagation();
     this.getVisibleDropdown().cursorDown();
-    this.textarea!.textarea.focus();
+    this.focus();
   }
 
   private handleTabKey(e: KeyboardEvent) {
@@ -566,7 +570,7 @@
   async handleTextChanged() {
     await this.computeSuggestions();
     this.openOrResetDropdown();
-    this.textarea!.textarea.focus();
+    this.focus();
   }
 
   private openEmojiDropdown() {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index 5caffe6..5058ce8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -11,12 +11,12 @@
 import {customElement, property, state} from 'lit/decorators.js';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {FixIronA11yAnnouncer} from '../../../types/types';
-import {getAppContext} from '../../../services/app-context';
 import {fireIronAnnounce} from '../../../utils/event-util';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {resolve} from '../../../models/dependency';
 import {css, html, LitElement} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {userModelToken} from '../../../models/user/user-model';
 
 @customElement('gr-diff-mode-selector')
 export class GrDiffModeSelector extends LitElement {
@@ -34,7 +34,7 @@
 
   private readonly getBrowserModel = resolve(this, browserModelToken);
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private subscriptions: Subscription[] = [];
 
@@ -118,7 +118,7 @@
    */
   private setMode(newMode: DiffViewMode) {
     if (this.saveOnChange && this.mode && this.mode !== newMode) {
-      this.userModel.updatePreferences({diff_view: newMode});
+      this.getUserModel().updatePreferences({diff_view: newMode});
     }
     this.mode = newMode;
     let announcement;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
index 34af01e..d646988 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
@@ -7,21 +7,17 @@
 import './gr-diff-mode-selector';
 import {GrDiffModeSelector} from './gr-diff-mode-selector';
 import {DiffViewMode} from '../../../constants/constants';
-import {
-  queryAndAssert,
-  stubUsers,
-  waitUntilObserved,
-} from '../../../test/test-utils';
+import {queryAndAssert, waitUntilObserved} from '../../../test/test-utils';
 import {fixture, html, assert} from '@open-wc/testing';
 import {wrapInProvider} from '../../../models/di-provider-element';
 import {
   BrowserModel,
   browserModelToken,
 } from '../../../models/browser/browser-model';
-import {getAppContext} from '../../../services/app-context';
-import {UserModel} from '../../../models/user/user-model';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
 import {createPreferences} from '../../../test/test-data-generators';
 import {GrButton} from '../../../elements/shared/gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-diff-mode-selector tests', () => {
   let element: GrDiffModeSelector;
@@ -29,7 +25,7 @@
   let userModel: UserModel;
 
   setup(async () => {
-    userModel = getAppContext().userModel;
+    userModel = testResolver(userModelToken);
     browserModel = new BrowserModel(userModel);
     element = (
       await fixture(
@@ -129,7 +125,7 @@
 
   test('set mode', async () => {
     browserModel.setScreenWidth(0);
-    const saveStub = stubUsers('updatePreferences');
+    const saveStub = sinon.stub(userModel, 'updatePreferences');
 
     // Setting the mode initially does not save prefs.
     element.saveOnChange = true;
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index a9f88bd..e970664 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -8,9 +8,11 @@
 import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {Side} from '../../../constants/constants';
-import {getAppContext} from '../../../services/app-context';
 import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
 import {CancelablePromise, makeCancelable} from '../../../scripts/util';
+import {HighlightService} from '../../../services/highlight/highlight-service';
+import {Provider} from '../../../models/dependency';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 const LANGUAGE_MAP = new Map<string, string>([
   ['application/dart', 'dart'],
@@ -162,9 +164,10 @@
 
   private listeners: DiffLayerListener[] = [];
 
-  private readonly highlightService = getAppContext().highlightService;
-
-  private readonly reportingService = getAppContext().reportingService;
+  constructor(
+    private readonly getHighlightService: Provider<HighlightService>,
+    private readonly getReportingService: Provider<ReportingService>
+  ) {}
 
   setEnabled(enabled: boolean) {
     this.enabled = enabled;
@@ -276,7 +279,7 @@
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
     } catch (err: any) {
       if (!err.isCanceled)
-        this.reportingService.error('Diff Syntax Layer', err as Error);
+        this.getReportingService().error('Diff Syntax Layer', err as Error);
       // One source of "error" can promise cancelation.
       this.leftRanges = [];
       this.rightRanges = [];
@@ -287,7 +290,7 @@
     language?: string,
     code?: string
   ): CancelablePromise<SyntaxLayerLine[]> {
-    const hlPromise = this.highlightService.highlight(language, code);
+    const hlPromise = this.getHighlightService().highlight(language, code);
     return makeCancelable(hlPromise);
   }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
index 5c9a6cc..c6c46f9 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
@@ -5,8 +5,14 @@
  */
 import {assert} from '@open-wc/testing';
 import {DiffInfo, GrDiffLineType, Side} from '../../../api/diff';
+import {getAppContext} from '../../../services/app-context';
+import {
+  HighlightService,
+  highlightServiceToken,
+} from '../../../services/highlight/highlight-service';
 import '../../../test/common-test-setup';
-import {mockPromise, stubHighlightService} from '../../../test/test-utils';
+import {testResolver} from '../../../test/common-test-setup';
+import {mockPromise} from '../../../test/test-utils';
 import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {GrSyntaxLayerWorker} from './gr-syntax-layer-worker';
@@ -62,6 +68,7 @@
 suite('gr-syntax-layer-worker tests', () => {
   let layer: GrSyntaxLayerWorker;
   let listener: sinon.SinonStub;
+  let highlightService: HighlightService;
 
   const annotate = (side: Side, lineNumber: number, text: string) => {
     const el = document.createElement('div');
@@ -76,7 +83,11 @@
   };
 
   setup(() => {
-    layer = new GrSyntaxLayerWorker();
+    highlightService = testResolver(highlightServiceToken);
+    layer = new GrSyntaxLayerWorker(
+      () => highlightService,
+      () => getAppContext().reportingService
+    );
   });
 
   test('cancel processing', async () => {
@@ -84,7 +95,7 @@
     const mockPromise2 = mockPromise<SyntaxLayerLine[]>();
     const mockPromise3 = mockPromise<SyntaxLayerLine[]>();
     const mockPromise4 = mockPromise<SyntaxLayerLine[]>();
-    const stub = stubHighlightService('highlight');
+    const stub = sinon.stub(highlightService, 'highlight');
     stub.onCall(0).returns(mockPromise1);
     stub.onCall(1).returns(mockPromise2);
     stub.onCall(2).returns(mockPromise3);
@@ -116,7 +127,7 @@
     setup(() => {
       listener = sinon.stub();
       layer.addListener(listener);
-      stubHighlightService('highlight').callsFake((lang?: string) => {
+      sinon.stub(highlightService, 'highlight').callsFake((lang?: string) => {
         if (lang === 'lang-left') return Promise.resolve(leftRanges);
         if (lang === 'lang-right') return Promise.resolve(rightRanges);
         return Promise.resolve([]);
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
index f865d6d..aedf493 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -66,27 +66,9 @@
     jsApiService: (_ctx: Partial<AppContext>) => {
       throw new Error('jsApiService is not implemented');
     },
-    storageService: (_ctx: Partial<AppContext>) => {
-      throw new Error('storageService is not implemented');
-    },
-    userModel: (_ctx: Partial<AppContext>) => {
-      throw new Error('userModel is not implemented');
-    },
-    accountsModel: (_ctx: Partial<AppContext>) => {
-      throw new Error('accountsModel is not implemented');
-    },
-    routerModel: (_ctx: Partial<AppContext>) => {
-      throw new Error('routerModel is not implemented');
-    },
-    shortcutsService: (_ctx: Partial<AppContext>) => {
-      throw new Error('shortcutsService is not implemented');
-    },
     pluginsModel: (_ctx: Partial<AppContext>) => {
       throw new Error('pluginsModel is not implemented');
     },
-    highlightService: (_ctx: Partial<AppContext>) => {
-      throw new Error('highlightService is not implemented');
-    },
   };
   return create<AppContext>(appRegistry);
 }
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model.ts b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
index 3f35127..2bf6068 100644
--- a/polygerrit-ui/app/models/accounts-model/accounts-model.ts
+++ b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
@@ -6,7 +6,6 @@
 
 import {AccountDetailInfo, AccountInfo} from '../../api/rest-api';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {Finalizable} from '../../services/registry';
 import {UserId} from '../../types/common';
 import {getUserId, isDetailedAccount} from '../../utils/account-util';
 import {define} from '../dependency';
@@ -18,7 +17,7 @@
 
 export const accountsModelToken = define<AccountsModel>('accounts-model');
 
-export class AccountsModel extends Model<AccountsState> implements Finalizable {
+export class AccountsModel extends Model<AccountsState> {
   constructor(readonly restApiService: RestApiService) {
     super({
       accounts: {},
diff --git a/polygerrit-ui/app/models/browser/browser-model.ts b/polygerrit-ui/app/models/browser/browser-model.ts
index 1592cd8..50b6325 100644
--- a/polygerrit-ui/app/models/browser/browser-model.ts
+++ b/polygerrit-ui/app/models/browser/browser-model.ts
@@ -4,7 +4,6 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {Observable, combineLatest} from 'rxjs';
-import {Finalizable} from '../../services/registry';
 import {define} from '../dependency';
 import {DiffViewMode} from '../../api/diff';
 import {UserModel} from '../user/user-model';
@@ -26,7 +25,7 @@
 
 export const browserModelToken = define<BrowserModel>('browser-model');
 
-export class BrowserModel extends Model<BrowserState> implements Finalizable {
+export class BrowserModel extends Model<BrowserState> {
   private readonly isScreenTooSmall$ = select(
     this.state$,
     state =>
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index f706712..b13a16f 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -14,7 +14,6 @@
   Hashtag,
 } from '../../api/rest-api';
 import {Model} from '../model';
-import {Finalizable} from '../../services/registry';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {define} from '../dependency';
 import {select} from '../../utils/observable-util';
@@ -50,10 +49,7 @@
   allChanges: new Map(),
 };
 
-export class BulkActionsModel
-  extends Model<BulkActionsState>
-  implements Finalizable
-{
+export class BulkActionsModel extends Model<BulkActionsState> {
   constructor(private readonly restApiService: RestApiService) {
     super(initialState);
   }
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 12d09b3..8282a3f 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -32,7 +32,6 @@
 
 import {ChangeInfo} from '../../types/common';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {Finalizable} from '../../services/registry';
 import {select} from '../../utils/observable-util';
 import {assertIsDefined} from '../../utils/common-util';
 import {Model} from '../model';
@@ -148,7 +147,7 @@
 
 export const changeModelToken = define<ChangeModel>('change-model');
 
-export class ChangeModel extends Model<ChangeState> implements Finalizable {
+export class ChangeModel extends Model<ChangeState> {
   private change?: ParsedChangeInfo;
 
   private patchNum?: PatchSetNum;
@@ -258,9 +257,9 @@
   );
 
   constructor(
-    readonly routerModel: RouterModel,
-    readonly restApiService: RestApiService,
-    readonly userModel: UserModel
+    private readonly routerModel: RouterModel,
+    private readonly restApiService: RestApiService,
+    private readonly userModel: UserModel
   ) {
     super(initialState);
     this.subscriptions = [
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index 4b51d5b..a2fc7c9 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -28,10 +28,12 @@
 } from '../../types/common';
 import {ParsedChangeInfo} from '../../types/types';
 import {getAppContext} from '../../services/app-context';
-import {GerritView} from '../../services/router/router-model';
+import {GerritView, routerModelToken} from '../../services/router/router-model';
 import {ChangeState, LoadingStatus, updateChangeWithEdit} from './change-model';
 import {ChangeModel} from './change-model';
 import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {userModelToken} from '../user/user-model';
 
 suite('updateChangeWithEdit() tests', () => {
   test('undefined change', async () => {
@@ -81,9 +83,9 @@
 
   setup(() => {
     changeModel = new ChangeModel(
-      getAppContext().routerModel,
+      testResolver(routerModelToken),
       getAppContext().restApiService,
-      getAppContext().userModel
+      testResolver(userModelToken)
     );
     knownChange = {
       ...createChange(),
@@ -119,7 +121,7 @@
     assert.equal(stub.callCount, 0);
     assert.isUndefined(state?.change);
 
-    changeModel.routerModel.setState({
+    testResolver(routerModelToken).setState({
       view: GerritView.CHANGE,
       changeNum: knownChange._number,
     });
@@ -138,7 +140,7 @@
     const promise = mockPromise<ParsedChangeInfo | undefined>();
     const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
     let state: ChangeState;
-    changeModel.routerModel.setState({
+    testResolver(routerModelToken).setState({
       view: GerritView.CHANGE,
       changeNum: knownChange._number,
     });
@@ -162,7 +164,7 @@
     let promise = mockPromise<ParsedChangeInfo | undefined>();
     const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
     let state: ChangeState;
-    changeModel.routerModel.setState({
+    testResolver(routerModelToken).setState({
       view: GerritView.CHANGE,
       changeNum: knownChange._number,
     });
@@ -176,7 +178,7 @@
       _number: 123 as NumericChangeId,
     };
     promise = mockPromise<ParsedChangeInfo | undefined>();
-    changeModel.routerModel.setState({
+    testResolver(routerModelToken).setState({
       view: GerritView.CHANGE,
       changeNum: otherChange._number,
     });
@@ -195,7 +197,7 @@
     let promise = mockPromise<ParsedChangeInfo | undefined>();
     const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
     let state: ChangeState;
-    changeModel.routerModel.setState({
+    testResolver(routerModelToken).setState({
       view: GerritView.CHANGE,
       changeNum: knownChange._number,
     });
@@ -206,7 +208,7 @@
 
     promise = mockPromise<ParsedChangeInfo | undefined>();
     promise.resolve(undefined);
-    changeModel.routerModel.setState({
+    testResolver(routerModelToken).setState({
       view: GerritView.CHANGE,
       changeNum: undefined,
     });
@@ -218,7 +220,7 @@
 
     promise = mockPromise<ParsedChangeInfo | undefined>();
     promise.resolve(knownChange);
-    changeModel.routerModel.setState({
+    testResolver(routerModelToken).setState({
       view: GerritView.CHANGE,
       changeNum: knownChange._number,
     });
@@ -297,7 +299,9 @@
     assert.equal(spy.lastCall.firstArg, PARENT);
 
     // test update
-    changeModel.routerModel.updateState({basePatchNum: 1 as PatchSetNumber});
+    testResolver(routerModelToken).updateState({
+      basePatchNum: 1 as PatchSetNumber,
+    });
     assert.equal(spy.callCount, 2);
     assert.equal(spy.lastCall.firstArg, 1 as PatchSetNumber);
 
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
index 6922f6d..07e64a2 100644
--- a/polygerrit-ui/app/models/change/files-model.ts
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -15,7 +15,6 @@
 import {combineLatest, of, from} from 'rxjs';
 import {switchMap, map} from 'rxjs/operators';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {Finalizable} from '../../services/registry';
 import {select} from '../../utils/observable-util';
 import {FileInfoStatus, SpecialFilePath} from '../../constants/constants';
 import {specialFilePathCompare} from '../../utils/path-list-util';
@@ -113,7 +112,7 @@
 
 export const filesModelToken = define<FilesModel>('files-model');
 
-export class FilesModel extends Model<FilesState> implements Finalizable {
+export class FilesModel extends Model<FilesState> {
   public readonly files$ = select(this.state$, state => state.files);
 
   public readonly filesWithUnmodified$ = select(
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 6b35056..e201b88 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -12,7 +12,6 @@
 } from './checks-util';
 import {assertIsDefined} from '../../utils/common-util';
 import {select} from '../../utils/observable-util';
-import {Finalizable} from '../../services/registry';
 import {
   BehaviorSubject,
   combineLatest,
@@ -53,7 +52,6 @@
 import {ReportingService} from '../../services/gr-reporting/gr-reporting';
 import {Execution, Interaction, Timing} from '../../constants/reporting';
 import {fireAlert, fireEvent} from '../../utils/event-util';
-import {RouterModel} from '../../services/router/router-model';
 import {Model} from '../model';
 import {define} from '../dependency';
 import {
@@ -182,7 +180,7 @@
   [name: string]: string;
 }
 
-export class ChecksModel extends Model<ChecksState> implements Finalizable {
+export class ChecksModel extends Model<ChecksState> {
   private readonly providers: {[name: string]: ChecksProvider} = {};
 
   private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
@@ -374,11 +372,10 @@
   );
 
   constructor(
-    readonly routerModel: RouterModel,
-    readonly changeViewModel: ChangeViewModel,
-    readonly changeModel: ChangeModel,
-    readonly reporting: ReportingService,
-    readonly pluginsModel: PluginsModel
+    private readonly changeViewModel: ChangeViewModel,
+    private readonly changeModel: ChangeModel,
+    private readonly reporting: ReportingService,
+    private readonly pluginsModel: PluginsModel
   ) {
     super({
       pluginStateLatest: {},
@@ -753,15 +750,14 @@
         patchset === ChecksPatchset.LATEST
           ? this.changeModel.latestPatchNum$
           : this.checksSelectedPatchsetNumber$,
-        this.reloadSubjects[pluginName].pipe(
-          throttleTime(1000, undefined, {trailing: true, leading: true})
-        ),
+        this.reloadSubjects[pluginName],
         pollIntervalMs === 0 ? from([0]) : timer(0, pollIntervalMs),
         this.documentVisibilityChange$,
       ])
         .pipe(
           takeWhile(_ => !!this.providers[pluginName]),
           filter(_ => document.visibilityState !== 'hidden'),
+          throttleTime(500, undefined, {leading: true, trailing: true}),
           switchMap(([change, patchNum]): Observable<FetchResponse> => {
             if (!change || !patchNum) return of(this.empty());
             if (typeof patchNum !== 'number') return of(this.empty());
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index 3489c5a..dc2ad4b 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -69,7 +69,6 @@
 
   setup(() => {
     model = new ChecksModel(
-      getAppContext().routerModel,
       testResolver(changeViewModelToken),
       testResolver(changeModelToken),
       getAppContext().reportingService,
@@ -84,7 +83,7 @@
 
   test('register and fetch', async () => {
     let change: ParsedChangeInfo | undefined = undefined;
-    model.changeModel.change$.subscribe(c => (change = c));
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
     const provider = createProvider();
     const fetchSpy = sinon.spy(provider, 'fetch');
 
@@ -96,7 +95,7 @@
     await waitUntil(() => change === undefined);
 
     const testChange = createParsedChange();
-    model.changeModel.updateStateChange(testChange);
+    testResolver(changeModelToken).updateStateChange(testChange);
     await waitUntil(() => change === testChange);
     await waitUntilCalled(fetchSpy, 'fetch');
 
@@ -108,10 +107,10 @@
     assert.equal(model.changeNum, testChange._number);
   });
 
-  test('reload throttle', async () => {
+  test('fetch throttle', async () => {
     const clock = sinon.useFakeTimers();
     let change: ParsedChangeInfo | undefined = undefined;
-    model.changeModel.change$.subscribe(c => (change = c));
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
     const provider = createProvider();
     const fetchSpy = sinon.spy(provider, 'fetch');
 
@@ -123,18 +122,33 @@
     await waitUntil(() => change === undefined);
 
     const testChange = createParsedChange();
-    model.changeModel.updateStateChange(testChange);
+    testResolver(changeModelToken).updateStateChange(testChange);
     await waitUntil(() => change === testChange);
-    clock.tick(1);
+
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+
+    // Does not emit at 'leading' of throttle interval,
+    // because fetch() is not called when change is undefined.
+    assert.equal(fetchSpy.callCount, 0);
+
+    // 600 ms is greater than the 500 ms throttle time.
+    clock.tick(600);
+    // emits at 'trailing' of throttle interval
     assert.equal(fetchSpy.callCount, 1);
 
-    // The second reload call will be processed, but only after a 1s throttle.
     model.reload('test-plugin');
-    clock.tick(100);
-    assert.equal(fetchSpy.callCount, 1);
-    // 2000 ms is greater than the 1000 ms throttle time.
-    clock.tick(2000);
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    // emits at 'leading' of throttle interval
     assert.equal(fetchSpy.callCount, 2);
+
+    // 600 ms is greater than the 500 ms throttle time.
+    clock.tick(600);
+    // emits at 'trailing' of throttle interval
+    assert.equal(fetchSpy.callCount, 3);
   });
 
   test('triggerAction', async () => {
@@ -268,7 +282,7 @@
   test('polls for changes', async () => {
     const clock = sinon.useFakeTimers();
     let change: ParsedChangeInfo | undefined = undefined;
-    model.changeModel.change$.subscribe(c => (change = c));
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
     const provider = createProvider();
     const fetchSpy = sinon.spy(provider, 'fetch');
 
@@ -280,10 +294,10 @@
     await waitUntil(() => change === undefined);
     clock.tick(1);
     const testChange = createParsedChange();
-    model.changeModel.updateStateChange(testChange);
+    testResolver(changeModelToken).updateStateChange(testChange);
     await waitUntil(() => change === testChange);
+    clock.tick(600); // need to wait for 500ms throttle
     await waitUntilCalled(fetchSpy, 'fetch');
-    clock.tick(1);
     const pollCount = fetchSpy.callCount;
 
     // polling should continue while we wait
@@ -295,7 +309,7 @@
   test('does not poll when config specifies 0 seconds', async () => {
     const clock = sinon.useFakeTimers();
     let change: ParsedChangeInfo | undefined = undefined;
-    model.changeModel.change$.subscribe(c => (change = c));
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
     const provider = createProvider();
     const fetchSpy = sinon.spy(provider, 'fetch');
 
@@ -307,8 +321,9 @@
     await waitUntil(() => change === undefined);
     clock.tick(1);
     const testChange = createParsedChange();
-    model.changeModel.updateStateChange(testChange);
+    testResolver(changeModelToken).updateStateChange(testChange);
     await waitUntil(() => change === testChange);
+    clock.tick(600); // need to wait for 500ms throttle
     await waitUntilCalled(fetchSpy, 'fetch');
     clock.tick(1);
     const pollCount = fetchSpy.callCount;
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index da49ddb..a7b43ca 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -28,7 +28,6 @@
 import {deepEqual} from '../../utils/deep-util';
 import {select} from '../../utils/observable-util';
 import {RouterModel} from '../../services/router/router-model';
-import {Finalizable} from '../../services/registry';
 import {define} from '../dependency';
 import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
 import {fire, fireAlert, fireEvent} from '../../utils/event-util';
@@ -225,7 +224,7 @@
 }
 
 export const commentsModelToken = define<CommentsModel>('comments-model');
-export class CommentsModel extends Model<CommentState> implements Finalizable {
+export class CommentsModel extends Model<CommentState> {
   public readonly commentsLoading$ = select(
     this.state$,
     commentState =>
@@ -385,11 +384,11 @@
   private discardedDrafts: DraftInfo[] = [];
 
   constructor(
-    readonly routerModel: RouterModel,
-    readonly changeModel: ChangeModel,
-    readonly accountsModel: AccountsModel,
-    readonly restApiService: RestApiService,
-    readonly reporting: ReportingService
+    private readonly routerModel: RouterModel,
+    private readonly changeModel: ChangeModel,
+    private readonly accountsModel: AccountsModel,
+    private readonly restApiService: RestApiService,
+    private readonly reporting: ReportingService
   ) {
     super(initialState);
     this.subscriptions.push(
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index 32ea1bc..4db5d57 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -23,11 +23,12 @@
 } from '../../test/test-data-generators';
 import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {getAppContext} from '../../services/app-context';
-import {GerritView} from '../../services/router/router-model';
+import {GerritView, routerModelToken} from '../../services/router/router-model';
 import {PathToCommentsInfoMap} from '../../types/common';
 import {changeModelToken} from '../change/change-model';
 import {assert} from '@open-wc/testing';
 import {testResolver} from '../../test/common-test-setup';
+import {accountsModelToken} from '../accounts-model/accounts-model';
 
 suite('comments model tests', () => {
   test('updateStateDeleteDraft', () => {
@@ -69,9 +70,9 @@
 
   test('loads comments', async () => {
     const model = new CommentsModel(
-      getAppContext().routerModel,
+      testResolver(routerModelToken),
       testResolver(changeModelToken),
-      getAppContext().accountsModel,
+      testResolver(accountsModelToken),
       getAppContext().restApiService,
       getAppContext().reportingService
     );
@@ -97,11 +98,11 @@
       model.portedComments$.subscribe(c => (portedComments = c ?? {}))
     );
 
-    model.routerModel.setState({
+    testResolver(routerModelToken).setState({
       view: GerritView.CHANGE,
       changeNum: TEST_NUMERIC_CHANGE_ID,
     });
-    model.changeModel.updateStateChange(createParsedChange());
+    testResolver(changeModelToken).updateStateChange(createParsedChange());
 
     await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
     await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
@@ -130,9 +131,9 @@
     };
     stubRestApi('getAccountDetails').returns(Promise.resolve(account));
     const model = new CommentsModel(
-      getAppContext().routerModel,
+      testResolver(routerModelToken),
       testResolver(changeModelToken),
-      getAppContext().accountsModel,
+      testResolver(accountsModelToken),
       getAppContext().restApiService,
       getAppContext().reportingService
     );
@@ -158,9 +159,9 @@
     };
     stubRestApi('getAccountDetails').returns(Promise.resolve(account));
     const model = new CommentsModel(
-      getAppContext().routerModel,
+      testResolver(routerModelToken),
       testResolver(changeModelToken),
-      getAppContext().accountsModel,
+      testResolver(accountsModelToken),
       getAppContext().restApiService,
       getAppContext().reportingService
     );
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
index 6e374d1..4c9bb35c 100644
--- a/polygerrit-ui/app/models/config/config-model.ts
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -6,7 +6,6 @@
 import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
 import {from, of} from 'rxjs';
 import {switchMap} from 'rxjs/operators';
-import {Finalizable} from '../../services/registry';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {ChangeModel} from '../change/change-model';
 import {select} from '../../utils/observable-util';
@@ -20,7 +19,7 @@
 }
 
 export const configModelToken = define<ConfigModel>('config-model');
-export class ConfigModel extends Model<ConfigState> implements Finalizable {
+export class ConfigModel extends Model<ConfigState> {
   public repoConfig$ = select(
     this.state$,
     configState => configState.repoConfig
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 7826c45..75de68a 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -3,7 +3,6 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Finalizable} from '../../services/registry';
 import {Observable, Subject} from 'rxjs';
 import {
   CheckResult,
@@ -37,7 +36,7 @@
 
 export const pluginsModelToken = define<PluginsModel>('plugins-model');
 
-export class PluginsModel extends Model<PluginsState> implements Finalizable {
+export class PluginsModel extends Model<PluginsState> {
   /** Private version of the event bus below. */
   private checksAnnounceSubject$ = new Subject<ChecksPlugin>();
 
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index 428ddbb..97f90fa 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -23,8 +23,8 @@
 } from '../../constants/constants';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {DiffPreferencesInfo} from '../../types/diff';
-import {Finalizable} from '../../services/registry';
 import {select} from '../../utils/observable-util';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {isDefined} from '../../types/types';
 
@@ -56,7 +56,9 @@
   capabilities?: AccountCapabilityInfo;
 }
 
-export class UserModel extends Model<UserState> implements Finalizable {
+export const userModelToken = define<UserModel>('user-model');
+
+export class UserModel extends Model<UserState> {
   /**
    * Note that the initially emitted `undefined` value can mean "not loaded
    * the account into object yet" or "user is not logged in". Consider using
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index e662d5f..2b54eb5 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -15,13 +15,13 @@
 import {FilesModel, filesModelToken} from '../models/change/files-model';
 import {ChecksModel, checksModelToken} from '../models/checks/checks-model';
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
-import {GrStorageService} from './storage/gr-storage_impl';
-import {UserModel} from '../models/user/user-model';
+import {GrStorageService, storageServiceToken} from './storage/gr-storage_impl';
+import {UserModel, userModelToken} from '../models/user/user-model';
 import {
   CommentsModel,
   commentsModelToken,
 } from '../models/comments/comments-model';
-import {RouterModel} from './router/router-model';
+import {RouterModel, routerModelToken} from './router/router-model';
 import {
   ShortcutsService,
   shortcutsServiceToken,
@@ -30,8 +30,14 @@
 import {ConfigModel, configModelToken} from '../models/config/config-model';
 import {BrowserModel, browserModelToken} from '../models/browser/browser-model';
 import {PluginsModel} from '../models/plugins/plugins-model';
-import {HighlightService} from './highlight/highlight-service';
-import {AccountsModel} from '../models/accounts-model/accounts-model';
+import {
+  HighlightService,
+  highlightServiceToken,
+} from './highlight/highlight-service';
+import {
+  AccountsModel,
+  accountsModelToken,
+} from '../models/accounts-model/accounts-model';
 import {
   DashboardViewModel,
   dashboardViewModelToken,
@@ -57,17 +63,13 @@
 import {PluginViewModel, pluginViewModelToken} from '../models/views/plugin';
 import {RepoViewModel, repoViewModelToken} from '../models/views/repo';
 import {SearchViewModel, searchViewModelToken} from '../models/views/search';
-import {
-  NavigationService,
-  navigationToken,
-} from '../elements/core/gr-navigation/gr-navigation';
+import {navigationToken} from '../elements/core/gr-navigation/gr-navigation';
 
 /**
  * The AppContext lazy initializator for all services
  */
 export function createAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
-    routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
     flagsService: (_ctx: Partial<AppContext>) =>
       new FlagsServiceImplementation(),
     reportingService: (ctx: Partial<AppContext>) => {
@@ -81,131 +83,132 @@
     },
     restApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.authService, 'authService');
-      assertIsDefined(ctx.flagsService, 'flagsService');
-      return new GrRestApiServiceImpl(ctx.authService, ctx.flagsService);
+      return new GrRestApiServiceImpl(ctx.authService);
     },
     jsApiService: (ctx: Partial<AppContext>) => {
       const reportingService = ctx.reportingService;
       assertIsDefined(reportingService, 'reportingService');
       return new GrJsApiInterface(reportingService);
     },
-    storageService: (_ctx: Partial<AppContext>) => new GrStorageService(),
-    userModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new UserModel(ctx.restApiService);
-    },
-    accountsModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new AccountsModel(ctx.restApiService);
-    },
     pluginsModel: (_ctx: Partial<AppContext>) => new PluginsModel(),
-    highlightService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new HighlightService(ctx.reportingService);
-    },
   };
   return create<AppContext>(appRegistry);
 }
 
+export type Creator<T> = () => T & Finalizable;
+
+// Dependencies are provided as creator functions to ensure that they are
+// not created until they are utilized.
+// This is mainly useful in tests: E.g. don't create a
+// change-model in change-model_test.ts because it creates one in the test
+// after setting up stubs.
 export function createAppDependencies(
-  appContext: AppContext
-): Map<DependencyToken<unknown>, Finalizable> {
-  const dependencies = new Map<DependencyToken<unknown>, Finalizable>();
-  const browserModel = new BrowserModel(appContext.userModel);
-  dependencies.set(browserModelToken, browserModel);
-
-  const adminViewModel = new AdminViewModel();
-  dependencies.set(adminViewModelToken, adminViewModel);
-  const agreementViewModel = new AgreementViewModel();
-  dependencies.set(agreementViewModelToken, agreementViewModel);
-  const changeViewModel = new ChangeViewModel();
-  dependencies.set(changeViewModelToken, changeViewModel);
-  const dashboardViewModel = new DashboardViewModel();
-  dependencies.set(dashboardViewModelToken, dashboardViewModel);
-  const diffViewModel = new DiffViewModel();
-  dependencies.set(diffViewModelToken, diffViewModel);
-  const documentationViewModel = new DocumentationViewModel();
-  dependencies.set(documentationViewModelToken, documentationViewModel);
-  const editViewModel = new EditViewModel();
-  dependencies.set(editViewModelToken, editViewModel);
-  const groupViewModel = new GroupViewModel();
-  dependencies.set(groupViewModelToken, groupViewModel);
-  const pluginViewModel = new PluginViewModel();
-  dependencies.set(pluginViewModelToken, pluginViewModel);
-  const repoViewModel = new RepoViewModel();
-  dependencies.set(repoViewModelToken, repoViewModel);
-  const searchViewModel = new SearchViewModel(
-    appContext.restApiService,
-    appContext.userModel,
-    () => dependencies.get(navigationToken) as unknown as NavigationService
-  );
-  dependencies.set(searchViewModelToken, searchViewModel);
-  const settingsViewModel = new SettingsViewModel();
-  dependencies.set(settingsViewModelToken, settingsViewModel);
-
-  const router = new GrRouter(
-    appContext.reportingService,
-    appContext.routerModel,
-    appContext.restApiService,
-    adminViewModel,
-    agreementViewModel,
-    changeViewModel,
-    dashboardViewModel,
-    diffViewModel,
-    documentationViewModel,
-    editViewModel,
-    groupViewModel,
-    pluginViewModel,
-    repoViewModel,
-    searchViewModel,
-    settingsViewModel
-  );
-  dependencies.set(routerToken, router);
-  dependencies.set(navigationToken, router);
-
-  const changeModel = new ChangeModel(
-    appContext.routerModel,
-    appContext.restApiService,
-    appContext.userModel
-  );
-  dependencies.set(changeModelToken, changeModel);
-
-  const accountsModel = new AccountsModel(appContext.restApiService);
-
-  const commentsModel = new CommentsModel(
-    appContext.routerModel,
-    changeModel,
-    accountsModel,
-    appContext.restApiService,
-    appContext.reportingService
-  );
-  dependencies.set(commentsModelToken, commentsModel);
-
-  const filesModel = new FilesModel(
-    changeModel,
-    commentsModel,
-    appContext.restApiService
-  );
-  dependencies.set(filesModelToken, filesModel);
-
-  const configModel = new ConfigModel(changeModel, appContext.restApiService);
-  dependencies.set(configModelToken, configModel);
-
-  const checksModel = new ChecksModel(
-    appContext.routerModel,
-    changeViewModel,
-    changeModel,
-    appContext.reportingService,
-    appContext.pluginsModel
-  );
-
-  dependencies.set(checksModelToken, checksModel);
-
-  const shortcutsService = new ShortcutsService(
-    appContext.userModel,
-    appContext.reportingService
-  );
-  dependencies.set(shortcutsServiceToken, shortcutsService);
-
-  return dependencies;
+  appContext: AppContext,
+  resolver: <T>(token: DependencyToken<T>) => T
+): Map<DependencyToken<unknown>, Creator<unknown>> {
+  return new Map<DependencyToken<unknown>, Creator<unknown>>([
+    [routerModelToken, () => new RouterModel()],
+    [userModelToken, () => new UserModel(appContext.restApiService)],
+    [browserModelToken, () => new BrowserModel(resolver(userModelToken))],
+    [accountsModelToken, () => new AccountsModel(appContext.restApiService)],
+    [adminViewModelToken, () => new AdminViewModel()],
+    [agreementViewModelToken, () => new AgreementViewModel()],
+    [changeViewModelToken, () => new ChangeViewModel()],
+    [dashboardViewModelToken, () => new DashboardViewModel()],
+    [diffViewModelToken, () => new DiffViewModel()],
+    [documentationViewModelToken, () => new DocumentationViewModel()],
+    [editViewModelToken, () => new EditViewModel()],
+    [groupViewModelToken, () => new GroupViewModel()],
+    [pluginViewModelToken, () => new PluginViewModel()],
+    [repoViewModelToken, () => new RepoViewModel()],
+    [
+      searchViewModelToken,
+      () =>
+        new SearchViewModel(
+          appContext.restApiService,
+          resolver(userModelToken),
+          () => resolver(navigationToken)
+        ),
+    ],
+    [settingsViewModelToken, () => new SettingsViewModel()],
+    [
+      routerToken,
+      () =>
+        new GrRouter(
+          appContext.reportingService,
+          resolver(routerModelToken),
+          appContext.restApiService,
+          resolver(adminViewModelToken),
+          resolver(agreementViewModelToken),
+          resolver(changeViewModelToken),
+          resolver(dashboardViewModelToken),
+          resolver(diffViewModelToken),
+          resolver(documentationViewModelToken),
+          resolver(editViewModelToken),
+          resolver(groupViewModelToken),
+          resolver(pluginViewModelToken),
+          resolver(repoViewModelToken),
+          resolver(searchViewModelToken),
+          resolver(settingsViewModelToken)
+        ),
+    ],
+    [navigationToken, () => resolver(routerToken)],
+    [
+      changeModelToken,
+      () =>
+        new ChangeModel(
+          resolver(routerModelToken),
+          appContext.restApiService,
+          resolver(userModelToken)
+        ),
+    ],
+    [
+      commentsModelToken,
+      () =>
+        new CommentsModel(
+          resolver(routerModelToken),
+          resolver(changeModelToken),
+          resolver(accountsModelToken),
+          appContext.restApiService,
+          appContext.reportingService
+        ),
+    ],
+    [
+      filesModelToken,
+      () =>
+        new FilesModel(
+          resolver(changeModelToken),
+          resolver(commentsModelToken),
+          appContext.restApiService
+        ),
+    ],
+    [
+      configModelToken,
+      () =>
+        new ConfigModel(resolver(changeModelToken), appContext.restApiService),
+    ],
+    [
+      checksModelToken,
+      () =>
+        new ChecksModel(
+          resolver(changeViewModelToken),
+          resolver(changeModelToken),
+          appContext.reportingService,
+          appContext.pluginsModel
+        ),
+    ],
+    [
+      shortcutsServiceToken,
+      () =>
+        new ShortcutsService(
+          resolver(userModelToken),
+          appContext.reportingService
+        ),
+    ],
+    [storageServiceToken, () => new GrStorageService()],
+    [
+      highlightServiceToken,
+      () => new HighlightService(appContext.reportingService),
+    ],
+  ]);
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 5f47c43..a8074b6 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -10,26 +10,16 @@
 import {AuthService} from './gr-auth/gr-auth';
 import {RestApiService} from './gr-rest-api/gr-rest-api';
 import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
-import {StorageService} from './storage/gr-storage';
-import {UserModel} from '../models/user/user-model';
-import {RouterModel} from './router/router-model';
 import {PluginsModel} from '../models/plugins/plugins-model';
-import {HighlightService} from './highlight/highlight-service';
-import {AccountsModel} from '../models/accounts-model/accounts-model';
 
 export interface AppContext {
-  routerModel: RouterModel;
   flagsService: FlagsService;
   reportingService: ReportingService;
   eventEmitter: EventEmitterService;
   authService: AuthService;
   restApiService: RestApiService;
   jsApiService: JsApiService;
-  storageService: StorageService;
-  userModel: UserModel;
-  accountsModel: AccountsModel;
   pluginsModel: PluginsModel;
-  highlightService: HighlightService;
 }
 
 /**
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 66498d1..5ce4e39 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -146,7 +146,6 @@
 import {addDraftProp, DraftInfo} from '../../utils/comment-util';
 import {BaseScheduler} from '../scheduler/scheduler';
 import {MaxInFlightScheduler} from '../scheduler/max-in-flight-scheduler';
-import {FlagsService} from '../flags/flags';
 
 const MAX_PROJECT_RESULTS = 25;
 
@@ -280,16 +279,14 @@
 
   readonly _etags = grEtagDecorator; // Shared across instances.
 
-  readonly _projectLookup = projectLookup; // Shared across instances.
+  // readonly, but set in tests.
+  _projectLookup = projectLookup; // Shared across instances.
 
   // The value is set in created, before any other actions
-  private readonly _restApiHelper: GrRestApiHelper;
+  // Private, but used in tests.
+  readonly _restApiHelper: GrRestApiHelper;
 
-  constructor(
-    private readonly authService: AuthService,
-    // @ts-ignore: it's ok.
-    private readonly _flagsService: FlagsService
-  ) {
+  constructor(private readonly authService: AuthService) {
     this._restApiHelper = new GrRestApiHelper(
       this._cache,
       this.authService,
@@ -3110,7 +3107,7 @@
   }
 
   /**
-   * Alias for _changeBaseURL.then(_fetchJSON).
+   * Alias for _changeBaseURL.then(fetchJSON).
    */
   _getChangeURLAndFetch(
     req: FetchChangeJSON,
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
deleted file mode 100644
index 5af123a..0000000
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
+++ /dev/null
@@ -1,1552 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../test/common-test-setup';
-import {
-  addListenerForTest,
-  mockPromise,
-  stubAuth,
-  waitEventLoop,
-} from '../../test/test-utils';
-import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {
-  ListChangesOption,
-  listChangesOptionsToHex,
-} from '../../utils/change-util';
-import {getAppContext} from '../app-context';
-import {createChange} from '../../test/test-data-generators';
-import {CURRENT} from '../../utils/patch-set-util';
-import {
-  parsePrefixedJSON,
-  readResponsePayload,
-  JSON_PREFIX,
-  // eslint-disable-next-line max-len
-} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
-import {GrRestApiServiceImpl} from './gr-rest-api-impl';
-import {CommentSide} from '../../constants/constants';
-import {EDIT, PARENT} from '../../types/common';
-import {assert} from '@open-wc/testing';
-
-const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
-    ListChangesOption.CHANGE_ACTIONS,
-    ListChangesOption.CURRENT_ACTIONS,
-    ListChangesOption.CURRENT_REVISION,
-    ListChangesOption.DETAILED_LABELS,
-    ListChangesOption.SUBMIT_REQUIREMENTS
-);
-
-suite('gr-rest-api-service-impl tests', () => {
-  let element;
-
-  let ctr = 0;
-  let originalCanonicalPath;
-
-  setup(() => {
-    // Modify CANONICAL_PATH to effectively reset cache.
-    ctr += 1;
-    originalCanonicalPath = window.CANONICAL_PATH;
-    window.CANONICAL_PATH = `test${ctr}`;
-
-    const testJSON = ')]}\'\n{"hello": "bonjour"}';
-    sinon.stub(window, 'fetch').returns(
-        Promise.resolve({
-          ok: true,
-          text() {
-            return Promise.resolve(testJSON);
-          },
-        })
-    );
-    // fake auth
-    sinon
-        .stub(getAppContext().authService, 'authCheck')
-        .returns(Promise.resolve(true));
-    element = new GrRestApiServiceImpl(
-        getAppContext().authService,
-        getAppContext().flagsService
-    );
-    element._projectLookup = {};
-  });
-
-  teardown(() => {
-    window.CANONICAL_PATH = originalCanonicalPath;
-  });
-
-  test('parent diff comments are properly grouped', () => {
-    sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(() =>
-      Promise.resolve({
-        '/COMMIT_MSG': [],
-        'sieve.go': [
-          {
-            updated: '2017-02-03 22:32:28.000000000',
-            message: 'this isn’t quite right',
-          },
-          {
-            side: CommentSide.PARENT,
-            message: 'how did this work in the first place?',
-            updated: '2017-02-03 22:33:28.000000000',
-          },
-        ],
-      })
-    );
-    return element
-        ._getDiffComments('42', '', undefined, PARENT, 1, 'sieve.go')
-        .then(obj => {
-          assert.equal(obj.baseComments.length, 1);
-          assert.deepEqual(obj.baseComments[0], {
-            side: CommentSide.PARENT,
-            message: 'how did this work in the first place?',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:33:28.000000000',
-          });
-          assert.equal(obj.comments.length, 1);
-          assert.deepEqual(obj.comments[0], {
-            message: 'this isn’t quite right',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-        });
-  });
-
-  test('_setRange', () => {
-    const comments = [
-      {
-        id: 1,
-        side: CommentSide.PARENT,
-        message: 'how did this work in the first place?',
-        updated: '2017-02-03 22:32:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-      {
-        id: 2,
-        in_reply_to: 1,
-        message: 'this isn’t quite right',
-        updated: '2017-02-03 22:33:28.000000000',
-      },
-    ];
-    const expectedResult = {
-      id: 2,
-      in_reply_to: 1,
-      message: 'this isn’t quite right',
-      updated: '2017-02-03 22:33:28.000000000',
-      range: {
-        start_line: 1,
-        start_character: 1,
-        end_line: 2,
-        end_character: 1,
-      },
-    };
-    const comment = comments[1];
-    assert.deepEqual(element._setRange(comments, comment), expectedResult);
-  });
-
-  test('_setRanges', () => {
-    const comments = [
-      {
-        id: 3,
-        in_reply_to: 2,
-        message: 'this isn’t quite right either',
-        updated: '2017-02-03 22:34:28.000000000',
-      },
-      {
-        id: 2,
-        in_reply_to: 1,
-        message: 'this isn’t quite right',
-        updated: '2017-02-03 22:33:28.000000000',
-      },
-      {
-        id: 1,
-        side: CommentSide.PARENT,
-        message: 'how did this work in the first place?',
-        updated: '2017-02-03 22:32:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-    ];
-    const expectedResult = [
-      {
-        id: 1,
-        side: CommentSide.PARENT,
-        message: 'how did this work in the first place?',
-        updated: '2017-02-03 22:32:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-      {
-        id: 2,
-        in_reply_to: 1,
-        message: 'this isn’t quite right',
-        updated: '2017-02-03 22:33:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-      {
-        id: 3,
-        in_reply_to: 2,
-        message: 'this isn’t quite right either',
-        updated: '2017-02-03 22:34:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-    ];
-    assert.deepEqual(element._setRanges(comments), expectedResult);
-  });
-
-  test('differing patch diff comments are properly grouped', () => {
-    sinon
-        .stub(element, 'getFromProjectLookup')
-        .returns(Promise.resolve('test'));
-    sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(request => {
-      const url = request.url;
-      if (url === '/changes/test~42/revisions/1') {
-        return Promise.resolve({
-          '/COMMIT_MSG': [],
-          'sieve.go': [
-            {
-              message: 'this isn’t quite right',
-              updated: '2017-02-03 22:32:28.000000000',
-            },
-            {
-              side: CommentSide.PARENT,
-              message: 'how did this work in the first place?',
-              updated: '2017-02-03 22:33:28.000000000',
-            },
-          ],
-        });
-      } else if (url === '/changes/test~42/revisions/2') {
-        return Promise.resolve({
-          '/COMMIT_MSG': [],
-          'sieve.go': [
-            {
-              message: 'What on earth are you thinking, here?',
-              updated: '2017-02-03 22:32:28.000000000',
-            },
-            {
-              side: CommentSide.PARENT,
-              message: 'Yeah not sure how this worked either?',
-              updated: '2017-02-03 22:33:28.000000000',
-            },
-            {
-              message: '¯\\_(ツ)_/¯',
-              updated: '2017-02-04 22:33:28.000000000',
-            },
-          ],
-        });
-      }
-    });
-    return element
-        ._getDiffComments('42', '', undefined, 1, 2, 'sieve.go')
-        .then(obj => {
-          assert.equal(obj.baseComments.length, 1);
-          assert.deepEqual(obj.baseComments[0], {
-            message: 'this isn’t quite right',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          assert.equal(obj.comments.length, 2);
-          assert.deepEqual(obj.comments[0], {
-            message: 'What on earth are you thinking, here?',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          assert.deepEqual(obj.comments[1], {
-            message: '¯\\_(ツ)_/¯',
-            path: 'sieve.go',
-            updated: '2017-02-04 22:33:28.000000000',
-          });
-        });
-  });
-
-  test('server error', () => {
-    const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
-    stubAuth('fetch').returns(Promise.resolve({ok: false}));
-    const serverErrorEventPromise = new Promise(resolve => {
-      addListenerForTest(document, 'server-error', resolve);
-    });
-
-    return Promise.all([
-      element._restApiHelper.fetchJSON({}).then(response => {
-        assert.isUndefined(response);
-        assert.isTrue(getResponseObjectStub.notCalled);
-      }),
-      serverErrorEventPromise,
-    ]);
-  });
-
-  test('legacy n,z key in change url is replaced', async () => {
-    sinon.stub(element, 'getConfig').callsFake(async () => {
-      return {};
-    });
-    const stub = sinon
-        .stub(element._restApiHelper, 'fetchJSON')
-        .returns(Promise.resolve([]));
-    await element.getChanges(1, null, 'n,z');
-    assert.equal(stub.lastCall.args[0].params.S, 0);
-  });
-
-  test('saveDiffPreferences invalidates cache line', () => {
-    const cacheKey = '/accounts/self/preferences.diff';
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
-    element._cache.set(cacheKey, {tab_size: 4});
-    element.saveDiffPreferences({tab_size: 8});
-    assert.isTrue(sendStub.called);
-    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-  });
-
-  suite('getAccountSuggestions', () => {
-    let fetchStub;
-    setup(() => {
-      fetchStub = sinon
-          .stub(element._restApiHelper, 'fetch')
-          .returns(Promise.resolve(new Response()));
-    });
-
-    test('url with just email', () => {
-      element.getSuggestedAccounts('bro');
-      assert.isTrue(fetchStub.calledOnce);
-      assert.equal(
-          fetchStub.firstCall.args[0].url,
-          'test52/accounts/?o=DETAILS&q=bro'
-      );
-    });
-
-    test('url with email and canSee changeId', () => {
-      element.getSuggestedAccounts('bro', undefined, 341682);
-      assert.isTrue(fetchStub.calledOnce);
-      assert.equal(
-          fetchStub.firstCall.args[0].url,
-          'test53/accounts/?o=DETAILS&q=bro%20and%20cansee%3A341682'
-      );
-    });
-
-    test('url with email and canSee changeId and isActive', () => {
-      element.getSuggestedAccounts('bro', undefined, 341682, true);
-      assert.isTrue(fetchStub.calledOnce);
-      assert.equal(
-          fetchStub.firstCall.args[0].url,
-          'test54/accounts/?o=DETAILS&q=bro%20and%20' +
-          'cansee%3A341682%20and%20is%3Aactive'
-      );
-    });
-  });
-
-  test('getAccount when resp is null does not add to cache', async () => {
-    const cacheKey = '/accounts/self/detail';
-    const stub = sinon
-        .stub(element._restApiHelper, 'fetchCacheURL')
-        .callsFake(() => Promise.resolve());
-
-    await element.getAccount();
-    assert.isTrue(stub.called);
-    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-
-    element._restApiHelper._cache.set(cacheKey, 'fake cache');
-    stub.lastCall.args[0].errFn();
-  });
-
-  test('getAccount does not add to cache when status is 403', async () => {
-    const cacheKey = '/accounts/self/detail';
-    const stub = sinon
-        .stub(element._restApiHelper, 'fetchCacheURL')
-        .callsFake(() => Promise.resolve());
-
-    await element.getAccount();
-    assert.isTrue(stub.called);
-    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-
-    element._cache.set(cacheKey, 'fake cache');
-    stub.lastCall.args[0].errFn({status: 403});
-  });
-
-  test('getAccount when resp is successful', async () => {
-    const cacheKey = '/accounts/self/detail';
-    const stub = sinon
-        .stub(element._restApiHelper, 'fetchCacheURL')
-        .callsFake(() => Promise.resolve());
-
-    await element.getAccount();
-
-    element._restApiHelper._cache.set(cacheKey, 'fake cache');
-    assert.isTrue(stub.called);
-    assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
-    stub.lastCall.args[0].errFn({});
-  });
-
-  const preferenceSetup = function(testJSON, loggedIn) {
-    sinon
-        .stub(element, 'getLoggedIn')
-        .callsFake(() => Promise.resolve(loggedIn));
-    sinon
-        .stub(element._restApiHelper, 'fetchCacheURL')
-        .callsFake(() => Promise.resolve(testJSON));
-  };
-
-  test('getPreferences returns correctly logged in', () => {
-    const testJSON = {diff_view: 'SIDE_BY_SIDE'};
-    const loggedIn = true;
-
-    preferenceSetup(testJSON, loggedIn);
-
-    return element.getPreferences().then(obj => {
-      assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-    });
-  });
-
-  test('getPreferences returns correctly on larger screens logged in', () => {
-    const testJSON = {diff_view: 'UNIFIED_DIFF'};
-    const loggedIn = true;
-
-    preferenceSetup(testJSON, loggedIn);
-
-    return element.getPreferences().then(obj => {
-      assert.equal(obj.diff_view, 'UNIFIED_DIFF');
-    });
-  });
-
-  test('getPreferences returns correctly on larger screens no login', () => {
-    const testJSON = {diff_view: 'UNIFIED_DIFF'};
-    const loggedIn = false;
-
-    preferenceSetup(testJSON, loggedIn);
-
-    return element.getPreferences().then(obj => {
-      assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-    });
-  });
-
-  test('savPreferences normalizes download scheme', () => {
-    const sendStub = sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve(new Response()));
-    element.savePreferences({download_scheme: 'HTTP'});
-    assert.isTrue(sendStub.called);
-    assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
-  });
-
-  test('getDiffPreferences returns correct defaults', () => {
-    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
-
-    return element.getDiffPreferences().then(obj => {
-      assert.equal(obj.context, 10);
-      assert.equal(obj.cursor_blink_rate, 0);
-      assert.equal(obj.font_size, 12);
-      assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
-      assert.equal(obj.line_length, 100);
-      assert.equal(obj.line_wrapping, false);
-      assert.equal(obj.show_line_endings, true);
-      assert.equal(obj.show_tabs, true);
-      assert.equal(obj.show_whitespace_errors, true);
-      assert.equal(obj.syntax_highlighting, true);
-      assert.equal(obj.tab_size, 8);
-    });
-  });
-
-  test('saveDiffPreferences set show_tabs to false', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
-    element.saveDiffPreferences({show_tabs: false});
-    assert.isTrue(sendStub.called);
-    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
-  });
-
-  test('getEditPreferences returns correct defaults', () => {
-    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
-
-    return element.getEditPreferences().then(obj => {
-      assert.equal(obj.auto_close_brackets, false);
-      assert.equal(obj.cursor_blink_rate, 0);
-      assert.equal(obj.hide_line_numbers, false);
-      assert.equal(obj.hide_top_menu, false);
-      assert.equal(obj.indent_unit, 2);
-      assert.equal(obj.indent_with_tabs, false);
-      assert.equal(obj.key_map_type, 'DEFAULT');
-      assert.equal(obj.line_length, 100);
-      assert.equal(obj.line_wrapping, false);
-      assert.equal(obj.match_brackets, true);
-      assert.equal(obj.show_base, false);
-      assert.equal(obj.show_tabs, true);
-      assert.equal(obj.show_whitespace_errors, true);
-      assert.equal(obj.syntax_highlighting, true);
-      assert.equal(obj.tab_size, 8);
-      assert.equal(obj.theme, 'DEFAULT');
-    });
-  });
-
-  test('saveEditPreferences set show_tabs to false', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
-    element.saveEditPreferences({show_tabs: false});
-    assert.isTrue(sendStub.called);
-    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
-  });
-
-  test('confirmEmail', () => {
-    const sendStub = sinon.spy(element._restApiHelper, 'send');
-    element.confirmEmail('foo');
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-    assert.equal(sendStub.lastCall.args[0].url, '/config/server/email.confirm');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
-  });
-
-  test('setAccountStatus', () => {
-    const sendStub = sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve('OOO'));
-    element._cache.set('/accounts/self/detail', {});
-    return element.setAccountStatus('OOO').then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-      assert.equal(sendStub.lastCall.args[0].url, '/accounts/self/status');
-      assert.deepEqual(sendStub.lastCall.args[0].body, {status: 'OOO'});
-      assert.deepEqual(
-          element._restApiHelper._cache.get('/accounts/self/detail'),
-          {status: 'OOO'}
-      );
-    });
-  });
-
-  suite('draft comments', () => {
-    test('_sendDiffDraftRequest pending requests tracked', () => {
-      const obj = element._pendingRequests;
-      sinon
-          .stub(element, '_getChangeURLAndSend')
-          .callsFake(() => mockPromise());
-      assert.notOk(element.hasPendingDiffDrafts());
-
-      element._sendDiffDraftRequest(null, null, null, {});
-      assert.equal(obj.sendDiffDraft.length, 1);
-      assert.isTrue(!!element.hasPendingDiffDrafts());
-
-      element._sendDiffDraftRequest(null, null, null, {});
-      assert.equal(obj.sendDiffDraft.length, 2);
-      assert.isTrue(!!element.hasPendingDiffDrafts());
-
-      for (const promise of obj.sendDiffDraft) {
-        promise.resolve();
-      }
-
-      return element.awaitPendingDiffDrafts().then(() => {
-        assert.equal(obj.sendDiffDraft.length, 0);
-        assert.isFalse(!!element.hasPendingDiffDrafts());
-      });
-    });
-
-    suite('_failForCreate200', () => {
-      test('_sendDiffDraftRequest checks for 200 on create', () => {
-        const sendPromise = Promise.resolve();
-        sinon.stub(element, '_getChangeURLAndSend').returns(sendPromise);
-        const failStub = sinon
-            .stub(element, '_failForCreate200')
-            .returns(Promise.resolve());
-        return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
-          assert.isTrue(failStub.calledOnce);
-          assert.isTrue(failStub.calledWithExactly(sendPromise));
-        });
-      });
-
-      test('_sendDiffDraftRequest no checks for 200 on non create', () => {
-        sinon.stub(element, '_getChangeURLAndSend').returns(Promise.resolve());
-        const failStub = sinon
-            .stub(element, '_failForCreate200')
-            .returns(Promise.resolve());
-        return element
-            ._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
-            .then(() => {
-              assert.isFalse(failStub.called);
-            });
-      });
-
-      test('_failForCreate200 fails on 200', () => {
-        const result = {
-          ok: true,
-          status: 200,
-          headers: {
-            entries: () => [
-              ['Set-CoOkiE', 'secret'],
-              ['Innocuous', 'hello'],
-            ],
-          },
-        };
-        return element
-            ._failForCreate200(Promise.resolve(result))
-            .then(() => {
-              assert.fail('Error expected.');
-            })
-            .catch(e => {
-              assert.isOk(e);
-              assert.include(e.message, 'Saving draft resulted in HTTP 200');
-              assert.include(e.message, 'hello');
-              assert.notInclude(e.message, 'secret');
-            });
-      });
-
-      test('_failForCreate200 does not fail on 201', () => {
-        const result = {
-          ok: true,
-          status: 201,
-          headers: {entries: () => []},
-        };
-        return element._failForCreate200(Promise.resolve(result));
-      });
-    });
-  });
-
-  test('saveChangeEdit', () => {
-    element._projectLookup = {1: Promise.resolve('test')};
-    const change_num = '1';
-    const file_name = 'index.php';
-    const file_contents = '<?php';
-    sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve([change_num, file_name, file_contents]));
-    sinon
-        .stub(element, 'getResponseObject')
-        .returns(Promise.resolve([change_num, file_name, file_contents]));
-    element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
-    return element
-        .saveChangeEdit(change_num, file_name, file_contents)
-        .then(() => {
-          assert.isTrue(element._restApiHelper.send.calledOnce);
-          assert.equal(
-              element._restApiHelper.send.lastCall.args[0].method,
-              'PUT'
-          );
-          assert.equal(
-              element._restApiHelper.send.lastCall.args[0].url,
-              '/changes/test~1/edit/' + file_name
-          );
-          assert.equal(
-              element._restApiHelper.send.lastCall.args[0].body,
-              file_contents
-          );
-        });
-  });
-
-  test('putChangeCommitMessage', () => {
-    element._projectLookup = {1: Promise.resolve('test')};
-    const change_num = '1';
-    const message = 'this is a commit message';
-    sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve([change_num, message]));
-    sinon
-        .stub(element, 'getResponseObject')
-        .returns(Promise.resolve([change_num, message]));
-    element._cache.set('/changes/' + change_num + '/message', {});
-    return element.putChangeCommitMessage(change_num, message).then(() => {
-      assert.isTrue(element._restApiHelper.send.calledOnce);
-      assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
-      assert.equal(
-          element._restApiHelper.send.lastCall.args[0].url,
-          '/changes/test~1/message'
-      );
-      assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body, {
-        message,
-      });
-    });
-  });
-
-  test('deleteChangeCommitMessage', () => {
-    element._projectLookup = {1: Promise.resolve('test')};
-    const change_num = '1';
-    const messageId = 'abc';
-    sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve([change_num, messageId]));
-    sinon
-        .stub(element, 'getResponseObject')
-        .returns(Promise.resolve([change_num, messageId]));
-    return element.deleteChangeCommitMessage(change_num, messageId).then(() => {
-      assert.isTrue(element._restApiHelper.send.calledOnce);
-      assert.equal(
-          element._restApiHelper.send.lastCall.args[0].method,
-          'DELETE'
-      );
-      assert.equal(
-          element._restApiHelper.send.lastCall.args[0].url,
-          '/changes/test~1/messages/abc'
-      );
-    });
-  });
-
-  test('startWorkInProgress', () => {
-    const sendStub = sinon
-        .stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve('ok'));
-    element.startWorkInProgress('42');
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-    assert.equal(sendStub.lastCall.args[0].method, 'POST');
-    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {});
-
-    element.startWorkInProgress('42', 'revising...');
-    assert.isTrue(sendStub.calledTwice);
-    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-    assert.equal(sendStub.lastCall.args[0].method, 'POST');
-    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {
-      message: 'revising...',
-    });
-  });
-
-  test('deleteComment', () => {
-    const sendStub = sinon
-        .stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve('some response'));
-    return element
-        .deleteComment('foo', 'bar', '01234', 'removal reason')
-        .then(response => {
-          assert.equal(response, 'some response');
-          assert.isTrue(sendStub.calledOnce);
-          assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
-          assert.equal(sendStub.lastCall.args[0].method, 'POST');
-          assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
-          assert.equal(
-              sendStub.lastCall.args[0].endpoint,
-              '/comments/01234/delete'
-          );
-          assert.deepEqual(sendStub.lastCall.args[0].body, {
-            reason: 'removal reason',
-          });
-        });
-  });
-
-  test('createRepo encodes name', () => {
-    const sendStub = sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve());
-    return element.createRepo({name: 'x/y'}).then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
-    });
-  });
-
-  test('queryChangeFiles', () => {
-    const fetchStub = sinon
-        .stub(element, '_getChangeURLAndFetch')
-        .returns(Promise.resolve());
-    return element.queryChangeFiles('42', EDIT, 'test/path.js').then(() => {
-      assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
-      assert.equal(
-          fetchStub.lastCall.args[0].endpoint,
-          '/files?q=test%2Fpath.js'
-      );
-      assert.equal(fetchStub.lastCall.args[0].revision, EDIT);
-    });
-  });
-
-  test('normal use', () => {
-    const defaultQuery = '';
-
-    assert.equal(
-        element._getReposUrl('test', 25).toString(),
-        [false, '/projects/?n=26&S=0&d=&m=test'].toString()
-    );
-
-    assert.equal(
-        element._getReposUrl(null, 25).toString(),
-        [false, `/projects/?n=26&S=0&d=&m=${defaultQuery}`].toString()
-    );
-
-    assert.equal(
-        element._getReposUrl('test', 25, 25).toString(),
-        [false, '/projects/?n=26&S=25&d=&m=test'].toString()
-    );
-
-    assert.equal(
-        element._getReposUrl('inname:test', 25, 25).toString(),
-        [true, '/projects/?n=26&S=25&query=inname%3Atest'].toString()
-    );
-  });
-
-  test('invalidateReposCache', () => {
-    const url = '/projects/?n=26&S=0&query=test';
-
-    element._cache.set(url, {});
-
-    element.invalidateReposCache();
-
-    assert.isUndefined(element._sharedFetchPromises[url]);
-
-    assert.isFalse(element._cache.has(url));
-  });
-
-  test('invalidateAccountsCache', () => {
-    const url = '/accounts/self/detail';
-
-    element._cache.set(url, {});
-
-    element.invalidateAccountsCache();
-
-    assert.isUndefined(element._sharedFetchPromises[url]);
-
-    assert.isFalse(element._cache.has(url));
-  });
-
-  suite('getRepos', () => {
-    const defaultQuery = '';
-    let fetchCacheURLStub;
-    setup(() => {
-      fetchCacheURLStub = sinon
-          .stub(element._restApiHelper, 'fetchCacheURL')
-          .returns(Promise.resolve([]));
-    });
-
-    test('normal use', () => {
-      element.getRepos('test', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=test'
-      );
-
-      element.getRepos(null, 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&d=&m=${defaultQuery}`
-      );
-
-      element.getRepos('test', 25, 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=25&d=&m=test'
-      );
-    });
-
-    test('with blank', () => {
-      element.getRepos('test/test', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=test%2Ftest'
-      );
-    });
-
-    test('with hyphen', () => {
-      element.getRepos('foo-bar', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=foo-bar'
-      );
-    });
-
-    test('with leading hyphen', () => {
-      element.getRepos('-bar', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=-bar'
-      );
-    });
-
-    test('with trailing hyphen', () => {
-      element.getRepos('foo-bar-', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=foo-bar-'
-      );
-    });
-
-    test('with underscore', () => {
-      element.getRepos('foo_bar', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=foo_bar'
-      );
-    });
-
-    test('with underscore', () => {
-      element.getRepos('foo_bar', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=foo_bar'
-      );
-    });
-
-    test('hyphen only', () => {
-      element.getRepos('-', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&d=&m=-`
-      );
-    });
-
-    test('using query', () => {
-      element.getRepos('description:project', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&query=description%3Aproject`
-      );
-    });
-  });
-
-  test('_getGroupsUrl normal use', () => {
-    assert.equal(element._getGroupsUrl('test', 25), '/groups/?n=26&S=0&m=test');
-
-    assert.equal(element._getGroupsUrl(null, 25), '/groups/?n=26&S=0');
-
-    assert.equal(
-        element._getGroupsUrl('test', 25, 25),
-        '/groups/?n=26&S=25&m=test'
-    );
-  });
-
-  test('invalidateGroupsCache', () => {
-    const url = '/groups/?n=26&S=0&m=test';
-
-    element._cache.set(url, {});
-
-    element.invalidateGroupsCache();
-
-    assert.isUndefined(element._sharedFetchPromises[url]);
-
-    assert.isFalse(element._cache.has(url));
-  });
-
-  suite('getGroups', () => {
-    let fetchCacheURLStub;
-    setup(() => {
-      fetchCacheURLStub = sinon.stub(element._restApiHelper, 'fetchCacheURL');
-    });
-
-    test('normal use', () => {
-      element.getGroups('test', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0&m=test'
-      );
-
-      element.getGroups(null, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url, '/groups/?n=26&S=0');
-
-      element.getGroups('test', 25, 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=25&m=test'
-      );
-    });
-
-    test('regex', () => {
-      element.getGroups('^test.*', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0&r=%5Etest.*'
-      );
-
-      element.getGroups('^test.*', 25, 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=25&r=%5Etest.*'
-      );
-    });
-  });
-
-  test('gerrit auth is used', () => {
-    stubAuth('fetch').returns(Promise.resolve());
-    element._restApiHelper.fetchJSON({url: 'foo'});
-    assert(getAppContext().authService.fetch.called);
-  });
-
-  test('getSuggestedAccounts does not return _fetchJSON', () => {
-    const _fetchJSONSpy = sinon.spy(element._restApiHelper, 'fetchJSON');
-    return element.getSuggestedAccounts().then(accts => {
-      assert.isFalse(_fetchJSONSpy.called);
-      assert.equal(accts.length, 0);
-    });
-  });
-
-  test('_fetchJSON gets called by getSuggestedAccounts', () => {
-    const _fetchJSONStub = sinon
-        .stub(element._restApiHelper, 'fetchJSON')
-        .callsFake(() => Promise.resolve());
-    return element.getSuggestedAccounts('own').then(() => {
-      assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
-        q: 'own',
-        o: 'DETAILS',
-      });
-    });
-  });
-
-  suite('getChangeDetail', () => {
-    suite('change detail options', () => {
-      setup(() => {
-        sinon
-            .stub(element, '_getChangeDetail')
-            .callsFake(async (changeNum, options) => {
-              return {changeNum, options};
-            });
-      });
-
-      test('signed pushes disabled', async () => {
-        sinon.stub(element, 'getConfig').callsFake(async () => {
-          return {};
-        });
-        const {changeNum, options} = await element.getChangeDetail(123);
-        assert.strictEqual(123, changeNum);
-        assert.isNotOk(
-            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
-        );
-      });
-
-      test('signed pushes enabled', async () => {
-        sinon.stub(element, 'getConfig').callsFake(async () => {
-          return {receive: {enable_signed_push: true}};
-        });
-        const {changeNum, options} = await element.getChangeDetail(123);
-        assert.strictEqual(123, changeNum);
-        assert.ok(
-            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
-        );
-      });
-    });
-
-    test('GrReviewerUpdatesParser.parse is used', () => {
-      sinon
-          .stub(GrReviewerUpdatesParser, 'parse')
-          .returns(Promise.resolve('foo'));
-      return element.getChangeDetail(42).then(result => {
-        assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
-        assert.equal(result, 'foo');
-      });
-    });
-
-    test('_getChangeDetail passes params to ETags decorator', () => {
-      const changeNum = 4321;
-      element._projectLookup[changeNum] = Promise.resolve('test');
-      const expectedUrl =
-        window.CANONICAL_PATH + '/changes/test~4321/detail?O=516714';
-      sinon.stub(element._etags, 'getOptions');
-      sinon.stub(element._etags, 'collect');
-      return element._getChangeDetail(changeNum, '516714').then(() => {
-        assert.isTrue(element._etags.getOptions.calledWithExactly(expectedUrl));
-        assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
-      });
-    });
-
-    test('_getChangeDetail calls errFn on 500', () => {
-      const errFn = sinon.stub();
-      sinon.stub(element, 'getChangeActionURL').returns(Promise.resolve(''));
-      sinon
-          .stub(element._restApiHelper, 'fetchRawJSON')
-          .returns(Promise.resolve({ok: false, status: 500}));
-      return element._getChangeDetail(123, '516714', errFn).then(() => {
-        assert.isTrue(errFn.called);
-      });
-    });
-
-    test('_getChangeDetail populates _projectLookup', async () => {
-      sinon.stub(element, 'getChangeActionURL').returns(Promise.resolve(''));
-      sinon.stub(element._restApiHelper, 'fetchRawJSON').returns(
-          Promise.resolve({
-            ok: true,
-            status: 200,
-            text: () => Promise.resolve(`)]}'{"_number":1,"project":"test"}`),
-          })
-      );
-      await element._getChangeDetail(1, '516714');
-      assert.equal(Object.keys(element._projectLookup).length, 1);
-      const project = await element._projectLookup[1];
-      assert.equal(project, 'test');
-    });
-
-    suite('_getChangeDetail ETag cache', () => {
-      let requestUrl;
-      let mockResponseSerial;
-      let collectSpy;
-
-      setup(() => {
-        requestUrl = '/foo/bar';
-        const mockResponse = {foo: 'bar', baz: 42};
-        mockResponseSerial = JSON_PREFIX + JSON.stringify(mockResponse);
-        sinon.stub(element._restApiHelper, 'urlWithParams').returns(requestUrl);
-        sinon
-            .stub(element, 'getChangeActionURL')
-            .returns(Promise.resolve(requestUrl));
-        collectSpy = sinon.spy(element._etags, 'collect');
-      });
-
-      test('contributes to cache', () => {
-        const getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
-        sinon.stub(element._restApiHelper, 'fetchRawJSON').returns(
-            Promise.resolve({
-              text: () => Promise.resolve(mockResponseSerial),
-              status: 200,
-              ok: true,
-            })
-        );
-
-        return element._getChangeDetail(123, '516714').then(detail => {
-          assert.isFalse(getPayloadSpy.called);
-          assert.isTrue(collectSpy.calledOnce);
-          const cachedResponse = element._etags.getCachedPayload(requestUrl);
-          assert.equal(cachedResponse, mockResponseSerial);
-        });
-      });
-
-      test('uses cache on HTTP 304', () => {
-        const getPayloadStub = sinon.stub(element._etags, 'getCachedPayload');
-        getPayloadStub.returns(mockResponseSerial);
-        sinon.stub(element._restApiHelper, 'fetchRawJSON').returns(
-            Promise.resolve({
-              text: () => Promise.resolve(''),
-              status: 304,
-              ok: true,
-            })
-        );
-
-        return element._getChangeDetail(123, '').then(detail => {
-          assert.isFalse(collectSpy.called);
-          assert.isTrue(getPayloadStub.calledOnce);
-        });
-      });
-    });
-  });
-
-  test('setInProjectLookup', async () => {
-    await element.setInProjectLookup('test', 'project');
-    const project = await element.getFromProjectLookup('test');
-    assert.deepEqual(project, 'project');
-  });
-
-  suite('getFromProjectLookup', () => {
-    test('getChange succeeds, no project', async () => {
-      sinon.stub(element, 'getChange').returns(Promise.resolve(null));
-      const val = await element.getFromProjectLookup();
-      assert.strictEqual(val, undefined);
-    });
-
-    test('getChange succeeds with project', () => {
-      sinon
-          .stub(element, 'getChange')
-          .returns(Promise.resolve({project: 'project'}));
-      const projectLookup = element.getFromProjectLookup('test');
-      return projectLookup.then(val => {
-        assert.equal(val, 'project');
-        assert.deepEqual(element._projectLookup, {test: projectLookup});
-      });
-    });
-  });
-
-  suite('getChanges populates _projectLookup', () => {
-    test('multiple queries', async () => {
-      sinon.stub(element._restApiHelper, 'fetchJSON').returns(
-          Promise.resolve([
-            [
-              {_number: 1, project: 'test'},
-              {_number: 2, project: 'test'},
-            ],
-            [{_number: 3, project: 'test/test'}],
-          ])
-      );
-      // When opt_query instanceof Array, _fetchJSON returns
-      // Array<Array<Object>>.
-      await element.getChangesForMultipleQueries(null, []);
-      assert.equal(Object.keys(element._projectLookup).length, 3);
-      const project1 = await element.getFromProjectLookup(1);
-      assert.equal(project1, 'test');
-      const project2 = await element.getFromProjectLookup(2);
-      assert.equal(project2, 'test');
-      const project3 = await element.getFromProjectLookup(3);
-      assert.equal(project3, 'test/test');
-    });
-
-    test('no query', async () => {
-      sinon.stub(element._restApiHelper, 'fetchJSON').returns(
-          Promise.resolve([
-            {_number: 1, project: 'test'},
-            {_number: 2, project: 'test'},
-            {_number: 3, project: 'test/test'},
-          ])
-      );
-
-      // When opt_query !instanceof Array, _fetchJSON returns
-      // Array<Object>.
-      await element.getChanges();
-      assert.equal(Object.keys(element._projectLookup).length, 3);
-      const project1 = await element.getFromProjectLookup(1);
-      assert.equal(project1, 'test');
-      const project2 = await element.getFromProjectLookup(2);
-      assert.equal(project2, 'test');
-      const project3 = await element.getFromProjectLookup(3);
-      assert.equal(project3, 'test/test');
-    });
-  });
-
-  test('getDetailedChangesWithActions', async () => {
-    const c1 = createChange();
-    c1._number = 1;
-    const c2 = createChange();
-    c2._number = 2;
-    const getChangesStub = sinon
-        .stub(element, 'getChanges')
-        .callsFake((changesPerPage, query, offset, options) => {
-          assert.isUndefined(changesPerPage);
-          assert.strictEqual(query, 'change:1 OR change:2');
-          assert.isUndefined(offset);
-          assert.strictEqual(options, EXPECTED_QUERY_OPTIONS);
-          return Promise.resolve([]);
-        });
-    await element.getDetailedChangesWithActions([c1._number, c2._number]);
-    assert.isTrue(getChangesStub.calledOnce);
-  });
-
-  test('_getChangeURLAndFetch', () => {
-    element._projectLookup = {1: Promise.resolve('test')};
-    const fetchStub = sinon
-        .stub(element._restApiHelper, 'fetchJSON')
-        .returns(Promise.resolve());
-    const req = {changeNum: 1, endpoint: '/test', revision: 1};
-    return element._getChangeURLAndFetch(req).then(() => {
-      assert.equal(
-          fetchStub.lastCall.args[0].url,
-          '/changes/test~1/revisions/1/test'
-      );
-    });
-  });
-
-  test('_getChangeURLAndSend', () => {
-    element._projectLookup = {1: Promise.resolve('test')};
-    const sendStub = sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve());
-
-    const req = {
-      changeNum: 1,
-      method: 'POST',
-      patchNum: 1,
-      endpoint: '/test',
-    };
-    return element._getChangeURLAndSend(req).then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].method, 'POST');
-      assert.equal(
-          sendStub.lastCall.args[0].url,
-          '/changes/test~1/revisions/1/test'
-      );
-    });
-  });
-
-  suite('reading responses', () => {
-    test('_readResponsePayload', async () => {
-      const mockObject = {foo: 'bar', baz: 'foo'};
-      const serial = JSON_PREFIX + JSON.stringify(mockObject);
-      const mockResponse = {text: () => Promise.resolve(serial)};
-      const payload = await readResponsePayload(mockResponse);
-      assert.deepEqual(payload.parsed, mockObject);
-      assert.equal(payload.raw, serial);
-    });
-
-    test('_parsePrefixedJSON', () => {
-      const obj = {x: 3, y: {z: 4}, w: 23};
-      const serial = JSON_PREFIX + JSON.stringify(obj);
-      const result = parsePrefixedJSON(serial);
-      assert.deepEqual(result, obj);
-    });
-  });
-
-  test('setChangeTopic', () => {
-    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
-    return element.setChangeTopic(123, 'foo-bar').then(() => {
-      assert.isTrue(sendSpy.calledOnce);
-      assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
-    });
-  });
-
-  test('setChangeHashtag', () => {
-    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
-    return element.setChangeHashtag(123, 'foo-bar').then(() => {
-      assert.isTrue(sendSpy.calledOnce);
-      assert.equal(sendSpy.lastCall.args[0].body, 'foo-bar');
-    });
-  });
-
-  test('generateAccountHttpPassword', () => {
-    const sendSpy = sinon.spy(element._restApiHelper, 'send');
-    return element.generateAccountHttpPassword().then(() => {
-      assert.isTrue(sendSpy.calledOnce);
-      assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
-    });
-  });
-
-  suite('getChangeFiles', () => {
-    test('patch only', () => {
-      const fetchStub = sinon
-          .stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      const range = {basePatchNum: PARENT, patchNum: 2};
-      return element.getChangeFiles(123, range).then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 2);
-        assert.isNotOk(fetchStub.lastCall.args[0].params);
-      });
-    });
-
-    test('simple range', () => {
-      const fetchStub = sinon
-          .stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      const range = {basePatchNum: 4, patchNum: 5};
-      return element.getChangeFiles(123, range).then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-      });
-    });
-
-    test('parent index', () => {
-      const fetchStub = sinon
-          .stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      const range = {basePatchNum: -3, patchNum: 5};
-      return element.getChangeFiles(123, range).then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
-      });
-    });
-  });
-
-  suite('getDiff', () => {
-    test('patchOnly', () => {
-      const fetchStub = sinon
-          .stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.getDiff(123, PARENT, 2, 'foo/bar.baz').then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 2);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-      });
-    });
-
-    test('simple range', () => {
-      const fetchStub = sinon
-          .stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
-      });
-    });
-
-    test('parent index', () => {
-      const fetchStub = sinon
-          .stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
-      });
-    });
-  });
-
-  test('getDashboard', () => {
-    const fetchCacheURLStub = sinon.stub(
-        element._restApiHelper,
-        'fetchCacheURL'
-    );
-    element.getDashboard('gerrit/project', 'default:main');
-    assert.isTrue(fetchCacheURLStub.calledOnce);
-    assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
-        '/projects/gerrit%2Fproject/dashboards/default%3Amain'
-    );
-  });
-
-  test('getFileContent', () => {
-    sinon.stub(element, '_getChangeURLAndSend').returns(
-        Promise.resolve({
-          ok: 'true',
-          headers: {
-            get(header) {
-              if (header === 'X-FYI-Content-Type') {
-                return 'text/java';
-              }
-            },
-          },
-        })
-    );
-
-    sinon
-        .stub(element, 'getResponseObject')
-        .returns(Promise.resolve('new content'));
-
-    const edit = element.getFileContent('1', 'tst/path', 'EDIT').then(res => {
-      assert.deepEqual(res, {
-        content: 'new content',
-        type: 'text/java',
-        ok: true,
-      });
-    });
-
-    const normal = element.getFileContent('1', 'tst/path', '3').then(res => {
-      assert.deepEqual(res, {
-        content: 'new content',
-        type: 'text/java',
-        ok: true,
-      });
-    });
-
-    return Promise.all([edit, normal]);
-  });
-
-  test('getFileContent suppresses 404s', () => {
-    const res = {status: 404};
-    const spy = sinon.spy();
-    addListenerForTest(document, 'server-error', spy);
-    sinon
-        .stub(getAppContext().authService, 'fetch')
-        .returns(Promise.resolve(res));
-    sinon.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
-    return element
-        .getFileContent('1', 'tst/path', '1')
-        .then(() => waitEventLoop())
-        .then(() => {
-          assert.isFalse(spy.called);
-
-          res.status = 500;
-          return element.getFileContent('1', 'tst/path', '1');
-        })
-        .then(() => {
-          assert.isTrue(spy.called);
-          assert.notEqual(spy.lastCall.args[0].detail.response.status, 404);
-        });
-  });
-
-  test('getChangeFilesOrEditFiles is edit-sensitive', () => {
-    const fn = element.getChangeOrEditFiles.bind(element);
-    const getChangeFilesStub = sinon
-        .stub(element, 'getChangeFiles')
-        .returns(Promise.resolve({}));
-    const getChangeEditFilesStub = sinon
-        .stub(element, 'getChangeEditFiles')
-        .returns(Promise.resolve({}));
-
-    return fn('1', {patchNum: EDIT}).then(() => {
-      assert.isTrue(getChangeEditFilesStub.calledOnce);
-      assert.isFalse(getChangeFilesStub.called);
-      return fn('1', {patchNum: '1'}).then(() => {
-        assert.isTrue(getChangeEditFilesStub.calledOnce);
-        assert.isTrue(getChangeFilesStub.calledOnce);
-      });
-    });
-  });
-
-  test('_fetch forwards request and logs', () => {
-    const logStub = sinon.stub(element._restApiHelper, '_logCall');
-    const response = {status: 404, text: sinon.stub()};
-    const url = 'my url';
-    const fetchOptions = {method: 'DELETE'};
-    sinon.stub(element.authService, 'fetch').returns(Promise.resolve(response));
-    const startTime = 123;
-    sinon.stub(Date, 'now').returns(startTime);
-    const req = {url, fetchOptions};
-    return element._restApiHelper.fetch(req).then(() => {
-      assert.isTrue(logStub.calledOnce);
-      assert.isTrue(logStub.calledWith(req, startTime, response.status));
-      assert.isFalse(response.text.called);
-    });
-  });
-
-  test('_logCall only reports requests with anonymized URLss', async () => {
-    sinon.stub(Date, 'now').returns(200);
-    const handler = sinon.stub();
-    addListenerForTest(document, 'gr-rpc-log', handler);
-
-    element._restApiHelper._logCall({url: 'url'}, 100, 200);
-    assert.isFalse(handler.called);
-
-    element._restApiHelper._logCall(
-        {url: 'url', anonymizedUrl: 'not url'},
-        100,
-        200
-    );
-    await waitEventLoop();
-    assert.isTrue(handler.calledOnce);
-  });
-
-  test('ported comment errors do not trigger error dialog', () => {
-    const change = createChange();
-    const handler = sinon.stub();
-    addListenerForTest(document, 'server-error', handler);
-    sinon.stub(element._restApiHelper, 'fetchJSON').returns(
-        Promise.resolve({
-          ok: false,
-        })
-    );
-
-    element.getPortedComments(change._number, CURRENT);
-
-    assert.isFalse(handler.called);
-  });
-
-  test('ported drafts are not requested user is not logged in', () => {
-    const change = createChange();
-    sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(false));
-    const getChangeURLAndFetchStub = sinon.stub(
-        element,
-        '_getChangeURLAndFetch'
-    );
-
-    element.getPortedDrafts(change._number, CURRENT);
-
-    assert.isFalse(getChangeURLAndFetchStub.called);
-  });
-
-  test('saveChangeStarred', async () => {
-    sinon
-        .stub(element, 'getFromProjectLookup')
-        .returns(Promise.resolve('test'));
-    const sendStub = sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve());
-
-    await element.saveChangeStarred(123, true);
-    assert.isTrue(sendStub.calledOnce);
-    assert.deepEqual(sendStub.lastCall.args[0], {
-      method: 'PUT',
-      url: '/accounts/self/starred.changes/test~123',
-      anonymizedUrl: '/accounts/self/starred.changes/*',
-    });
-
-    await element.saveChangeStarred(456, false);
-    assert.isTrue(sendStub.calledTwice);
-    assert.deepEqual(sendStub.lastCall.args[0], {
-      method: 'DELETE',
-      url: '/accounts/self/starred.changes/test~456',
-      anonymizedUrl: '/accounts/self/starred.changes/*',
-    });
-  });
-});
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
new file mode 100644
index 0000000..00db4f4
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -0,0 +1,1587 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {
+  addListenerForTest,
+  assertFails,
+  MockPromise,
+  mockPromise,
+  stubAuth,
+  waitEventLoop,
+} from '../../test/test-utils';
+import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  ListChangesOption,
+  listChangesOptionsToHex,
+} from '../../utils/change-util';
+import {getAppContext} from '../app-context';
+import {
+  createAccountDetailWithId,
+  createChange,
+  createComment,
+  createParsedChange,
+  createServerInfo,
+} from '../../test/test-data-generators';
+import {CURRENT} from '../../utils/patch-set-util';
+import {
+  parsePrefixedJSON,
+  readResponsePayload,
+  JSON_PREFIX,
+} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {GrRestApiServiceImpl} from './gr-rest-api-impl';
+import {
+  CommentSide,
+  createDefaultEditPrefs,
+  HttpMethod,
+} from '../../constants/constants';
+import {
+  BasePatchSetNum,
+  ChangeMessageId,
+  CommentInfo,
+  DashboardId,
+  DiffPreferenceInput,
+  EDIT,
+  EditPreferencesInfo,
+  Hashtag,
+  HashtagsInput,
+  NumericChangeId,
+  PARENT,
+  ParsedJSON,
+  PatchSetNum,
+  PreferencesInfo,
+  RepoName,
+  RevisionId,
+  RevisionPatchSetNum,
+  RobotCommentInfo,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../types/common';
+import {assert} from '@open-wc/testing';
+import {AuthService} from '../gr-auth/gr-auth';
+
+const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
+  ListChangesOption.CHANGE_ACTIONS,
+  ListChangesOption.CURRENT_ACTIONS,
+  ListChangesOption.CURRENT_REVISION,
+  ListChangesOption.DETAILED_LABELS,
+  ListChangesOption.SUBMIT_REQUIREMENTS
+);
+
+suite('gr-rest-api-service-impl tests', () => {
+  let element: GrRestApiServiceImpl;
+  let authService: AuthService;
+
+  let ctr = 0;
+  let originalCanonicalPath: string | undefined;
+
+  setup(() => {
+    // Modify CANONICAL_PATH to effectively reset cache.
+    ctr += 1;
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = `test${ctr}`;
+
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    sinon.stub(window, 'fetch').resolves(new Response(testJSON));
+    // fake auth
+    authService = getAppContext().authService;
+    sinon.stub(authService, 'authCheck').resolves(true);
+    element = new GrRestApiServiceImpl(authService);
+
+    element._projectLookup = {};
+  });
+
+  teardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  test('parent diff comments are properly grouped', async () => {
+    sinon.stub(element._restApiHelper, 'fetchJSON').resolves({
+      '/COMMIT_MSG': [],
+      'sieve.go': [
+        {
+          updated: '2017-02-03 22:32:28.000000000',
+          message: 'this isn’t quite right',
+        },
+        {
+          side: CommentSide.PARENT,
+          message: 'how did this work in the first place?',
+          updated: '2017-02-03 22:33:28.000000000',
+        },
+      ],
+    } as unknown as ParsedJSON);
+    const obj = await element._getDiffComments(
+      42 as NumericChangeId,
+      '/comments',
+      undefined,
+      PARENT,
+      1 as PatchSetNum,
+      'sieve.go'
+    );
+    assert.equal(obj.baseComments.length, 1);
+    assert.deepEqual(obj.baseComments[0], {
+      side: CommentSide.PARENT,
+      message: 'how did this work in the first place?',
+      path: 'sieve.go',
+      updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+    assert.equal(obj.comments.length, 1);
+    assert.deepEqual(obj.comments[0], {
+      message: 'this isn’t quite right',
+      path: 'sieve.go',
+      updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+  });
+
+  test('_setRange', () => {
+    const comments: CommentInfo[] = [
+      {
+        id: '1' as UrlEncodedCommentId,
+        side: CommentSide.PARENT,
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: '2' as UrlEncodedCommentId,
+        in_reply_to: '1' as UrlEncodedCommentId,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+      },
+    ];
+    const expectedResult: CommentInfo = {
+      id: '2' as UrlEncodedCommentId,
+      in_reply_to: '1' as UrlEncodedCommentId,
+      message: 'this isn’t quite right',
+      updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+      range: {
+        start_line: 1,
+        start_character: 1,
+        end_line: 2,
+        end_character: 1,
+      },
+    };
+    const comment = comments[1];
+    assert.deepEqual(element._setRange(comments, comment), expectedResult);
+  });
+
+  test('_setRanges', () => {
+    const comments: CommentInfo[] = [
+      {
+        id: '3' as UrlEncodedCommentId,
+        in_reply_to: '2' as UrlEncodedCommentId,
+        message: 'this isn’t quite right either',
+        updated: '2017-02-03 22:34:28.000000000' as Timestamp,
+      },
+      {
+        id: '2' as UrlEncodedCommentId,
+        in_reply_to: '1' as UrlEncodedCommentId,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+      },
+      {
+        id: '1' as UrlEncodedCommentId,
+        side: CommentSide.PARENT,
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+    ];
+    const expectedResult: CommentInfo[] = [
+      {
+        id: '1' as UrlEncodedCommentId,
+        side: CommentSide.PARENT,
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: '2' as UrlEncodedCommentId,
+        in_reply_to: '1' as UrlEncodedCommentId,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: '3' as UrlEncodedCommentId,
+        in_reply_to: '2' as UrlEncodedCommentId,
+        message: 'this isn’t quite right either',
+        updated: '2017-02-03 22:34:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+    ];
+    assert.deepEqual(element._setRanges(comments), expectedResult);
+  });
+
+  test('differing patch diff comments are properly grouped', async () => {
+    sinon.stub(element, 'getFromProjectLookup').resolves('test' as RepoName);
+    sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(async request => {
+      const url = request.url;
+      if (url === '/changes/test~42/revisions/1/comments') {
+        return {
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
+              message: 'this isn’t quite right',
+              updated: '2017-02-03 22:32:28.000000000',
+            },
+            {
+              side: CommentSide.PARENT,
+              message: 'how did this work in the first place?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+          ],
+        } as unknown as ParsedJSON;
+      } else if (url === '/changes/test~42/revisions/2/comments') {
+        return {
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
+              message: 'What on earth are you thinking, here?',
+              updated: '2017-02-03 22:32:28.000000000',
+            },
+            {
+              side: CommentSide.PARENT,
+              message: 'Yeah not sure how this worked either?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+            {
+              message: '¯\\_(ツ)_/¯',
+              updated: '2017-02-04 22:33:28.000000000',
+            },
+          ],
+        } as unknown as ParsedJSON;
+      }
+      return undefined;
+    });
+    const obj = await element._getDiffComments(
+      42 as NumericChangeId,
+      '/comments',
+      undefined,
+      1 as BasePatchSetNum,
+      2 as PatchSetNum,
+      'sieve.go'
+    );
+    assert.equal(obj.baseComments.length, 1);
+    assert.deepEqual(obj.baseComments[0], {
+      message: 'this isn’t quite right',
+      path: 'sieve.go',
+      updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+    assert.equal(obj.comments.length, 2);
+    assert.deepEqual(obj.comments[0], {
+      message: 'What on earth are you thinking, here?',
+      path: 'sieve.go',
+      updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+    assert.deepEqual(obj.comments[1], {
+      message: '¯\\_(ツ)_/¯',
+      path: 'sieve.go',
+      updated: '2017-02-04 22:33:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+  });
+
+  test('server error', async () => {
+    const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
+    stubAuth('fetch').resolves(new Response(undefined, {status: 502}));
+    const serverErrorEventPromise = new Promise(resolve => {
+      addListenerForTest(document, 'server-error', resolve);
+    });
+    const response = await element._restApiHelper.fetchJSON({url: ''});
+    assert.isUndefined(response);
+    assert.isTrue(getResponseObjectStub.notCalled);
+    await serverErrorEventPromise;
+  });
+
+  test('legacy n,z key in change url is replaced', async () => {
+    const stub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves([] as unknown as ParsedJSON);
+    await element.getChanges(1, undefined, 'n,z');
+    assert.equal(stub.lastCall.args[0].params!.S, 0);
+  });
+
+  test('saveDiffPreferences invalidates cache line', () => {
+    const cacheKey = '/accounts/self/preferences.diff';
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element._cache.set(cacheKey, {tab_size: 4} as unknown as ParsedJSON);
+    element.saveDiffPreferences({
+      tab_size: 8,
+      ignore_whitespace: 'IGNORE_NONE',
+    });
+    assert.isTrue(sendStub.called);
+    assert.isFalse(element._cache.has(cacheKey));
+  });
+
+  suite('getAccountSuggestions', () => {
+    let fetchStub: sinon.SinonStub;
+    setup(() => {
+      fetchStub = sinon
+        .stub(element._restApiHelper, 'fetch')
+        .resolves(new Response());
+    });
+
+    test('url with just email', () => {
+      element.getSuggestedAccounts('bro');
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.firstCall.args[0].url,
+        'test52/accounts/?o=DETAILS&q=bro'
+      );
+    });
+
+    test('url with email and canSee changeId', () => {
+      element.getSuggestedAccounts('bro', undefined, 341682 as NumericChangeId);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.firstCall.args[0].url,
+        'test53/accounts/?o=DETAILS&q=bro%20and%20cansee%3A341682'
+      );
+    });
+
+    test('url with email and canSee changeId and isActive', () => {
+      element.getSuggestedAccounts(
+        'bro',
+        undefined,
+        341682 as NumericChangeId,
+        true
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.firstCall.args[0].url,
+        'test54/accounts/?o=DETAILS&q=bro%20and%20' +
+          'cansee%3A341682%20and%20is%3Aactive'
+      );
+    });
+  });
+
+  test('getAccount when resp is undefined clears cache', async () => {
+    const cacheKey = '/accounts/self/detail';
+    const account = createAccountDetailWithId();
+    element._cache.set(cacheKey, account);
+    const stub = sinon
+      .stub(element._restApiHelper, 'fetchCacheURL')
+      .callsFake(async req => {
+        req.errFn!(undefined);
+        return undefined;
+      });
+    assert.isTrue(element._cache.has(cacheKey));
+
+    await element.getAccount();
+    assert.isTrue(stub.called);
+    assert.isFalse(element._cache.has(cacheKey));
+  });
+
+  test('getAccount when status is 403 clears cache', async () => {
+    const cacheKey = '/accounts/self/detail';
+    const account = createAccountDetailWithId();
+    element._cache.set(cacheKey, account);
+    const stub = sinon
+      .stub(element._restApiHelper, 'fetchCacheURL')
+      .callsFake(async req => {
+        req.errFn!(new Response(undefined, {status: 403}));
+        return undefined;
+      });
+    assert.isTrue(element._cache.has(cacheKey));
+
+    await element.getAccount();
+    assert.isTrue(stub.called);
+    assert.isFalse(element._cache.has(cacheKey));
+  });
+
+  test('getAccount when resp is successful updates cache', async () => {
+    const cacheKey = '/accounts/self/detail';
+    const account = createAccountDetailWithId();
+    const stub = sinon
+      .stub(element._restApiHelper, 'fetchCacheURL')
+      .callsFake(async () => {
+        element._cache.set(cacheKey, account);
+        return undefined;
+      });
+    assert.isFalse(element._cache.has(cacheKey));
+
+    await element.getAccount();
+    assert.isTrue(stub.called);
+    assert.equal(element._cache.get(cacheKey), account);
+  });
+
+  const preferenceSetup = function (testJSON: unknown, loggedIn: boolean) {
+    sinon
+      .stub(element, 'getLoggedIn')
+      .callsFake(() => Promise.resolve(loggedIn));
+    sinon
+      .stub(element._restApiHelper, 'fetchCacheURL')
+      .callsFake(() => Promise.resolve(testJSON as ParsedJSON));
+  };
+
+  test('getPreferences returns correctly logged in', async () => {
+    const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+    const loggedIn = true;
+
+    preferenceSetup(testJSON, loggedIn);
+
+    const obj = await element.getPreferences();
+    assert.equal(obj!.diff_view, 'SIDE_BY_SIDE');
+  });
+
+  test('getPreferences returns correctly on larger screens logged in', async () => {
+    const testJSON = {diff_view: 'UNIFIED_DIFF'};
+    const loggedIn = true;
+
+    preferenceSetup(testJSON, loggedIn);
+
+    const obj = await element.getPreferences();
+    assert.equal(obj!.diff_view, 'UNIFIED_DIFF');
+  });
+
+  test('getPreferences returns correctly on larger screens no login', async () => {
+    const testJSON = {diff_view: 'UNIFIED_DIFF'};
+    const loggedIn = false;
+
+    preferenceSetup(testJSON, loggedIn);
+
+    const obj = await element.getPreferences();
+    assert.equal(obj!.diff_view, 'SIDE_BY_SIDE');
+  });
+
+  test('savPreferences normalizes download scheme', () => {
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves(new Response());
+    element.savePreferences({download_scheme: 'HTTP'});
+    assert.isTrue(sendStub.called);
+    assert.equal(
+      (sendStub.lastCall.args[0].body as Partial<PreferencesInfo>)
+        .download_scheme,
+      'http'
+    );
+  });
+
+  test('getDiffPreferences returns correct defaults', async () => {
+    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
+
+    const obj = (await element.getDiffPreferences())!;
+    assert.equal(obj.context, 10);
+    assert.equal(obj.cursor_blink_rate, 0);
+    assert.equal(obj.font_size, 12);
+    assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
+    assert.equal(obj.line_length, 100);
+    assert.equal(obj.line_wrapping, false);
+    assert.equal(obj.show_line_endings, true);
+    assert.equal(obj.show_tabs, true);
+    assert.equal(obj.show_whitespace_errors, true);
+    assert.equal(obj.syntax_highlighting, true);
+    assert.equal(obj.tab_size, 8);
+  });
+
+  test('saveDiffPreferences set show_tabs to false', () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element.saveDiffPreferences({
+      show_tabs: false,
+      ignore_whitespace: 'IGNORE_NONE',
+    });
+    assert.isTrue(sendStub.called);
+    assert.equal(
+      (sendStub.lastCall.args[0].body as Partial<DiffPreferenceInput>)
+        .show_tabs,
+      false
+    );
+  });
+
+  test('getEditPreferences returns correct defaults', async () => {
+    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
+
+    const obj = (await element.getEditPreferences())!;
+    assert.equal(obj.auto_close_brackets, false);
+    assert.equal(obj.cursor_blink_rate, 0);
+    assert.equal(obj.hide_line_numbers, false);
+    assert.equal(obj.hide_top_menu, false);
+    assert.equal(obj.indent_unit, 2);
+    assert.equal(obj.indent_with_tabs, false);
+    assert.equal(obj.key_map_type, 'DEFAULT');
+    assert.equal(obj.line_length, 100);
+    assert.equal(obj.line_wrapping, false);
+    assert.equal(obj.match_brackets, true);
+    assert.equal(obj.show_base, false);
+    assert.equal(obj.show_tabs, true);
+    assert.equal(obj.show_whitespace_errors, true);
+    assert.equal(obj.syntax_highlighting, true);
+    assert.equal(obj.tab_size, 8);
+    assert.equal(obj.theme, 'DEFAULT');
+  });
+
+  test('saveEditPreferences set show_tabs to false', () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element.saveEditPreferences({
+      ...createDefaultEditPrefs(),
+      show_tabs: false,
+    });
+    assert.isTrue(sendStub.called);
+    assert.equal(
+      (sendStub.lastCall.args[0].body as EditPreferencesInfo).show_tabs,
+      false
+    );
+  });
+
+  test('confirmEmail', () => {
+    const sendStub = sinon.spy(element._restApiHelper, 'send');
+    element.confirmEmail('foo');
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(sendStub.lastCall.args[0].url, '/config/server/email.confirm');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
+  });
+
+  test('setAccountStatus', async () => {
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves('OOO' as unknown as ParsedJSON);
+    element._cache.set('/accounts/self/detail', createAccountDetailWithId());
+    await element.setAccountStatus('OOO');
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(sendStub.lastCall.args[0].url, '/accounts/self/status');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {status: 'OOO'});
+    assert.deepEqual(
+      element._cache.get('/accounts/self/detail')!.status,
+      'OOO'
+    );
+  });
+
+  suite('draft comments', () => {
+    test('_sendDiffDraftRequest pending requests tracked', async () => {
+      const obj = element._pendingRequests;
+      sinon
+        .stub(element, '_getChangeURLAndSend')
+        .callsFake(() => mockPromise());
+      assert.notOk(element.hasPendingDiffDrafts());
+
+      element._sendDiffDraftRequest(
+        HttpMethod.PUT,
+        123 as NumericChangeId,
+        1 as PatchSetNum,
+        {}
+      );
+      assert.equal(obj.sendDiffDraft.length, 1);
+      assert.isTrue(!!element.hasPendingDiffDrafts());
+
+      element._sendDiffDraftRequest(
+        HttpMethod.PUT,
+        123 as NumericChangeId,
+        1 as PatchSetNum,
+        {}
+      );
+      assert.equal(obj.sendDiffDraft.length, 2);
+      assert.isTrue(!!element.hasPendingDiffDrafts());
+
+      for (const promise of obj.sendDiffDraft) {
+        (promise as MockPromise<void>).resolve();
+      }
+
+      await element.awaitPendingDiffDrafts();
+      assert.equal(obj.sendDiffDraft.length, 0);
+      assert.isFalse(!!element.hasPendingDiffDrafts());
+    });
+
+    suite('_failForCreate200', () => {
+      test('_sendDiffDraftRequest checks for 200 on create', async () => {
+        const sendPromise = Promise.resolve({} as unknown as ParsedJSON);
+        sinon.stub(element, '_getChangeURLAndSend').returns(sendPromise);
+        const failStub = sinon.stub(element, '_failForCreate200').resolves();
+        await element._sendDiffDraftRequest(
+          HttpMethod.PUT,
+          123 as NumericChangeId,
+          4 as PatchSetNum,
+          {}
+        );
+        assert.isTrue(failStub.calledOnce);
+        assert.isTrue(failStub.calledWithExactly(sendPromise));
+      });
+
+      test('_sendDiffDraftRequest no checks for 200 on non create', async () => {
+        sinon.stub(element, '_getChangeURLAndSend').resolves();
+        const failStub = sinon.stub(element, '_failForCreate200').resolves();
+        await element._sendDiffDraftRequest(
+          HttpMethod.PUT,
+          123 as NumericChangeId,
+          4 as PatchSetNum,
+          {
+            id: '123' as UrlEncodedCommentId,
+          }
+        );
+        assert.isFalse(failStub.called);
+      });
+
+      test('_failForCreate200 fails on 200', async () => {
+        const result = new Response(undefined, {
+          status: 200,
+          headers: {
+            'Set-CoOkiE': 'secret',
+            Innocuous: 'hello',
+          },
+        });
+        const error = (await assertFails(
+          element._failForCreate200(Promise.resolve(result))
+        )) as Error;
+        assert.isOk(error);
+        assert.include(error.message, 'Saving draft resulted in HTTP 200');
+        assert.include(error.message, 'hello');
+        assert.notInclude(error.message, 'secret');
+      });
+
+      test('_failForCreate200 does not fail on 201', () => {
+        const result = new Response(undefined, {status: 201});
+        return element._failForCreate200(Promise.resolve(result));
+      });
+    });
+  });
+
+  test('saveChangeEdit', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const change_num = 1 as NumericChangeId;
+    const file_name = 'index.php';
+    const file_contents = '<?php';
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves([
+        change_num,
+        file_name,
+        file_contents,
+      ] as unknown as ParsedJSON);
+    sinon
+      .stub(element, 'getResponseObject')
+      .resolves([
+        change_num,
+        file_name,
+        file_contents,
+      ] as unknown as ParsedJSON);
+    element._cache.set(
+      `/changes/${change_num}/edit/${file_name}`,
+      {} as unknown as ParsedJSON
+    );
+    await element.saveChangeEdit(change_num, file_name, file_contents);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(
+      sendStub.lastCall.args[0].url,
+      '/changes/test~1/edit/' + file_name
+    );
+    assert.equal(sendStub.lastCall.args[0].body, file_contents);
+  });
+
+  test('putChangeCommitMessage', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const change_num = 1 as NumericChangeId;
+    const message = 'this is a commit message';
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves([change_num, message] as unknown as ParsedJSON);
+    sinon
+      .stub(element, 'getResponseObject')
+      .resolves([change_num, message] as unknown as ParsedJSON);
+    element._cache.set(
+      `/changes/${change_num}/message`,
+      {} as unknown as ParsedJSON
+    );
+    await element.putChangeCommitMessage(change_num, message);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(sendStub.lastCall.args[0].url, '/changes/test~1/message');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {
+      message,
+    });
+  });
+
+  test('deleteChangeCommitMessage', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const change_num = 1 as NumericChangeId;
+    const messageId = 'abc' as ChangeMessageId;
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves([change_num, messageId] as unknown as ParsedJSON);
+    sinon
+      .stub(element, 'getResponseObject')
+      .resolves([change_num, messageId] as unknown as ParsedJSON);
+    await element.deleteChangeCommitMessage(change_num, messageId);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.DELETE);
+    assert.equal(sendStub.lastCall.args[0].url, '/changes/test~1/messages/abc');
+  });
+
+  test('startWorkInProgress', () => {
+    const sendStub = sinon
+      .stub(element, '_getChangeURLAndSend')
+      .resolves('ok' as unknown as ParsedJSON);
+    element.startWorkInProgress(42 as NumericChangeId);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {});
+
+    element.startWorkInProgress(42 as NumericChangeId, 'revising...');
+    assert.isTrue(sendStub.calledTwice);
+    assert.equal(sendStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {
+      message: 'revising...',
+    });
+  });
+
+  test('deleteComment', async () => {
+    const comment = createComment();
+    const sendStub = sinon
+      .stub(element, '_getChangeURLAndSend')
+      .resolves(comment as unknown as ParsedJSON);
+    const response = await element.deleteComment(
+      123 as NumericChangeId,
+      1 as PatchSetNum,
+      '01234' as UrlEncodedCommentId,
+      'removal reason'
+    );
+    assert.equal(response, comment);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].changeNum, 123 as NumericChangeId);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+    assert.equal(sendStub.lastCall.args[0].patchNum, 1 as PatchSetNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/comments/01234/delete');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {
+      reason: 'removal reason',
+    });
+  });
+
+  test('createRepo encodes name', async () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+    await element.createRepo({name: 'x/y' as RepoName});
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
+  });
+
+  test('queryChangeFiles', async () => {
+    const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+    await element.queryChangeFiles(42 as NumericChangeId, EDIT, 'test/path.js');
+    assert.equal(fetchStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
+    assert.equal(
+      fetchStub.lastCall.args[0].endpoint,
+      '/files?q=test%2Fpath.js'
+    );
+    assert.equal(fetchStub.lastCall.args[0].revision, EDIT);
+  });
+
+  test('normal use', () => {
+    const defaultQuery = '';
+
+    assert.equal(
+      element._getReposUrl('test', 25).toString(),
+      [false, '/projects/?n=26&S=0&d=&m=test'].toString()
+    );
+
+    assert.equal(
+      element._getReposUrl(undefined, 25).toString(),
+      [false, `/projects/?n=26&S=0&d=&m=${defaultQuery}`].toString()
+    );
+
+    assert.equal(
+      element._getReposUrl('test', 25, 25).toString(),
+      [false, '/projects/?n=26&S=25&d=&m=test'].toString()
+    );
+
+    assert.equal(
+      element._getReposUrl('inname:test', 25, 25).toString(),
+      [true, '/projects/?n=26&S=25&query=inname%3Atest'].toString()
+    );
+  });
+
+  test('invalidateReposCache', () => {
+    const url = '/projects/?n=26&S=0&query=test';
+
+    element._cache.set(url, {} as unknown as ParsedJSON);
+
+    element.invalidateReposCache();
+
+    assert.isUndefined(element._sharedFetchPromises.get(url));
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  test('invalidateAccountsCache', () => {
+    const url = '/accounts/self/detail';
+
+    element._cache.set(url, {} as unknown as ParsedJSON);
+
+    element.invalidateAccountsCache();
+
+    assert.isUndefined(element._sharedFetchPromises.get(url));
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  suite('getRepos', () => {
+    const defaultQuery = '';
+    let fetchCacheURLStub: sinon.SinonStub;
+    setup(() => {
+      fetchCacheURLStub = sinon
+        .stub(element._restApiHelper, 'fetchCacheURL')
+        .resolves([] as unknown as ParsedJSON);
+    });
+
+    test('normal use', () => {
+      element.getRepos('test', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=test'
+      );
+
+      element.getRepos(undefined, 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        `/projects/?n=26&S=0&d=&m=${defaultQuery}`
+      );
+
+      element.getRepos('test', 25, 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=25&d=&m=test'
+      );
+    });
+
+    test('with blank', () => {
+      element.getRepos('test/test', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=test%2Ftest'
+      );
+    });
+
+    test('with hyphen', () => {
+      element.getRepos('foo-bar', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=foo-bar'
+      );
+    });
+
+    test('with leading hyphen', () => {
+      element.getRepos('-bar', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=-bar'
+      );
+    });
+
+    test('with trailing hyphen', () => {
+      element.getRepos('foo-bar-', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=foo-bar-'
+      );
+    });
+
+    test('with underscore', () => {
+      element.getRepos('foo_bar', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=foo_bar'
+      );
+    });
+
+    test('with underscore', () => {
+      element.getRepos('foo_bar', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=foo_bar'
+      );
+    });
+
+    test('hyphen only', () => {
+      element.getRepos('-', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=-'
+      );
+    });
+
+    test('using query', () => {
+      element.getRepos('description:project', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&query=description%3Aproject'
+      );
+    });
+  });
+
+  test('_getGroupsUrl normal use', () => {
+    assert.equal(element._getGroupsUrl('test', 25), '/groups/?n=26&S=0&m=test');
+
+    assert.equal(element._getGroupsUrl('', 25), '/groups/?n=26&S=0');
+
+    assert.equal(
+      element._getGroupsUrl('test', 25, 25),
+      '/groups/?n=26&S=25&m=test'
+    );
+  });
+
+  test('invalidateGroupsCache', () => {
+    const url = '/groups/?n=26&S=0&m=test';
+
+    element._cache.set(url, {} as unknown as ParsedJSON);
+
+    element.invalidateGroupsCache();
+
+    assert.isUndefined(element._sharedFetchPromises.get(url));
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  suite('getGroups', () => {
+    let fetchCacheURLStub: sinon.SinonStub;
+    setup(() => {
+      fetchCacheURLStub = sinon.stub(element._restApiHelper, 'fetchCacheURL');
+    });
+
+    test('normal use', () => {
+      element.getGroups('test', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/groups/?n=26&S=0&m=test'
+      );
+
+      element.getGroups('', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url, '/groups/?n=26&S=0');
+
+      element.getGroups('test', 25, 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/groups/?n=26&S=25&m=test'
+      );
+    });
+
+    test('regex', () => {
+      element.getGroups('^test.*', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/groups/?n=26&S=0&r=%5Etest.*'
+      );
+
+      element.getGroups('^test.*', 25, 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/groups/?n=26&S=25&r=%5Etest.*'
+      );
+    });
+  });
+
+  test('gerrit auth is used', () => {
+    const fetchStub = stubAuth('fetch').resolves();
+    element._restApiHelper.fetchJSON({url: 'foo'});
+    assert(fetchStub.called);
+  });
+
+  test('getSuggestedAccounts does not return fetchJSON', async () => {
+    const fetchJSONSpy = sinon.spy(element._restApiHelper, 'fetchJSON');
+    const accts = await element.getSuggestedAccounts('');
+    assert.isFalse(fetchJSONSpy.called);
+    assert.equal(accts!.length, 0);
+  });
+
+  test('fetchJSON gets called by getSuggestedAccounts', async () => {
+    const fetchJSONStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves();
+    await element.getSuggestedAccounts('own');
+    assert.deepEqual(fetchJSONStub.lastCall.args[0].params, {
+      q: 'own',
+      o: 'DETAILS',
+    });
+  });
+
+  suite('getChangeDetail', () => {
+    suite('change detail options', () => {
+      let changeDetailStub: sinon.SinonStub;
+      setup(() => {
+        changeDetailStub = sinon
+          .stub(element, '_getChangeDetail')
+          .resolves({...createChange(), _number: 123 as NumericChangeId});
+      });
+
+      test('signed pushes disabled', async () => {
+        sinon.stub(element, 'getConfig').resolves({
+          ...createServerInfo(),
+          receive: {enable_signed_push: undefined},
+        });
+        const change = await element.getChangeDetail(123 as NumericChangeId);
+        assert.strictEqual(123, change!._number);
+        const options = changeDetailStub.firstCall.args[1];
+        assert.isNotOk(
+          parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
+        );
+      });
+
+      test('signed pushes enabled', async () => {
+        sinon.stub(element, 'getConfig').resolves({
+          ...createServerInfo(),
+          receive: {enable_signed_push: 'true'},
+        });
+        const change = await element.getChangeDetail(123 as NumericChangeId);
+        assert.strictEqual(123, change!._number);
+        const options = changeDetailStub.firstCall.args[1];
+        assert.ok(
+          parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
+        );
+      });
+    });
+
+    test('GrReviewerUpdatesParser.parse is used', async () => {
+      const changeInfo = createParsedChange();
+      const parseStub = sinon
+        .stub(GrReviewerUpdatesParser, 'parse')
+        .resolves(changeInfo);
+      const result = await element.getChangeDetail(42 as NumericChangeId);
+      assert.isTrue(parseStub.calledOnce);
+      assert.equal(result, changeInfo);
+    });
+
+    test('_getChangeDetail passes params to ETags decorator', async () => {
+      const changeNum = 4321 as NumericChangeId;
+      element._projectLookup[changeNum] = Promise.resolve('test' as RepoName);
+      const expectedUrl = `${window.CANONICAL_PATH}/changes/test~4321/detail?O=516714`;
+      const optionsStub = sinon.stub(element._etags, 'getOptions');
+      const collectStub = sinon.stub(element._etags, 'collect');
+      await element._getChangeDetail(changeNum, '516714');
+      assert.isTrue(optionsStub.calledWithExactly(expectedUrl));
+      assert.equal(collectStub.lastCall.args[0], expectedUrl);
+    });
+
+    test('_getChangeDetail calls errFn on 500', async () => {
+      const errFn = sinon.stub();
+      sinon.stub(element, 'getChangeActionURL').resolves('');
+      sinon
+        .stub(element._restApiHelper, 'fetchRawJSON')
+        .resolves(new Response(undefined, {status: 500}));
+      await element._getChangeDetail(123 as NumericChangeId, '516714', errFn);
+      assert.isTrue(errFn.called);
+    });
+
+    test('_getChangeDetail populates _projectLookup', async () => {
+      sinon.stub(element, 'getChangeActionURL').resolves('');
+      sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+        new Response(')]}\'{"_number":1,"project":"test"}', {
+          status: 200,
+        })
+      );
+      await element._getChangeDetail(1 as NumericChangeId, '516714');
+      assert.equal(Object.keys(element._projectLookup).length, 1);
+      const project = await element._projectLookup[1];
+      assert.equal(project, 'test' as RepoName);
+    });
+
+    suite('_getChangeDetail ETag cache', () => {
+      let requestUrl: string;
+      let mockResponseSerial: string;
+      let collectSpy: sinon.SinonSpy;
+
+      setup(() => {
+        requestUrl = '/foo/bar';
+        const mockResponse = {foo: 'bar', baz: 42};
+        mockResponseSerial = JSON_PREFIX + JSON.stringify(mockResponse);
+        sinon.stub(element._restApiHelper, 'urlWithParams').returns(requestUrl);
+        sinon.stub(element, 'getChangeActionURL').resolves(requestUrl);
+        collectSpy = sinon.spy(element._etags, 'collect');
+      });
+
+      test('contributes to cache', async () => {
+        const getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
+        sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+          new Response(mockResponseSerial, {
+            status: 200,
+          })
+        );
+
+        await element._getChangeDetail(123 as NumericChangeId, '516714');
+        assert.isFalse(getPayloadSpy.called);
+        assert.isTrue(collectSpy.calledOnce);
+        const cachedResponse = element._etags.getCachedPayload(requestUrl);
+        assert.equal(cachedResponse, mockResponseSerial);
+      });
+
+      test('uses cache on HTTP 304', async () => {
+        const getPayloadStub = sinon.stub(element._etags, 'getCachedPayload');
+        getPayloadStub.returns(mockResponseSerial);
+        sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+          new Response(undefined, {
+            status: 304,
+          })
+        );
+
+        await element._getChangeDetail(123 as NumericChangeId, '');
+        assert.isFalse(collectSpy.called);
+        assert.isTrue(getPayloadStub.calledOnce);
+      });
+    });
+  });
+
+  test('setInProjectLookup', async () => {
+    await element.setInProjectLookup(
+      555 as NumericChangeId,
+      'project' as RepoName
+    );
+    const project = await element.getFromProjectLookup(555 as NumericChangeId);
+    assert.deepEqual(project, 'project' as RepoName);
+  });
+
+  suite('getFromProjectLookup', () => {
+    test('getChange succeeds, no project', async () => {
+      sinon.stub(element, 'getChange').resolves(null);
+      const val = await element.getFromProjectLookup(555 as NumericChangeId);
+      assert.strictEqual(val, undefined);
+    });
+
+    test('getChange succeeds with project', async () => {
+      sinon
+        .stub(element, 'getChange')
+        .resolves({...createChange(), project: 'project' as RepoName});
+      const projectLookup = element.getFromProjectLookup(
+        555 as NumericChangeId
+      );
+      const val = await projectLookup;
+      assert.equal(val, 'project' as RepoName);
+      assert.deepEqual(element._projectLookup, {'555': projectLookup});
+    });
+  });
+
+  suite('getChanges populates _projectLookup', () => {
+    test('multiple queries', async () => {
+      sinon.stub(element._restApiHelper, 'fetchJSON').resolves([
+        [
+          {_number: 1, project: 'test'},
+          {_number: 2, project: 'test'},
+        ],
+        [{_number: 3, project: 'test/test'}],
+      ] as unknown as ParsedJSON);
+      // When opt_query instanceof Array, fetchJSON returns
+      // Array<Array<Object>>.
+      await element.getChangesForMultipleQueries(undefined, []);
+      assert.equal(Object.keys(element._projectLookup).length, 3);
+      const project1 = await element.getFromProjectLookup(1 as NumericChangeId);
+      assert.equal(project1, 'test' as RepoName);
+      const project2 = await element.getFromProjectLookup(2 as NumericChangeId);
+      assert.equal(project2, 'test' as RepoName);
+      const project3 = await element.getFromProjectLookup(3 as NumericChangeId);
+      assert.equal(project3, 'test/test' as RepoName);
+    });
+
+    test('no query', async () => {
+      sinon.stub(element._restApiHelper, 'fetchJSON').resolves([
+        {_number: 1, project: 'test'},
+        {_number: 2, project: 'test'},
+        {_number: 3, project: 'test/test'},
+      ] as unknown as ParsedJSON);
+
+      // When opt_query !instanceof Array, fetchJSON returns Array<Object>.
+      await element.getChanges();
+      assert.equal(Object.keys(element._projectLookup).length, 3);
+      const project1 = await element.getFromProjectLookup(1 as NumericChangeId);
+      assert.equal(project1, 'test' as RepoName);
+      const project2 = await element.getFromProjectLookup(2 as NumericChangeId);
+      assert.equal(project2, 'test' as RepoName);
+      const project3 = await element.getFromProjectLookup(3 as NumericChangeId);
+      assert.equal(project3, 'test/test' as RepoName);
+    });
+  });
+
+  test('getDetailedChangesWithActions', async () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+    const getChangesStub = sinon
+      .stub(element, 'getChanges')
+      .callsFake((changesPerPage, query, offset, options) => {
+        assert.isUndefined(changesPerPage);
+        assert.strictEqual(query, 'change:1 OR change:2');
+        assert.isUndefined(offset);
+        assert.strictEqual(options, EXPECTED_QUERY_OPTIONS);
+        return Promise.resolve([]);
+      });
+    await element.getDetailedChangesWithActions([c1._number, c2._number]);
+    assert.isTrue(getChangesStub.calledOnce);
+  });
+
+  test('_getChangeURLAndFetch', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves();
+    const req = {
+      changeNum: 1 as NumericChangeId,
+      endpoint: '/test',
+      revision: 1 as RevisionId,
+    };
+    await element._getChangeURLAndFetch(req);
+    assert.equal(
+      fetchStub.lastCall.args[0].url,
+      '/changes/test~1/revisions/1/test'
+    );
+  });
+
+  test('_getChangeURLAndSend', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+
+    const req = {
+      changeNum: 1 as NumericChangeId,
+      method: HttpMethod.POST,
+      patchNum: 1 as PatchSetNum,
+      endpoint: '/test',
+    };
+    await element._getChangeURLAndSend(req);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+    assert.equal(
+      sendStub.lastCall.args[0].url,
+      '/changes/test~1/revisions/1/test'
+    );
+  });
+
+  suite('reading responses', () => {
+    test('_readResponsePayload', async () => {
+      const mockObject = {foo: 'bar', baz: 'foo'} as unknown as ParsedJSON;
+      const serial = JSON_PREFIX + JSON.stringify(mockObject);
+      const response = new Response(serial);
+      const payload = await readResponsePayload(response);
+      assert.deepEqual(payload.parsed, mockObject);
+      assert.equal(payload.raw, serial);
+    });
+
+    test('_parsePrefixedJSON', () => {
+      const obj = {x: 3, y: {z: 4}, w: 23} as unknown as ParsedJSON;
+      const serial = JSON_PREFIX + JSON.stringify(obj);
+      const result = parsePrefixedJSON(serial);
+      assert.deepEqual(result, obj);
+    });
+  });
+
+  test('setChangeTopic', async () => {
+    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
+    await element.setChangeTopic(123 as NumericChangeId, 'foo-bar');
+    assert.isTrue(sendSpy.calledOnce);
+    assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
+  });
+
+  test('setChangeHashtag', async () => {
+    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
+    await element.setChangeHashtag(123 as NumericChangeId, {
+      add: ['foo-bar' as Hashtag],
+    });
+    assert.isTrue(sendSpy.calledOnce);
+    assert.sameDeepMembers(
+      (sendSpy.lastCall.args[0].body! as HashtagsInput).add!,
+      ['foo-bar']
+    );
+  });
+
+  test('generateAccountHttpPassword', async () => {
+    const sendSpy = sinon.spy(element._restApiHelper, 'send');
+    await element.generateAccountHttpPassword();
+    assert.isTrue(sendSpy.calledOnce);
+    assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
+  });
+
+  suite('getChangeFiles', () => {
+    test('patch only', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      const range = {basePatchNum: PARENT, patchNum: 2 as RevisionPatchSetNum};
+      await element.getChangeFiles(123 as NumericChangeId, range);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.lastCall.args[0].revision,
+        2 as RevisionPatchSetNum
+      );
+      assert.isNotOk(fetchStub.lastCall.args[0].params);
+    });
+
+    test('simple range', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      const range = {
+        basePatchNum: 4 as BasePatchSetNum,
+        patchNum: 5 as RevisionPatchSetNum,
+      };
+      await element.getChangeFiles(123 as NumericChangeId, range);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.equal(fetchStub.lastCall.args[0].params!.base, 4);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
+    });
+
+    test('parent index', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      const range = {
+        basePatchNum: -3 as BasePatchSetNum,
+        patchNum: 5 as RevisionPatchSetNum,
+      };
+      await element.getChangeFiles(123 as NumericChangeId, range);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
+      assert.equal(fetchStub.lastCall.args[0].params!.parent, 3);
+    });
+  });
+
+  suite('getDiff', () => {
+    test('patchOnly', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      await element.getDiff(
+        123 as NumericChangeId,
+        PARENT,
+        2 as PatchSetNum,
+        'foo/bar.baz'
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 2 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
+    });
+
+    test('simple range', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      await element.getDiff(
+        123 as NumericChangeId,
+        4 as PatchSetNum,
+        5 as PatchSetNum,
+        'foo/bar.baz'
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
+      assert.equal(fetchStub.lastCall.args[0].params!.base, 4);
+    });
+
+    test('parent index', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      await element.getDiff(
+        123 as NumericChangeId,
+        -3 as PatchSetNum,
+        5 as PatchSetNum,
+        'foo/bar.baz'
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
+      assert.equal(fetchStub.lastCall.args[0].params!.parent, 3);
+    });
+  });
+
+  test('getDashboard', () => {
+    const fetchCacheURLStub = sinon.stub(
+      element._restApiHelper,
+      'fetchCacheURL'
+    );
+    element.getDashboard(
+      'gerrit/project' as RepoName,
+      'default:main' as DashboardId
+    );
+    assert.isTrue(fetchCacheURLStub.calledOnce);
+    assert.equal(
+      fetchCacheURLStub.lastCall.args[0].url,
+      '/projects/gerrit%2Fproject/dashboards/default%3Amain'
+    );
+  });
+
+  test('getFileContent', async () => {
+    sinon.stub(element, '_getChangeURLAndSend').resolves(
+      new Response(undefined, {
+        status: 200,
+        headers: {
+          'X-FYI-Content-Type': 'text/java',
+        },
+      }) as unknown as ParsedJSON
+    );
+
+    sinon
+      .stub(element, 'getResponseObject')
+      .resolves('new content' as unknown as ParsedJSON);
+
+    const edit = await element.getFileContent(
+      1 as NumericChangeId,
+      'tst/path',
+      'EDIT' as PatchSetNum
+    );
+
+    assert.deepEqual(edit, {
+      content: 'new content',
+      type: 'text/java',
+      ok: true,
+    });
+
+    const normal = await element.getFileContent(
+      1 as NumericChangeId,
+      'tst/path',
+      '3' as PatchSetNum
+    );
+    assert.deepEqual(normal, {
+      content: 'new content',
+      type: 'text/java',
+      ok: true,
+    });
+  });
+
+  test('getFileContent suppresses 404s', async () => {
+    const res404 = new Response(undefined, {status: 404});
+    const res500 = new Response(undefined, {status: 500});
+    const spy = sinon.spy();
+    addListenerForTest(document, 'server-error', spy);
+    const authStub = sinon.stub(authService, 'fetch').resolves(res404);
+    sinon.stub(element, '_changeBaseURL').resolves('');
+    await element.getFileContent(
+      1 as NumericChangeId,
+      'tst/path',
+      1 as PatchSetNum
+    );
+    await waitEventLoop();
+    assert.isFalse(spy.called);
+    authStub.reset();
+    authStub.resolves(res500);
+    await element.getFileContent(
+      1 as NumericChangeId,
+      'tst/path',
+      1 as PatchSetNum
+    );
+    assert.isTrue(spy.called);
+    assert.notEqual(spy.lastCall.args[0].detail.response.status, 404);
+  });
+
+  test('getChangeFilesOrEditFiles is edit-sensitive', async () => {
+    const getChangeFilesStub = sinon
+      .stub(element, 'getChangeFiles')
+      .resolves({});
+    const getChangeEditFilesStub = sinon
+      .stub(element, 'getChangeEditFiles')
+      .resolves({files: {}});
+
+    await element.getChangeOrEditFiles(1 as NumericChangeId, {
+      basePatchNum: PARENT,
+      patchNum: EDIT,
+    });
+    assert.isTrue(getChangeEditFilesStub.calledOnce);
+    assert.isFalse(getChangeFilesStub.called);
+    await element.getChangeOrEditFiles(1 as NumericChangeId, {
+      basePatchNum: PARENT,
+      patchNum: 1 as RevisionPatchSetNum,
+    });
+    assert.isTrue(getChangeEditFilesStub.calledOnce);
+    assert.isTrue(getChangeFilesStub.calledOnce);
+  });
+
+  test('_fetch forwards request and logs', async () => {
+    const logStub = sinon.stub(element._restApiHelper, '_logCall');
+    const response = new Response(undefined, {status: 404});
+    const url = 'my url';
+    const fetchOptions = {method: 'DELETE'};
+    sinon.stub(authService, 'fetch').resolves(response);
+    const startTime = 123;
+    sinon.stub(Date, 'now').returns(startTime);
+    const req = {url, fetchOptions};
+    await element._restApiHelper.fetch(req);
+    assert.isTrue(logStub.calledOnce);
+    assert.isTrue(logStub.calledWith(req, startTime, response.status));
+  });
+
+  test('_logCall only reports requests with anonymized URLss', async () => {
+    sinon.stub(Date, 'now').returns(200);
+    const handler = sinon.stub();
+    addListenerForTest(document, 'gr-rpc-log', handler);
+
+    element._restApiHelper._logCall({url: 'url'}, 100, 200);
+    assert.isFalse(handler.called);
+
+    element._restApiHelper._logCall(
+      {url: 'url', anonymizedUrl: 'not url'},
+      100,
+      200
+    );
+    await waitEventLoop();
+    assert.isTrue(handler.calledOnce);
+  });
+
+  test('ported comment errors do not trigger error dialog', () => {
+    const change = createChange();
+    const handler = sinon.stub();
+    addListenerForTest(document, 'server-error', handler);
+    sinon.stub(element._restApiHelper, 'fetchJSON').resolves({
+      ok: false,
+    } as unknown as ParsedJSON);
+
+    element.getPortedComments(change._number, CURRENT);
+
+    assert.isFalse(handler.called);
+  });
+
+  test('ported drafts are not requested user is not logged in', () => {
+    const change = createChange();
+    sinon.stub(element, 'getLoggedIn').resolves(false);
+    const getChangeURLAndFetchStub = sinon.stub(
+      element,
+      '_getChangeURLAndFetch'
+    );
+
+    element.getPortedDrafts(change._number, CURRENT);
+
+    assert.isFalse(getChangeURLAndFetchStub.called);
+  });
+
+  test('saveChangeStarred', async () => {
+    sinon.stub(element, 'getFromProjectLookup').resolves('test' as RepoName);
+    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+
+    await element.saveChangeStarred(123 as NumericChangeId, true);
+    assert.isTrue(sendStub.calledOnce);
+    assert.deepEqual(sendStub.lastCall.args[0], {
+      method: HttpMethod.PUT,
+      url: '/accounts/self/starred.changes/test~123',
+      anonymizedUrl: '/accounts/self/starred.changes/*',
+    });
+
+    await element.saveChangeStarred(456 as NumericChangeId, false);
+    assert.isTrue(sendStub.calledTwice);
+    assert.deepEqual(sendStub.lastCall.args[0], {
+      method: HttpMethod.DELETE,
+      url: '/accounts/self/starred.changes/test~456',
+      anonymizedUrl: '/accounts/self/starred.changes/*',
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/highlight/highlight-service.ts b/polygerrit-ui/app/services/highlight/highlight-service.ts
index bfaa263..d10d875 100644
--- a/polygerrit-ui/app/services/highlight/highlight-service.ts
+++ b/polygerrit-ui/app/services/highlight/highlight-service.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {define} from '../../models/dependency';
 import {
   SyntaxWorkerRequest,
   SyntaxWorkerInit,
@@ -39,6 +40,8 @@
  */
 const CODE_MAX_LENGTH = 25 * CODE_MAX_LINES;
 
+export const highlightServiceToken =
+  define<HighlightService>('highlight-service');
 /**
  * Service for syntax highlighting. Maintains some HighlightJS workers doing
  * their job in the background.
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index f8bc778..edde7a4 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -4,7 +4,6 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {Observable} from 'rxjs';
-import {Finalizable} from '../registry';
 import {
   NumericChangeId,
   RevisionPatchSetNum,
@@ -12,6 +11,7 @@
 } from '../../types/common';
 import {Model} from '../../models/model';
 import {select} from '../../utils/observable-util';
+import {define} from '../../models/dependency';
 
 export enum GerritView {
   ADMIN = 'admin',
@@ -36,7 +36,8 @@
   basePatchNum?: BasePatchSetNum;
 }
 
-export class RouterModel extends Model<RouterState> implements Finalizable {
+export const routerModelToken = define<RouterModel>('router-model');
+export class RouterModel extends Model<RouterState> {
   readonly routerView$: Observable<GerritView | undefined> = select(
     this.state$,
     state => state.view
diff --git a/polygerrit-ui/app/services/service-worker-installer.ts b/polygerrit-ui/app/services/service-worker-installer.ts
index 53cd325..ffc5be2 100644
--- a/polygerrit-ui/app/services/service-worker-installer.ts
+++ b/polygerrit-ui/app/services/service-worker-installer.ts
@@ -12,11 +12,14 @@
 import {UserModel} from '../models/user/user-model';
 import {AccountDetailInfo} from '../api/rest-api';
 import {until} from '../utils/async-util';
+import {LifeCycle} from '../constants/reporting';
+import {ReportingService} from './gr-reporting/gr-reporting';
 
 /** Type of incoming messages for ServiceWorker. */
 export enum ServiceWorkerMessageType {
   TRIGGER_NOTIFICATIONS = 'TRIGGER_NOTIFICATIONS',
   USER_PREFERENCE_CHANGE = 'USER_PREFERENCE_CHANGE',
+  REPORTING = 'REPORTING',
 }
 
 export const TRIGGER_NOTIFICATION_UPDATES_MS = 5 * 60 * 1000;
@@ -30,6 +33,7 @@
 
   constructor(
     private readonly flagsService: FlagsService,
+    private readonly reportingService: ReportingService,
     private readonly userModel: UserModel
   ) {
     if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
@@ -74,8 +78,19 @@
     }
     await registerServiceWorker('/service-worker.js');
     const permission = await Notification.requestPermission();
+    this.reportingService.reportLifeCycle(LifeCycle.NOTIFICATION_PERMISSION, {
+      permission,
+    });
     if (this.isPermitted(permission)) this.startTriggerTimer();
     this.initialized = true;
+    // Assumption: service worker will send event only to 1 client.
+    navigator.serviceWorker.onmessage = event => {
+      if (event.data?.type === ServiceWorkerMessageType.REPORTING) {
+        this.reportingService.reportLifeCycle(LifeCycle.SERVICE_WORKER_UPDATE, {
+          eventName: event.data.eventName as string | undefined,
+        });
+      }
+    };
   }
 
   areNotificationsEnabled() {
diff --git a/polygerrit-ui/app/services/service-worker-installer_test.ts b/polygerrit-ui/app/services/service-worker-installer_test.ts
index e8fd233..a036289 100644
--- a/polygerrit-ui/app/services/service-worker-installer_test.ts
+++ b/polygerrit-ui/app/services/service-worker-installer_test.ts
@@ -9,14 +9,17 @@
 import {assert} from '@open-wc/testing';
 import {createDefaultPreferences} from '../constants/constants';
 import {waitUntilObserved} from '../test/test-utils';
+import {testResolver} from '../test/common-test-setup';
+import {userModelToken} from '../models/user/user-model';
 
 suite('service worker installer tests', () => {
   test('init', async () => {
     const registerStub = sinon.stub(window.navigator.serviceWorker, 'register');
     const flagsService = getAppContext().flagsService;
-    const userModel = getAppContext().userModel;
+    const reportingService = getAppContext().reportingService;
+    const userModel = testResolver(userModelToken);
     sinon.stub(flagsService, 'isEnabled').returns(true);
-    new ServiceWorkerInstaller(flagsService, userModel);
+    new ServiceWorkerInstaller(flagsService, reportingService, userModel);
     const prefs = {
       ...createDefaultPreferences(),
       allow_browser_notifications: true,
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index 5b38a8a..0a5e0a4 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -15,6 +15,8 @@
 import {getAppContext} from '../app-context';
 import {pressKey} from '../../test/test-utils';
 import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {userModelToken} from '../../models/user/user-model';
 
 const KEY_A: Binding = {key: 'a'};
 
@@ -23,7 +25,7 @@
 
   setup(() => {
     service = new ShortcutsService(
-      getAppContext().userModel,
+      testResolver(userModelToken),
       getAppContext().reportingService
     );
   });
diff --git a/polygerrit-ui/app/services/storage/gr-storage_impl.ts b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
index 0caffbc..c177ddc 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_impl.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
@@ -6,6 +6,7 @@
 import {StorageLocation, StorageObject, StorageService} from './gr-storage';
 import {Finalizable} from '../registry';
 import {NumericChangeId} from '../../types/common';
+import {define} from '../../models/dependency';
 
 export const DURATION_DAY = 24 * 60 * 60 * 1000;
 
@@ -16,6 +17,8 @@
 CLEANUP_PREFIXES_MAX_AGE_MAP.set('draft', DURATION_DAY);
 CLEANUP_PREFIXES_MAX_AGE_MAP.set('editablecontent', DURATION_DAY);
 
+export const storageServiceToken = define<StorageService>('storage-service');
+
 export class GrStorageService implements StorageService, Finalizable {
   private lastCleanup = 0;
 
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 306747b..31462b5 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -11,7 +11,6 @@
 import {
   createTestAppContext,
   createTestDependencies,
-  Creator,
 } from './test-app-context-init';
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
 import {_testOnlyResetGrRestApiSharedObjects} from '../services/gr-rest-api/gr-rest-api-impl';
@@ -39,6 +38,7 @@
 } from '../models/dependency';
 import * as sinon from 'sinon';
 import '../styles/themes/app-theme.ts';
+import {Creator} from '../services/app-context-init';
 
 declare global {
   interface Window {
@@ -129,6 +129,10 @@
   _testOnlyResetGrRestApiSharedObjects();
 });
 
+export function removeRequestDependencyListener() {
+  document.removeEventListener('request-dependency', resolveDependency);
+}
+
 // Very simple function to catch unexpected elements in documents body.
 // It can't catch everything, but in most cases it is enough.
 function checkChildAllowed(element: Element) {
@@ -189,7 +193,7 @@
   removeThemeStyles();
   cancelAllTasks();
   cleanUpStorage();
-  document.removeEventListener('request-dependency', resolveDependency);
+  removeRequestDependencyListener();
   injectedDependencies.clear();
   // Reset state
   for (const f of finalizers) {
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 0693570..a0b6130 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -6,7 +6,6 @@
 
 // Init app context before any other imports
 import {create, Registry, Finalizable} from '../services/registry';
-import {DependencyToken} from '../models/dependency';
 import {assertIsDefined} from '../utils/common-util';
 import {AppContext} from '../services/app-context';
 import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
@@ -15,58 +14,17 @@
 import {GrAuthMock} from '../services/gr-auth/gr-auth_mock';
 import {FlagsServiceImplementation} from '../services/flags/flags_impl';
 import {EventEmitter} from '../services/gr-event-interface/gr-event-interface_impl';
-import {ChangeModel, changeModelToken} from '../models/change/change-model';
-import {FilesModel, filesModelToken} from '../models/change/files-model';
-import {ChecksModel, checksModelToken} from '../models/checks/checks-model';
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
-import {UserModel} from '../models/user/user-model';
-import {
-  CommentsModel,
-  commentsModelToken,
-} from '../models/comments/comments-model';
-import {RouterModel} from '../services/router/router-model';
-import {
-  ShortcutsService,
-  shortcutsServiceToken,
-} from '../services/shortcuts/shortcuts-service';
-import {ConfigModel, configModelToken} from '../models/config/config-model';
-import {BrowserModel, browserModelToken} from '../models/browser/browser-model';
 import {PluginsModel} from '../models/plugins/plugins-model';
 import {MockHighlightService} from '../services/highlight/highlight-service-mock';
-import {
-  AccountsModel,
-  accountsModelToken,
-} from '../models/accounts-model/accounts-model';
-import {
-  DashboardViewModel,
-  dashboardViewModelToken,
-} from '../models/views/dashboard';
-import {
-  SettingsViewModel,
-  settingsViewModelToken,
-} from '../models/views/settings';
-import {GrRouter, routerToken} from '../elements/core/gr-router/gr-router';
-import {AdminViewModel, adminViewModelToken} from '../models/views/admin';
-import {
-  AgreementViewModel,
-  agreementViewModelToken,
-} from '../models/views/agreement';
-import {ChangeViewModel, changeViewModelToken} from '../models/views/change';
-import {DiffViewModel, diffViewModelToken} from '../models/views/diff';
-import {
-  DocumentationViewModel,
-  documentationViewModelToken,
-} from '../models/views/documentation';
-import {EditViewModel, editViewModelToken} from '../models/views/edit';
-import {GroupViewModel, groupViewModelToken} from '../models/views/group';
-import {PluginViewModel, pluginViewModelToken} from '../models/views/plugin';
-import {RepoViewModel, repoViewModelToken} from '../models/views/repo';
-import {SearchViewModel, searchViewModelToken} from '../models/views/search';
+import {createAppDependencies, Creator} from '../services/app-context-init';
 import {navigationToken} from '../elements/core/gr-navigation/gr-navigation';
+import {DependencyToken} from '../models/dependency';
+import {storageServiceToken} from '../services/storage/gr-storage_impl';
+import {highlightServiceToken} from '../services/highlight/highlight-service';
 
 export function createTestAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
-    routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
     flagsService: (_ctx: Partial<AppContext>) =>
       new FlagsServiceImplementation(),
     reportingService: (_ctx: Partial<AppContext>) => grReportingMock,
@@ -80,91 +38,17 @@
       assertIsDefined(ctx.reportingService, 'reportingService');
       return new GrJsApiInterface(ctx.reportingService);
     },
-    storageService: (_ctx: Partial<AppContext>) => grStorageMock,
-    userModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new UserModel(ctx.restApiService);
-    },
-    accountsModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new AccountsModel(ctx.restApiService);
-    },
-    shortcutsService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.userModel, 'userModel');
-      assertIsDefined(ctx.flagsService, 'flagsService');
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ShortcutsService(ctx.userModel, ctx.reportingService);
-    },
     pluginsModel: (_ctx: Partial<AppContext>) => new PluginsModel(),
-    highlightService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new MockHighlightService(ctx.reportingService);
-    },
   };
   return create<AppContext>(appRegistry);
 }
 
-export type Creator<T> = () => T & Finalizable;
-
-// Test dependencies are provides as creator functions to ensure that they are
-// not created if a test doesn't depend on them. E.g. don't create a
-// change-model in change-model_test.ts because it creates one in the test
-// after setting up stubs.
 export function createTestDependencies(
   appContext: AppContext,
   resolver: <T>(token: DependencyToken<T>) => T
 ): Map<DependencyToken<unknown>, Creator<unknown>> {
-  const dependencies = new Map<DependencyToken<unknown>, Creator<unknown>>();
-  const browserModel = () => new BrowserModel(appContext.userModel);
-  dependencies.set(browserModelToken, browserModel);
-
-  const adminViewModelCreator = () => new AdminViewModel();
-  dependencies.set(adminViewModelToken, adminViewModelCreator);
-  const agreementViewModelCreator = () => new AgreementViewModel();
-  dependencies.set(agreementViewModelToken, agreementViewModelCreator);
-  const changeViewModelCreator = () => new ChangeViewModel();
-  dependencies.set(changeViewModelToken, changeViewModelCreator);
-  const dashboardViewModelCreator = () => new DashboardViewModel();
-  dependencies.set(dashboardViewModelToken, dashboardViewModelCreator);
-  const diffViewModelCreator = () => new DiffViewModel();
-  dependencies.set(diffViewModelToken, diffViewModelCreator);
-  const documentationViewModelCreator = () => new DocumentationViewModel();
-  dependencies.set(documentationViewModelToken, documentationViewModelCreator);
-  const editViewModelCreator = () => new EditViewModel();
-  dependencies.set(editViewModelToken, editViewModelCreator);
-  const groupViewModelCreator = () => new GroupViewModel();
-  dependencies.set(groupViewModelToken, groupViewModelCreator);
-  const pluginViewModelCreator = () => new PluginViewModel();
-  dependencies.set(pluginViewModelToken, pluginViewModelCreator);
-  const repoViewModelCreator = () => new RepoViewModel();
-  dependencies.set(repoViewModelToken, repoViewModelCreator);
-  const searchViewModelCreator = () =>
-    new SearchViewModel(appContext.restApiService, appContext.userModel, () =>
-      resolver(navigationToken)
-    );
-  dependencies.set(searchViewModelToken, searchViewModelCreator);
-  const settingsViewModelCreator = () => new SettingsViewModel();
-  dependencies.set(settingsViewModelToken, settingsViewModelCreator);
-
-  const routerCreator = () =>
-    new GrRouter(
-      appContext.reportingService,
-      appContext.routerModel,
-      appContext.restApiService,
-      resolver(adminViewModelToken),
-      resolver(agreementViewModelToken),
-      resolver(changeViewModelToken),
-      resolver(dashboardViewModelToken),
-      resolver(diffViewModelToken),
-      resolver(documentationViewModelToken),
-      resolver(editViewModelToken),
-      resolver(groupViewModelToken),
-      resolver(pluginViewModelToken),
-      resolver(repoViewModelToken),
-      resolver(searchViewModelToken),
-      resolver(settingsViewModelToken)
-    );
-  dependencies.set(routerToken, routerCreator);
+  const dependencies = createAppDependencies(appContext, resolver);
+  dependencies.set(storageServiceToken, () => grStorageMock);
   dependencies.set(navigationToken, () => {
     return {
       setUrl: () => {},
@@ -172,55 +56,9 @@
       finalize: () => {},
     };
   });
-
-  const changeModelCreator = () =>
-    new ChangeModel(
-      appContext.routerModel,
-      appContext.restApiService,
-      appContext.userModel
-    );
-  dependencies.set(changeModelToken, changeModelCreator);
-
-  const accountsModelCreator = () =>
-    new AccountsModel(appContext.restApiService);
-  dependencies.set(accountsModelToken, accountsModelCreator);
-
-  const commentsModelCreator = () =>
-    new CommentsModel(
-      appContext.routerModel,
-      resolver(changeModelToken),
-      resolver(accountsModelToken),
-      appContext.restApiService,
-      appContext.reportingService
-    );
-  dependencies.set(commentsModelToken, commentsModelCreator);
-
-  const filesModelCreator = () =>
-    new FilesModel(
-      resolver(changeModelToken),
-      resolver(commentsModelToken),
-      appContext.restApiService
-    );
-  dependencies.set(filesModelToken, filesModelCreator);
-
-  const configModelCreator = () =>
-    new ConfigModel(resolver(changeModelToken), appContext.restApiService);
-  dependencies.set(configModelToken, configModelCreator);
-
-  const checksModelCreator = () =>
-    new ChecksModel(
-      appContext.routerModel,
-      resolver(changeViewModelToken),
-      resolver(changeModelToken),
-      appContext.reportingService,
-      appContext.pluginsModel
-    );
-
-  dependencies.set(checksModelToken, checksModelCreator);
-
-  const shortcutServiceCreator = () =>
-    new ShortcutsService(appContext.userModel, appContext.reportingService);
-  dependencies.set(shortcutsServiceToken, shortcutServiceCreator);
-
+  dependencies.set(
+    highlightServiceToken,
+    () => new MockHighlightService(appContext.reportingService)
+  );
   return dependencies;
 }
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index d6ad434..d7b178a 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -9,16 +9,13 @@
 import {getAppContext} from '../services/app-context';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {SinonSpy, SinonStub} from 'sinon';
-import {StorageService} from '../services/storage/gr-storage';
 import {AuthService} from '../services/gr-auth/gr-auth';
 import {ReportingService} from '../services/gr-reporting/gr-reporting';
-import {UserModel} from '../models/user/user-model';
 import {queryAndAssert, query} from '../utils/common-util';
 import {FlagsService} from '../services/flags/flags';
-import {Key, Modifier} from '../utils/dom-util';
+import {Key, Modifier, whenVisible} from '../utils/dom-util';
 import {Observable} from 'rxjs';
 import {filter, take, timeout} from 'rxjs/operators';
-import {HighlightService} from '../services/highlight/highlight-service';
 import {assert} from '@open-wc/testing';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
 
@@ -109,24 +106,6 @@
   return sinon.spy(getAppContext().restApiService, method);
 }
 
-export function stubUsers<K extends keyof UserModel>(method: K) {
-  return sinon.stub(getAppContext().userModel, method);
-}
-
-export function stubHighlightService<K extends keyof HighlightService>(
-  method: K
-) {
-  return sinon.stub(getAppContext().highlightService, method);
-}
-
-export function stubStorage<K extends keyof StorageService>(method: K) {
-  return sinon.stub(getAppContext().storageService, method);
-}
-
-export function spyStorage<K extends keyof StorageService>(method: K) {
-  return sinon.spy(getAppContext().storageService, method);
-}
-
 export function stubAuth<K extends keyof AuthService>(method: K) {
   return sinon.stub(getAppContext().authService, method);
 }
@@ -220,6 +199,12 @@
   });
 }
 
+export async function waitUntilVisible(element: Element): Promise<void> {
+  return new Promise(resolve => {
+    whenVisible(element, () => resolve());
+  });
+}
+
 export function waitUntilCalled(stub: SinonStub | SinonSpy, name: string) {
   return waitUntil(() => stub.called, `${name} was not called`);
 }
diff --git a/polygerrit-ui/app/workers/service-worker-class.ts b/polygerrit-ui/app/workers/service-worker-class.ts
index 218744d..ee85c0e 100644
--- a/polygerrit-ui/app/workers/service-worker-class.ts
+++ b/polygerrit-ui/app/workers/service-worker-class.ts
@@ -133,6 +133,7 @@
 
     // TODO(milutin): Add gerrit host icon
     this.ctx.registration.showNotification(change.subject, {body, data});
+    this.sendReport('notify about 1 change');
   }
 
   private showNotificationForDashboard(numOfChangesToNotifyAbout: number) {
@@ -140,6 +141,7 @@
     const dashboardUrl = createDashboardUrl({});
     const data = {url: `${self.location.origin}${dashboardUrl}`};
     this.ctx.registration.showNotification(title, {data});
+    this.sendReport(`notify about ${numOfChangesToNotifyAbout} changes`);
   }
 
   // private but used in test
@@ -154,6 +156,7 @@
     const prevLatestUpdateTimestampMs = this.latestUpdateTimestampMs;
     this.latestUpdateTimestampMs = Date.now();
     await this.saveState();
+    this.sendReport('polling');
     const changes = await this.getLatestAttentionSetChanges();
     const latestAttentionChanges = filterAttentionChangesAfter(
       changes,
@@ -173,4 +176,19 @@
     const changes = payload.parsed as unknown as ParsedChangeInfo[] | undefined;
     return changes ?? [];
   }
+
+  /**
+   * Send report event to 1 client (last focused one). The client will use
+   * gr-reporting service to send event to metric event collectors.
+   */
+  async sendReport(eventName: string) {
+    const clientsArr = await this.ctx.clients.matchAll({type: 'window'});
+    const lastFocusedClient = clientsArr?.[0];
+    if (!lastFocusedClient) return;
+
+    lastFocusedClient.postMessage({
+      type: ServiceWorkerMessageType.REPORTING,
+      eventName,
+    });
+  }
 }
diff --git a/tools/BUILD b/tools/BUILD
index 3665bc2..e25dcc5 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -428,6 +428,7 @@
         "-Xep:WrongOneof:ERROR",
         "-Xep:XorPower:ERROR",
         "-Xep:ZoneIdOfZ:ERROR",
+        "-Xlint:unchecked",
     ],
     packages = ["error_prone_packages"],
 )