Merge "Forward dependency-requests from gr-alert to gr-error-manager"
diff --git a/Documentation/config-groups.txt b/Documentation/config-groups.txt
index 0917515..4abb223 100644
--- a/Documentation/config-groups.txt
+++ b/Documentation/config-groups.txt
@@ -34,7 +34,7 @@
 group, there is a ref, stored as a sharded UUID, e.g.
 
 ----
-  refs/groups/ef/deafbeefdeafbeefdeafbeefdeafbeefdeafbeef
+  refs/groups/de/deafbeefdeafbeefdeafbeefdeafbeefdeafbeef
 ----
 
 The ref points to commits holding files. The files are
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 8239d62..b1f9912 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -9,14 +9,15 @@
 
 [options="header"]
 |=================================================
-|Description          | Default Query
-|All > Open           | status:open '(or is:open)'
-|All > Merged         | status:merged
-|All > Abandoned      | status:abandoned
-|My > Watched Changes | is:watched is:open
-|My > Starred Changes | is:starred
-|My > Draft Comments  | has:draft
-|Open changes in Foo  | status:open project:Foo
+|Description            | Default Query
+|Changes > Open         | status:open '(or is:open)'
+|Changes > Merged       | status:merged
+|Changes > Abandoned    | status:abandoned
+|Your > Watched Changes | is:watched is:open
+|Your > Starred Changes | is:starred
+|Your > Draft Comments  | has:draft
+|Your > Edits           | has:edit
+|Open changes in Foo    | status:open project:Foo
 |=================================================
 
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
index e4cb239..0bb0578 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
@@ -256,10 +257,12 @@
     return c;
   }
 
+  @CanIgnoreReturnValue
   private AttentionSetUpdate addToAttentionSet(ChangeUpdate update) {
     return addToAttentionSet(update, otherUser);
   }
 
+  @CanIgnoreReturnValue
   private AttentionSetUpdate addToAttentionSet(ChangeUpdate update, IdentifiedUser user) {
     AttentionSetUpdate attentionSetUpdate =
         AttentionSetUpdate.createForWrite(
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 a20a389..62de868 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
@@ -38,13 +38,28 @@
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, PropertyValues, css, html} from 'lit';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {ValueChangedEvent} from '../../../types/events';
-import {AdminChildView, AdminViewState} from '../../../models/views/admin';
-import {GroupDetailView, GroupViewState} from '../../../models/views/group';
-import {RepoDetailView, RepoViewState} from '../../../models/views/repo';
+import {
+  AdminChildView,
+  adminViewModelToken,
+  AdminViewState,
+} from '../../../models/views/admin';
+import {
+  GroupDetailView,
+  groupViewModelToken,
+  GroupViewState,
+} from '../../../models/views/group';
+import {
+  RepoDetailView,
+  repoViewModelToken,
+  RepoViewState,
+} from '../../../models/views/repo';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {merge} from 'rxjs';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -57,14 +72,25 @@
   parent?: GroupId | RepoName;
 }
 
-// The type is matched to the _showAdminView function from the gr-app-element
-type AdminViewParams = AdminViewState | GroupViewState | RepoViewState;
+type ViewState = AdminViewState | GroupViewState | RepoViewState;
 
-function getAdminViewParamsDetail(
-  params: AdminViewParams
+function isAdminView(viewState?: ViewState): viewState is AdminViewState {
+  return viewState?.view === GerritView.ADMIN;
+}
+
+function isGroupView(viewState?: ViewState): viewState is GroupViewState {
+  return viewState?.view === GerritView.GROUP;
+}
+
+function isRepoView(viewState?: ViewState): viewState is RepoViewState {
+  return viewState?.view === GerritView.REPO;
+}
+
+function getDetailView(
+  state: ViewState
 ): GroupDetailView | RepoDetailView | undefined {
-  if (params.view !== GerritView.ADMIN) {
-    return params.detail;
+  if (state.view !== GerritView.ADMIN) {
+    return state.detail;
   }
   return undefined;
 }
@@ -74,10 +100,7 @@
   private account?: AccountDetailInfo;
 
   @property({type: Object})
-  params?: AdminViewParams;
-
-  @property({type: String})
-  path?: string;
+  viewState?: ViewState;
 
   @property({type: String})
   adminView?: string;
@@ -109,6 +132,29 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getAdminViewModel = resolve(this, adminViewModelToken);
+
+  private readonly getGroupViewModel = resolve(this, groupViewModelToken);
+
+  private readonly getRepoViewModel = resolve(this, repoViewModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () =>
+        merge(
+          this.getAdminViewModel().state$,
+          this.getGroupViewModel().state$,
+          this.getRepoViewModel().state$
+        ),
+      x => {
+        this.viewState = x;
+        if (this.needsReload()) this.reload();
+      }
+    );
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     this.reload();
@@ -186,7 +232,7 @@
   }
 
   private renderAdminNavSubsection(item: NavLink) {
-    if (!item.subsection) return;
+    if (!item.subsection) return nothing;
 
     return html`
       <!--If a section has a subsection, render that.-->
@@ -224,7 +270,7 @@
   }
 
   private renderSubsectionLinks() {
-    if (!this.subsectionLinks?.length) return;
+    if (!this.subsectionLinks?.length) return nothing;
 
     return html`
       <section class="mainHeader">
@@ -244,82 +290,62 @@
   }
 
   private renderRepoList() {
-    const params = this.params as AdminViewState;
-    if (
-      !(
-        params?.view === GerritView.ADMIN &&
-        params?.adminView === AdminChildView.REPOS
-      )
-    )
-      return;
+    if (!isAdminView(this.viewState)) return nothing;
+    if (this.viewState.adminView !== AdminChildView.REPOS) return nothing;
 
     return html`
       <div class="main table">
-        <gr-repo-list class="table" .params=${params}></gr-repo-list>
+        <gr-repo-list class="table" .params=${this.viewState}></gr-repo-list>
       </div>
     `;
   }
 
   private renderGroupList() {
-    const params = this.params as AdminViewState;
-    if (
-      !(
-        params?.view === GerritView.ADMIN &&
-        params?.adminView === AdminChildView.GROUPS
-      )
-    )
-      return;
+    if (!isAdminView(this.viewState)) return nothing;
+    if (this.viewState.adminView !== AdminChildView.GROUPS) return nothing;
 
     return html`
       <div class="main table">
-        <gr-admin-group-list class="table" .params=${params}>
+        <gr-admin-group-list class="table" .params=${this.viewState}>
         </gr-admin-group-list>
       </div>
     `;
   }
 
   private renderPluginList() {
-    const params = this.params as AdminViewState;
-    if (
-      !(
-        params?.view === GerritView.ADMIN &&
-        params?.adminView === AdminChildView.PLUGINS
-      )
-    )
-      return;
+    if (!isAdminView(this.viewState)) return nothing;
+    if (this.viewState.adminView !== AdminChildView.PLUGINS) return nothing;
 
     return html`
       <div class="main table">
-        <gr-plugin-list class="table" .params=${params}></gr-plugin-list>
+        <gr-plugin-list
+          class="table"
+          .params=${this.viewState}
+        ></gr-plugin-list>
       </div>
     `;
   }
 
   private renderRepoMain() {
-    const params = this.params as RepoViewState;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        (!params?.detail || params?.detail === RepoDetailView.GENERAL)
-      )
-    )
-      return;
+    if (!isRepoView(this.viewState)) return nothing;
+    const detail = this.viewState.detail ?? RepoDetailView.GENERAL;
+    if (detail !== RepoDetailView.GENERAL) return nothing;
 
     return html`
       <div class="main breadcrumbs">
-        <gr-repo .repo=${params.repo}></gr-repo>
+        <gr-repo .repo=${this.viewState.repo}></gr-repo>
       </div>
     `;
   }
 
   private renderGroup() {
-    const params = this.params as GroupViewState;
-    if (!(params?.view === GerritView.GROUP && !params?.detail)) return;
+    if (!isGroupView(this.viewState)) return nothing;
+    if (this.viewState.detail !== undefined) return nothing;
 
     return html`
       <div class="main breadcrumbs">
         <gr-group
-          .groupId=${params.groupId}
+          .groupId=${this.viewState.groupId}
           @name-changed=${(e: CustomEvent<GroupNameChangedDetail>) => {
             this.updateGroupName(e);
           }}
@@ -329,122 +355,81 @@
   }
 
   private renderGroupMembers() {
-    const params = this.params as GroupViewState;
-    if (
-      !(
-        params?.view === GerritView.GROUP &&
-        params?.detail === GroupDetailView.MEMBERS
-      )
-    )
-      return;
+    if (!isGroupView(this.viewState)) return nothing;
+    if (this.viewState.detail !== GroupDetailView.MEMBERS) return nothing;
 
     return html`
       <div class="main breadcrumbs">
-        <gr-group-members .groupId=${params.groupId}></gr-group-members>
+        <gr-group-members .groupId=${this.viewState.groupId}></gr-group-members>
       </div>
     `;
   }
 
   private renderGroupAuditLog() {
-    const params = this.params as GroupViewState;
-    if (
-      !(
-        params?.view === GerritView.GROUP &&
-        params?.detail === GroupDetailView.LOG
-      )
-    )
-      return;
+    if (!isGroupView(this.viewState)) return nothing;
+    if (this.viewState.detail !== GroupDetailView.LOG) return nothing;
 
     return html`
       <div class="main table breadcrumbs">
         <gr-group-audit-log
           class="table"
-          .groupId=${params.groupId}
+          .groupId=${this.viewState.groupId}
         ></gr-group-audit-log>
       </div>
     `;
   }
 
   private renderRepoDetailList() {
-    const params = this.params as RepoViewState;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        (params?.detail === RepoDetailView.BRANCHES ||
-          params?.detail === RepoDetailView.TAGS)
-      )
-    )
-      return;
+    if (!isRepoView(this.viewState)) return nothing;
+    const detail = this.viewState.detail;
+    if (detail !== RepoDetailView.BRANCHES && detail !== RepoDetailView.TAGS) {
+      return nothing;
+    }
 
     return html`
       <div class="main table breadcrumbs">
         <gr-repo-detail-list
           class="table"
-          .params=${params}
+          .params=${this.viewState}
         ></gr-repo-detail-list>
       </div>
     `;
   }
 
   private renderRepoCommands() {
-    const params = this.params as RepoViewState;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        params?.detail === RepoDetailView.COMMANDS
-      )
-    )
-      return;
+    if (!isRepoView(this.viewState)) return nothing;
+    if (this.viewState.detail !== RepoDetailView.COMMANDS) return nothing;
 
     return html`
       <div class="main breadcrumbs">
-        <gr-repo-commands .repo=${params.repo}></gr-repo-commands>
+        <gr-repo-commands .repo=${this.viewState.repo}></gr-repo-commands>
       </div>
     `;
   }
 
   private renderRepoAccess() {
-    const params = this.params as RepoViewState;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        params?.detail === RepoDetailView.ACCESS
-      )
-    )
-      return;
+    if (!isRepoView(this.viewState)) return nothing;
+    if (this.viewState.detail !== RepoDetailView.ACCESS) return nothing;
 
     return html`
       <div class="main breadcrumbs">
-        <gr-repo-access
-          .path=${this.path}
-          .repo=${params.repo}
-        ></gr-repo-access>
+        <gr-repo-access .repo=${this.viewState.repo}></gr-repo-access>
       </div>
     `;
   }
 
   private renderRepoDashboards() {
-    const params = this.params as RepoViewState;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        params?.detail === RepoDetailView.DASHBOARDS
-      )
-    )
-      return;
+    if (!isRepoView(this.viewState)) return nothing;
+    if (this.viewState.detail !== RepoDetailView.DASHBOARDS) return nothing;
 
     return html`
       <div class="main table breadcrumbs">
-        <gr-repo-dashboards .repo=${params.repo}></gr-repo-dashboards>
+        <gr-repo-dashboards .repo=${this.viewState.repo}></gr-repo-dashboards>
       </div>
     `;
   }
 
   override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('params')) {
-      this.paramsChanged();
-    }
-
     if (changedProperties.has('groupId')) {
       this.computeGroupName();
     }
@@ -516,18 +501,18 @@
   }
 
   private computeSelectValue() {
-    if (!this.params?.view) return;
-    return `${this.params.view}${getAdminViewParamsDetail(this.params) ?? ''}`;
+    if (!this.viewState?.view) return;
+    return `${this.viewState.view}${getDetailView(this.viewState) ?? ''}`;
   }
 
   // private but used in test
   selectedIsCurrentPage(selected: AdminSubsectionLink) {
-    if (!this.params) return false;
+    if (!this.viewState) return false;
 
     return (
       selected.parent === (this.repoName ?? this.groupId) &&
-      selected.view === this.params.view &&
-      selected.detailType === getAdminViewParamsDetail(this.params)
+      selected.view === this.viewState.view &&
+      selected.detailType === getDetailView(this.viewState)
     );
   }
 
@@ -548,23 +533,21 @@
     GerritNav.navigateToRelativeUrl(selected.url);
   }
 
-  private async paramsChanged() {
-    if (this.needsReload()) await this.reload();
-  }
-
   needsReload(): boolean {
-    if (!this.params) return false;
+    if (!this.viewState) return false;
 
     let needsReload = false;
     const newRepoName =
-      this.params.view === GerritView.REPO ? this.params.repo : undefined;
+      this.viewState.view === GerritView.REPO ? this.viewState.repo : undefined;
     if (newRepoName !== this.repoName) {
       this.repoName = newRepoName;
       // Reloads the admin menu.
       needsReload = true;
     }
     const newGroupId =
-      this.params.view === GerritView.GROUP ? this.params.groupId : undefined;
+      this.viewState.view === GerritView.GROUP
+        ? this.viewState.groupId
+        : undefined;
     if (newGroupId !== this.groupId) {
       this.groupId = newGroupId;
       // Reloads the admin menu.
@@ -572,8 +555,8 @@
     }
     if (
       this.breadcrumbParentName &&
-      (this.params.view !== GerritView.GROUP || !this.params.groupId) &&
-      (this.params.view !== GerritView.REPO || !this.params.repo)
+      (this.viewState.view !== GerritView.GROUP || !this.viewState.groupId) &&
+      (this.viewState.view !== GerritView.REPO || !this.viewState.repo)
     ) {
       needsReload = true;
     }
@@ -595,39 +578,32 @@
     itemView?: GerritView | AdminChildView,
     detailType?: GroupDetailView | RepoDetailView
   ) {
-    const params = this.params;
-    if (!params) return '';
-    // Group params are structured differently from admin params. Compute
+    const viewState = this.viewState;
+    if (!viewState) return '';
+    // Group view state is structured differently than admin view state. Compute
     // selected differently for groups.
-    // TODO(wyatta): Simplify this when all routes work like group params.
-    if (params.view === GerritView.GROUP && itemView === GerritView.GROUP) {
-      if (!params.detail && !detailType) {
+    // TODO(wyatta): Simplify this when all routes work like group view state.
+    if (viewState.view === GerritView.GROUP && itemView === GerritView.GROUP) {
+      if (!viewState.detail && !detailType) {
         return 'selected';
       }
-      if (params.detail === detailType) {
+      if (viewState.detail === detailType) {
         return 'selected';
       }
       return '';
     }
 
-    if (params.view === GerritView.REPO && itemView === GerritView.REPO) {
-      if (!params.detail && !detailType) {
+    if (viewState.view === GerritView.REPO && itemView === GerritView.REPO) {
+      if (!viewState.detail && !detailType) {
         return 'selected';
       }
-      if (params.detail === detailType) {
+      if (viewState.detail === detailType) {
         return 'selected';
       }
       return '';
     }
-    // TODO(TS): The following condition seems always false, because params
-    // never has detailType property. Remove it.
-    if (
-      (params as unknown as AdminSubsectionLink).detailType &&
-      (params as unknown as AdminSubsectionLink).detailType !== detailType
-    ) {
-      return '';
-    }
-    return params.view === GerritView.ADMIN && itemView === params.adminView
+    return viewState.view === GerritView.ADMIN &&
+      itemView === viewState.adminView
       ? 'selected'
       : '';
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
index 3097e62..a40f1de 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -8,12 +8,7 @@
 import {AdminSubsectionLink, GrAdminView} from './gr-admin-view';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {
-  mockPromise,
-  stubBaseUrl,
-  stubElement,
-  stubRestApi,
-} from '../../../test/test-utils';
+import {stubBaseUrl, stubElement, stubRestApi} from '../../../test/test-utils';
 import {GerritView} from '../../../services/router/router-model';
 import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
 import {GrRepoList} from '../gr-repo-list/gr-repo-list';
@@ -81,7 +76,7 @@
       },
     ];
 
-    element.params = {
+    element.viewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.REPOS,
     };
@@ -215,7 +210,7 @@
     assert.isNotOk(element.filteredLinks![2].subsection);
   });
 
-  test('Nav is reloaded when repo changes', async () => {
+  test('Needs reload when repo changes', async () => {
     stubRestApi('getAccountCapabilities').returns(
       Promise.resolve(createAdminCapabilities())
     );
@@ -225,16 +220,18 @@
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       })
     );
-    const reloadStub = sinon.stub(element, 'reload');
-    element.params = {repo: 'Test Repo' as RepoName, view: GerritView.REPO};
+
+    element.viewState = {repo: 'Repo 1' as RepoName, view: GerritView.REPO};
+    assert.isTrue(element.needsReload());
+    await element.reload();
     await element.updateComplete;
-    assert.equal(reloadStub.callCount, 1);
-    element.params = {repo: 'Test Repo 2' as RepoName, view: GerritView.REPO};
+
+    element.viewState = {repo: 'Repo 2' as RepoName, view: GerritView.REPO};
+    assert.isTrue(element.needsReload());
     await element.updateComplete;
-    assert.equal(reloadStub.callCount, 2);
   });
 
-  test('Nav is reloaded when group changes', async () => {
+  test('Needs reload when group changes', async () => {
     sinon.stub(element, 'computeGroupName');
     stubRestApi('getAccountCapabilities').returns(
       Promise.resolve(createAdminCapabilities())
@@ -245,13 +242,11 @@
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       })
     );
-    const reloadStub = sinon.stub(element, 'reload');
-    element.params = {groupId: '1' as GroupId, view: GerritView.GROUP};
-    await element.updateComplete;
-    assert.equal(reloadStub.callCount, 1);
+    element.viewState = {groupId: '1' as GroupId, view: GerritView.GROUP};
+    assert.isTrue(element.needsReload());
   });
 
-  test('Nav is reloaded when changing from repo to group', async () => {
+  test('Needs reload when changing from repo to group', async () => {
     element.repoName = 'Test Repo' as RepoName;
     stubRestApi('getAccount').returns(
       Promise.resolve({
@@ -266,26 +261,23 @@
     await element.updateComplete;
 
     sinon.stub(element, 'computeGroupName');
-    const reloadStub = sinon.stub(element, 'reload');
     const groupId = '1' as GroupId;
-    element.params = {groupId, view: GerritView.GROUP};
+    element.viewState = {groupId, view: GerritView.GROUP};
     await element.updateComplete;
 
-    assert.equal(reloadStub.callCount, 1);
+    assert.isTrue(element.needsReload());
     assert.equal(element.groupId, groupId);
   });
 
-  test('Nav is reloaded when group name changes', async () => {
+  test('Needs reload when group name changes', async () => {
     const newName = 'newName' as GroupName;
-    const reloadCalled = mockPromise();
     sinon.stub(element, 'computeGroupName');
-    sinon.stub(element, 'reload').callsFake(() => {
-      reloadCalled.resolve();
-      return Promise.resolve();
-    });
-    element.params = {groupId: '1' as GroupId, view: GerritView.GROUP};
+    element.viewState = {groupId: '1' as GroupId, view: GerritView.GROUP};
     element.groupName = 'oldName' as GroupName;
+    assert.isTrue(element.needsReload());
+    await element.reload();
     await element.updateComplete;
+
     queryAndAssert<GrGroup>(element, 'gr-group').dispatchEvent(
       new CustomEvent('name-changed', {
         detail: {name: newName},
@@ -293,7 +285,6 @@
         bubbles: true,
       })
     );
-    await reloadCalled;
     assert.equal(element.groupName, newName);
   });
 
@@ -317,7 +308,7 @@
 
   test('Dropdown only triggers navigation on explicit select', async () => {
     element.repoName = 'my-repo' as RepoName;
-    element.params = {
+    element.viewState = {
       repo: 'my-repo' as RepoName,
       view: GerritView.REPO,
       detail: RepoDetailView.ACCESS,
@@ -488,7 +479,7 @@
 
   test('selectedIsCurrentPage', () => {
     element.repoName = 'my-repo' as RepoName;
-    element.params = {view: GerritView.REPO, repo: 'my-repo' as RepoName};
+    element.viewState = {view: GerritView.REPO, repo: 'my-repo' as RepoName};
     const selected = {
       view: GerritView.REPO,
       parent: 'my-repo' as RepoName,
@@ -563,7 +554,7 @@
       });
 
       test('repo list', async () => {
-        element.params = {
+        element.viewState = {
           view: GerritView.ADMIN,
           adminView: AdminChildView.REPOS,
           openCreateModal: false,
@@ -575,7 +566,7 @@
       });
 
       test('repo', async () => {
-        element.params = {
+        element.viewState = {
           view: GerritView.REPO,
           repo: 'foo' as RepoName,
         };
@@ -588,7 +579,7 @@
       });
 
       test('repo access', async () => {
-        element.params = {
+        element.viewState = {
           view: GerritView.REPO,
           detail: RepoDetailView.ACCESS,
           repo: 'foo' as RepoName,
@@ -602,7 +593,7 @@
       });
 
       test('repo dashboards', async () => {
-        element.params = {
+        element.viewState = {
           view: GerritView.REPO,
           detail: RepoDetailView.DASHBOARDS,
           repo: 'foo' as RepoName,
@@ -637,7 +628,7 @@
       });
 
       test('group list', async () => {
-        element.params = {
+        element.viewState = {
           view: GerritView.ADMIN,
           adminView: AdminChildView.GROUPS,
           openCreateModal: false,
@@ -649,12 +640,12 @@
       });
 
       test('internal group', async () => {
-        element.params = {
+        element.viewState = {
           view: GerritView.GROUP,
           groupId: '1234' as GroupId,
         };
         element.groupName = 'foo' as GroupName;
-        await element.reload();
+        if (element.needsReload()) await element.reload();
         await element.updateComplete;
         const subsectionItems = queryAll<HTMLLIElement>(
           element,
@@ -674,12 +665,12 @@
             id: 'external-id',
           })
         );
-        element.params = {
+        element.viewState = {
           view: GerritView.GROUP,
           groupId: '1234' as GroupId,
         };
         element.groupName = 'foo' as GroupName;
-        await element.reload();
+        if (element.needsReload()) await element.reload();
         await element.updateComplete;
         const subsectionItems = queryAll<HTMLLIElement>(
           element,
@@ -693,13 +684,13 @@
       });
 
       test('group members', async () => {
-        element.params = {
+        element.viewState = {
           view: GerritView.GROUP,
           detail: GroupDetailView.MEMBERS,
           groupId: '1234' as GroupId,
         };
         element.groupName = 'foo' as GroupName;
-        await element.reload();
+        if (element.needsReload()) await element.reload();
         await element.updateComplete;
         const selected = queryAndAssert(element, 'gr-page-nav .selected');
         assert.isOk(selected);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index ad97bea..d47926b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -64,9 +64,6 @@
   @property({type: String})
   repo?: RepoName;
 
-  @property({type: String})
-  path?: string;
-
   // private but used in test
   @state() canUpload?: boolean = false; // restAPI can return undefined
 
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 66569bb..937276d 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
@@ -8,7 +8,6 @@
 import '../gr-user-header/gr-user-header';
 import {page} from '../../../utils/page-wrapper-utils';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {AppElementParams} from '../../gr-app-types';
 import {
   AccountDetailInfo,
   AccountId,
@@ -20,12 +19,17 @@
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
-import {GerritView} from '../../../services/router/router-model';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state, query} from 'lit/decorators.js';
+import {LitElement, PropertyValues, html, css, nothing} from 'lit';
+import {customElement, state, query} from 'lit/decorators.js';
 import {ValueChangedEvent} from '../../../types/events';
-import {createSearchUrl} from '../../../models/views/search';
+import {
+  createSearchUrl,
+  searchViewModelToken,
+  SearchViewState,
+} from '../../../models/views/search';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
 
 const LOOKUP_QUERY_PATTERNS: RegExp[] = [
   /^\s*i?[0-9a-f]{7,40}\s*$/i, // CHANGE_ID
@@ -52,14 +56,29 @@
 
   @query('#nextArrow') protected nextArrow?: HTMLAnchorElement;
 
-  @property({type: Object})
-  params?: AppElementParams;
+  private _viewState?: SearchViewState;
 
-  @property({type: Object})
-  account: AccountDetailInfo | null = null;
+  @state()
+  get viewState() {
+    return this._viewState;
+  }
 
-  @property({type: Object})
-  preferences?: PreferencesInput;
+  set viewState(viewState: SearchViewState | undefined) {
+    if (this._viewState === viewState) return;
+    const oldViewState = this._viewState;
+    this._viewState = viewState;
+    this.viewStateChanged();
+    this.requestUpdate('viewState', oldViewState);
+  }
+
+  // private but used in test
+  @state() account?: AccountDetailInfo;
+
+  // private but used in test
+  @state() loggedIn = false;
+
+  // private but used in test
+  @state() preferences?: PreferencesInput;
 
   // private but used in test
   @state() changesPerPage?: number;
@@ -88,20 +107,41 @@
 
   private reporting = getAppContext().reportingService;
 
+  private userModel = getAppContext().userModel;
+
+  private readonly getViewModel = resolve(this, searchViewModelToken);
+
   constructor() {
     super();
     this.addEventListener('next-page', () => this.handleNextPage());
     this.addEventListener('previous-page', () => this.handlePreviousPage());
     this.addEventListener('reload', () => this.reload());
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.loadPreferences();
-  }
-
-  override disconnectedCallback() {
-    super.disconnectedCallback();
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      x => (this.viewState = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.loggedIn$,
+      x => (this.loggedIn = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      x => {
+        this.preferences = x;
+        if (this.changesPerPage !== x.changes_per_page) {
+          this.changesPerPage = x.changes_per_page;
+          this.viewStateChanged();
+        }
+      }
+    );
   }
 
   static override get styles() {
@@ -148,19 +188,18 @@
   }
 
   override render() {
-    const loggedIn = !!(this.account && Object.keys(this.account).length > 0);
     // In case of an internal reload we want the ChangeList section components
     // to remain in the DOM so that the Bulk Actions Model associated with them
     // is not recreated after the reload resulting in user selections being lost
     return html`
       <div class="loading" ?hidden=${!this.loading}>Loading...</div>
       <div ?hidden=${this.loading}>
-        ${this.renderRepoHeader()} ${this.renderUserHeader(loggedIn)}
+        ${this.renderRepoHeader()} ${this.renderUserHeader()}
         <gr-change-list
           .account=${this.account}
           .changes=${this.changes}
           .preferences=${this.preferences}
-          .showStar=${loggedIn}
+          .showStar=${this.loggedIn}
           .selectedIndex=${this.selectedIndex}
           @selected-index-changed=${(e: ValueChangedEvent<number>) => {
             this.selectedIndex = e.detail.value;
@@ -176,25 +215,25 @@
   }
 
   private renderRepoHeader() {
-    if (!this.repo) return;
+    if (!this.repo) return nothing;
 
     return html` <gr-repo-header .repo=${this.repo}></gr-repo-header> `;
   }
 
-  private renderUserHeader(loggedIn: boolean) {
-    if (!this.userId) return;
+  private renderUserHeader() {
+    if (!this.userId) return nothing;
 
     return html`
       <gr-user-header
         .userId=${this.userId}
         showDashboardLink
-        .loggedIn=${loggedIn}
+        .loggedIn=${this.loggedIn}
       ></gr-user-header>
     `;
   }
 
   private renderChangeListViewNav() {
-    if (this.loading || !this.changes || !this.changes.length) return;
+    if (this.loading || !this.changes || !this.changes.length) return nothing;
 
     return html`
       <nav>
@@ -205,7 +244,7 @@
   }
 
   private renderPrevArrow() {
-    if (this.offset === 0) return;
+    if (this.offset === 0) return nothing;
 
     return html`
       <a id="prevArrow" href=${this.computeNavLink(-1)}>
@@ -215,13 +254,9 @@
   }
 
   private renderNextArrow() {
-    if (
-      !(
-        this.changes?.length &&
-        this.changes[this.changes.length - 1]._more_changes
-      )
-    )
-      return;
+    const changesCount = this.changes?.length ?? 0;
+    if (changesCount === 0) return nothing;
+    if (!this.changes?.[changesCount - 1]._more_changes) return nothing;
 
     return html`
       <a id="nextArrow" href=${this.computeNavLink(1)}>
@@ -231,10 +266,6 @@
   }
 
   override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('params')) {
-      this.paramsChanged();
-    }
-
     if (changedProperties.has('changes')) {
       this.changesChanged();
     }
@@ -249,60 +280,39 @@
     });
   }
 
-  private paramsChanged() {
-    const value = this.params;
-    if (!value || value.view !== GerritView.SEARCH) return;
-    const offset = isNaN(Number(value.offset)) ? 0 : Number(value.offset);
+  // private, but visible for testing
+  viewStateChanged() {
+    if (!this.viewState) return;
 
-    if (this.query !== (value.query ?? '')) {
-      this.selectedIndex = 0;
-    }
+    let offset = Number(this.viewState.offset);
+    if (isNaN(offset)) offset = 0;
+    const query = this.viewState.query ?? '';
 
+    if (this.query !== query) this.selectedIndex = 0;
     this.loading = true;
-    this.query = value.query ?? '';
+    this.query = query;
     this.offset = offset;
 
     // NOTE: This method may be called before attachment. Fire title-change
     // in an async so that attachment to the DOM can take place first.
     setTimeout(() => fireTitleChange(this, this.query));
 
-    this.restApiService
-      .getPreferences()
-      .then(prefs => {
-        if (!prefs) {
-          throw new Error('getPreferences returned undefined');
-        }
-        this.changesPerPage = prefs.changes_per_page;
-        return this.getChanges();
-      })
-      .then(changes => {
-        changes = changes || [];
-        if (this.query && changes.length === 1) {
-          for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
-            if (this.query.match(queryPattern)) {
-              // "Back"/"Forward" buttons work correctly only with
-              // opt_redirect options
-              GerritNav.navigateToChange(changes[0], {
-                redirect: true,
-              });
-              return;
-            }
+    return this.getChanges().then(changes => {
+      changes = changes || [];
+      if (this.query && changes.length === 1) {
+        for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
+          if (this.query.match(queryPattern)) {
+            // "Back"/"Forward" buttons work correctly only with
+            // opt_redirect options
+            GerritNav.navigateToChange(changes[0], {
+              redirect: true,
+            });
+            return;
           }
         }
-        this.changes = changes;
-        this.loading = false;
-      });
-  }
-
-  private loadPreferences() {
-    return this.restApiService.getLoggedIn().then(loggedIn => {
-      if (loggedIn) {
-        this.restApiService.getPreferences().then(preferences => {
-          this.preferences = preferences;
-        });
-      } else {
-        this.preferences = {};
       }
+      this.changes = changes;
+      this.loading = false;
     });
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index e360899..f88f668 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -32,11 +32,14 @@
   let element: GrChangeListView;
 
   setup(async () => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     stubRestApi('getChanges').returns(Promise.resolve([]));
-    stubRestApi('getAccountDetails').returns(Promise.resolve(undefined));
-    stubRestApi('getAccountStatus').returns(Promise.resolve(undefined));
     element = await fixture(html`<gr-change-list-view></gr-change-list-view>`);
+    element.viewState = {
+      view: GerritView.SEARCH,
+      query: 'test-query',
+      offset: '0',
+    };
+    await element.updateComplete;
   });
 
   teardown(async () => {
@@ -57,12 +60,7 @@
         <div class="loading" hidden="">Loading...</div>
         <div>
           <gr-change-list> </gr-change-list>
-          <nav>
-            Page
-            <a href="/q/" id="prevArrow">
-              <gr-icon icon="chevron_left" aria-label="Older"></gr-icon>
-            </a>
-          </nav>
+          <nav>Page 1</nav>
         </div>
       `
     );
@@ -295,7 +293,7 @@
         promise.resolve();
       });
 
-      element.params = {
+      element.viewState = {
         view: GerritView.SEARCH,
         query: CHANGE_ID,
         offset: '',
@@ -314,7 +312,7 @@
         promise.resolve();
       });
 
-      element.params = {view: GerritView.SEARCH, query: '1', offset: ''};
+      element.viewState = {view: GerritView.SEARCH, query: '1', offset: ''};
       await promise;
     });
 
