Move change loading from diff/change-views into change-service

This also fixes a bug where change$ was not emitting when going from a
change page to the dashboard and then coming back to the same change.
The root cause for this was because the change model and the views had
two separate truths about what the current change is.

Google-Bug-Id: b/207119073
Change-Id: I66855e3b488a422b3b7735f48bc27a9bf9ea20c2
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 a84b33d..7d8bb86 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
@@ -177,13 +177,18 @@
   fireAlert,
   fireDialogChange,
   fireEvent,
-  firePageError,
   fireReload,
   fireTitleChange,
 } from '../../../utils/event-util';
 import {GerritView, routerView$} from '../../../services/router/router-model';
 import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
-import {debounce, DelayedTask, throttleWrap} from '../../../utils/async-util';
+import {
+  debounce,
+  DelayedTask,
+  isFalse,
+  throttleWrap,
+  until,
+} from '../../../utils/async-util';
 import {Interaction, Timing} from '../../../constants/reporting';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
@@ -198,6 +203,7 @@
 } from '../../../utils/attention-set-util';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
 import {preferenceDiffViewMode$} from '../../../services/user/user-model';
+import {change$, changeLoading$} from '../../../services/change/change-model';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -338,7 +344,7 @@
   _canStartReview?: boolean;
 
   @property({type: Object, observer: '_changeChanged'})
-  _change?: ChangeInfo | ParsedChangeInfo;
+  _change?: ParsedChangeInfo;
 
   @property({type: Object, computed: '_getRevisionInfo(_change)'})
   _revisionInfo?: RevisionInfoClass;
@@ -651,6 +657,13 @@
         this._changeComments = changeComments;
       })
     );
+    this.subscriptions.push(
+      change$.subscribe(change => {
+        // The change view is tied to a specific change number, so don't update
+        // _change to undefined.
+        if (change) this._change = change;
+      })
+    );
   }
 
   constructor() {
@@ -1019,8 +1032,8 @@
       .sort((a, b) => (b.value as number) - (a.value as number));
   }
 
-  _handleCurrentRevisionUpdate(currentRevision: RevisionInfo) {
-    this._currentRobotCommentsPatchSet = currentRevision._number;
+  _handleCurrentRevisionUpdate(currentRevision?: RevisionInfo) {
+    this._currentRobotCommentsPatchSet = currentRevision?._number;
   }
 
   _handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
@@ -1753,10 +1766,6 @@
     this._changeViewAriaHidden = true;
   }
 
-  _handleGetChangeDetailError(response?: Response | null) {
-    firePageError(response);
-  }
-
   _getLoggedIn() {
     return this.restApiService.getLoggedIn();
   }
