Merge "Replace email with getUserId"
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/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-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 2bd217a..c20ca1d 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
@@ -3224,6 +3224,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/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 6c96965..e4e9310 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -447,12 +447,7 @@
   private renderDashboardView() {
     return cache(
       this.view === GerritView.DASHBOARD
-        ? html`
-            <gr-dashboard-view
-              .account=${this.account}
-              .params=${this.params}
-            ></gr-dashboard-view>
-          `
+        ? html`<gr-dashboard-view></gr-dashboard-view>`
         : nothing
     );
   }
@@ -479,7 +474,7 @@
 
   private renderEditorView() {
     if (this.view !== GerritView.EDIT) return nothing;
-    return html`<gr-editor-view .params=${this.params}></gr-editor-view>`;
+    return html`<gr-editor-view></gr-editor-view>`;
   }
 
   private renderDiffView() {
@@ -500,7 +495,6 @@
     if (this.view !== GerritView.SETTINGS) return nothing;
     return html`
       <gr-settings-view
-        .params=${this.params}
         @account-detail-update=${this.handleAccountDetailUpdate}
       >
       </gr-settings-view>
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/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
index 0de6bf8..979debb 100644
--- a/polygerrit-ui/app/models/views/dashboard.ts
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -29,10 +29,6 @@
   title?: string;
 }
 
-const DEFAULT_STATE: DashboardViewState = {
-  view: GerritView.DASHBOARD,
-};
-
 const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;
 
 function sectionsToEncodedParams(
@@ -73,9 +69,9 @@
   'dashboard-view-model'
 );
 
-export class DashboardViewModel extends Model<DashboardViewState> {
+export class DashboardViewModel extends Model<DashboardViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 
   finalize() {}
diff --git a/polygerrit-ui/app/models/views/edit.ts b/polygerrit-ui/app/models/views/edit.ts
index 7079630..0eeaae8 100644
--- a/polygerrit-ui/app/models/views/edit.ts
+++ b/polygerrit-ui/app/models/views/edit.ts
@@ -17,17 +17,13 @@
 
 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};
@@ -53,9 +49,9 @@
 
 export const editViewModelToken = define<EditViewModel>('edit-view-model');
 
-export class EditViewModel extends Model<EditViewState> {
+export class EditViewModel extends Model<EditViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 
   finalize() {}
diff --git a/polygerrit-ui/app/models/views/settings.ts b/polygerrit-ui/app/models/views/settings.ts
index 6bbf4f3..af02d0a 100644
--- a/polygerrit-ui/app/models/views/settings.ts
+++ b/polygerrit-ui/app/models/views/settings.ts
@@ -4,6 +4,7 @@
  * 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';
@@ -14,8 +15,6 @@
   emailToken?: string;
 }
 
-const DEFAULT_STATE: SettingsViewState = {view: GerritView.SETTINGS};
-
 export function createSettingsUrl() {
   return getBaseUrl() + '/settings';
 }
@@ -24,10 +23,16 @@
   'settings-view-model'
 );
 
-export class SettingsViewModel extends Model<SettingsViewState> {
+export class SettingsViewModel extends Model<SettingsViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 
   finalize() {}
+
+  public emailToken$ = select(this.state$, state => state?.emailToken);
+
+  clearToken() {
+    this.updateState({emailToken: undefined});
+  }
 }
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.