Merge "Fix group and repo views"
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/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/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/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 0329dd3..a0fec50 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -431,12 +431,7 @@
   private renderChangeListView() {
     return cache(
       this.view === GerritView.SEARCH
-        ? html`
-            <gr-change-list-view
-              .params=${this.params}
-              .account=${this.account}
-            ></gr-change-list-view>
-          `
+        ? html` <gr-change-list-view></gr-change-list-view> `
         : nothing
     );
   }
@@ -485,7 +480,7 @@
   }
 
   private diffViewTemplate() {
-    return html`<gr-diff-view .params=${this.params}></gr-diff-view>`;
+    return html`<gr-diff-view></gr-diff-view>`;
   }
 
   private renderSettingsView() {
@@ -528,9 +523,7 @@
 
   private renderDocumentationSearch() {
     if (this.view !== GerritView.DOCUMENTATION_SEARCH) return nothing;
-    return html`
-      <gr-documentation-search .params=${this.params}></gr-documentation-search>
-    `;
+    return html`<gr-documentation-search></gr-documentation-search>`;
   }
 
   private renderKeyboardShortcutsDialog() {
diff --git a/polygerrit-ui/app/models/views/diff.ts b/polygerrit-ui/app/models/views/diff.ts
index 059747f..85fa081 100644
--- a/polygerrit-ui/app/models/views/diff.ts
+++ b/polygerrit-ui/app/models/views/diff.ts
@@ -18,7 +18,7 @@
 
 export interface DiffViewState extends ViewState {
   view: GerritView.DIFF;
-  changeNum?: NumericChangeId;
+  changeNum: NumericChangeId;
   project?: RepoName;
   commentId?: UrlEncodedCommentId;
   path?: string;
@@ -29,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;
@@ -61,8 +57,8 @@
 
 export const diffViewModelToken = define<DiffViewModel>('diff-view-model');
 
-export class DiffViewModel extends Model<DiffViewState> {
+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 6f1763a..b564d64 100644
--- a/polygerrit-ui/app/models/views/documentation.ts
+++ b/polygerrit-ui/app/models/views/documentation.ts
@@ -13,16 +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/search.ts b/polygerrit-ui/app/models/views/search.ts
index db39af8..58cb8f7 100644
--- a/polygerrit-ui/app/models/views/search.ts
+++ b/polygerrit-ui/app/models/views/search.ts
@@ -83,15 +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);
   }
 }