@@ -329,7 +327,7 @@
         promise.resolve();
       });
 
-      element.params = {
+      element.viewState = {
         view: GerritView.SEARCH,
         query: COMMIT_HASH,
         offset: '',
@@ -341,7 +339,7 @@
       sinon.stub(element, 'getChanges').returns(Promise.resolve([]));
       const stub = sinon.stub(GerritNav, 'navigateToChange');
 
-      element.params = {
+      element.viewState = {
         view: GerritView.SEARCH,
         query: CHANGE_ID,
         offset: '',
@@ -355,7 +353,7 @@
       sinon.stub(element, 'getChanges').returns(Promise.resolve(undefined));
       const stub = sinon.stub(GerritNav, 'navigateToChange');
 
-      element.params = {
+      element.viewState = {
         view: GerritView.SEARCH,
         query: CHANGE_ID,
         offset: '',
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 88bf943..3af856f 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
@@ -41,18 +41,22 @@
   firePageError,
   fireTitleChange,
 } from '../../../utils/event-util';
-import {GerritView} from '../../../services/router/router-model';
 import {RELOAD_DASHBOARD_INTERVAL_MS} from '../../../constants/constants';
 import {ChangeListSection} from '../gr-change-list/gr-change-list';
 import {a11yStyles} from '../../../styles/gr-a11y-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, PropertyValues, html, css} from 'lit';
+import {LitElement, html, css, nothing} from 'lit';
 import {customElement, property, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {Shortcut} from '../../../services/shortcuts/shortcuts-config';
 import {ShortcutController} from '../../lit/shortcut-controller';
-import {DashboardViewState} from '../../../models/views/dashboard';
+import {
+  dashboardViewModelToken,
+  DashboardViewState,
+} from '../../../models/views/dashboard';
 import {createSearchUrl} from '../../../models/views/search';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
 
 const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
 
@@ -79,13 +83,13 @@
   @query('#confirmDeleteOverlay') protected confirmDeleteOverlay?: GrOverlay;
 
   @property({type: Object})
-  account: AccountDetailInfo | null = null;
+  account?: AccountDetailInfo;
 
   @property({type: Object})
   preferences?: PreferencesInput;
 
-  @property({type: Object})
-  params?: DashboardViewState;
+  @state()
+  viewState?: DashboardViewState;
 
   // private but used in test
   @state() results?: ChangeListSection[];
@@ -103,12 +107,29 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly getViewModel = resolve(this, dashboardViewModelToken);
+
   private lastVisibleTimestampMs = 0;
 
   private readonly shortcuts = new ShortcutController(this);
 
   constructor() {
     super();
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      x => {
+        this.viewState = x;
+        this.reload();
+      }
+    );
     this.addEventListener('reload', () => this.reload());
     this.shortcuts.addAbstract(Shortcut.UP_TO_DASHBOARD, () => this.reload());
   }
@@ -184,6 +205,7 @@
   }
 
   override render() {
+    if (!this.viewState) return nothing;
     return html`
       ${this.renderBanner()} ${this.renderContent()}
       <gr-overlay id="confirmDeleteOverlay" with-backdrop>
@@ -271,17 +293,15 @@
 
   private renderUserHeader() {
     if (
-      !this.params ||
-      this.params.view !== GerritView.DASHBOARD ||
-      !!this.params.project ||
-      !this.params.user ||
-      this.params.user === 'self'
+      !!this.viewState?.project ||
+      !this.viewState?.user ||
+      this.viewState?.user === 'self'
     ) {
       return;
     }
 
     return html`
-      <gr-user-header .userId=${this.params?.user}></gr-user-header>
+      <gr-user-header .userId=${this.viewState?.user}></gr-user-header>
     `;
   }
 
@@ -297,12 +317,6 @@
     `;
   }
 
-  override updated(changedProperties: PropertyValues) {
-    if (changedProperties.has('params')) {
-      this.paramsChanged();
-    }
-  }
-
   private loadPreferences() {
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
@@ -354,26 +368,15 @@
     return 'Dashboard for ' + user;
   }
 
-  private isViewActive(params: DashboardViewState) {
-    return params.view === GerritView.DASHBOARD;
-  }
-
-  // private but used in test
-  paramsChanged() {
-    return this.reload();
-  }
-
   /**
    * Reloads the element.
    *
    * private but used in test
    */
   reload() {
-    if (!this.params || !this.isViewActive(this.params)) {
-      return Promise.resolve();
-    }
+    if (!this.viewState) return Promise.resolve();
     this.loading = true;
-    const {project, dashboard, title, user, sections} = this.params;
+    const {project, dashboard, title, user, sections} = this.viewState;
     const dashboardPromise: Promise<UserDashboard | undefined> = project
       ? this.getProjectDashboard(project, dashboard)
       : Promise.resolve(
@@ -476,7 +479,7 @@
    * And then we want to emphasize the changes where the waiting time is larger.
    */
   private maybeSortResults(name: string, results: ChangeInfo[]) {
-    const userId = this.account && this.account._account_id;
+    const userId = this.account?._account_id;
     const sortedResults = [...results];
     if (name === YOUR_TURN.name && userId) {
       sortedResults.sort((c1, c2) => {
@@ -544,9 +547,7 @@
    */
   maybeShowDraftsBanner() {
     this.showDraftsBanner = false;
-    if (!(this.params?.user === 'self')) {
-      return;
-    }
+    if (!(this.viewState?.user === 'self')) return;
 
     if (!this.results) {
       throw new Error('this.results must be set. restAPI returned undefined');
@@ -555,16 +556,12 @@
     const draftSection = this.results.find(
       section => section.query === 'has:draft'
     );
-    if (!draftSection || !draftSection.results.length) {
-      return;
-    }
+    if (!draftSection || !draftSection.results.length) return;
 
     const closedChanges = draftSection.results.filter(
       change => !changeIsOpen(change)
     );
-    if (!closedChanges.length) {
-      return;
-    }
+    if (!closedChanges.length) return;
 
     this.showDraftsBanner = true;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
index 92afae1..7889e20 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
@@ -41,7 +41,6 @@
 suite('gr-dashboard-view tests', () => {
   let element: GrDashboardView;
 
-  let paramsChangedPromise: Promise<any>;
   let getChangesStub: SinonStubbedMember<
     RestApiService['getChangesForMultipleQueries']
   >;
@@ -54,30 +53,25 @@
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       })
     );
-    stubRestApi('getAccountStatus').returns(Promise.resolve(undefined));
 
     element = await fixture<GrDashboardView>(html`
       <gr-dashboard-view></gr-dashboard-view>
     `);
 
     await element.updateComplete;
-    let resolver: (value?: any) => void;
-    paramsChangedPromise = new Promise(resolve => {
-      resolver = resolve;
-    });
-    const paramsChanged = element.paramsChanged.bind(element);
-    sinon
-      .stub(element, 'paramsChanged')
-      .callsFake(() => paramsChanged().then(() => resolver()));
   });
 
   test('render', async () => {
-    const sections = [
-      {name: 'test1', query: 'test1', hideIfEmpty: true},
-      {name: 'test2', query: 'test2', hideIfEmpty: true},
-    ];
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+      user: 'self',
+      sections: [
+        {name: 'test1', query: 'test1', hideIfEmpty: true},
+        {name: 'test2', query: 'test2', hideIfEmpty: true},
+      ],
+    };
     getChangesStub.returns(Promise.resolve([[createChange()]]));
-    await element.fetchDashboardChanges({sections}, false);
+    await element.reload();
     element.loading = false;
     stubFlags('isEnabled').returns(true);
     element.requestUpdate();
@@ -125,14 +119,18 @@
 
   suite('bulk actions', () => {
     setup(async () => {
-      const sections = [
-        {name: 'test1', query: 'test1', hideIfEmpty: true},
-        {name: 'test2', query: 'test2', hideIfEmpty: true},
-      ];
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        user: 'user',
+        sections: [
+          {name: 'test1', query: 'test1', hideIfEmpty: true},
+          {name: 'test2', query: 'test2', hideIfEmpty: true},
+        ],
+      };
       getChangesStub.returns(Promise.resolve([[createChange()]]));
-      await element.fetchDashboardChanges({sections}, false);
-      element.loading = false;
       stubFlags('isEnabled').returns(true);
+      await element.reload();
+      element.loading = false;
       element.requestUpdate();
       await element.updateComplete;
     });
@@ -151,11 +149,6 @@
       getChangesStub.restore();
       getChangesStub.returns(Promise.resolve([[createChange()]]));
 
-      element.params = {
-        view: GerritView.DASHBOARD,
-        user: 'notself',
-        dashboard: '' as DashboardId,
-      };
       await element.reload();
       await element.updateComplete;
       assert.isTrue(checkbox.checked);
@@ -163,9 +156,20 @@
   });
 
   suite('drafts banner functionality', () => {
+    setup(async () => {
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        user: 'self',
+        sections: [
+          {name: 'test1', query: 'test1', hideIfEmpty: true},
+          {name: 'test2', query: 'test2', hideIfEmpty: true},
+        ],
+      };
+    });
+
     suite('maybeShowDraftsBanner', () => {
       test('not dashboard/self', () => {
-        element.params = {
+        element.viewState = {
           view: GerritView.DASHBOARD,
           user: 'notself',
           dashboard: '' as DashboardId,
@@ -176,7 +180,7 @@
 
       test('no drafts at all', () => {
         element.results = [];
-        element.params = {
+        element.viewState = {
           view: GerritView.DASHBOARD,
           user: 'self',
           dashboard: '' as DashboardId,
@@ -190,7 +194,7 @@
         element.results = [
           {countLabel: '', name: '', query: 'has:draft', results: [openChange]},
         ];
-        element.params = {
+        element.viewState = {
           view: GerritView.DASHBOARD,
           user: 'self',
           dashboard: '' as DashboardId,
@@ -210,7 +214,7 @@
           },
         ];
         assert.isFalse(changeIsOpen(element.results[0].results[0]));
-        element.params = {
+        element.viewState = {
           view: GerritView.DASHBOARD,
           user: 'self',
           dashboard: '' as DashboardId,
@@ -314,27 +318,10 @@
     });
   });
 
-  suite('_isViewActive', () => {
-    test('nothing happens when user param is falsy', async () => {
-      element.params = undefined;
-      await element.updateComplete;
-      assert.equal(getChangesStub.callCount, 0);
-    });
-
-    test('content is refreshed when user param is updated', async () => {
-      element.params = {
-        view: GerritView.DASHBOARD,
-        user: 'self',
-        dashboard: '' as DashboardId,
-      };
-      await paramsChangedPromise;
-      assert.isTrue(getChangesStub.called);
-    });
-  });
-
   suite('selfOnly sections', () => {
     test('viewing self dashboard includes selfOnly sections', async () => {
-      element.params = {
+      element.account = undefined;
+      element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'self',
         dashboard: '' as DashboardId,
@@ -343,13 +330,13 @@
           {name: '', query: '2', selfOnly: true},
         ],
       };
-      await paramsChangedPromise;
+      await element.reload();
       assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2']));
     });
 
     test('viewing dashboard when logged in includes owner:self query', async () => {
       element.account = createAccountDetailWithId(1);
-      element.params = {
+      element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'self',
         dashboard: '' as DashboardId,
@@ -358,14 +345,14 @@
           {name: '', query: '2', selfOnly: true},
         ],
       };
-      await paramsChangedPromise;
+      await element.reload();
       assert.isTrue(
         getChangesStub.calledWith(undefined, ['1', '2', 'owner:self limit:1'])
       );
     });
 
     test("viewing another user's dashboard omits selfOnly sections", async () => {
-      element.params = {
+      element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'user',
         dashboard: '' as DashboardId,
@@ -374,13 +361,13 @@
           {name: '', query: '2', selfOnly: true},
         ],
       };
-      await paramsChangedPromise;
+      await element.reload();
       assert.isTrue(getChangesStub.calledWith(undefined, ['1']));
     });
   });
 
   test('suffixForDashboard is included in getChanges query', async () => {
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: '' as DashboardId,
       sections: [
@@ -388,7 +375,7 @@
         {name: '', query: '2', suffixForDashboard: 'suffix'},
       ],
     };
-    await paramsChangedPromise;
+    await element.reload();
     assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2 suffix']));
   });
 
@@ -520,6 +507,9 @@
   });
 
   test('showNewUserHelp', async () => {
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+    };
     element.loading = false;
     element.showNewUserHelp = false;
     await element.updateComplete;
@@ -547,11 +537,11 @@
   });
 
   test('gr-user-header', async () => {
-    element.params = undefined;
+    element.viewState = undefined;
     await element.updateComplete;
     assert.isNotOk(query(element, 'gr-user-header'));
 
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: '' as DashboardId,
       user: 'self',
@@ -560,7 +550,7 @@
     assert.isNotOk(query(element, 'gr-user-header'));
 
     element.loading = false;
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: '' as DashboardId,
       user: 'user',
@@ -568,7 +558,7 @@
     await element.updateComplete;
     assert.isOk(query(element, 'gr-user-header'));
 
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: '' as DashboardId,
       project: 'p' as RepoName,
@@ -593,16 +583,16 @@
       assert.strictEqual((e as PageErrorEvent).detail.response, response);
       promise.resolve();
     });
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: 'dashboard' as DashboardId,
       project: 'project' as RepoName,
       user: '',
     };
-    await Promise.all([paramsChangedPromise, promise]);
+    await Promise.all([element.reload(), promise]);
   });
 
-  test('params change triggers dashboardDisplayed()', async () => {
+  test('viewState change triggers dashboardDisplayed()', async () => {
     stubRestApi('getDashboard').returns(
       Promise.resolve({
         id: '' as DashboardId,
@@ -618,13 +608,13 @@
     );
     getChangesStub.returns(Promise.resolve([]));
     const dashboardDisplayedStub = stubReporting('dashboardDisplayed');
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: 'dashboard' as DashboardId,
       project: 'project' as RepoName,
       user: '',
     };
-    await paramsChangedPromise;
+    await element.reload();
     assert.isTrue(dashboardDisplayedStub.calledOnce);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index 7485a6d..6fde9ca 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -54,7 +54,6 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {GrRouter} from '../../core/gr-router/gr-router';
 import {nothing} from 'lit';
 import {fixture, html, assert} from '@open-wc/testing';
 import {EventType} from '../../../types/events';
@@ -296,10 +295,9 @@
   });
 
   test('weblinks are visible when other weblinks', async () => {
-    const router = new GrRouter();
     sinon
       .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router.generateWeblinks.bind(router));
+      .callsFake(() => [{name: 'test-name'}]);
 
     element.commitInfo = {
       ...createCommitInfoWithRequiredCommit(),
@@ -309,22 +307,12 @@
     const webLinks = element.webLinks!;
     assert.isFalse(webLinks.hasAttribute('hidden'));
     assert.equal(element.computeWebLinks().length, 1);
-    // With two non-gitiles weblinks, there are two returned.
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [
-        {...createWebLinkInfo(), name: 'test', url: '#'},
-        {...createWebLinkInfo(), name: 'test2', url: '#'},
-      ],
-    };
-    assert.equal(element.computeWebLinks().length, 2);
   });
 
   test('weblinks are visible when gitiles and other weblinks', async () => {
-    const router = new GrRouter();
     sinon
       .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router.generateWeblinks.bind(router));
+      .callsFake(() => [{name: 'test-name'}]);
 
     element.commitInfo = {
       ...createCommitInfoWithRequiredCommit(),
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 2bd217a..d076439 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
@@ -181,7 +181,11 @@
 import {getBaseUrl, prependOrigin} from '../../../utils/url-util';
 import {CopyLink, GrCopyLinks} from '../gr-copy-links/gr-copy-links';
 import {KnownExperimentId} from '../../../services/flags/flags';
-import {ChangeViewState, createChangeUrl} from '../../../models/views/change';
+import {
+  changeViewModelToken,
+  ChangeViewState,
+  createChangeUrl,
+} from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {createEditUrl} from '../../../models/views/edit';
 
@@ -275,23 +279,19 @@
 
   @query('gr-copy-links') private copyLinksDropdown?: GrCopyLinks;
 
-  /**
-   * URL params passed from the router.
-   * Use params getter/setter.
-   */
-  private _params?: ChangeViewState;
+  private _viewState?: ChangeViewState;
 
   @property({type: Object})
-  get params() {
-    return this._params;
+  get viewState() {
+    return this._viewState;
   }
 
-  set params(params: ChangeViewState | undefined) {
-    if (this._params === params) return;
-    const oldParams = this._params;
-    this._params = params;
-    this.paramsChanged();
-    this.requestUpdate('params', oldParams);
+  set viewState(viewState: ChangeViewState | undefined) {
+    if (this._viewState === viewState) return;
+    const oldViewState = this._viewState;
+    this._viewState = viewState;
+    this.viewStateChanged();
+    this.requestUpdate('viewState', oldViewState);
   }
 
   @property({type: String})
@@ -433,11 +433,11 @@
 
   // Private but used in tests.
   getEditMode() {
-    if (!this.patchRange || !this.params) {
+    if (!this.patchRange || !this.viewState) {
       return false;
     }
 
-    if (this.params.edit) {
+    if (this.viewState.edit) {
       return true;
     }
 
@@ -554,6 +554,8 @@
 
   private readonly getFilesModel = resolve(this, filesModelToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   private replyRefitTask?: DelayedTask;
@@ -693,6 +695,11 @@
   private setupSubscriptions() {
     subscribe(
       this,
+      () => this.getViewModel().state$,
+      s => (this.viewState = s)
+    );
+    subscribe(
+      this,
       () => this.getChecksModel().aPluginHasRegistered$,
       b => {
         this.showChecksTab = b;
@@ -2094,25 +2101,25 @@
    */
   private isChangeObsolete() {
     // While this.changeNum is undefined the change view is fresh and has just
-    // not updated it to params.changeNum yet. Not obsolete in that case.
+    // not updated it to viewState.changeNum yet. Not obsolete in that case.
     if (this.changeNum === undefined) return false;
-    // this.params reflects the current state of the URL. If this.changeNum
+    // this.viewState reflects the current state of the URL. If this.changeNum
     // does not match it anymore, then this view must be considered obsolete.
-    return this.changeNum !== this.params?.changeNum;
+    return this.changeNum !== this.viewState?.changeNum;
   }
 
   // Private but used in tests.
-  hasPatchRangeChanged(value: ChangeViewState) {
+  hasPatchRangeChanged(viewState: ChangeViewState) {
     if (!this.patchRange) return false;
-    if (this.patchRange.basePatchNum !== value.basePatchNum) return true;
-    return this.hasPatchNumChanged(value);
+    if (this.patchRange.basePatchNum !== viewState.basePatchNum) return true;
+    return this.hasPatchNumChanged(viewState);
   }
 
   // Private but used in tests.
-  hasPatchNumChanged(value: ChangeViewState) {
+  hasPatchNumChanged(viewState: ChangeViewState) {
     if (!this.patchRange) return false;
-    if (value.patchNum !== undefined) {
-      return this.patchRange.patchNum !== value.patchNum;
+    if (viewState.patchNum !== undefined) {
+      return this.patchRange.patchNum !== viewState.patchNum;
     } else {
       // value.patchNum === undefined specifies the latest patchset
       return (
@@ -2122,8 +2129,8 @@
   }
 
   // Private but used in tests.
-  paramsChanged() {
-    if (this.params?.view !== GerritView.CHANGE) {
+  viewStateChanged() {
+    if (this.viewState === undefined) {
       this.initialLoadComplete = false;
       querySelectorAll(this, 'gr-overlay').forEach(overlay =>
         (overlay as GrOverlay).close()
@@ -2138,24 +2145,24 @@
       return;
     }
 
-    if (this.params.changeNum && this.params.project) {
+    if (this.viewState.changeNum && this.viewState.project) {
       this.restApiService.setInProjectLookup(
-        this.params.changeNum,
-        this.params.project
+        this.viewState.changeNum,
+        this.viewState.project
       );
     }
 
-    if (this.params.basePatchNum === undefined)
-      this.params.basePatchNum = PARENT;
+    if (this.viewState.basePatchNum === undefined)
+      this.viewState.basePatchNum = PARENT;
 
-    const patchChanged = this.hasPatchRangeChanged(this.params);
-    let patchNumChanged = this.hasPatchNumChanged(this.params);
+    const patchChanged = this.hasPatchRangeChanged(this.viewState);
+    let patchNumChanged = this.hasPatchNumChanged(this.viewState);
 
     this.patchRange = {
-      patchNum: this.params.patchNum,
-      basePatchNum: this.params.basePatchNum,
+      patchNum: this.viewState.patchNum,
+      basePatchNum: this.viewState.basePatchNum,
     };
-    this.scrollCommentId = this.params.commentId;
+    this.scrollCommentId = this.viewState.commentId;
 
     const patchKnown =
       !this.patchRange.patchNum ||
@@ -2163,7 +2170,7 @@
         ps => ps.num === this.patchRange!.patchNum
       );
     // _allPatchsets does not know value.patchNum so force a reload.
-    const forceReload = this.params.forceReload || !patchKnown;
+    const forceReload = this.viewState.forceReload || !patchKnown;
 
     // If changeNum is defined that means the change has already been
     // rendered once before so a full reload is not required.
@@ -2176,7 +2183,7 @@
         patchNumChanged = true;
       }
       if (patchChanged) {
-        // We need to collapse all diffs when params change so that a non
+        // We need to collapse all diffs when viewState changes so that a non
         // existing diff is not requested. See Issue 125270 for more details.
         this.fileList?.resetFileState();
         this.fileList?.collapseAllDiffs();
@@ -2198,8 +2205,8 @@
       return;
     }
 
-    // We need to collapse all diffs when params change so that a non existing
-    // diff is not requested. See Issue 125270 for more details.
+    // We need to collapse all diffs when viewState changes so that a non
+    // existing diff is not requested. See Issue 125270 for more details.
     this.updateComplete.then(() => {
       assertIsDefined(this.fileList);
       this.fileList?.collapseAllDiffs();
@@ -2218,7 +2225,7 @@
     }
 
     this.initialLoadComplete = false;
-    this.changeNum = this.params.changeNum;
+    this.changeNum = this.viewState.changeNum;
     this.loadData(true).then(() => {
       this.performPostLoadTasks();
     });
@@ -2232,9 +2239,9 @@
 
   private initActiveTab() {
     let tab = Tab.FILES;
-    if (this.params?.tab) {
-      tab = this.params?.tab as Tab;
-    } else if (this.params?.commentId) {
+    if (this.viewState?.tab) {
+      tab = this.viewState?.tab as Tab;
+    } else if (this.viewState?.commentId) {
       tab = Tab.COMMENT_THREADS;
     }
     const detail: SwitchTabEventDetail = {
@@ -2243,8 +2250,8 @@
     if (tab === Tab.CHECKS) {
       const state: ChecksTabState = {};
       detail.tabState = {checksTab: state};
-      if (this.params?.filter) state.filter = this.params.filter;
-      if (this.params?.attempt) state.attempt = this.params.attempt;
+      if (this.viewState?.filter) state.filter = this.viewState.filter;
+      if (this.viewState?.attempt) state.attempt = this.viewState.attempt;
     }
 
     this.setActiveTab(
@@ -2340,7 +2347,7 @@
 
   private maybeShowReplyDialog() {
     if (!this.loggedIn) return;
-    if (this.params?.openReplyDialog) {
+    if (this.viewState?.openReplyDialog) {
       this.openReplyDialog(FocusTarget.ANY);
     }
   }
@@ -2750,7 +2757,7 @@
 
   private async untilModelLoaded() {
     // NOTE: Wait until this page is connected before determining whether the
-    // model is loaded.  This can happen when params are changed when setting up
+    // model is loaded.  This can happen when viewState changes when setting up
     // this view. It's unclear whether this issue is related to Polymer
     // specifically.
     if (!this.isConnected) {
@@ -3224,6 +3231,7 @@
         controls.openDeleteDialog(path);
         break;
       case GrEditConstants.Actions.OPEN.id:
+        assertIsDefined(this.patchRange.patchNum, 'patchset number');
         GerritNav.navigateToRelativeUrl(
           createEditUrl({
             changeNum: this.change._number,
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 8bb937f..0e6fb71 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
@@ -367,7 +367,7 @@
     element = await fixture<GrChangeView>(
       html`<gr-change-view></gr-change-view>`
     );
-    element.params = {
+    element.viewState = {
       view: GerritView.CHANGE,
       changeNum: TEST_NUMERIC_CHANGE_ID,
       project: 'gerrit' as RepoName,
@@ -730,9 +730,9 @@
       assert.equal(element.activeTab, Tab.FILES);
       // view is required
       element.changeNum = undefined;
-      element.params = {
+      element.viewState = {
         ...createChangeViewState(),
-        ...element.params,
+        ...element.viewState,
         tab: Tab.COMMENT_THREADS,
       };
       await element.updateComplete;
@@ -742,9 +742,9 @@
     test('invalid param change should not switch primary tab', async () => {
       assert.equal(element.activeTab, Tab.FILES);
       // view is required
-      element.params = {
+      element.viewState = {
         ...createChangeViewState(),
-        ...element.params,
+        ...element.viewState,
         tab: 'random',
       };
       await element.updateComplete;
@@ -1054,8 +1054,8 @@
       ) as GrRelatedChangesList;
       sinon.stub(relatedChanges, 'reload');
       sinon.stub(element, 'loadData').returns(Promise.resolve());
-      sinon.spy(element, 'paramsChanged');
-      element.params = createChangeViewState();
+      sinon.spy(element, 'viewStateChanged');
+      element.viewState = createChangeViewState();
     });
   });
 
@@ -1549,7 +1549,7 @@
     await element.updateComplete;
 
     element.changeNum = 2 as NumericChangeId;
-    element.params = {
+    element.viewState = {
       ...createChangeViewState(),
       changeNum: 2 as NumericChangeId,
     };
@@ -1574,7 +1574,7 @@
       patchNum: 1 as RevisionPatchSetNum,
     };
     element.changeNum = undefined;
-    element.params = value;
+    element.viewState = value;
     await element.updateComplete;
     assert.isTrue(reloadStub.calledOnce);
 
@@ -1590,7 +1590,7 @@
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
-    element.params = {...value};
+    element.viewState = {...value};
     await element.updateComplete;
     await waitEventLoop();
     assert.equal(element.fileList.selectedIndex, 0);
@@ -1619,7 +1619,7 @@
       view: GerritView.CHANGE,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element.params = value;
+    element.viewState = value;
     await element.updateComplete;
 
     element.initialLoadComplete = true;
@@ -1633,7 +1633,7 @@
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
-    element.params = {...value};
+    element.viewState = {...value};
     await element.updateComplete;
     assert.isTrue(reloadPortedCommentsStub.calledOnce);
     assert.isTrue(reloadPortedDraftsStub.calledOnce);
@@ -1646,13 +1646,13 @@
       .callsFake(() => Promise.resolve());
     const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
     const value: ChangeViewState = createChangeViewState();
-    element.params = value;
+    element.viewState = value;
     // change already loaded
     assert.isOk(element.changeNum);
     await element.updateComplete;
     assert.isFalse(reloadStub.calledOnce);
     element.initialLoadComplete = true;
-    element.params = {...value};
+    element.viewState = {...value};
     await element.updateComplete;
     assert.isFalse(reloadStub.calledTwice);
     assert.isFalse(collapseStub.calledTwice);
@@ -1667,7 +1667,7 @@
       .stub(element, 'loadData')
       .callsFake(() => Promise.resolve());
     const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
-    element.params = {...createChangeViewState(), forceReload: true};
+    element.viewState = {...createChangeViewState(), forceReload: true};
     await element.updateComplete;
     assert.isTrue(getChangeStub.called);
     assert.isTrue(loadDataStub.called);
@@ -1681,12 +1681,12 @@
     element.addEventListener('recreate-change-view', recreateSpy);
 
     const value: ChangeViewState = createChangeViewState();
-    element.params = value;
+    element.viewState = value;
     await element.updateComplete;
     assert.isFalse(recreateSpy.calledOnce);
 
     value.changeNum = 555111333 as NumericChangeId;
-    element.params = {...value};
+    element.viewState = {...value};
     await element.updateComplete;
     assert.isTrue(recreateSpy.calledOnce);
   });
@@ -1701,7 +1701,7 @@
       Promise.resolve({...createMergeable(), mergeable: true})
     );
 
-    element.params = createChangeViewState();
+    element.viewState = createChangeViewState();
     element.getChangeModel().setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
@@ -2019,8 +2019,8 @@
 
   test('header class computation', () => {
     assert.equal(element.computeHeaderClass(), 'header');
-    assertIsDefined(element.params);
-    element.params.edit = true;
+    assertIsDefined(element.viewState);
+    element.viewState.edit = true;
     assert.equal(element.computeHeaderClass(), 'header editMode');
   });
 
@@ -2038,8 +2038,8 @@
   });
 
   test('computeEditMode', async () => {
-    const callCompute = async (params: ChangeViewState) => {
-      element.params = params;
+    const callCompute = async (viewState: ChangeViewState) => {
+      element.viewState = viewState;
       await element.updateComplete;
       return element.getEditMode();
     };
@@ -2256,30 +2256,30 @@
     element.change.current_revision = '1' as CommitId;
     element.change = {...element.change};
 
-    const params = createChangeViewState();
+    const viewState = createChangeViewState();
 
-    assert.isFalse(element.hasPatchRangeChanged(params));
-    assert.isFalse(element.hasPatchNumChanged(params));
+    assert.isFalse(element.hasPatchRangeChanged(viewState));
+    assert.isFalse(element.hasPatchNumChanged(viewState));
 
-    params.basePatchNum = PARENT;
+    viewState.basePatchNum = PARENT;
     // undefined means navigate to latest patchset
-    params.patchNum = undefined;
+    viewState.patchNum = undefined;
 
     element.patchRange = {
       patchNum: 2 as RevisionPatchSetNum,
       basePatchNum: PARENT,
     };
 
-    assert.isTrue(element.hasPatchRangeChanged(params));
-    assert.isTrue(element.hasPatchNumChanged(params));
+    assert.isTrue(element.hasPatchRangeChanged(viewState));
+    assert.isTrue(element.hasPatchNumChanged(viewState));
 
     element.patchRange = {
       patchNum: 4 as RevisionPatchSetNum,
       basePatchNum: PARENT,
     };
 
-    assert.isFalse(element.hasPatchRangeChanged(params));
-    assert.isFalse(element.hasPatchNumChanged(params));
+    assert.isFalse(element.hasPatchRangeChanged(viewState));
+    assert.isFalse(element.hasPatchNumChanged(viewState));
   });
 
   suite('handleEditTap', () => {
@@ -2485,7 +2485,7 @@
       assert.isFalse(changeFullyLoadedStub.called);
     });
 
-    test('report changeDisplayed on paramsChanged', async () => {
+    test('report changeDisplayed on viewStateChanged', async () => {
       stubRestApi('getChangeOrEditFiles').resolves({
         'a-file.js': {},
       });
@@ -2499,7 +2499,7 @@
       );
       // reset so reload is triggered
       element.changeNum = undefined;
-      element.params = {
+      element.viewState = {
         ...createChangeViewState(),
         changeNum: TEST_NUMERIC_CHANGE_ID,
         project: TEST_PROJECT_NAME,
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
index ea4b202..92fc2bb 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
@@ -13,7 +13,6 @@
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {CommitId, RepoName} from '../../../types/common';
-import {GrRouter} from '../../core/gr-router/gr-router';
 import {fixture, html, assert} from '@open-wc/testing';
 import {waitEventLoop} from '../../../test/test-utils';
 
@@ -61,10 +60,9 @@
   });
 
   test('use web link when available', () => {
-    const router = new GrRouter();
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router.generateWeblinks.bind(router));
+    sinon.stub(GerritNav, 'getPatchSetWeblink').callsFake(() => {
+      return {name: 'test-name', url: 'test-url'};
+    });
 
     element.change = {...createChange(), labels: {}, project: '' as RepoName};
     element.commitInfo = {
@@ -75,65 +73,6 @@
     element.serverConfig = createServerInfo();
 
     assert.isOk(element._showWebLink);
-    assert.equal(element._webLink, 'link-url');
-  });
-
-  test('does not relativize web links that begin with scheme', () => {
-    const router = new GrRouter();
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router.generateWeblinks.bind(router));
-
-    element.change = {...createChange(), labels: {}, project: '' as RepoName};
-    element.commitInfo = {
-      ...createCommit(),
-      commit: 'commitsha' as CommitId,
-      web_links: [{name: 'gitweb', url: 'https://link-url'}],
-    };
-    element.serverConfig = createServerInfo();
-
-    assert.isOk(element._showWebLink);
-    assert.equal(element._webLink, 'https://link-url');
-  });
-
-  test('ignore web links that are neither gitweb nor gitiles', () => {
-    const router = new GrRouter();
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router.generateWeblinks.bind(router));
-
-    element.change = {...createChange(), project: 'project-name' as RepoName};
-    element.commitInfo = {
-      ...createCommit(),
-      commit: 'commit-sha' as CommitId,
-      web_links: [
-        {
-          name: 'ignore',
-          url: 'ignore',
-        },
-        {
-          name: 'gitiles',
-          url: 'https://link-url',
-        },
-      ],
-    };
-    element.serverConfig = createServerInfo();
-
-    assert.isOk(element._showWebLink);
-    assert.equal(element._webLink, 'https://link-url');
-
-    // Remove gitiles link.
-    element.commitInfo = {
-      ...createCommit(),
-      commit: 'commit-sha' as CommitId,
-      web_links: [
-        {
-          name: 'ignore',
-          url: 'ignore',
-        },
-      ],
-    };
-    assert.isNotOk(element._showWebLink);
-    assert.isNotOk(element._webLink);
+    assert.equal(element._webLink, 'test-url');
   });
 });
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 3ddcae4..81a29e3 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
@@ -1779,12 +1779,7 @@
   }
 
   computeHasNewAttention(account?: AccountInfo) {
-    return !!(
-      account &&
-      ((account._account_id &&
-        this.newAttentionSet?.has(account._account_id)) ||
-        (account.email && this.newAttentionSet?.has(account.email)))
-    );
+    return !!(account && this.newAttentionSet?.has(getUserId(account)));
   }
 
   computeNewAttention() {
@@ -1813,7 +1808,7 @@
     const newAttention = new Set(this.currentAttentionSet);
 
     for (const user of this.mentionedUsersInUnresolvedDrafts) {
-      newAttention.add(user.email!);
+      newAttention.add(getUserId(user));
     }
 
     if (this.change.status === ChangeStatus.NEW) {
@@ -1942,9 +1937,7 @@
   }
 
   findAccountById(userId: UserId) {
-    return this.allAccounts().find(
-      r => r._account_id === userId || r.email === userId
-    );
+    return this.allAccounts().find(r => getUserId(r) === userId);
   }
 
   allAccounts() {
@@ -2148,7 +2141,7 @@
   private alreadyExists(ccs: AccountInput[], user: AccountInfoInput) {
     return ccs
       .filter(cc => isAccount(cc))
-      .some(cc => (cc as AccountInfoInput).email === user.email);
+      .some(cc => getUserId(cc) === getUserId(user));
   }
 
   private isAlreadyReviewerOrCC(user: AccountInfo) {
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 bac054f..8e46786 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -35,7 +35,7 @@
 } from '../../../types/common';
 import {AppElement, AppElementParams} from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
-import {GerritView} from '../../../services/router/router-model';
+import {GerritView, RouterModel} from '../../../services/router/router-model';
 import {firePageError} from '../../../utils/event-util';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {
@@ -50,13 +50,46 @@
   LATEST_ATTEMPT,
   stringToAttemptChoice,
 } from '../../../models/checks/checks-util';
-import {AdminChildView} from '../../../models/views/admin';
-import {AgreementViewState} from '../../../models/views/agreement';
-import {RepoDetailView} from '../../../models/views/repo';
-import {GroupDetailView} from '../../../models/views/group';
-import {DiffViewState} from '../../../models/views/diff';
-import {ChangeViewState} from '../../../models/views/change';
-import {EditViewState} from '../../../models/views/edit';
+import {
+  AdminChildView,
+  AdminViewModel,
+  AdminViewState,
+} from '../../../models/views/admin';
+import {
+  AgreementViewModel,
+  AgreementViewState,
+} from '../../../models/views/agreement';
+import {
+  RepoDetailView,
+  RepoViewModel,
+  RepoViewState,
+} from '../../../models/views/repo';
+import {
+  GroupDetailView,
+  GroupViewModel,
+  GroupViewState,
+} from '../../../models/views/group';
+import {DiffViewModel, DiffViewState} from '../../../models/views/diff';
+import {ChangeViewModel, ChangeViewState} from '../../../models/views/change';
+import {EditViewModel, EditViewState} from '../../../models/views/edit';
+import {
+  DashboardViewModel,
+  DashboardViewState,
+} from '../../../models/views/dashboard';
+import {
+  SettingsViewModel,
+  SettingsViewState,
+} from '../../../models/views/settings';
+import {define} from '../../../models/dependency';
+import {Finalizable} from '../../../services/registry';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {
+  DocumentationViewModel,
+  DocumentationViewState,
+} from '../../../models/views/documentation';
+import {PluginViewModel, PluginViewState} from '../../../models/views/plugin';
+import {SearchViewModel, SearchViewState} from '../../../models/views/search';
 
 const RoutePattern = {
   ROOT: '/',
@@ -261,7 +294,9 @@
 
 type QueryStringItem = [string, string]; // [key, value]
 
-export class GrRouter {
+export const routerToken = define<GrRouter>('router');
+
+export class GrRouter implements Finalizable {
   readonly _app = app;
 
   _isRedirecting?: boolean;
@@ -270,11 +305,25 @@
   // and for first navigation in app after loaded from server (true).
   _isInitialLoad = true;
 
-  private readonly reporting = getAppContext().reportingService;
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly routerModel: RouterModel,
+    private readonly restApiService: RestApiService,
+    private readonly adminViewModel: AdminViewModel,
+    private readonly agreementViewModel: AgreementViewModel,
+    private readonly changeViewModel: ChangeViewModel,
+    private readonly dashboardViewModel: DashboardViewModel,
+    private readonly diffViewModel: DiffViewModel,
+    private readonly documentationViewModel: DocumentationViewModel,
+    private readonly editViewModel: EditViewModel,
+    private readonly groupViewModel: GroupViewModel,
+    private readonly pluginViewModel: PluginViewModel,
+    private readonly repoViewModel: RepoViewModel,
+    private readonly searchViewModel: SearchViewModel,
+    private readonly settingsViewModel: SettingsViewModel
+  ) {}
 
-  private readonly routerModel = getAppContext().routerModel;
-
-  private readonly restApiService = getAppContext().restApiService;
+  finalize(): void {}
 
   start() {
     if (!this._app) {
@@ -283,15 +332,15 @@
     this.startRouter();
   }
 
-  setParams(params: AppElementParams) {
+  setState(state: AppElementParams) {
     this.routerModel.updateState({
-      view: params.view,
-      changeNum: 'changeNum' in params ? params.changeNum : undefined,
-      patchNum: 'patchNum' in params ? params.patchNum ?? undefined : undefined,
+      view: state.view,
+      changeNum: 'changeNum' in state ? state.changeNum : undefined,
+      patchNum: 'patchNum' in state ? state.patchNum ?? undefined : undefined,
       basePatchNum:
-        'basePatchNum' in params ? params.basePatchNum ?? undefined : undefined,
+        'basePatchNum' in state ? state.basePatchNum ?? undefined : undefined,
     });
-    this.appElement().params = params;
+    this.appElement().params = state;
   }
 
   private appElement(): AppElement {
@@ -905,7 +954,7 @@
     this.mapRoute(
       RoutePattern.NEW_AGREEMENTS,
       'handleNewAgreementsRoute',
-      ctx => this.handleNewAgreementsRoute(ctx),
+      () => this.handleNewAgreementsRoute(),
       true
     );
 
@@ -1067,10 +1116,12 @@
           this.redirect('/q/owner:' + encodeURIComponent(data.params[0]));
         }
       } else {
-        this.setParams({
+        const state: DashboardViewState = {
           view: GerritView.DASHBOARD,
           user: data.params[0],
-        });
+        };
+        this.setState(state);
+        this.dashboardViewModel.updateState(state);
       }
     });
   }
@@ -1118,13 +1169,14 @@
     });
 
     if (sections.length > 0) {
-      // Custom dashboard view.
-      this.setParams({
+      const state: DashboardViewState = {
         view: GerritView.DASHBOARD,
         user: 'self',
         sections,
         title,
-      });
+      };
+      this.setState(state);
+      this.dashboardViewModel.updateState(state);
       return Promise.resolve();
     }
 
@@ -1135,11 +1187,13 @@
 
   handleProjectDashboardRoute(data: PageContextWithQueryMap) {
     const project = data.params[0] as RepoName;
-    this.setParams({
+    const state: DashboardViewState = {
       view: GerritView.DASHBOARD,
       project,
       dashboard: decodeURIComponent(data.params[1]) as DashboardId,
-    });
+    };
+    this.setState(state);
+    this.dashboardViewModel.updateState(state);
     this.reporting.setRepoName(project);
   }
 
@@ -1156,53 +1210,65 @@
   }
 
   handleGroupRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: GroupViewState = {
       view: GerritView.GROUP,
       groupId: data.params[0] as GroupId,
-    });
+    };
+    this.setState(state);
+    this.groupViewModel.updateState(state);
   }
 
   handleGroupAuditLogRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: GroupViewState = {
       view: GerritView.GROUP,
       detail: GroupDetailView.LOG,
       groupId: data.params[0] as GroupId,
-    });
+    };
+    this.setState(state);
+    this.groupViewModel.updateState(state);
   }
 
   handleGroupMembersRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: GroupViewState = {
       view: GerritView.GROUP,
       detail: GroupDetailView.MEMBERS,
       groupId: data.params[0] as GroupId,
-    });
+    };
+    this.setState(state);
+    this.groupViewModel.updateState(state);
   }
 
   handleGroupListOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.GROUPS,
       offset: data.params[1] || 0,
       filter: null,
       openCreateModal: data.hash === 'create',
-    });
+    };
+    this.setState(state);
+    this.adminViewModel.updateState(state);
   }
 
   handleGroupListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.GROUPS,
       offset: data.params['offset'],
       filter: data.params['filter'],
-    });
+    };
+    this.setState(state);
+    this.adminViewModel.updateState(state);
   }
 
   handleGroupListFilterRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.GROUPS,
       filter: data.params['filter'] || null,
-    });
+    };
+    this.setState(state);
+    this.adminViewModel.updateState(state);
   }
 
   handleProjectsOldRoute(data: PageContextWithQueryMap) {
@@ -1219,127 +1285,153 @@
 
   handleRepoCommandsRoute(data: PageContextWithQueryMap) {
     const repo = data.params[0] as RepoName;
-    this.setParams({
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.COMMANDS,
       repo,
-    });
+    };
+    this.setState(state);
+    this.repoViewModel.updateState(state);
     this.reporting.setRepoName(repo);
   }
 
   handleRepoGeneralRoute(data: PageContextWithQueryMap) {
     const repo = data.params[0] as RepoName;
-    this.setParams({
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.GENERAL,
       repo,
-    });
+    };
+    this.setState(state);
+    this.repoViewModel.updateState(state);
     this.reporting.setRepoName(repo);
   }
 
   handleRepoAccessRoute(data: PageContextWithQueryMap) {
     const repo = data.params[0] as RepoName;
-    this.setParams({
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.ACCESS,
       repo,
-    });
+    };
+    this.setState(state);
+    this.repoViewModel.updateState(state);
     this.reporting.setRepoName(repo);
   }
 
   handleRepoDashboardsRoute(data: PageContextWithQueryMap) {
     const repo = data.params[0] as RepoName;
-    this.setParams({
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.DASHBOARDS,
       repo,
-    });
+    };
+    this.setState(state);
+    this.repoViewModel.updateState(state);
     this.reporting.setRepoName(repo);
   }
 
   handleBranchListOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
       repo: data.params[0] as RepoName,
       offset: data.params[2] || 0,
       filter: null,
-    });
+    };
+    this.setState(state);
+    this.repoViewModel.updateState(state);
   }
 
   handleBranchListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
       repo: data.params['repo'] as RepoName,
       offset: data.params['offset'],
       filter: data.params['filter'],
-    });
+    };
+    this.setState(state);
+    this.repoViewModel.updateState(state);
   }
 
   handleBranchListFilterRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
       repo: data.params['repo'] as RepoName,
       filter: data.params['filter'] || null,
-    });
+    };
+    this.setState(state);
+    this.repoViewModel.updateState(state);
   }
 
   handleTagListOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: data.params[0] as RepoName,
       offset: data.params[2] || 0,
       filter: null,