@@ -1874,36 +1883,34 @@
   }
 
   _getChangeDetail() {
-    if (!this._changeNum)
+    if (!this._changeNum) {
       throw new Error('missing required changeNum property');
-    const detailCompletes = this.restApiService.getChangeDetail(
-      this._changeNum,
-      r => this._handleGetChangeDetailError(r)
-    );
+    }
+
+    const detailCompletes = until(changeLoading$, isFalse);
     const editCompletes = this._getEdit();
     const prefCompletes = this._getPreferences();
 
     return Promise.all([detailCompletes, editCompletes, prefCompletes]).then(
-      ([change, edit, prefs]) => {
+      ([_, edit, prefs]) => {
         this._prefs = prefs;
 
-        if (!change) {
-          return false;
-        }
-        this._processEdit(change, edit);
+        if (!this._change) return false;
+
+        this._processEdit(this._change, edit);
         // Issue 4190: Coalesce missing topics to null.
         // TODO(TS): code needs second thought,
         // it might be that nulls were assigned to trigger some bindings
-        if (!change.topic) {
-          change.topic = null as unknown as undefined;
+        if (!this._change.topic) {
+          this._change.topic = null as unknown as undefined;
         }
-        if (!change.reviewer_updates) {
-          change.reviewer_updates = null as unknown as undefined;
+        if (!this._change.reviewer_updates) {
+          this._change.reviewer_updates = null as unknown as undefined;
         }
-        const latestRevisionSha = this._getLatestRevisionSHA(change);
+        const latestRevisionSha = this._getLatestRevisionSHA(this._change);
         if (!latestRevisionSha)
           throw new Error('Could not find latest Revision Sha');
-        const currentRevision = change.revisions[latestRevisionSha];
+        const currentRevision = this._change.revisions[latestRevisionSha];
         if (currentRevision.commit && currentRevision.commit.message) {
           this._latestCommitMessage = this._prepareCommitMsgForLinkify(
             currentRevision.commit.message
@@ -1917,9 +1924,7 @@
         // Slice returns a number as a string, convert to an int.
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
-        this.changeService.updateChange(change);
-        this._change = change;
-        this.computeRevertSubmitted(change);
+        this.computeRevertSubmitted(this._change);
         if (
           !this._patchRange ||
           !this._patchRange.patchNum ||
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 df027a5..6104356 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
@@ -68,6 +68,7 @@
   createChangeViewChange,
   createRelatedChangeAndCommitInfo,
   createAccountDetailWithId,
+  createParsedChange,
 } from '../../../test/test-data-generators';
 import {ChangeViewPatchRange, GrChangeView} from './gr-change-view';
 import {
@@ -106,7 +107,8 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {_testOnly_setState} from '../../../services/user/user-model';
+import {_testOnly_setState as setUserState} from '../../../services/user/user-model';
+import {_testOnly_setState as setChangeState} from '../../../services/change/change-model';
 import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
@@ -674,15 +676,6 @@
         messages: createChangeMessages(1),
       };
       element._change.labels = {};
-      stubRestApi('getChangeDetail').callsFake(() =>
-        Promise.resolve({
-          ...createChangeViewChange(),
-          // element has latest info
-          revisions: createRevisions(1),
-          messages: createChangeMessages(1),
-          current_revision: 'rev1' as CommitId,
-        })
-      );
 
       const openSpy = sinon.spy(element, '_openReplyDialog');
 
@@ -825,7 +818,7 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       };
-      _testOnly_setState({
+      setUserState({
         preferences: prefs,
         diffPreferences: createDefaultDiffPrefs(),
       });
@@ -838,7 +831,7 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.UNIFIED,
       };
-      _testOnly_setState({
+      setUserState({
         preferences: newPrefs,
         diffPreferences: createDefaultDiffPrefs(),
       });
@@ -1036,7 +1029,7 @@
   suite('ChangeStatus revert', () => {
     test('do not show any chip if no revert created', async () => {
       const change = {
-        ...createChange(),
+        ...createParsedChange(),
         messages: createChangeMessages(2),
       };
       const getChangeStub = stubRestApi('getChange');
@@ -1066,7 +1059,7 @@
 
     test('do not show any chip if all reverts are abandoned', async () => {
       const change = {
-        ...createChange(),
+        ...createParsedChange(),
         messages: createChangeMessages(2),
       };
       change.messages[0].message = 'Created a revert of this change as 12345';
@@ -1104,7 +1097,7 @@
 
     test('show revert created if no revert is merged', async () => {
       const change = {
-        ...createChange(),
+        ...createParsedChange(),
         messages: createChangeMessages(2),
       };
       change.messages[0].message = 'Created a revert of this change as 12345';
@@ -1140,7 +1133,7 @@
 
     test('show revert submitted if revert is merged', async () => {
       const change = {
-        ...createChange(),
+        ...createParsedChange(),
         messages: createChangeMessages(2),
       };
       change.messages[0].message = 'Created a revert of this change as 12345';
@@ -1271,7 +1264,6 @@
       ...createChangeViewChange(),
       labels: {},
     } as ParsedChangeInfo;
-    stubRestApi('getChangeDetail').returns(Promise.resolve(change));
     element._changeNum = undefined;
     element._patchRange = {
       basePatchNum: ParentPatchSetNum,
@@ -1506,14 +1498,14 @@
 
   test('topic is coalesced to null', async () => {
     sinon.stub(element, '_changeChanged');
-    stubRestApi('getChangeDetail').returns(
-      Promise.resolve({
+    setChangeState({
+      change: {
         ...createChangeViewChange(),
         labels: {},
         current_revision: 'foo' as CommitId,
         revisions: {foo: createRevision()},
-      })
-    );
+      },
+    });
 
     await element._getChangeDetail();
     assert.isNull(element._change!.topic);
@@ -1521,14 +1513,14 @@
 
   test('commit sha is populated from getChangeDetail', async () => {
     sinon.stub(element, '_changeChanged');
-    stubRestApi('getChangeDetail').callsFake(() =>
-      Promise.resolve({
+    setChangeState({
+      change: {
         ...createChangeViewChange(),
         labels: {},
         current_revision: 'foo' as CommitId,
         revisions: {foo: createRevision()},
-      })
-    );
+      },
+    });
 
     await element._getChangeDetail();
     assert.equal('foo', element._commitInfo!.commit);
@@ -1537,14 +1529,14 @@
   test('edit is added to change', () => {
     sinon.stub(element, '_changeChanged');
     const changeRevision = createRevision();
-    stubRestApi('getChangeDetail').callsFake(() =>
-      Promise.resolve({
+    setChangeState({
+      change: {
         ...createChangeViewChange(),
         labels: {},
         current_revision: 'foo' as CommitId,
         revisions: {foo: {...changeRevision}},
-      })
-    );
+      },
+    });
     const editCommit: CommitInfo = {
       ...createCommit(),
       commit: 'bar' as CommitId,
@@ -1722,19 +1714,12 @@
     setup(() => {
       element._change = {
         ...createChangeViewChange(),
-        revisions: createRevisions(1),
+        // element has latest info
+        revisions: {rev1: createRevision()},
         messages: createChangeMessages(1),
+        current_revision: 'rev1' as CommitId,
+        labels: {},
       };
-      element._change.labels = {};
-      stubRestApi('getChangeDetail').callsFake(() =>
-        Promise.resolve({
-          ...createChangeViewChange(),
-          // element has latest info
-          revisions: {rev1: createRevision()},
-          messages: createChangeMessages(1),
-          current_revision: 'rev1' as CommitId,
-        })
-      );
     });
 
     test('show reply dialog on open-reply-dialog event', async () => {
@@ -1963,8 +1948,8 @@
   test('_selectedRevision updates when patchNum is changed', () => {
     const revision1: RevisionInfo = createRevision(1);
     const revision2: RevisionInfo = createRevision(2);
-    stubRestApi('getChangeDetail').returns(
-      Promise.resolve({
+    setChangeState({
+      change: {
         ...createChangeViewChange(),
         revisions: {
           aaa: revision1,
@@ -1973,8 +1958,9 @@
         labels: {},
         actions: {},
         current_revision: 'bbb' as CommitId,
-      })
-    );
+      },
+    });
+
     sinon.stub(element, '_getEdit').returns(Promise.resolve(false));
     sinon
       .stub(element, '_getPreferences')
@@ -1992,8 +1978,8 @@
     const revision1 = createRevision(1);
     const revision2 = createRevision(2);
     const revision3 = createEditRevision();
-    stubRestApi('getChangeDetail').returns(
-      Promise.resolve({
+    setChangeState({
+      change: {
         ...createChangeViewChange(),
         revisions: {
           aaa: revision1,
@@ -2003,8 +1989,8 @@
         labels: {},
         actions: {},
         current_revision: 'ccc' as CommitId,
-      })
-    );
+      },
+    });
     sinon.stub(element, '_getEdit').returns(Promise.resolve(undefined));
     sinon
       .stub(element, '_getPreferences')
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
index 0426cb6..72d56b9 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
@@ -41,7 +41,7 @@
       subject: 'my-subject',
       revisions: {},
     };
-    await flush();
+    await element.updateComplete;
     const header = queryAndAssert(element, '.header');
     assert.equal(header.textContent!.trim(), 'my-label');
 
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 393e093..8a681d1 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
@@ -124,6 +124,8 @@
 import {
   diffPath$,
   currentPatchNum$,
+  change$,
+  changeLoading$,
 } from '../../../services/change/change-model';
 import {DisplayLine} from '../../../api/diff';
 
@@ -405,6 +407,13 @@
         this._prefs = diffPreferences;
       })
     );
+    this.subscriptions.push(
+      change$.subscribe(change => {
+        // The diff view is tied to a specfic change number, so don't update
+        // _change to undefined.
+        if (change) this._change = change;
+      })
+    );
 
     // When user initially loads the diff view, we want to autmatically mark
     // the file as reviewed if they have it enabled. We can't observe these
@@ -502,15 +511,6 @@
     });
   }
 
-  _getChangeDetail(changeNum: NumericChangeId) {
-    return this.restApiService.getChangeDetail(changeNum).then(change => {
-      if (!change) throw new Error('Missing "change" in API response.');
-      this.changeService.updateChange(change);
-      this._change = change;
-      return change;
-    });
-  }
-
   _getChangeEdit() {
     assertIsDefined(this._changeNum, '_changeNum');
     return this.restApiService.getChangeEdit(this._changeNum);
@@ -1143,7 +1143,7 @@
     }
 
     const promises: Promise<unknown>[] = [];
-    if (!this._change) promises.push(this._getChangeDetail(this._changeNum));
+    if (!this._change) promises.push(until(changeLoading$, isFalse));
     promises.push(until(commentsLoading$, isFalse));
     promises.push(
       this._getChangeEdit().then(edit => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 819ab9a..7c4ff12 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -31,7 +31,6 @@
 import {EditPatchSetNum} from '../../../types/common.js';
 import {CursorMoveResult} from '../../../api/core.js';
 import {Side} from '../../../api/diff.js';
-import {EventType} from '../../../types/events.js';
 import {_testOnly_setState as browserModelSetState} from '../../../services/browser/browser-model.js';
 import {_testOnly_setState as setUserModelState, _testOnly_getState as getUserModelState} from '../../../services/user/user-model.js';
 import {_testOnly_setState as setChangeModelState} from '../../../services/change/change-model.js';
@@ -58,13 +57,10 @@
       };
     }
 
-    let getChangeDetailStub;
     setup(async () => {
       stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-      getChangeDetailStub = stubRestApi('getChangeDetail').returns(
-          Promise.resolve({}));
       stubRestApi('getChangeFiles').returns(Promise.resolve({}));
       stubRestApi('saveFileReviewed').returns(Promise.resolve());
       diffCommentsStub = stubRestApi('getDiffComments');
@@ -145,10 +141,10 @@
         sinon.stub(element.reporting, 'diffViewDisplayed');
         sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
         sinon.spy(element, '_paramsChanged');
-        sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+        setChangeModelState({change: {
           ...createChange(),
           revisions: createRevisions(11),
-        }));
+        }});
       });
 
       test('comment url resolves to comment.patch_set vs latest', () => {
@@ -254,10 +250,10 @@
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
           sinon.stub(element, '_isFileUnchanged').returns(true);
           sinon.spy(element, '_paramsChanged');
-          sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+          setChangeModelState({change: {
             ...createChange(),
             revisions: createRevisions(11),
-          }));
+          }});
           element.params = {
             view: GerritNav.View.DIFF,
             changeNum: '42',
@@ -306,10 +302,10 @@
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
           sinon.stub(element, '_isFileUnchanged').returns(true);
           sinon.spy(element, '_paramsChanged');
-          sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+          setChangeModelState({change: {
             ...createChange(),
             revisions: createRevisions(11),
-          }));
+          }});
           element.params = {
             view: GerritNav.View.DIFF,
             changeNum: '42',
@@ -357,61 +353,6 @@
       assert.equal(element._isFileUnchanged(diff), true);
     });
 
-    test('change detail is not rerequested if changeNum doesnt change',
-        async () => {
-          const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-          assert.isFalse(getChangeDetailStub.called);
-          sinon.stub(element.reporting, 'diffViewDisplayed');
-          sinon.stub(element, '_loadBlame');
-          sinon.stub(element, '_pathChanged');
-          sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-          sinon.spy(element, '_paramsChanged');
-          element._change = undefined;
-          getChangeDetailStub.returns(
-              Promise.resolve({
-                ...createChange(),
-                revisions: createRevisions(11),
-              }));
-          element._patchRange = {
-            patchNum: 2,
-            basePatchNum: 1,
-          };
-          sinon.stub(element, '_isFileUnchanged').returns(false);
-
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '42',
-            project: 'p',
-            commentId: 'c1',
-            commentLink: true,
-          };
-          await element._paramsChanged.returnValues[0];
-
-          assert.equal(getChangeDetailStub.callCount, 1);
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '42',
-            project: 'p',
-            commentId: 'c1',
-            commentLink: true,
-          };
-          await element._paramsChanged.returnValues[0];
-
-          assert.equal(getChangeDetailStub.callCount, 1);
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '43',
-            project: 'p',
-            commentId: 'c1',
-            commentLink: true,
-          };
-          await element._paramsChanged.returnValues[0];
-
-          // change page is recreated now
-          assert.equal(dispatchEventStub.lastCall.args[0].type,
-              EventType.RECREATE_DIFF_VIEW);
-        });
-
     test('diff toast to go to latest is shown and not base', async () => {
       setCommentState({
         comments: {
@@ -442,11 +383,10 @@
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sinon.spy(element, '_paramsChanged');
       element._change = undefined;
-      getChangeDetailStub.returns(
-          Promise.resolve({
-            ...createChange(),
-            revisions: createRevisions(11),
-          }));
+      setChangeModelState({change: {
+        ...createChange(),
+        revisions: createRevisions(11),
+      }});
       element._patchRange = {
         patchNum: 2,
         basePatchNum: 1,
@@ -1447,8 +1387,6 @@
         sinon.stub(element.$.diffHost, 'reload');
         sinon.stub(element, '_initCursor');
         element._change = change;
-        sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
-            change));
       });
 
       test('uses the patchNum and basePatchNum ', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 9b292e9..a3dc479 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -1112,10 +1112,11 @@
   }
 
   getChangeDetail(
-    changeNum: NumericChangeId,
+    changeNum?: NumericChangeId,
     errFn?: ErrorCallback,
     cancelCondition?: CancelConditionCallback
   ): Promise<ParsedChangeInfo | null | undefined> {
+    if (!changeNum) return Promise.resolve(undefined);
     return this.getConfig(false).then(config => {
       const optionsHex = this._getChangeOptionsHex(config);
       return this._getChangeDetail(
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 27a1ec7..84e55f9 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -108,6 +108,11 @@
   distinctUntilChanged()
 );
 
+export const changeLoading$ = change$.pipe(
+  map(change => change === undefined),
+  distinctUntilChanged()
+);
+
 export const diffPath$ = changeState$.pipe(
   map(changeState => changeState?.diffPath),
   distinctUntilChanged()
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
index 7a2d2f4..ff417b2 100644
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -14,7 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Subscription} from 'rxjs';
+import {from, Subscription} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
 import {routerChangeNum$} from '../router/router-model';
 import {change$, updateStateChange, updateStatePath} from './change-model';
 import {ParsedChangeInfo} from '../../types/types';
@@ -36,9 +37,22 @@
     // calls from a switchMap() here. For now just make sure to invalidate the
     // change when no changeNum is set.
     this.subscriptions.push(
-      routerChangeNum$.subscribe(changeNum => {
-        if (!changeNum) updateStateChange(undefined);
-      })
+      routerChangeNum$
+        .pipe(
+          // The change service is currently a singleton, so we have to be
+          // careful to avoid situations where the application state is
+          // partially set for the old change where the user is coming from,
+          // and partially for the new change where the user is navigating to.
+          // So setting the change explicitly to undefined when the user
+          // moves away from diff and change pages (changeNum === undefined)
+          // helps with that.
+          switchMap(changeNum =>
+            from(this.restApiService.getChangeDetail(changeNum))
+          )
+        )
+        .subscribe(change => {
+          updateStateChange(change ?? undefined);
+        })
     );
     this.subscriptions.push(
       change$.subscribe(change => {
@@ -54,17 +68,6 @@
     this.subscriptions.splice(0, this.subscriptions.length);
   }
 
-  /**
-   * This is a temporary indirection between change-view, which currently
-   * manages what the current change is, and the change-model, which will
-   * become the source of truth in the future. We will extract a substantial
-   * amount of code from change-view and move it into this change-service. This
-   * will take some time ...
-   */
-  updateChange(change: ParsedChangeInfo) {
-    updateStateChange(change);
-  }
-
   // Temporary workaround until path is derived in the model itself.
   updatePath(path?: string) {
     updateStatePath(path);
diff --git a/polygerrit-ui/app/services/change/change-services_test.ts b/polygerrit-ui/app/services/change/change-services_test.ts
index 74af94d..094362b 100644
--- a/polygerrit-ui/app/services/change/change-services_test.ts
+++ b/polygerrit-ui/app/services/change/change-services_test.ts
@@ -22,10 +22,14 @@
   createChangeMessageInfo,
   createRevision,
 } from '../../test/test-data-generators';
-import {stubRestApi} from '../../test/test-utils';
+import {stubRestApi, waitUntil} from '../../test/test-utils';
 import {CommitId, PatchSetNum} from '../../types/common';
 import {ParsedChangeInfo} from '../../types/types';
 import {getAppContext} from '../app-context';
+import {
+  GerritView,
+  _testOnly_setState as setRouterState,
+} from '../router/router-model';
 import {ChangeService} from './change-service';
 
 suite('change service tests', () => {
@@ -53,6 +57,29 @@
     };
   });
 
+  teardown(() => {
+    changeService.finalize();
+  });
+
+  test('changeService switching changes', async () => {
+    const change = knownChange;
+    const stub = stubRestApi('getChangeDetail').returns(
+      Promise.resolve(change)
+    );
+
+    setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
+    waitUntil(() => changeService.getChange() === knownChange);
+    assert.equal(stub.callCount, 1);
+
+    setRouterState({view: GerritView.DASHBOARD, changeNum: undefined});
+    waitUntil(() => changeService.getChange() === undefined);
+    assert.equal(stub.callCount, 2);
+
+    setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
+    waitUntil(() => changeService.getChange() === knownChange);
+    assert.equal(stub.callCount, 3);
+  });
+
   test('changeService.fetchChangeUpdates on latest', async () => {
     stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
     const result = await changeService.fetchChangeUpdates(knownChange);
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index b203715..837cab6 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -201,7 +201,7 @@
   ): Promise<BranchInfo[] | undefined>;
 
   getChangeDetail(
-    changeNum: number | string,
+    changeNum?: number | string,
     opt_errFn?: ErrorCallback,
     opt_cancelCondition?: Function
   ): Promise<ParsedChangeInfo | null | undefined>;
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index dfc0a0b..5afdeb7 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -225,7 +225,10 @@
   getChangeConflicts(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
   },
-  getChangeDetail(): Promise<ParsedChangeInfo | null | undefined> {
+  getChangeDetail(
+    changeNum?: number | string
+  ): Promise<ParsedChangeInfo | null | undefined> {
+    if (changeNum === undefined) return Promise.resolve(undefined);
     return Promise.resolve(createChange() as ParsedChangeInfo);
   },
   getChangeEdit(): Promise<false | EditInfo | undefined> {