-    });
+    };
+    this.setState(state);
+    this.repoViewModel.updateState(state);
   }
 
   handleTagListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: data.params['repo'] as RepoName,
       offset: data.params['offset'],
       filter: data.params['filter'],
-    });
+    };
+    this.setState(state);
+    this.repoViewModel.updateState(state);
   }
 
   handleTagListFilterRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: data.params['repo'] as RepoName,
       filter: data.params['filter'] || null,
-    });
+    };
+    this.setState(state);
+    this.repoViewModel.updateState(state);
   }
 
   handleRepoListOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.REPOS,
       offset: data.params[1] || 0,
       filter: null,
       openCreateModal: data.hash === 'create',
-    });
+    };
+    this.setState(state);
+    this.adminViewModel.updateState(state);
   }
 
   handleRepoListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.REPOS,
       offset: data.params['offset'],
       filter: data.params['filter'],
-    });
+    };
+    this.setState(state);
+    this.adminViewModel.updateState(state);
   }
 
   handleRepoListFilterRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.REPOS,
       filter: data.params['filter'] || null,
-    });
+    };
+    this.setState(state);
+    this.adminViewModel.updateState(state);
   }
 
   handleCreateProjectRoute(_: PageContextWithQueryMap) {
@@ -1359,54 +1451,66 @@
   }
 
   handlePluginListOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.PLUGINS,
       offset: data.params[1] || 0,
       filter: null,
-    });
+    };
+    this.setState(state);
+    this.adminViewModel.updateState(state);
   }
 
   handlePluginListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.PLUGINS,
       offset: data.params['offset'],
       filter: data.params['filter'],
-    });
+    };
+    this.setState(state);
+    this.adminViewModel.updateState(state);
   }
 
   handlePluginListFilterRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.PLUGINS,
       filter: data.params['filter'] || null,
-    });
+    };
+    this.setState(state);
+    this.adminViewModel.updateState(state);
   }
 
   handlePluginListRoute(_: PageContextWithQueryMap) {
-    this.setParams({
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.PLUGINS,
-    });
+    };
+    this.setState(state);
+    this.adminViewModel.updateState(state);
   }
 
   handleQueryRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: SearchViewState = {
       view: GerritView.SEARCH,
       query: data.params[0],
       offset: data.params[2],
-    });
+    };
+    this.setState(state);
+    this.searchViewModel.updateState(state);
   }
 
   handleChangeIdQueryRoute(data: PageContextWithQueryMap) {
     // TODO(pcc): This will need to indicate that this was a change ID query if
     // standard queries gain the ability to search places like commit messages
     // for change IDs.
-    this.setParams({
+    const state: SearchViewState = {
       view: GerritView.SEARCH,
       query: data.params[0],
-    });
+    };
+    this.setState(state);
+    this.searchViewModel.updateState(state);
   }
 
   handleQueryLegacySuffixRoute(ctx: PageContextWithQueryMap) {
@@ -1420,7 +1524,7 @@
   handleChangeRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const params: ChangeViewState = {
+    const state: ChangeViewState = {
       project: ctx.params[0] as RepoName,
       changeNum,
       basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
@@ -1429,7 +1533,7 @@
     };
 
     if (ctx.queryMap.has('forceReload')) {
-      params.forceReload = true;
+      state.forceReload = true;
       history.replaceState(
         null,
         '',
@@ -1438,7 +1542,7 @@
     }
 
     if (ctx.queryMap.has('openReplyDialog')) {
-      params.openReplyDialog = true;
+      state.openReplyDialog = true;
       history.replaceState(
         null,
         '',
@@ -1447,53 +1551,56 @@
     }
 
     const tab = ctx.queryMap.get('tab');
-    if (tab) params.tab = tab;
+    if (tab) state.tab = tab;
     const filter = ctx.queryMap.get('filter');
-    if (filter) params.filter = filter;
+    if (filter) state.filter = filter;
     const attempt = stringToAttemptChoice(ctx.queryMap.get('attempt'));
-    if (attempt && attempt !== LATEST_ATTEMPT) params.attempt = attempt;
+    if (attempt && attempt !== LATEST_ATTEMPT) state.attempt = attempt;
 
-    assertIsDefined(params.project, 'project');
-    this.reporting.setRepoName(params.project);
+    assertIsDefined(state.project, 'project');
+    this.reporting.setRepoName(state.project);
     this.reporting.setChangeId(changeNum);
-    this.normalizePatchRangeParams(params);
-    this.setParams(params);
+    this.normalizePatchRangeParams(state);
+    this.setState(state);
+    this.changeViewModel.updateState(state);
   }
 
   handleCommentRoute(ctx: PageContextWithQueryMap) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const params: DiffViewState = {
+    const state: DiffViewState = {
       project: ctx.params[0] as RepoName,
       changeNum,
       commentId: ctx.params[2] as UrlEncodedCommentId,
       view: GerritView.DIFF,
       commentLink: true,
     };
-    this.reporting.setRepoName(params.project ?? '');
+    this.reporting.setRepoName(state.project ?? '');
     this.reporting.setChangeId(changeNum);
-    this.normalizePatchRangeParams(params);
-    this.setParams(params);
+    this.normalizePatchRangeParams(state);
+    this.setState(state);
+    this.diffViewModel.updateState(state);
   }
 
   handleCommentsRoute(ctx: PageContextWithQueryMap) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const params: ChangeViewState = {
+    const state: ChangeViewState = {
       project: ctx.params[0] as RepoName,
       changeNum,
       commentId: ctx.params[2] as UrlEncodedCommentId,
       view: GerritView.CHANGE,
     };
-    assertIsDefined(params.project);
-    this.reporting.setRepoName(params.project);
+    assertIsDefined(state.project);
+    this.reporting.setRepoName(state.project);
     this.reporting.setChangeId(changeNum);
-    this.normalizePatchRangeParams(params);
-    this.setParams(params);
+    this.normalizePatchRangeParams(state);
+    this.setState(state);
+    this.changeViewModel.updateState(state);
   }
 
   handleDiffRoute(ctx: PageContextWithQueryMap) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
     // Parameter order is based on the regex group number matched.
-    const params: DiffViewState = {
+    const state: DiffViewState = {
       project: ctx.params[0] as RepoName,
       changeNum,
       basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
@@ -1503,13 +1610,14 @@
     };
     const address = this.parseLineAddress(ctx.hash);
     if (address) {
-      params.leftSide = address.leftSide;
-      params.lineNum = address.lineNum;
+      state.leftSide = address.leftSide;
+      state.lineNum = address.lineNum;
     }
-    this.reporting.setRepoName(params.project ?? '');
+    this.reporting.setRepoName(state.project ?? '');
     this.reporting.setChangeId(changeNum);
-    this.normalizePatchRangeParams(params);
-    this.setParams(params);
+    this.normalizePatchRangeParams(state);
+    this.setState(state);
+    this.diffViewModel.updateState(state);
   }
 
   handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
@@ -1537,7 +1645,7 @@
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const params: EditViewState = {
+    const state: EditViewState = {
       project,
       changeNum,
       // for edit view params, patchNum cannot be undefined
@@ -1546,8 +1654,9 @@
       lineNum: Number(ctx.hash),
       view: GerritView.EDIT,
     };
-    this.normalizePatchRangeParams(params);
-    this.setParams(params);
+    this.normalizePatchRangeParams(state);
+    this.setState(state);
+    this.editViewModel.updateState(state);
     this.reporting.setRepoName(project);
     this.reporting.setChangeId(changeNum);
   }
@@ -1556,7 +1665,7 @@
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const params: ChangeViewState = {
+    const state: ChangeViewState = {
       project,
       changeNum,
       patchNum: convertToPatchSetNum(ctx.params[3]) as RevisionPatchSetNum,
@@ -1565,16 +1674,16 @@
       tab: ctx.queryMap.get('tab') ?? '',
     };
     if (ctx.queryMap.has('forceReload')) {
-      params.forceReload = true;
+      state.forceReload = true;
       history.replaceState(
         null,
         '',
         location.href.replace(/[?&]forceReload=true/, '')
       );
     }
-    this.normalizePatchRangeParams(params);
-    this.setParams(params);
-
+    this.normalizePatchRangeParams(state);
+    this.setState(state);
+    this.changeViewModel.updateState(state);
     this.reporting.setRepoName(project);
     this.reporting.setChangeId(changeNum);
   }
@@ -1583,10 +1692,12 @@
     this.redirect('/settings/#Agreements');
   }
 
-  handleNewAgreementsRoute(data: PageContextWithQueryMap) {
-    data.params['view'] = GerritView.AGREEMENTS;
-    // TODO(TS): create valid object
-    this.setParams(data.params as unknown as AgreementViewState);
+  handleNewAgreementsRoute() {
+    const state: AgreementViewState = {
+      view: GerritView.AGREEMENTS,
+    };
+    this.setState(state);
+    this.agreementViewModel.updateState(state);
   }
 
   handleSettingsLegacyRoute(data: PageContextWithQueryMap) {
@@ -1594,18 +1705,22 @@
     // The parameter parsing replaces all '+' with a space,
     // undo that to have valid tokens.
     const token = data.params[0].replace(/ /g, '+');
-    this.setParams({
+    const state: SettingsViewState = {
       view: GerritView.SETTINGS,
       emailToken: token,
-    });
+    };
+    this.setState(state);
+    this.settingsViewModel.updateState(state);
   }
 
   handleSettingsRoute(_: PageContextWithQueryMap) {
-    this.setParams({view: GerritView.SETTINGS});
+    const state: SettingsViewState = {view: GerritView.SETTINGS};
+    this.setState(state);
+    this.settingsViewModel.updateState(state);
   }
 
   handleRegisterRoute(ctx: PageContextWithQueryMap) {
-    this.setParams({justRegistered: true});
+    this.setState({justRegistered: true});
     let path = ctx.params[0] || '/';
 
     // Prevent redirect looping.
@@ -1640,17 +1755,22 @@
   }
 
   handlePluginScreen(ctx: PageContextWithQueryMap) {
-    const view = GerritView.PLUGIN_SCREEN;
-    const plugin = ctx.params[0];
-    const screen = ctx.params[1];
-    this.setParams({view, plugin, screen});
+    const state: PluginViewState = {
+      view: GerritView.PLUGIN_SCREEN,
+      plugin: ctx.params[0],
+      screen: ctx.params[1],
+    };
+    this.setState(state);
+    this.pluginViewModel.updateState(state);
   }
 
   handleDocumentationSearchRoute(data: PageContextWithQueryMap) {
-    this.setParams({
+    const state: DocumentationViewState = {
       view: GerritView.DOCUMENTATION_SEARCH,
       filter: data.params['filter'] || null,
-    });
+    };
+    this.setState(state);
+    this.documentationViewModel.updateState(state);
   }
 
   handleDocumentationSearchRedirectRoute(data: PageContextWithQueryMap) {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index da57994..42659ca 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -16,6 +16,7 @@
 import {
   GrRouter,
   PageContextWithQueryMap,
+  routerToken,
   _testOnly_RoutePattern,
 } from './gr-router';
 import {GerritView} from '../../../services/router/router-model';
@@ -42,12 +43,15 @@
 import {EditViewState} from '../../../models/views/edit';
 import {ChangeViewState} from '../../../models/views/change';
 import {PatchRangeParams} from '../../../utils/url-util';
+import {DependencyRequestEvent} from '../../../models/dependency';
 
 suite('gr-router tests', () => {
   let router: GrRouter;
 
   setup(() => {
-    router = new GrRouter();
+    document.dispatchEvent(
+      new DependencyRequestEvent(routerToken, x => (router = x))
+    );
   });
 
   test('firstCodeBrowserWeblink', () => {
@@ -343,7 +347,7 @@
 
   suite('route handlers', () => {
     let redirectStub: sinon.SinonStub;
-    let setParamsStub: sinon.SinonStub;
+    let setStateStub: sinon.SinonStub;
     let handlePassThroughRoute: sinon.SinonStub;
 
     // Simple route handlers are direct mappings from parsed route data to a
@@ -355,7 +359,7 @@
       params: AppElementParams
     ) {
       (router as any)[methodName](data);
-      assert.deepEqual(setParamsStub.lastCall.args[0], params);
+      assert.deepEqual(setStateStub.lastCall.args[0], params);
     }
 
     function createPageContext(): PageContextWithQueryMap {
@@ -376,7 +380,7 @@
 
     setup(() => {
       redirectStub = sinon.stub(router, 'redirect');
-      setParamsStub = sinon.stub(router, 'setParams');
+      setStateStub = sinon.stub(router, 'setState');
       handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
     });
 
@@ -400,10 +404,9 @@
     });
 
     test('handleNewAgreementsRoute', () => {
-      const params = createPageContext();
-      router.handleNewAgreementsRoute(params);
-      assert.isTrue(setParamsStub.calledOnce);
-      assert.equal(setParamsStub.lastCall.args[0].view, GerritView.AGREEMENTS);
+      router.handleNewAgreementsRoute();
+      assert.isTrue(setStateStub.calledOnce);
+      assert.equal(setStateStub.lastCall.args[0].view, GerritView.AGREEMENTS);
     });
 
     test('handleSettingsLegacyRoute', () => {
@@ -518,24 +521,24 @@
         const ctx = {...createPageContext(), params: {0: '/foo/bar'}};
         router.handleRegisterRoute(ctx);
         assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+        assert.isTrue(setStateStub.calledOnce);
+        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
       });
 
       test('no param', () => {
         const ctx = createPageContext();
         router.handleRegisterRoute(ctx);
         assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+        assert.isTrue(setStateStub.calledOnce);
+        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
       });
 
       test('prevent redirect', () => {
         const ctx = {...createPageContext(), params: {0: '/register'}};
         router.handleRegisterRoute(ctx);
         assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+        assert.isTrue(setStateStub.calledOnce);
+        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
       });
     });
 
@@ -663,7 +666,7 @@
         return router.handleDashboardRoute(data).then(() => {
           assert.isTrue(redirectToLoginStub.calledOnce);
           assert.isFalse(redirectStub.called);
-          assert.isFalse(setParamsStub.called);
+          assert.isFalse(setStateStub.called);
         });
       });
 
@@ -676,7 +679,7 @@
         };
         return router.handleDashboardRoute(data).then(() => {
           assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(setParamsStub.called);
+          assert.isFalse(setStateStub.called);
           assert.isTrue(redirectStub.calledOnce);
           assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
         });
@@ -691,8 +694,8 @@
         return router.handleDashboardRoute(data).then(() => {
           assert.isFalse(redirectToLoginStub.called);
           assert.isFalse(redirectStub.called);
-          assert.isTrue(setParamsStub.calledOnce);
-          assert.deepEqual(setParamsStub.lastCall.args[0], {
+          assert.isTrue(setStateStub.calledOnce);
+          assert.deepEqual(setStateStub.lastCall.args[0], {
             view: GerritView.DASHBOARD,
             user: 'foo',
           });
@@ -714,7 +717,7 @@
           params: {0: ''},
         };
         return router.handleCustomDashboardRoute(data, '').then(() => {
-          assert.isFalse(setParamsStub.called);
+          assert.isFalse(setStateStub.called);
           assert.isTrue(redirectStub.called);
           assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
         });
@@ -730,8 +733,8 @@
           .handleCustomDashboardRoute(data, '?a=b&c&d=e')
           .then(() => {
             assert.isFalse(redirectStub.called);
-            assert.isTrue(setParamsStub.calledOnce);
-            assert.deepEqual(setParamsStub.lastCall.args[0], {
+            assert.isTrue(setStateStub.calledOnce);
+            assert.deepEqual(setStateStub.lastCall.args[0], {
               view: GerritView.DASHBOARD,
               user: 'self',
               sections: [
@@ -754,8 +757,8 @@
           .then(() => {
             assert.isFalse(redirectToLoginStub.called);
             assert.isFalse(redirectStub.called);
-            assert.isTrue(setParamsStub.calledOnce);
-            assert.deepEqual(setParamsStub.lastCall.args[0], {
+            assert.isTrue(setStateStub.calledOnce);
+            assert.deepEqual(setStateStub.lastCall.args[0], {
               view: GerritView.DASHBOARD,
               user: 'self',
               sections: [{name: 'a', query: 'b'}],
@@ -775,8 +778,8 @@
           .then(() => {
             assert.isFalse(redirectToLoginStub.called);
             assert.isFalse(redirectStub.called);
-            assert.isTrue(setParamsStub.calledOnce);
-            assert.deepEqual(setParamsStub.lastCall.args[0], {
+            assert.isTrue(setStateStub.calledOnce);
+            assert.deepEqual(setStateStub.lastCall.args[0], {
               view: GerritView.DASHBOARD,
               user: 'self',
               sections: [{name: 'a', query: 'is:open b'}],
@@ -1353,7 +1356,7 @@
 
         router.handleDiffEditRoute(ctx);
         assert.isFalse(redirectStub.called);
-        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
       });
 
       test('handleDiffEditRoute with lineNum', () => {
@@ -1379,7 +1382,7 @@
 
         router.handleDiffEditRoute(ctx);
         assert.isFalse(redirectStub.called);
-        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
       });
 
       test('handleChangeEditRoute', () => {
@@ -1404,7 +1407,7 @@
 
         router.handleChangeEditRoute(ctx);
         assert.isFalse(redirectStub.called);
-        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
       });
     });
 
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 2c75741c..464102a 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
@@ -82,7 +82,6 @@
   getPatchRangeForCommentUrl,
   isInBaseOfPatchRange,
 } from '../../../utils/comment-util';
-import {AppElementParams} from '../../gr-app-types';
 import {
   EventType,
   OpenFixPreviewEvent,
@@ -118,7 +117,11 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {when} from 'lit/directives/when.js';
-import {createDiffUrl, DiffViewState} from '../../../models/views/diff';
+import {
+  createDiffUrl,
+  diffViewModelToken,
+  DiffViewState,
+} from '../../../models/views/diff';
 import {createChangeUrl} from '../../../models/views/change';
 import {createEditUrl} from '../../../models/views/edit';
 
@@ -172,23 +175,19 @@
   @query('#diffPreferencesDialog')
   diffPreferencesDialog?: GrOverlay;
 
-  /**
-   * URL params passed from the router.
-   * Use params getter/setter.
-   */
-  private _params?: AppElementParams;
+  private _viewState: DiffViewState | undefined;
 
-  @property({type: Object})
-  get params() {
-    return this._params;
+  @state()
+  get viewState(): DiffViewState | undefined {
+    return this._viewState;
   }
 
-  set params(params: AppElementParams | undefined) {
-    if (this._params === params) return;
-    const oldParams = this._params;
-    this._params = params;
-    this.paramsChanged();
-    this.requestUpdate('params', oldParams);
+  set viewState(viewState: DiffViewState | undefined) {
+    if (this._viewState === viewState) return;
+    const oldViewState = this._viewState;
+    this._viewState = viewState;
+    this.viewStateChanged();
+    this.requestUpdate('viewState', oldViewState);
   }
 
   // Private but used in tests.
@@ -310,6 +309,8 @@
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
+  private readonly getViewModel = resolve(this, diffViewModelToken);
+
   private throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
 
   @state()
@@ -323,6 +324,11 @@
     super();
     this.setupKeyboardShortcuts();
     this.setupSubscriptions();
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      x => (this.viewState = x)
+    );
   }
 
   private setupKeyboardShortcuts() {
@@ -1525,10 +1531,10 @@
   initPatchRange() {
     let leftSide = false;
     if (!this.change) return;
-    if (this.params?.view !== GerritView.DIFF) return;
-    if (this.params?.commentId) {
+    if (this.viewState?.view !== GerritView.DIFF) return;
+    if (this.viewState?.commentId) {
       const comment = this.changeComments?.findCommentById(
-        this.params.commentId
+        this.viewState.commentId
       );
       if (!comment) {
         fireAlert(this, 'comment not found');
@@ -1544,24 +1550,24 @@
 
       this.focusLineNum = comment.line;
     } else {
-      if (this.params.path) {
-        this.getChangeModel().updatePath(this.params.path);
+      if (this.viewState.path) {
+        this.getChangeModel().updatePath(this.viewState.path);
       }
-      if (this.params.patchNum) {
+      if (this.viewState.patchNum) {
         this.patchRange = {
-          patchNum: this.params.patchNum,
-          basePatchNum: this.params.basePatchNum || PARENT,
+          patchNum: this.viewState.patchNum,
+          basePatchNum: this.viewState.basePatchNum || PARENT,
         };
       }
-      if (this.params.lineNum) {
-        this.focusLineNum = this.params.lineNum;
-        leftSide = !!this.params.leftSide;
+      if (this.viewState.lineNum) {
+        this.focusLineNum = this.viewState.lineNum;
+        leftSide = !!this.viewState.leftSide;
       }
     }
     assertIsDefined(this.patchRange, 'patchRange');
     this.initLineOfInterestAndCursor(leftSide);
 
-    if (this.params?.commentId) {
+    if (this.viewState?.commentId) {
       // url is of type /comment/{commentId} which isn't meaningful
       this.updateUrlToDiffUrl(this.focusLineNum, leftSide);
     }
@@ -1601,12 +1607,9 @@
   }
 
   // Private but used in tests.
-  paramsChanged() {
-    if (this.params?.view !== GerritView.DIFF) {
-      return;
-    }
-
-    const params = this.params;
+  viewStateChanged() {
+    if (this.viewState === undefined) return;
+    const viewState = this.viewState;
 
     // The diff view is kept in the background once created. If the user
     // scrolls in the change page, the scrolling is reflected in the diff view
@@ -1617,11 +1620,14 @@
 
     // Everything in the diff view is tied to the change. It seems better to
     // force the re-creation of the diff view when the change number changes.
-    const changeChanged = this.changeNum !== params.changeNum;
+    const changeChanged = this.changeNum !== viewState.changeNum;
     if (this.changeNum !== undefined && changeChanged) {
       fireEvent(this, EventType.RECREATE_DIFF_VIEW);
       return;
-    } else if (this.changeNum !== undefined && this.isSameDiffLoaded(params)) {
+    } else if (
+      this.changeNum !== undefined &&
+      this.isSameDiffLoaded(viewState)
+    ) {
       // changeNum has not changed, so check if there are changes in patchRange
       // path. If no changes then we can simply render the view as is.
       this.reporting.reportInteraction('diff-view-re-rendered');
@@ -1641,20 +1647,23 @@
     this.commitRange = undefined;
     this.focusLineNum = undefined;
 
-    if (params.changeNum && params.project) {
-      this.restApiService.setInProjectLookup(params.changeNum, params.project);
+    if (viewState.changeNum && viewState.project) {
+      this.restApiService.setInProjectLookup(
+        viewState.changeNum,
+        viewState.project
+      );
     }
 
-    this.changeNum = params.changeNum;
+    this.changeNum = viewState.changeNum;
     this.classList.remove('hideComments');
 
     // When navigating away from the page, there is a possibility that the
     // patch number is no longer a part of the URL (say when navigating to
     // the top-level change info view) and therefore undefined in `params`.
     // If route is of type /comment/<commentId>/ then no patchNum is present
-    if (!params.patchNum && !params.commentLink) {
+    if (!viewState.patchNum && !viewState.commentLink) {
       this.reporting.error(
-        new Error(`Invalid diff view URL, no patchNum found: ${this.params}`)
+        new Error(`Invalid diff view URL, no patchNum found: ${this.viewState}`)
       );
       return;
     }
@@ -1682,7 +1691,7 @@
       })
       .then(() => {
         const fileUnchanged = this.isFileUnchanged(this.diff);
-        if (fileUnchanged && params.commentLink) {
+        if (fileUnchanged && viewState.commentLink) {
           assertIsDefined(this.change, 'change');
           assertIsDefined(this.path, 'path');
           assertIsDefined(this.patchRange, 'patchRange');
@@ -1709,7 +1718,7 @@
           );
           return;
         }
-        if (params.commentLink) {
+        if (viewState.commentLink) {
           this.displayToasts();
         }
         // If the blame was loaded for a previous file and user navigates to
@@ -2115,7 +2124,7 @@
       this.path,
       this.patchRange.basePatchNum as RevisionPatchSetNum,
       PARENT,
-      this.params?.view === GerritView.DIFF && this.params?.commentLink
+      this.viewState?.view === GerritView.DIFF && this.viewState?.commentLink
         ? this.focusLineNum
         : undefined
     );
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 9a6089f..46e04db 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
@@ -43,7 +43,6 @@
   BasePatchSetNum,
   CommentInfo,
   CommitId,
-  DashboardId,
   EDIT,
   FileInfo,
   NumericChangeId,
@@ -155,14 +154,14 @@
       sinon.restore();
     });
 
-    test('params change triggers diffViewDisplayed()', () => {
+    test('viewState change triggers diffViewDisplayed()', () => {
       const diffViewDisplayedStub = stubReporting('diffViewDisplayed');
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
       sinon.stub(element, 'initPatchRange');
       sinon.stub(element, 'fetchFiles');
-      const paramsChangedSpy = sinon.spy(element, 'paramsChanged');
-      element.params = {
+      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
+      element.viewState = {
         view: GerritView.DIFF,
         changeNum: 42 as NumericChangeId,
         patchNum: 2 as RevisionPatchSetNum,
@@ -171,7 +170,7 @@
       };
       element.path = '/COMMIT_MSG';
       element.patchRange = createPatchRange();
-      return paramsChangedSpy.returnValues[0]?.then(() => {
+      return viewStateChangedSpy.returnValues[0]?.then(() => {
         assert.isTrue(diffViewDisplayedStub.calledOnce);
       });
     });
@@ -179,7 +178,7 @@
     suite('comment route', () => {
       let initLineOfInterestAndCursorStub: SinonStub;
       let replaceStateStub: SinonStub;
-      let paramsChangedSpy: SinonSpy;
+      let viewStateChangedSpy: SinonSpy;
       setup(() => {
         initLineOfInterestAndCursorStub = sinon.stub(
           element,
@@ -190,7 +189,7 @@
         stubReporting('diffViewDisplayed');
         assertIsDefined(element.diffHost);
         sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-        paramsChangedSpy = sinon.spy(element, 'paramsChanged');
+        viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
         element.getChangeModel().setState({
           change: {
             ...createParsedChange(),
@@ -214,7 +213,7 @@
           portedDrafts: {},
           discardedDrafts: [],
         });
-        element.params = {
+        element.viewState = {
           view: GerritView.DIFF,
           changeNum: 42 as NumericChangeId,
           commentLink: true,
@@ -226,7 +225,7 @@
           ...createParsedChange(),
           revisions: createRevisions(11),
         };
-        return paramsChangedSpy.returnValues[0].then(() => {
+        return viewStateChangedSpy.returnValues[0].then(() => {
           assert.isTrue(
             initLineOfInterestAndCursorStub.calledWithExactly(true)
           );
@@ -238,17 +237,17 @@
       });
     });
 
-    test('params change causes blame to load if it was set to true', () => {
+    test('viewState change causes blame to load if it was set to true', () => {
       // Blame loads for subsequent files if it was loaded for one file
       element.isBlameLoaded = true;
       stubReporting('diffViewDisplayed');
       const loadBlameStub = sinon.stub(element, 'loadBlame');
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-      const paramsChangedSpy = sinon.spy(element, 'paramsChanged');
+      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
       sinon.stub(element, 'initPatchRange');
       sinon.stub(element, 'fetchFiles');
-      element.params = {
+      element.viewState = {
         view: GerritView.DIFF,
         changeNum: 42 as NumericChangeId,
         patchNum: 2 as RevisionPatchSetNum,
@@ -257,7 +256,7 @@
       };
       element.path = '/COMMIT_MSG';
       element.patchRange = createPatchRange();
-      return paramsChangedSpy.returnValues[0]!.then(() => {
+      return viewStateChangedSpy.returnValues[0]!.then(() => {
         assert.isTrue(element.isBlameLoaded);
         assert.isTrue(loadBlameStub.calledOnce);
       });
@@ -283,7 +282,7 @@
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
       sinon.stub(element, 'isFileUnchanged').returns(true);
-      const paramsChangedSpy = sinon.spy(element, 'paramsChanged');
+      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
       element.getChangeModel().setState({
         change: {
           ...createParsedChange(),
@@ -291,7 +290,7 @@
         },
         loadingStatus: LoadingStatus.LOADED,
       });
-      element.params = {
+      element.viewState = {
         view: GerritView.DIFF,
         changeNum: 42 as NumericChangeId,
         path: '/COMMIT_MSG',
@@ -302,7 +301,7 @@
         ...createParsedChange(),
         revisions: createRevisions(11),
       };
-      return paramsChangedSpy.returnValues[0]?.then(() => {
+      return viewStateChangedSpy.returnValues[0]?.then(() => {
         assert.isTrue(
           diffNavStub.lastCall.calledWithExactly(
             element.change!,
@@ -335,7 +334,7 @@
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
       sinon.stub(element, 'isFileUnchanged').returns(true);
-      const paramsChangedSpy = sinon.spy(element, 'paramsChanged');
+      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
       element.getChangeModel().setState({
         change: {
           ...createParsedChange(),
@@ -343,7 +342,7 @@
         },
         loadingStatus: LoadingStatus.LOADED,
       });
-      element.params = {
+      element.viewState = {
         view: GerritView.DIFF,
         changeNum: 42 as NumericChangeId,
         path: '/COMMIT_MSG',
@@ -354,7 +353,7 @@
         ...createParsedChange(),
         revisions: createRevisions(11),
       };
-      return paramsChangedSpy.returnValues[0]!.then(() => {
+      return viewStateChangedSpy.returnValues[0]!.then(() => {
         assert.isFalse(diffNavStub.called);
       });
     });
@@ -410,7 +409,7 @@
       sinon.stub(element, 'loadBlame');
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-      const paramsChangedSpy = sinon.spy(element, 'paramsChanged');
+      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
       element.change = undefined;
       element.getChangeModel().setState({
         change: {
@@ -425,14 +424,14 @@
       };
       sinon.stub(element, 'isFileUnchanged').returns(false);
       const toastStub = sinon.stub(element, 'displayDiffBaseAgainstLeftToast');
-      element.params = {
+      element.viewState = {
         view: GerritView.DIFF,
         changeNum: 42 as NumericChangeId,
         project: 'p' as RepoName,
         commentId: 'c1' as UrlEncodedCommentId,
         commentLink: true,
       };
-      await paramsChangedSpy.returnValues[0];
+      await viewStateChangedSpy.returnValues[0];
       assert.isTrue(toastStub.called);
     });
 
@@ -887,9 +886,12 @@
         patchNum: 3 as RevisionPatchSetNum,
         basePatchNum: 1 as BasePatchSetNum,
       };
-      element.params = {
-        view: GerritView.DASHBOARD,
-        dashboard: 'id' as DashboardId,
+      element.viewState = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        patchNum: 3 as RevisionPatchSetNum,
+        basePatchNum: 1 as BasePatchSetNum,
+        path: 'foo',
       };
       await element.updateComplete;
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
@@ -910,8 +912,8 @@
         patchNum: 3 as RevisionPatchSetNum,
         basePatchNum: 1 as BasePatchSetNum,
       };
-      sinon.stub(element, 'paramsChanged');
-      element.params = {
+      sinon.stub(element, 'viewStateChanged');
+      element.viewState = {
         commentLink: true,
         view: GerritView.DIFF,
         changeNum: 42 as NumericChangeId,
@@ -1411,7 +1413,7 @@
       assert.isTrue(overlayOpenStub.called);
     });
 
-    suite('url params', () => {
+    suite('url parameters', () => {
       setup(() => {
         sinon.stub(element, 'fetchFiles');
       });
@@ -1804,14 +1806,14 @@
 
       const callCount = saveReviewedStub.callCount;
 
-      element.params = {
-        view: GerritView.CHANGE,
+      element.viewState = {
+        view: GerritView.DIFF,
         changeNum: 42 as NumericChangeId,
         project: 'test' as RepoName,
       };
       await element.updateComplete;
 
-      // saveReviewedState observer observes params, but should not fire when
+      // saveReviewedState observer observes viewState, but should not fire when
       // view !== GerritView.DIFF.
       assert.equal(saveReviewedStub.callCount, callCount);
     });
@@ -1833,13 +1835,13 @@
       assert.isFalse(saveReviewedStub.called);
     });
 
-    test('hash is determined from params', async () => {
+    test('hash is determined from viewState', async () => {
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload');
       const initLineStub = sinon.stub(element, 'initLineOfInterestAndCursor');
 
       element.loggedIn = true;
-      element.params = {
+      element.viewState = {
         view: GerritView.DIFF,
         changeNum: 42 as NumericChangeId,
         patchNum: 2 as RevisionPatchSetNum,
@@ -1927,7 +1929,7 @@
       });
 
       test('uses the patchNum and basePatchNum ', async () => {
-        element.params = {
+        element.viewState = {
           view: GerritView.DIFF,
           changeNum: 42 as NumericChangeId,
           patchNum: 4 as RevisionPatchSetNum,
@@ -1944,7 +1946,7 @@
       });
 
       test('uses the parent when there is no base patch num ', async () => {
-        element.params = {
+        element.viewState = {
           view: GerritView.DIFF,
           changeNum: 42 as NumericChangeId,
           patchNum: 5 as RevisionPatchSetNum,
@@ -1964,11 +1966,11 @@
       assertIsDefined(element.cursor);
       assert.isNotOk(element.cursor.initialLineNumber);
 
-      // Does nothing when params specify no cursor address:
+      // Does nothing when viewState specify no cursor address:
       element.initCursor(false);
       assert.isNotOk(element.cursor.initialLineNumber);
 
-      // Does nothing when params specify side but no number:
+      // Does nothing when viewState specify side but no number:
       element.initCursor(true);
       assert.isNotOk(element.cursor.initialLineNumber);
 
@@ -2081,7 +2083,7 @@
     suite('initPatchRange', () => {
       setup(async () => {
         getDiffRestApiStub.returns(Promise.resolve(createDiff()));
-        element.params = {
+        element.viewState = {
           view: GerritView.DIFF,
           changeNum: 42 as NumericChangeId,
           patchNum: 3 as RevisionPatchSetNum,
@@ -2496,7 +2498,7 @@
       const navigateToDiffStub = sinon.stub(GerritNav, 'navigateToDiff');
 
       // Load file1
-      element.params = {
+      element.viewState = {
         view: GerritView.DIFF,
         patchNum: 1 as RevisionPatchSetNum,
         changeNum: 101 as NumericChangeId,
@@ -2521,7 +2523,7 @@
       assert.isTrue(navigateToDiffStub.calledOnce);
 
       // This is to mock the param change triggered by above navigate
-      element.params = {
+      element.viewState = {
         view: GerritView.DIFF,
         patchNum: 1 as RevisionPatchSetNum,
         changeNum: 101 as NumericChangeId,
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index d2717fc..6dbdca6 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -10,28 +10,39 @@
 import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
-import {LitElement, PropertyValues, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
-import {DocumentationViewState} from '../../../models/views/documentation';
+import {LitElement, html} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {documentationViewModelToken} from '../../../models/views/documentation';
 
 @customElement('gr-documentation-search')
 export class GrDocumentationSearch extends LitElement {
-  /**
-   * URL params passed from the router.
-   */
-  @property({type: Object})
-  params?: DocumentationViewState;
-
   // private but used in test
   @state() documentationSearches?: DocResult[];
 
   // private but used in test
   @state() loading = true;
 
-  @state() private filter = '';
+  // private but used in test
+  @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getViewModel = resolve(this, documentationViewModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      x => {
+        this.filter = x?.filter ?? '';
+        if (x !== undefined) this.getDocumentationSearches();
+      }
+    );
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Documentation Search');
@@ -80,21 +91,9 @@
     `;
   }
 
-  override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('params')) {
-      this.paramsChanged();
-    }
-  }
-
-  // private but used in test
-  paramsChanged() {
+  getDocumentationSearches() {
+    const filter = this.filter;
     this.loading = true;
-    this.filter = this.params?.filter ?? '';
-
-    return this.getDocumentationSearches(this.filter);
-  }
-
-  private getDocumentationSearches(filter: string) {
     this.documentationSearches = [];
     return this.restApiService
       .getDocumentationSearches(filter)
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index 483db10..0092193 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -10,7 +10,6 @@
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {DocResult} from '../../../types/common';
 import {fixture, html, assert} from '@open-wc/testing';
-import {GerritView} from '../../../services/router/router-model';
 
 function documentationGenerator(counter: number) {
   return {
@@ -44,7 +43,7 @@
       stubRestApi('getDocumentationSearches').returns(
         Promise.resolve(documentationSearches)
       );
-      await element.paramsChanged();
+      await element.getDocumentationSearches();
       await element.updateComplete;
     });
 
@@ -327,8 +326,8 @@
       const stub = stubRestApi('getDocumentationSearches').returns(
         Promise.resolve(documentationSearches)
       );
-      element.params = {view: GerritView.DOCUMENTATION_SEARCH, filter: 'test'};
-      await element.paramsChanged();
+      element.filter = 'test';
+      await element.getDocumentationSearches();
       assert.isTrue(stub.lastCall.calledWithExactly('test'));
     });
   });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index 83bb4cf..6ef5406 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -423,6 +423,7 @@
       this.closeDialog(this.openDialog);
       return;
     }
+    assertIsDefined(this.patchNum, 'patchset number');
     const url = createEditUrl({
       changeNum: this.change._number,
       project: this.change.project,
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 c0b3760..4cbde8f 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
@@ -11,11 +11,8 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {computeTruncatedPath} from '../../../utils/path-list-util';
 import {
-  PatchSetNum,
   EditPreferencesInfo,
   Base64FileContent,
-  NumericChangeId,
-  EDIT,
   PatchSetNumber,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
@@ -31,11 +28,10 @@
 import {LitElement, PropertyValues, html, css, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
-import {GerritView} from '../../../services/router/router-model';
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
 import {ShortcutController} from '../../lit/shortcut-controller';
-import {EditViewState} from '../../../models/views/edit';
+import {editViewModelToken, EditViewState} from '../../../models/views/edit';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -61,21 +57,12 @@
    */
 
   @property({type: Object})
-  params?: EditViewState;
+  viewState?: EditViewState;
 
   // private but used in test
   @state() change?: ParsedChangeInfo;
 
   // private but used in test
-  @state() changeNum?: NumericChangeId;
-
-  // private but used in test
-  @state() patchNum?: PatchSetNum;
-
-  // private but used in test
-  @state() path?: string;
-
-  // private but used in test
   @state() type?: string;
 
   // private but used in test
@@ -92,9 +79,8 @@
 
   @state() private editPrefs?: EditPreferencesInfo;
 
-  @state() private lineNum?: number;
-
-  @state() private latestPatchsetNumber?: PatchSetNumber;
+  // private but used in test
+  @state() latestPatchsetNumber?: PatchSetNumber;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -106,6 +92,8 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getEditViewModel = resolve(this, editViewModelToken);
+
   private readonly shortcuts = new ShortcutController(this);
 
   // Tests use this so needs to be non private
@@ -119,8 +107,14 @@
     subscribe(
       this,
       () => this.userModel.editPreferences$,
-      editPreferences => {
-        this.editPrefs = editPreferences;
+      editPreferences => (this.editPrefs = editPreferences)
+    );
+    subscribe(
+      this,
+      () => this.getEditViewModel().state$,
+      state => {
+        this.viewState = state;
+        this.viewStateChanged();
       }
     );
     subscribe(
@@ -207,6 +201,7 @@
   }
 
   override render() {
+    if (!this.viewState) return;
     return html` ${this.renderHeader()} ${this.renderEndpoint()} `;
   }
 
@@ -220,7 +215,7 @@
             <span class="separator"></span>
             <gr-editable-label
               labelText="File path"
-              .value=${this.path}
+              .value=${this.viewState?.path}
               placeholder="File path..."
               @changed=${this.handlePathChanged}
             ></gr-editable-label>
@@ -254,7 +249,7 @@
   }
 
   private renderEditingOldPatchsetWarning() {
-    const patchset = this.params?.patchNum;
+    const patchset = this.viewState?.patchNum;
     if (patchset === this.latestPatchsetNumber) return nothing;
     return html`<span class="warning">&nbsp;(Old Patchset)</span>`;
   }
@@ -277,7 +272,7 @@
           ></gr-endpoint-param>
           <gr-endpoint-param
             name="lineNum"
-            .value=${this.lineNum}
+            .value=${this.viewState?.lineNum}
           ></gr-endpoint-param>
           <gr-default-editor
             id="file"
@@ -289,58 +284,40 @@
   }
 
   override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('params')) {
-      this.paramsChanged();
-    }
-
     if (changedProperties.has('change')) {
       this.navigateToChangeIfEdit();
     }
-
     if (changedProperties.has('change') || changedProperties.has('type')) {
       this.navigateToChangeIfEditType();
     }
   }
 
   get storageKey() {
-    return `c${this.changeNum}_ps${this.patchNum}_${this.path}`;
+    return `c${this.viewState?.changeNum}_ps${this.viewState?.patchNum}_${this.viewState?.path}`;
   }
 
   // private but used in test
-  paramsChanged() {
-    if (!this.params) return;
-
-    if (this.params.view !== GerritView.EDIT) {
-      return;
-    }
-
-    this.changeNum = this.params.changeNum;
-    this.path = this.params.path;
-    this.patchNum = this.params.patchNum || (EDIT as PatchSetNum);
-    this.lineNum =
-      typeof this.params.lineNum === 'string'
-        ? Number(this.params.lineNum)
-        : this.params.lineNum;
+  viewStateChanged() {
+    if (!this.viewState) return;
 
     // NOTE: This may be called before attachment (e.g. while parentElement is
     // null). Fire title-change in an async so that, if attachment to the DOM
     // has been queued, the event can bubble up to the handler in gr-app.
     setTimeout(() => {
-      if (!this.params) return;
-      const title = `Editing ${computeTruncatedPath(this.params.path)}`;
+      if (!this.viewState) return;
+      const title = `Editing ${computeTruncatedPath(this.viewState.path)}`;
       fireTitleChange(this, title);
     });
 
     const promises = [];
-
-    assertIsDefined(this.changeNum, 'change number');
-    assertIsDefined(this.path, 'path');
-    promises.push(this.getChangeDetail(this.changeNum));
-    promises.push(this.getFileData(this.changeNum, this.path, this.patchNum));
+    promises.push(this.getChangeDetail());
+    promises.push(this.getFileData());
     return Promise.all(promises);
   }
 
-  private async getChangeDetail(changeNum: NumericChangeId) {
+  private async getChangeDetail() {
+    const changeNum = this.viewState?.changeNum;
+    assertIsDefined(changeNum, 'change number');
     this.change = await this.restApiService.getChangeDetail(changeNum);
   }
 
@@ -364,16 +341,17 @@
 
   // private but used in test
   async handlePathChanged(e: CustomEvent<string>): Promise<void> {
-    // TODO(TS) could be cleaned up, it was added for type requirements
-    if (this.changeNum === undefined || !this.path) {
-      throw new Error('changeNum or path undefined');
-    }
-    const path = e.detail;
-    if (path === this.path) return;
+    const changeNum = this.viewState?.changeNum;
+    const currentPath = this.viewState?.path;
+    assertIsDefined(changeNum, 'change number');
+    assertIsDefined(currentPath, 'path');
+
+    const newPath = e.detail;
+    if (newPath === currentPath) return;
     const res = await this.restApiService.renameFileInChangeEdit(
-      this.changeNum,
-      this.path,
-      path
+      changeNum,
+      currentPath,
+      newPath
     );
     if (!res?.ok) return;
 
@@ -383,22 +361,22 @@
 
   // private but used in test
   viewEditInChangeView() {
-    if (this.change)
-      GerritNav.navigateToChange(this.change, {
-        isEdit: true,
-        forceReload: true,
-      });
+    if (!this.change) return;
+    GerritNav.navigateToChange(this.change, {
+      isEdit: true,
+      forceReload: true,
+    });
   }
 
   // private but used in test
-  getFileData(
-    changeNum: NumericChangeId,
-    path: string,
-    patchNum?: PatchSetNum
-  ) {
-    if (patchNum === undefined) {
-      return Promise.reject(new Error('patchNum undefined'));
-    }
+  getFileData() {
+    const changeNum = this.viewState?.changeNum;
+    const patchNum = this.viewState?.patchNum;
+    const path = this.viewState?.path;
+    assertIsDefined(changeNum, 'change number');
+    assertIsDefined(patchNum, 'patchset number');
+    assertIsDefined(path, 'path');
+
     const storedContent = this.storage.getEditableContentItem(this.storageKey);
 
     return this.restApiService
@@ -431,16 +409,18 @@
 
   // private but used in test
   saveEdit() {
-    if (this.changeNum === undefined || !this.path) {
-      return Promise.reject(new Error('changeNum or path undefined'));
-    }
+    const changeNum = this.viewState?.changeNum;
+    const path = this.viewState?.path;
+    assertIsDefined(changeNum, 'change number');
+    assertIsDefined(path, 'path');
+
     this.saving = true;
     this.showAlert(SAVING_MESSAGE);
     this.storage.eraseEditableContentItem(this.storageKey);
     if (!this.newContent)
       return Promise.reject(new Error('new content undefined'));
     return this.restApiService
-      .saveChangeEdit(this.changeNum, this.path, this.newContent)
+      .saveChangeEdit(changeNum, path, this.newContent)
       .then(res => {
         this.saving = false;
         this.showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
@@ -464,9 +444,7 @@
       return true;
     }
 
-    if (this.saving) {
-      return true;
-    }
+    if (this.saving) return true;
     return this.content === this.newContent;
   }
 
@@ -483,9 +461,9 @@
   };
 
   private handlePublishTap = () => {
-    assertIsDefined(this.changeNum, 'changeNum');
+    const changeNum = this.viewState?.changeNum;
+    assertIsDefined(changeNum, 'change number');
 
-    const changeNum = this.changeNum;
     this.saveEdit().then(() => {
       const handleError: ErrorCallback = response => {
         this.showAlert(PUBLISH_FAILED_MSG);
@@ -528,9 +506,7 @@
 
   // private but used in test
   handleSaveShortcut() {
-    if (!this.computeSaveDisabled()) {
-      this.saveEdit();
-    }
+    if (!this.computeSaveDisabled()) this.saveEdit();
   }
 }
 
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index 30d935c..8bc788c 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
@@ -15,7 +15,12 @@
   stubRestApi,
   stubStorage,
 } from '../../../test/test-utils';
-import {EDIT, NumericChangeId, PatchSetNum} from '../../../types/common';
+import {
+  EDIT,
+  NumericChangeId,
+  PatchSetNumber,
+  RevisionPatchSetNum,
+} from '../../../types/common';
 import {
   createChangeViewChange,
   createEditViewState,
@@ -41,6 +46,11 @@
     saveFileStub = stubRestApi('saveChangeEdit');
     changeDetailStub = stubRestApi('getChangeDetail');
     navigateStub = sinon.stub(element, 'viewEditInChangeView');
+    element.viewState = {
+      ...createEditViewState(),
+      patchNum: 1 as PatchSetNumber,
+    };
+    element.latestPatchsetNumber = 1 as PatchSetNumber;
     await element.updateComplete;
   });
 
@@ -58,7 +68,7 @@
                 labeltext="File path"
                 placeholder="File path..."
                 tabindex="0"
-                title="File path..."
+                title="${element.viewState?.path}"
               >
               </gr-editable-label>
             </span>
@@ -112,8 +122,8 @@
     );
   });
 
-  suite('paramsChanged', () => {
-    test('good params proceed', async () => {
+  suite('viewStateChanged', () => {
+    test('good view state proceed', async () => {
       changeDetailStub.returns(Promise.resolve({}));
       const fileStub = sinon.stub(element, 'getFileData').callsFake(() => {
         element.content = 'text';
@@ -122,20 +132,14 @@
         return Promise.resolve();
       });
 
-      element.params = {...createEditViewState()};
-      const promises = element.paramsChanged();
+      element.viewState = {...createEditViewState()};
+      const promises = element.viewStateChanged();
 
       await element.updateComplete;
 
       const changeNum = 42 as NumericChangeId;
-      assert.equal(element.changeNum, changeNum);
-      assert.equal(element.path, 'foo/bar.baz');
       assert.deepEqual(changeDetailStub.lastCall.args[0], changeNum);
-      assert.deepEqual(fileStub.lastCall.args, [
-        changeNum,
-        'foo/bar.baz',
-        EDIT as PatchSetNum,
-      ]);
+      assert.isTrue(fileStub.called);
 
       return promises?.then(() => {
         assert.equal(element.content, 'text');
@@ -146,8 +150,7 @@
   });
 
   test('edit file path', () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.path = 'foo/bar.baz';
+    element.viewState = {...createEditViewState()};
     savePathStub.onFirstCall().returns(Promise.resolve({}));
     savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
 
@@ -197,8 +200,7 @@
     const newText = 'file text changed';
 
     setup(async () => {
-      element.changeNum = 42 as NumericChangeId;
-      element.path = 'foo/bar.baz';
+      element.viewState = {...createEditViewState()};
       element.content = originalText;
       element.newContent = originalText;
       await element.updateComplete;
@@ -363,30 +365,38 @@
           content: 'new content',
         })
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: EDIT,
+        path: 'test/path',
+      };
 
       // Ensure no data is set with a bad response.
-      return element
-        .getFileData(1 as NumericChangeId, 'test/path', EDIT as PatchSetNum)
-        .then(() => {
-          assert.equal(element.newContent, 'new content');
-          assert.equal(element.content, 'new content');
-          assert.equal(element.type, 'text/javascript');
-        });
+      return element.getFileData().then(() => {
+        assert.equal(element.newContent, 'new content');
+        assert.equal(element.content, 'new content');
+        assert.equal(element.type, 'text/javascript');
+      });
     });
 
     test('!res.ok', () => {
       stubRestApi('getFileContent').returns(
         Promise.resolve(new Response(null, {status: 500}))
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: EDIT,
+        path: 'test/path',
+      };
 
       // Ensure no data is set with a bad response.
-      return element
-        .getFileData(1 as NumericChangeId, 'test/path', EDIT as PatchSetNum)
-        .then(() => {
-          assert.equal(element.newContent, '');
-          assert.equal(element.content, '');
-          assert.equal(element.type, '');
-        });
+      return element.getFileData().then(() => {
+        assert.equal(element.newContent, '');
+        assert.equal(element.content, '');
+        assert.equal(element.type, '');
+      });
     });
 
     test('content is undefined', () => {
@@ -397,28 +407,36 @@
           type: 'text/javascript' as ResponseType,
         })
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: EDIT,
+        path: 'test/path',
+      };
 
-      return element
-        .getFileData(1 as NumericChangeId, 'test/path', EDIT as PatchSetNum)
-        .then(() => {
-          assert.equal(element.newContent, '');
-          assert.equal(element.content, '');
-          assert.equal(element.type, 'text/javascript');
-        });
+      return element.getFileData().then(() => {
+        assert.equal(element.newContent, '');
+        assert.equal(element.content, '');
+        assert.equal(element.type, 'text/javascript');
+      });
     });
 
     test('content and type is undefined', () => {
       stubRestApi('getFileContent').returns(
         Promise.resolve({...new Response(), ok: true})
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: EDIT,
+        path: 'test/path',
+      };
 
-      return element
-        .getFileData(1 as NumericChangeId, 'test/path', EDIT as PatchSetNum)
-        .then(() => {
-          assert.equal(element.newContent, '');
-          assert.equal(element.content, '');
-          assert.equal(element.type, '');
-        });
+      return element.getFileData().then(() => {
+        assert.equal(element.newContent, '');
+        assert.equal(element.content, '');
+        assert.equal(element.type, '');
+      });
     });
   });
 
@@ -438,7 +456,6 @@
     element.change = createChangeViewChange();
     navigateStub.restore();
     const navStub = sinon.stub(GerritNav, 'navigateToChange');
-    element.patchNum = EDIT;
     element.viewEditInChangeView();
     assert.equal(navStub.lastCall.args[1]!.patchNum, undefined);
     assert.equal(navStub.lastCall.args[1]!.isEdit, true);
@@ -500,20 +517,24 @@
           content: 'old content',
         })
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: 1 as RevisionPatchSetNum,
+        path: 'test',
+      };
 
       const alertStub = sinon.stub();
       element.addEventListener(EventType.SHOW_ALERT, alertStub);
 
-      return element
-        .getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
-        .then(async () => {
-          await element.updateComplete;
+      return element.getFileData().then(async () => {
+        await element.updateComplete;
 
-          assert.isTrue(alertStub.called);
-          assert.equal(element.newContent, 'pending edit');
-          assert.equal(element.content, 'old content');
-          assert.equal(element.type, 'text/javascript');
-        });
+        assert.isTrue(alertStub.called);
+        assert.equal(element.newContent, 'pending edit');
+        assert.equal(element.content, 'old content');
+        assert.equal(element.type, 'text/javascript');
+      });
     });
 
     test('local edit exists, is same as remote edit', () => {
@@ -528,26 +549,33 @@
           content: 'pending edit',
         })
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: 1 as RevisionPatchSetNum,
+        path: 'test',
+      };
 
       const alertStub = sinon.stub();
       element.addEventListener(EventType.SHOW_ALERT, alertStub);
 
-      return element
-        .getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
-        .then(async () => {
-          await element.updateComplete;
+      return element.getFileData().then(async () => {
+        await element.updateComplete;
 
-          assert.isFalse(alertStub.called);
-          assert.equal(element.newContent, 'pending edit');
-          assert.equal(element.content, 'pending edit');
-          assert.equal(element.type, 'text/javascript');
-        });
+        assert.isFalse(alertStub.called);
+        assert.equal(element.newContent, 'pending edit');
+        assert.equal(element.content, 'pending edit');
+        assert.equal(element.type, 'text/javascript');
+      });
     });
 
     test('storage key computation', () => {
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.path = 'test';
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: 1 as RevisionPatchSetNum,
+        path: 'test',
+      };
       assert.equal(element.storageKey, 'c1_ps1_test');
     });
   });
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 52ff3db..354d596 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -31,7 +31,7 @@
 import {getBaseUrl} from '../utils/url-util';
 import {GerritNav} from './core/gr-navigation/gr-navigation';
 import {getAppContext} from '../services/app-context';
-import {GrRouter} from './core/gr-router/gr-router';
+import {routerToken} from './core/gr-router/gr-router';
 import {AccountDetailInfo} from '../types/common';
 import {
   constructServerErrorMsg,
@@ -49,7 +49,6 @@
 import {
   DialogChangeEventDetail,
   EventType,
-  LocationChangeEvent,
   PageErrorEventDetail,
   RpcLogEvent,
   TitleChangeEventDetail,
@@ -101,7 +100,7 @@
 
   @query('#keyboardShortcuts') keyboardShortcuts?: GrOverlay;
 
-  @query('gr-settings-view') settingdView?: GrSettingsView;
+  @query('gr-settings-view') settingsView?: GrSettingsView;
 
   @property({type: Object})
   params?: AppElementParams;
@@ -110,33 +109,13 @@
 
   @state() private version?: string;
 
-  @state() private showChangeListView?: boolean;
-
-  @state() private showDashboardView?: boolean;
-
-  @state() private showChangeView?: boolean;
-
-  @state() private showDiffView?: boolean;
-
-  @state() private showSettingsView?: boolean;
-
-  @state() private showAdminView?: boolean;
-
-  @state() private showCLAView?: boolean;
-
-  @state() private showEditorView?: boolean;
-
-  @state() private showPluginScreen?: boolean;
-
-  @state() private showDocumentationSearch?: boolean;
+  @state() private view?: GerritView;
 
   @state() private lastError?: ErrorInfo;
 
   // private but used in test
   @state() lastSearchPage?: string;
 
-  @state() private path?: string;
-
   @state() private settingsUrl?: string;
 
   @state() private mobileSearch = false;
@@ -167,7 +146,7 @@
 
   @state() private themeEndpoint = 'app-theme-light';
 
-  readonly router = new GrRouter();
+  readonly getRouter = resolve(this, routerToken);
 
   private reporting = getAppContext().reportingService;
 
@@ -181,8 +160,11 @@
 
   private readonly userModel = getAppContext().userModel;
 
+  private readonly routerModel = getAppContext().routerModel;
+
   constructor() {
     super();
+
     document.addEventListener(EventType.PAGE_ERROR, e => {
       this.handlePageError(e);
     });
@@ -192,8 +174,8 @@
     this.addEventListener(EventType.DIALOG_CHANGE, e => {
       this.handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
     });
-    this.addEventListener(EventType.LOCATION_CHANGE, e =>
-      this.handleLocationChange(e)
+    this.addEventListener(EventType.LOCATION_CHANGE, () =>
+      this.handleLocationChange()
     );
     this.addEventListener(EventType.RECREATE_CHANGE_VIEW, () =>
       this.handleRecreateView()
@@ -229,6 +211,14 @@
         this.applyTheme();
       }
     );
+    subscribe(
+      this,
+      () => this.routerModel.routerView$,
+      view => {
+        this.view = view;
+        if (view) this.errorView?.classList.remove('show');
+      }
+    );
 
     prefersDarkColorScheme().addEventListener('change', () => {
       if (this.theme === AppTheme.AUTO) {
@@ -244,7 +234,7 @@
 
     this.updateLoginUrl();
     this.reporting.appStarted();
-    this.router.start();
+    this.getRouter().start();
 
     this.restApiService.getAccount().then(account => {
       this.account = account;
@@ -440,26 +430,16 @@
 
   private renderChangeListView() {
     return cache(
-      this.showChangeListView
-        ? html`
-            <gr-change-list-view
-              .params=${this.params}
-              .account=${this.account}
-            ></gr-change-list-view>
-          `
+      this.view === GerritView.SEARCH
+        ? html` <gr-change-list-view></gr-change-list-view> `
         : nothing
     );
   }
 
   private renderDashboardView() {
     return cache(
-      this.showDashboardView
-        ? html`
-            <gr-dashboard-view
-              .account=${this.account}
-              .params=${this.params}
-            ></gr-dashboard-view>
-          `
+      this.view === GerritView.DASHBOARD
+        ? html`<gr-dashboard-view></gr-dashboard-view>`
         : nothing
     );
   }
@@ -469,22 +449,21 @@
       this.updateComplete.then(() => (this.invalidateChangeViewCache = false));
       return nothing;
     }
-    return cache(this.showChangeView ? this.changeViewTemplate() : nothing);
+    return cache(
+      this.view === GerritView.CHANGE ? this.changeViewTemplate() : nothing
+    );
   }
 
   // Template as not to create duplicates, for renderChangeView() only.
   private changeViewTemplate() {
     return html`
-      <gr-change-view
-        .params=${this.params}
-        .backPage=${this.lastSearchPage}
-      ></gr-change-view>
+      <gr-change-view .backPage=${this.lastSearchPage}></gr-change-view>
     `;
   }
 
   private renderEditorView() {
-    if (!this.showEditorView) return nothing;
-    return html`<gr-editor-view .params=${this.params}></gr-editor-view>`;
+    if (this.view !== GerritView.EDIT) return nothing;
+    return html`<gr-editor-view></gr-editor-view>`;
   }
 
   private renderDiffView() {
@@ -492,18 +471,19 @@
       this.updateComplete.then(() => (this.invalidateDiffViewCache = false));
       return nothing;
     }
-    return cache(this.showDiffView ? this.diffViewTemplate() : nothing);
+    return cache(
+      this.view === GerritView.DIFF ? this.diffViewTemplate() : nothing
+    );
   }
 
   private diffViewTemplate() {
-    return html`<gr-diff-view .params=${this.params}></gr-diff-view>`;
+    return html`<gr-diff-view></gr-diff-view>`;
   }
 
   private renderSettingsView() {
-    if (!this.showSettingsView) return nothing;
+    if (this.view !== GerritView.SETTINGS) return nothing;
     return html`
       <gr-settings-view
-        .params=${this.params}
         @account-detail-update=${this.handleAccountDetailUpdate}
       >
       </gr-settings-view>
@@ -511,35 +491,36 @@
   }
 
   private renderAdminView() {
-    if (!this.showAdminView) return nothing;
-    return html`<gr-admin-view
-      .path=${this.path}
-      .params=${this.params}
-    ></gr-admin-view>`;
+    if (
+      this.view !== GerritView.ADMIN &&
+      this.view !== GerritView.GROUP &&
+      this.view !== GerritView.REPO
+    )
+      return nothing;
+    return html`<gr-admin-view></gr-admin-view>`;
   }
 
   private renderPluginScreen() {
-    if (!this.showPluginScreen) return nothing;
+    if (this.view !== GerritView.PLUGIN_SCREEN) return nothing;
+    const pluginViewState = this.params as PluginViewState;
     return html`
       <gr-endpoint-decorator .name=${this.computePluginScreenName()}>
         <gr-endpoint-param
           name="token"
-          .value=${(this.params as PluginViewState).screen}
+          .value=${pluginViewState.screen}
         ></gr-endpoint-param>
       </gr-endpoint-decorator>
     `;
   }
 
   private renderCLAView() {
-    if (!this.showCLAView) return nothing;
+    if (this.view !== GerritView.AGREEMENTS) return nothing;
     return html`<gr-cla-view></gr-cla-view>`;
   }
 
   private renderDocumentationSearch() {
-    if (!this.showDocumentationSearch) return nothing;
-    return html`
-      <gr-documentation-search .params=${this.params}></gr-documentation-search>
-    `;
+    if (this.view !== GerritView.DOCUMENTATION_SEARCH) return nothing;
+    return html`<gr-documentation-search></gr-documentation-search>`;
   }
 
   private renderKeyboardShortcutsDialog() {
@@ -579,7 +560,6 @@
 
     if (changedProperties.has('params')) {
       this.viewChanged();
-
       this.paramsChanged();
     }
   }
@@ -606,29 +586,6 @@
   }
 
   private async viewChanged() {
-    const view = this.params?.view;
-    this.errorView?.classList.remove('show');
-    this.showChangeListView = view === GerritView.SEARCH;
-    this.showDashboardView = view === GerritView.DASHBOARD;
-    this.showChangeView = view === GerritView.CHANGE;
-    this.showDiffView = view === GerritView.DIFF;
-    this.showSettingsView = view === GerritView.SETTINGS;
-    // showAdminView must be in sync with the gr-admin-view AdminViewParams type
-    this.showAdminView =
-      view === GerritView.ADMIN ||
-      view === GerritView.GROUP ||
-      view === GerritView.REPO;
-    this.showCLAView = view === GerritView.AGREEMENTS;
-    this.showEditorView = view === GerritView.EDIT;
-    const isPluginScreen = view === GerritView.PLUGIN_SCREEN;
-    this.showPluginScreen = false;
-    // Navigation within plugin screens does not restamp gr-endpoint-decorator
-    // because showPluginScreen value does not change. To force restamp,
-    // change showPluginScreen value between true and false.
-    if (isPluginScreen) {
-      setTimeout(() => (this.showPluginScreen = true), 1);
-    }
-    this.showDocumentationSearch = view === GerritView.DOCUMENTATION_SEARCH;
     if (
       this.params &&
       isAppElementJustRegisteredParams(this.params) &&
@@ -665,19 +622,7 @@
   }
 
   private handlePageError(e: CustomEvent<PageErrorEventDetail>) {
-    const props = [
-      'showChangeListView',
-      'showDashboardView',
-      'showChangeView',
-      'showDiffView',
-      'showSettingsView',
-      'showAdminView',
-    ];
-    for (const showProp of props) {
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      (this as any)[showProp as any] = false;
-    }
-
+    this.view = undefined;
     this.errorView?.classList.add('show');
     const response = e.detail.response;
     const err: ErrorInfo = {
@@ -705,15 +650,8 @@
     }
   }
 
-  private handleLocationChange(e: LocationChangeEvent) {
+  private handleLocationChange() {
     this.updateLoginUrl();
-
-    const hash = e.detail.hash.substring(1);
-    let pathname = e.detail.pathname;
-    if (pathname.startsWith('/c/') && Number(hash) > 0) {
-      pathname += '@' + hash;
-    }
-    this.path = pathname;
   }
 
   private updateLoginUrl() {
@@ -791,10 +729,7 @@
 
   private handleAccountDetailUpdate() {
     this.mainHeader?.reload();
-    if (this.params?.view === GerritView.SETTINGS) {
-      assertIsDefined(this.settingdView, 'settingdView');
-      this.settingdView.reloadAccountDetail();
-    }
+    this.settingsView?.reloadAccountDetail();
   }
 
   private handleRegistrationDialogClose() {
@@ -827,9 +762,11 @@
   }
 
   private computePluginScreenName() {
-    if (this.params?.view !== GerritView.PLUGIN_SCREEN) return '';
-    if (!this.params.plugin || !this.params.screen) return '';
-    return `${this.params.plugin}-screen-${this.params.screen}`;
+    if (this.view !== GerritView.PLUGIN_SCREEN) return '';
+    if (this.params === undefined) return '';
+    const pluginViewState = this.params as PluginViewState;
+    if (!pluginViewState.plugin || !pluginViewState.screen) return '';
+    return `${pluginViewState.plugin}-screen-${pluginViewState.screen}`;
   }
 
   private logWelcome() {
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 470d847..1c5d30d 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
@@ -23,7 +23,6 @@
 import '../gr-ssh-editor/gr-ssh-editor';
 import '../gr-watched-projects-editor/gr-watched-projects-editor';
 import {getDocsBaseUrl} from '../../../utils/url-util';
-import {AppElementParams} from '../../gr-app-types';
 import {GrAccountInfo} from '../gr-account-info/gr-account-info';
 import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
 import {GrGroupList} from '../gr-group-list/gr-group-list';
@@ -35,7 +34,6 @@
 import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
-import {GerritView} from '../../../services/router/router-model';
 import {
   ColumnNames,
   DateFormat,
@@ -48,13 +46,7 @@
 } from '../../../constants/constants';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {LitElement, css, html, nothing} from 'lit';
-import {
-  customElement,
-  property,
-  query,
-  queryAsync,
-  state,
-} from 'lit/decorators.js';
+import {customElement, query, queryAsync, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {paperStyles} from '../../../styles/gr-paper-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
@@ -64,6 +56,8 @@
 import {formStyles} from '../../../styles/gr-form-styles';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {settingsViewModelToken} from '../../../models/views/settings';
 
 const GERRIT_DOCS_BASE_URL =
   'https://gerrit-review.googlesource.com/' + 'Documentation';
@@ -146,8 +140,6 @@
 
   @state() prefs: PreferencesInput = {};
 
-  @property({type: Object}) params?: AppElementParams;
-
   @state() private accountInfoChanged = false;
 
   // private but used in test
@@ -189,6 +181,9 @@
   @state() private emailsChanged = false;
 
   // private but used in test
+  @state() emailToken?: string;
+
+  // private but used in test
   @state() showNumber?: boolean;
 
   // private but used in test
@@ -200,10 +195,20 @@
 
   private readonly flagsService = getAppContext().flagsService;
 
+  private readonly getViewModel = resolve(this, settingsViewModelToken);
+
   constructor() {
     super();
     subscribe(
       this,
+      () => this.getViewModel().emailToken$,
+      x => {
+        this.emailToken = x;
+        this.confirmEmail();
+      }
+    );
+    subscribe(
+      this,
       () => this.userModel.preferences$,
       prefs => {
         if (!prefs) {
@@ -223,6 +228,15 @@
     );
   }
 
+  // private, but used in tests
+  async confirmEmail() {
+    if (!this.emailToken) return;
+    const message = await this.restApiService.confirmEmail(this.emailToken);
+    if (message) fireAlert(this, message);
+    this.getViewModel().clearToken();
+    this.emailEditor.loadData();
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     // Polymer 2: anchor tag won't work on shadow DOM
@@ -266,24 +280,7 @@
       })
     );
 
-    if (
-      this.params &&
-      this.params.view === GerritView.SETTINGS &&
-      this.params.emailToken
-    ) {
-      promises.push(
-        this.restApiService
-          .confirmEmail(this.params.emailToken)
-          .then(message => {
-            if (message) {
-              fireAlert(this, message);
-            }
-            this.emailEditor.loadData();
-          })
-      );
-    } else {
-      promises.push(this.emailEditor.loadData());
-    }
+    promises.push(this.emailEditor.loadData());
 
     this._testOnly_loadingPromise = Promise.all(promises).then(() => {
       this.loading = false;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index 5e0c381..72c19b4 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-settings-view';
 import {GrSettingsView} from './gr-settings-view';
-import {GerritView} from '../../../services/router/router-model';
 import {queryAll, stubRestApi, waitEventLoop} from '../../../test/test-utils';
 import {
   AuthInfo,
@@ -34,7 +33,6 @@
 import {GrSelect} from '../../shared/gr-select/gr-select';
 import {fixture, html, assert} from '@open-wc/testing';
 import {EventType} from '../../../types/events';
-import {SettingsViewState} from '../../../models/views/settings';
 
 suite('gr-settings-view tests', () => {
   let element: GrSettingsView;
@@ -755,9 +753,6 @@
 
   test('emails are loaded without emailToken', () => {
     const emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
-    element.params = {
-      view: GerritView.SETTINGS,
-    } as SettingsViewState;
     element.firstUpdated();
     assert.isTrue(emailEditorLoadDataStub.calledOnce);
   });
@@ -861,8 +856,8 @@
         })
       );
 
-      element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
-      element.firstUpdated();
+      element.emailToken = 'foo';
+      element.confirmEmail();
     });
 
     test('it is used to confirm email via rest API', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index acf4d13..f33f7d8 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -8,10 +8,9 @@
 import {votingStyles} from '../../../styles/gr-voting-styles';
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
-import {getEventPath, Key} from '../../../utils/dom-util';
+import {addShortcut, getEventPath, Key} from '../../../utils/dom-util';
 import {getAppContext} from '../../../services/app-context';
 import {classMap} from 'lit/directives/class-map.js';
-import {ShortcutController} from '../../lit/shortcut-controller';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -56,8 +55,6 @@
   @property({type: Boolean, reflect: true})
   disabled: boolean | null = null;
 
-  private readonly shortcuts = new ShortcutController(this);
-
   static override get styles() {
     return [
       votingStyles,
@@ -211,8 +208,8 @@
     super();
     this.initialTabindex = this.getAttribute('tabindex') || '0';
     this.addEventListener('click', e => this._handleAction(e));
-    this.shortcuts.addLocal({key: Key.ENTER}, () => this.click());
-    this.shortcuts.addLocal({key: Key.SPACE}, () => this.click());
+    addShortcut(this, {key: Key.ENTER}, () => this.click());
+    addShortcut(this, {key: Key.SPACE}, () => this.click());
   }
 
   override updated(changedProperties: PropertyValues) {
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index 3887ee5b..eab6638 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -34,7 +34,7 @@
 
 export const MERGE_CONFLICT_TOOLTIP =
   'This change has merge conflicts. ' +
-  'Download the patch and run "git rebase". ' +
+  'Rebase on the upstream branch (e.g. "git pull --rebase"). ' +
   'Upload a new patchset after resolving all merge conflicts.';
 
 export const GIT_CONFLICT_TOOLTIP =
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 f5e8a1a..cc76150 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -501,6 +501,9 @@
         .draft gr-account-label {
           width: unset;
         }
+        .draft gr-formatted-text.message {
+          margin-bottom: var(--spacing-m);
+        }
         .portedMessage {
           margin: 0 var(--spacing-m);
         }
@@ -723,7 +726,6 @@
         class="message"
         .content=${this.comment?.message}
         .config=${this.commentLinks}
-        ?noTrailingMargin=${!isDraftOrUnsaved(this.comment)}
       ></gr-formatted-text>
     `;
   }
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 2e4383e..5686c6b 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
@@ -141,10 +141,7 @@
               </div>
             </div>
             <div class="body">
-              <gr-formatted-text
-                class="message"
-                notrailingmargin=""
-              ></gr-formatted-text>
+              <gr-formatted-text class="message"></gr-formatted-text>
             </div>
           </div>
         `
@@ -178,10 +175,7 @@
             </div>
             <div class="body">
               <div class="robotId"></div>
-              <gr-formatted-text
-                class="message"
-                notrailingmargin=""
-              ></gr-formatted-text>
+              <gr-formatted-text class="message"></gr-formatted-text>
               <div class="robotActions">
                 <gr-icon
                   icon="link"
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 988f970..3ea9c5d 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -612,7 +612,7 @@
       .filter(account => account.email)
       .map(account => {
         return {
-          text: account.email,
+          text: `${account.name ?? ''} <${account.email}>`,
           dataValue: account.email,
         };
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 93560ab..76c5033 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -137,8 +137,8 @@
     test('mention selector opens when previous char is \n', async () => {
       stubRestApi('getSuggestedAccounts').returns(
         Promise.resolve([
-          createAccountWithEmail('abc@google.com'),
-          createAccountWithEmail('abcdef@google.com'),
+          {...createAccountWithEmail('abc@google.com'), name: 'A'},
+          {...createAccountWithEmail('abcdef@google.com'), name: 'B'},
         ])
       );
       element.textarea!.focus();
@@ -151,6 +151,17 @@
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
 
+      assert.deepEqual(element.suggestions, [
+        {
+          dataValue: 'abc@google.com',
+          text: 'A <abc@google.com>',
+        },
+        {
+          dataValue: 'abcdef@google.com',
+          text: 'B <abcdef@google.com>',
+        },
+      ]);
+
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
     });
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model.ts b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
index 74eb813..b994614 100644
--- a/polygerrit-ui/app/models/accounts-model/accounts-model.ts
+++ b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
@@ -25,8 +25,6 @@
     });
   }
 
-  finalize() {}
-
   private updateStateAccount(id: UserId, account?: AccountDetailInfo) {
     const current = {...this.subject$.getValue()};
     if (!account) return;
diff --git a/polygerrit-ui/app/models/browser/browser-model.ts b/polygerrit-ui/app/models/browser/browser-model.ts
index cd5b753..9fd3f79 100644
--- a/polygerrit-ui/app/models/browser/browser-model.ts
+++ b/polygerrit-ui/app/models/browser/browser-model.ts
@@ -66,6 +66,4 @@
   setScreenWidth(screenWidth: number) {
     this.subject$.next({...this.subject$.getValue(), screenWidth});
   }
-
-  finalize() {}
 }
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index c40a339..2ebb820 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -309,6 +309,4 @@
         }) ?? []
     );
   }
-
-  finalize() {}
 }
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 057e818..138c3ca 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -316,7 +316,7 @@
     ];
   }
 
-  finalize() {
+  override finalize() {
     for (const s of this.subscriptions) {
       s.unsubscribe();
     }
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
index e26167c..d15ea8d 100644
--- a/polygerrit-ui/app/models/change/files-model.ts
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -199,7 +199,7 @@
       });
   }
 
-  finalize() {
+  override finalize() {
     for (const s of this.subscriptions) {
       s.unsubscribe();
     }
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 28dec3c..dd73879 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -477,7 +477,7 @@
     this.reporting.reportInteraction(Interaction.CHECKS_STATS, stats);
   }
 
-  finalize() {
+  override finalize() {
     document.removeEventListener('reload', this.reloadListener);
     document.removeEventListener(
       'visibilitychange',
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index 3372b26..9957c66 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -410,7 +410,7 @@
     document.addEventListener('reload', this.reloadListener);
   }
 
-  finalize() {
+  override finalize() {
     document.removeEventListener('reload', this.reloadListener);
     for (const s of this.subscriptions) {
       s.unsubscribe();
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
index 6f8b066..83b6103 100644
--- a/polygerrit-ui/app/models/config/config-model.ts
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -86,7 +86,7 @@
     this.subject$.next({...current, serverConfig});
   }
 
-  finalize() {
+  override finalize() {
     for (const s of this.subscriptions) {
       s.unsubscribe();
     }
diff --git a/polygerrit-ui/app/models/model.ts b/polygerrit-ui/app/models/model.ts
index 3854c08..9301dc4 100644
--- a/polygerrit-ui/app/models/model.ts
+++ b/polygerrit-ui/app/models/model.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {BehaviorSubject, Observable} from 'rxjs';
+import {Finalizable} from '../services/registry';
 
 /**
  * A Model stores a value <T> and controls changes to that value via `subject$`
@@ -18,7 +19,7 @@
  *
  *  Any new subscriber will immediately receive the current value.
  */
-export abstract class Model<T> {
+export abstract class Model<T> implements Finalizable {
   protected subject$: BehaviorSubject<T>;
 
   public state$: Observable<T>;
@@ -32,4 +33,6 @@
     const currentState = this.subject$.getValue();
     this.subject$.next({...currentState, ...newState});
   }
+
+  finalize() {}
 }
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 372a34e..19aefbb 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -60,7 +60,7 @@
     });
   }
 
-  finalize() {
+  override finalize() {
     this.subject$.complete();
   }
 
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index c62a94e..4b4541a 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -140,7 +140,7 @@
     ];
   }
 
-  finalize() {
+  override finalize() {
     for (const s of this.subscriptions) {
       s.unsubscribe();
     }
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index 856d947..2ad95a2 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GerritView} from '../../services/router/router-model';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
@@ -20,13 +21,10 @@
   offset?: number | string;
 }
 
-const DEFAULT_STATE: AdminViewState = {
-  view: GerritView.ADMIN,
-  adminView: AdminChildView.REPOS,
-};
+export const adminViewModelToken = define<AdminViewModel>('admin-view-model');
 
-export class AdminViewModel extends Model<AdminViewState> {
+export class AdminViewModel extends Model<AdminViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 }
diff --git a/polygerrit-ui/app/models/views/agreement.ts b/polygerrit-ui/app/models/views/agreement.ts
index 4f1763d..839699c 100644
--- a/polygerrit-ui/app/models/views/agreement.ts
+++ b/polygerrit-ui/app/models/views/agreement.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GerritView} from '../../services/router/router-model';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
@@ -13,6 +14,10 @@
 
 const DEFAULT_STATE: AgreementViewState = {view: GerritView.AGREEMENTS};
 
+export const agreementViewModelToken = define<AgreementViewModel>(
+  'agreement-view-model'
+);
+
 export class AgreementViewModel extends Model<AgreementViewState> {
   constructor() {
     super(DEFAULT_STATE);
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 94d465e6..7f75d83 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -17,6 +17,7 @@
   getPatchRangeExpression,
 } from '../../utils/url-util';
 import {AttemptChoice} from '../checks/checks-util';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
@@ -40,10 +41,6 @@
   usp?: string;
 }
 
-const DEFAULT_STATE: ChangeViewState = {
-  view: GerritView.CHANGE,
-};
-
 export function createChangeUrl(state: Omit<ChangeViewState, 'view'>) {
   let range = getPatchRangeExpression(state);
   if (range.length) {
@@ -80,8 +77,11 @@
   }
 }
 
-export class ChangeViewModel extends Model<ChangeViewState> {
+export const changeViewModelToken =
+  define<ChangeViewModel>('change-view-model');
+
+export class ChangeViewModel extends Model<ChangeViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 }
diff --git a/polygerrit-ui/app/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
index dec53d1..9326b9e 100644
--- a/polygerrit-ui/app/models/views/dashboard.ts
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -7,6 +7,7 @@
 import {GerritView} from '../../services/router/router-model';
 import {DashboardId} from '../../types/common';
 import {encodeURL} from '../../utils/url-util';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
@@ -28,10 +29,6 @@
   title?: string;
 }
 
-const DEFAULT_STATE: DashboardViewState = {
-  view: GerritView.DASHBOARD,
-};
-
 const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;
 
 function sectionsToEncodedParams(
@@ -68,8 +65,12 @@
   }
 }
 
-export class DashboardViewModel extends Model<DashboardViewState> {
+export const dashboardViewModelToken = define<DashboardViewModel>(
+  'dashboard-view-model'
+);
+
+export class DashboardViewModel extends Model<DashboardViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 }
diff --git a/polygerrit-ui/app/models/views/diff.ts b/polygerrit-ui/app/models/views/diff.ts
index 68f416f..85fa081 100644
--- a/polygerrit-ui/app/models/views/diff.ts
+++ b/polygerrit-ui/app/models/views/diff.ts
@@ -12,12 +12,13 @@
 import {GerritView} from '../../services/router/router-model';
 import {UrlEncodedCommentId} from '../../types/common';
 import {encodeURL, getPatchRangeExpression} from '../../utils/url-util';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
 export interface DiffViewState extends ViewState {
   view: GerritView.DIFF;
-  changeNum?: NumericChangeId;
+  changeNum: NumericChangeId;
   project?: RepoName;
   commentId?: UrlEncodedCommentId;
   path?: string;
@@ -28,10 +29,6 @@
   commentLink?: boolean;
 }
 
-const DEFAULT_STATE: DiffViewState = {
-  view: GerritView.DIFF,
-};
-
 export function createDiffUrl(state: Omit<DiffViewState, 'view'>): string {
   let range = getPatchRangeExpression(state);
   if (range.length) range = '/' + range;
@@ -58,8 +55,10 @@
   }
 }
 
-export class DiffViewModel extends Model<DiffViewState> {
+export const diffViewModelToken = define<DiffViewModel>('diff-view-model');
+
+export class DiffViewModel extends Model<DiffViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 }
diff --git a/polygerrit-ui/app/models/views/documentation.ts b/polygerrit-ui/app/models/views/documentation.ts
index 4273b13..b564d64 100644
--- a/polygerrit-ui/app/models/views/documentation.ts
+++ b/polygerrit-ui/app/models/views/documentation.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GerritView} from '../../services/router/router-model';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
@@ -12,12 +13,14 @@
   filter?: string | null;
 }
 
-const DEFAULT_STATE: DocumentationViewState = {
-  view: GerritView.DOCUMENTATION_SEARCH,
-};
+export const documentationViewModelToken = define<DocumentationViewModel>(
+  'documentation-view-model'
+);
 
-export class DocumentationViewModel extends Model<DocumentationViewState> {
+export class DocumentationViewModel extends Model<
+  DocumentationViewState | undefined
+> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 }
diff --git a/polygerrit-ui/app/models/views/edit.ts b/polygerrit-ui/app/models/views/edit.ts
index 102a7e0..d8f4770 100644
--- a/polygerrit-ui/app/models/views/edit.ts
+++ b/polygerrit-ui/app/models/views/edit.ts
@@ -11,22 +11,19 @@
 } from '../../api/rest-api';
 import {GerritView} from '../../services/router/router-model';
 import {encodeURL, getPatchRangeExpression} from '../../utils/url-util';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
 export interface EditViewState extends ViewState {
   view: GerritView.EDIT;
-  changeNum?: NumericChangeId;
-  project?: RepoName;
-  path?: string;
-  patchNum?: RevisionPatchSetNum;
+  changeNum: NumericChangeId;
+  project: RepoName;
+  path: string;
+  patchNum: RevisionPatchSetNum;
   lineNum?: number;
 }
 
-const DEFAULT_STATE: EditViewState = {
-  view: GerritView.EDIT,
-};
-
 export function createEditUrl(state: Omit<EditViewState, 'view'>): string {
   if (state.patchNum === undefined) {
     state = {...state, patchNum: EDIT};
@@ -50,8 +47,10 @@
   }
 }
 
-export class EditViewModel extends Model<EditViewState> {
+export const editViewModelToken = define<EditViewModel>('edit-view-model');
+
+export class EditViewModel extends Model<EditViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 }
diff --git a/polygerrit-ui/app/models/views/group.ts b/polygerrit-ui/app/models/views/group.ts
index bac8eb5..277bcff 100644
--- a/polygerrit-ui/app/models/views/group.ts
+++ b/polygerrit-ui/app/models/views/group.ts
@@ -6,6 +6,7 @@
 import {GerritView} from '../../services/router/router-model';
 import {GroupId} from '../../types/common';
 import {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
@@ -30,6 +31,8 @@
   return getBaseUrl() + url;
 }
 
+export const groupViewModelToken = define<GroupViewModel>('group-view-model');
+
 export class GroupViewModel extends Model<GroupViewState | undefined> {
   constructor() {
     super(undefined);
diff --git a/polygerrit-ui/app/models/views/plugin.ts b/polygerrit-ui/app/models/views/plugin.ts
index 5b0e701..ac7e925 100644
--- a/polygerrit-ui/app/models/views/plugin.ts
+++ b/polygerrit-ui/app/models/views/plugin.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GerritView} from '../../services/router/router-model';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
@@ -15,6 +16,9 @@
 
 const DEFAULT_STATE: PluginViewState = {view: GerritView.PLUGIN_SCREEN};
 
+export const pluginViewModelToken =
+  define<PluginViewModel>('plugin-view-model');
+
 export class PluginViewModel extends Model<PluginViewState> {
   constructor() {
     super(DEFAULT_STATE);
diff --git a/polygerrit-ui/app/models/views/repo.ts b/polygerrit-ui/app/models/views/repo.ts
index d7e7a73..02fd17d 100644
--- a/polygerrit-ui/app/models/views/repo.ts
+++ b/polygerrit-ui/app/models/views/repo.ts
@@ -6,6 +6,7 @@
 import {GerritView} from '../../services/router/router-model';
 import {RepoName} from '../../types/common';
 import {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
@@ -26,10 +27,6 @@
   offset?: number | string;
 }
 
-const DEFAULT_STATE: RepoViewState = {
-  view: GerritView.REPO,
-};
-
 export function createRepoUrl(state: Omit<RepoViewState, 'view'>) {
   let url = `/admin/repos/${encodeURL(`${state.repo}`, true)}`;
   if (state.detail === RepoDetailView.GENERAL) {
@@ -48,8 +45,10 @@
   return getBaseUrl() + url;
 }
 
-export class RepoViewModel extends Model<RepoViewState> {
+export const repoViewModelToken = define<RepoViewModel>('repo-view-model');
+
+export class RepoViewModel extends Model<RepoViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 }
diff --git a/polygerrit-ui/app/models/views/search.ts b/polygerrit-ui/app/models/views/search.ts
index d932b39..58cb8f7 100644
--- a/polygerrit-ui/app/models/views/search.ts
+++ b/polygerrit-ui/app/models/views/search.ts
@@ -7,6 +7,7 @@
 import {GerritView} from '../../services/router/router-model';
 import {addQuotesWhen} from '../../utils/string-util';
 import {encodeURL} from '../../utils/url-util';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
@@ -82,12 +83,11 @@
   return '/q/' + operators.join('+') + offsetExpr;
 }
 
-const DEFAULT_STATE: SearchViewState = {
-  view: GerritView.SEARCH,
-};
+export const searchViewModelToken =
+  define<SearchViewModel>('search-view-model');
 
-export class SearchViewModel extends Model<SearchViewState> {
+export class SearchViewModel extends Model<SearchViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 }
diff --git a/polygerrit-ui/app/models/views/settings.ts b/polygerrit-ui/app/models/views/settings.ts
index f2e67a4..c1a8c08 100644
--- a/polygerrit-ui/app/models/views/settings.ts
+++ b/polygerrit-ui/app/models/views/settings.ts
@@ -4,7 +4,9 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GerritView} from '../../services/router/router-model';
+import {select} from '../../utils/observable-util';
 import {getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
@@ -13,14 +15,22 @@
   emailToken?: string;
 }
 
-const DEFAULT_STATE: SettingsViewState = {view: GerritView.SETTINGS};
-
 export function createSettingsUrl() {
   return getBaseUrl() + '/settings';
 }
 
-export class SettingsViewModel extends Model<SettingsViewState> {
+export const settingsViewModelToken = define<SettingsViewModel>(
+  'settings-view-model'
+);
+
+export class SettingsViewModel extends Model<SettingsViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
+  }
+
+  public emailToken$ = select(this.state$, state => state?.emailToken);
+
+  clearToken() {
+    this.updateState({emailToken: undefined});
   }
 }
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 2dca9a9..3a0079f 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -32,6 +32,31 @@
 import {PluginsModel} from '../models/plugins/plugins-model';
 import {HighlightService} from './highlight/highlight-service';
 import {AccountsModel} from '../models/accounts-model/accounts-model';
+import {
+  DashboardViewModel,
+  dashboardViewModelToken,
+} from '../models/views/dashboard';
+import {
+  SettingsViewModel,
+  settingsViewModelToken,
+} from '../models/views/settings';
+import {GrRouter, routerToken} from '../elements/core/gr-router/gr-router';
+import {AdminViewModel, adminViewModelToken} from '../models/views/admin';
+import {
+  AgreementViewModel,
+  agreementViewModelToken,
+} from '../models/views/agreement';
+import {ChangeViewModel, changeViewModelToken} from '../models/views/change';
+import {DiffViewModel, diffViewModelToken} from '../models/views/diff';
+import {
+  DocumentationViewModel,
+  documentationViewModelToken,
+} from '../models/views/documentation';
+import {EditViewModel, editViewModelToken} from '../models/views/edit';
+import {GroupViewModel, groupViewModelToken} from '../models/views/group';
+import {PluginViewModel, pluginViewModelToken} from '../models/views/plugin';
+import {RepoViewModel, repoViewModelToken} from '../models/views/repo';
+import {SearchViewModel, searchViewModelToken} from '../models/views/search';
 
 /**
  * The AppContext lazy initializator for all services
@@ -85,6 +110,50 @@
   const browserModel = new BrowserModel(appContext.userModel);
   dependencies.set(browserModelToken, browserModel);
 
+  const adminViewModel = new AdminViewModel();
+  dependencies.set(adminViewModelToken, adminViewModel);
+  const agreementViewModel = new AgreementViewModel();
+  dependencies.set(agreementViewModelToken, agreementViewModel);
+  const changeViewModel = new ChangeViewModel();
+  dependencies.set(changeViewModelToken, changeViewModel);
+  const dashboardViewModel = new DashboardViewModel();
+  dependencies.set(dashboardViewModelToken, dashboardViewModel);
+  const diffViewModel = new DiffViewModel();
+  dependencies.set(diffViewModelToken, diffViewModel);
+  const documentationViewModel = new DocumentationViewModel();
+  dependencies.set(documentationViewModelToken, documentationViewModel);
+  const editViewModel = new EditViewModel();
+  dependencies.set(editViewModelToken, editViewModel);
+  const groupViewModel = new GroupViewModel();
+  dependencies.set(groupViewModelToken, groupViewModel);
+  const pluginViewModel = new PluginViewModel();
+  dependencies.set(pluginViewModelToken, pluginViewModel);
+  const repoViewModel = new RepoViewModel();
+  dependencies.set(repoViewModelToken, repoViewModel);
+  const searchViewModel = new SearchViewModel();
+  dependencies.set(searchViewModelToken, searchViewModel);
+  const settingsViewModel = new SettingsViewModel();
+  dependencies.set(settingsViewModelToken, settingsViewModel);
+
+  const router = new GrRouter(
+    appContext.reportingService,
+    appContext.routerModel,
+    appContext.restApiService,
+    adminViewModel,
+    agreementViewModel,
+    changeViewModel,
+    dashboardViewModel,
+    diffViewModel,
+    documentationViewModel,
+    editViewModel,
+    groupViewModel,
+    pluginViewModel,
+    repoViewModel,
+    searchViewModel,
+    settingsViewModel
+  );
+  dependencies.set(routerToken, router);
+
   const changeModel = new ChangeModel(
     appContext.routerModel,
     appContext.restApiService,
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 106f1f3..33b9176 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -16,8 +16,7 @@
   LifeCycle,
   Timing,
 } from '../../constants/reporting';
-import {getCLS, getFID, getLCP} from 'web-vitals';
-import {Metric} from 'web-vitals/src/types';
+import {getCLS, getFID, getLCP, Metric} from 'web-vitals';
 
 // Latency reporting constants.
 
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index 8b3b0b8..72a3344 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -64,8 +64,6 @@
     );
   }
 
-  finalize() {}
-
   // Private but used in tests
   setState(state: RouterState) {
     this.subject$.next(state);
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 15857c8..eda4e58 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -37,6 +37,31 @@
   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';
 
 export function createTestAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
@@ -92,6 +117,51 @@
   const browserModel = () => new BrowserModel(appContext.userModel);
   dependencies.set(browserModelToken, browserModel);
 
+  const adminViewModelCreator = () => new AdminViewModel();
+  dependencies.set(adminViewModelToken, adminViewModelCreator);
+  const agreementViewModelCreator = () => new AgreementViewModel();
+  dependencies.set(agreementViewModelToken, agreementViewModelCreator);
+  const changeViewModelCreator = () => new ChangeViewModel();
+  dependencies.set(changeViewModelToken, changeViewModelCreator);
+  const dashboardViewModelCreator = () => new DashboardViewModel();
+  dependencies.set(dashboardViewModelToken, dashboardViewModelCreator);
+  const diffViewModelCreator = () => new DiffViewModel();
+  dependencies.set(diffViewModelToken, diffViewModelCreator);
+  const documentationViewModelCreator = () => new DocumentationViewModel();
+  dependencies.set(documentationViewModelToken, documentationViewModelCreator);
+  const editViewModelCreator = () => new EditViewModel();
+  dependencies.set(editViewModelToken, editViewModelCreator);
+  const groupViewModelCreator = () => new GroupViewModel();
+  dependencies.set(groupViewModelToken, groupViewModelCreator);
+  const pluginViewModelCreator = () => new PluginViewModel();
+  dependencies.set(pluginViewModelToken, pluginViewModelCreator);
+  const repoViewModelCreator = () => new RepoViewModel();
+  dependencies.set(repoViewModelToken, repoViewModelCreator);
+  const searchViewModelCreator = () => new SearchViewModel();
+  dependencies.set(searchViewModelToken, searchViewModelCreator);
+  const settingsViewModelCreator = () => new SettingsViewModel();
+  dependencies.set(settingsViewModelToken, settingsViewModelCreator);
+
+  const routerCreator = () =>
+    new GrRouter(
+      appContext.reportingService,
+      appContext.routerModel,
+      appContext.restApiService,
+      resolver(adminViewModelToken),
+      resolver(agreementViewModelToken),
+      resolver(changeViewModelToken),
+      resolver(dashboardViewModelToken),
+      resolver(diffViewModelToken),
+      resolver(documentationViewModelToken),
+      resolver(editViewModelToken),
+      resolver(groupViewModelToken),
+      resolver(pluginViewModelToken),
+      resolver(repoViewModelToken),
+      resolver(searchViewModelToken),
+      resolver(settingsViewModelToken)
+    );
+  dependencies.set(routerToken, routerCreator);
+
   const changeModelCreator = () =>
     new ChangeModel(
       appContext.routerModel,
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index f0bfda9..0e2317f 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -185,11 +185,11 @@
       if (!accounts) return [];
       const accountSuggestions = [];
       for (const account of accounts) {
-        let nameAndEmail;
+        let nameAndEmail: string;
         if (account.email !== undefined) {
-          nameAndEmail = `${account.name} <${account.email}>`;
+          nameAndEmail = `${account.name ?? ''} <${account.email}>`;
         } else {
-          nameAndEmail = account.name;
+          nameAndEmail = account.name ?? '';
         }
         accountSuggestions.push({
           name: nameAndEmail,