Migrate change-model to new pattern

- Rename service to model on appContext
- Merge ...-model.ts and ...-service.ts
- Rename ...Servcie to ...Model
- Move all observables onto ...Model
- Inject ...Model in the models and components that were
  directly accessing the observables

Google-Bug-Id: b/206459178
Change-Id: I5e719ba747f8b6d67948f1efee53a9a1bcb05d34
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index e635613..83808af 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -386,7 +386,7 @@
   // Accessed in tests
   readonly jsAPI = getAppContext().jsApiService;
 
-  private readonly changeService = getAppContext().changeService;
+  private readonly changeModel = getAppContext().changeModel;
 
   @property({type: Object})
   change?: ChangeViewChangeInfo;
@@ -1716,7 +1716,7 @@
         new Error('Properties change and changeNum must be set.')
       );
     }
-    return this.changeService.fetchChangeUpdates(change).then(result => {
+    return this.changeModel.fetchChangeUpdates(change).then(result => {
       if (!result.isLatest) {
         this.dispatchEvent(
           new CustomEvent<ShowAlertEventDetail>('show-alert', {
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 a0b6bfd..e397117 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
@@ -196,11 +196,7 @@
   hasAttention,
 } from '../../../utils/attention-set-util';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {
-  change$,
-  changeLoadingStatus$,
-  LoadingStatus,
-} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../services/change/change-model';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -277,15 +273,6 @@
    * @event show-auth-required
    */
 
-  // Accessed in tests.
-  readonly reporting = getAppContext().reportingService;
-
-  readonly jsAPI = getAppContext().jsApiService;
-
-  private readonly changeService = getAppContext().changeService;
-
-  private readonly checksModel = getAppContext().checksModel;
-
   /**
    * URL params passed from the router.
    */
@@ -556,15 +543,6 @@
   })
   resolveWeblinks?: GeneratedWebLink[];
 
-  readonly restApiService = getAppContext().restApiService;
-
-  // Private but used in tests.
-  readonly userModel = getAppContext().userModel;
-
-  private readonly commentsModel = getAppContext().commentsModel;
-
-  private readonly shortcuts = getAppContext().shortcutsService;
-
   override keyboardShortcuts(): ShortcutListener[] {
     return [
       listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
@@ -611,6 +589,25 @@
     ];
   }
 
+  // Accessed in tests.
+  readonly reporting = getAppContext().reportingService;
+
+  readonly jsAPI = getAppContext().jsApiService;
+
+  private readonly checksModel = getAppContext().checksModel;
+
+  readonly restApiService = getAppContext().restApiService;
+
+  // Private but used in tests.
+  readonly userModel = getAppContext().userModel;
+
+  // Private but used in tests.
+  readonly changeModel = getAppContext().changeModel;
+
+  private readonly commentsModel = getAppContext().commentsModel;
+
+  private readonly shortcuts = getAppContext().shortcutsService;
+
   private subscriptions: Subscription[] = [];
 
   private replyRefitTask?: DelayedTask;
@@ -655,7 +652,7 @@
       })
     );
     this.subscriptions.push(
-      change$.subscribe(change => {
+      this.changeModel.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;
@@ -1877,7 +1874,7 @@
     }
 
     const detailCompletes = until(
-      changeLoadingStatus$,
+      this.changeModel.changeLoadingStatus$,
       status => status === LoadingStatus.LOADED
     );
     const editCompletes = this._getEdit();
@@ -2326,7 +2323,7 @@
         return;
       }
       const change = this._change;
-      this.changeService.fetchChangeUpdates(change).then(result => {
+      this.changeModel.fetchChangeUpdates(change).then(result => {
         let toastMessage = null;
         if (!result.isLatest) {
           toastMessage = ReloadToastMessage.NEWER_REVISION;
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 96e1796..0e74cc6 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
@@ -103,10 +103,7 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {
-  LoadingStatus,
-  _testOnly_setState as setChangeState,
-} from '../../../services/change/change-model';
+import {LoadingStatus} 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';
@@ -1486,7 +1483,7 @@
 
   test('topic is coalesced to null', async () => {
     sinon.stub(element, '_changeChanged');
-    setChangeState({
+    element.changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1502,7 +1499,7 @@
 
   test('commit sha is populated from getChangeDetail', async () => {
     sinon.stub(element, '_changeChanged');
-    setChangeState({
+    element.changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1519,7 +1516,7 @@
   test('edit is added to change', () => {
     sinon.stub(element, '_changeChanged');
     const changeRevision = createRevision();
-    setChangeState({
+    element.changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1940,7 +1937,7 @@
   test('_selectedRevision updates when patchNum is changed', () => {
     const revision1: RevisionInfo = createRevision(1);
     const revision2: RevisionInfo = createRevision(2);
-    setChangeState({
+    element.changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1971,7 +1968,7 @@
     const revision1 = createRevision(1);
     const revision2 = createRevision(2);
     const revision3 = createEditRevision();
-    setChangeState({
+    element.changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index 6fa11be..c24e054 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -29,7 +29,6 @@
 import {customElement, property, query, state} from 'lit/decorators';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {subscribe} from '../../lit/subscription-controller';
-import {change$} from '../../../services/change/change-model';
 import {getAppContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
 
@@ -64,6 +63,8 @@
 
   private commentsModel = getAppContext().commentsModel;
 
+  private changeModel = getAppContext().changeModel;
+
   static override get styles() {
     return [
       sharedStyles,
@@ -92,7 +93,7 @@
 
   constructor() {
     super();
-    subscribe(this, change$, x => (this.change = x));
+    subscribe(this, this.changeModel.change$, x => (this.change = x));
     subscribe(
       this,
       this.commentsModel.threads$,
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index baaecc8..36d0ce6 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -51,11 +51,6 @@
   FormattedReviewerUpdateInfo,
   ParsedChangeInfo,
 } from '../../../types/types';
-import {
-  change$,
-  changeNum$,
-  repo$,
-} from '../../../services/change/change-model';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -259,6 +254,8 @@
   // Private but used in tests.
   readonly commentsModel = getAppContext().commentsModel;
 
+  private readonly changeModel = getAppContext().changeModel;
+
   private readonly reporting = getAppContext().reportingService;
 
   private readonly shortcuts = getAppContext().shortcutsService;
@@ -273,7 +270,7 @@
       })
     );
     this.subscriptions.push(
-      change$.subscribe(x => {
+      this.changeModel.change$.subscribe(x => {
         this.change = x;
       })
     );
@@ -283,12 +280,12 @@
       })
     );
     this.subscriptions.push(
-      repo$.subscribe(x => {
+      this.changeModel.repo$.subscribe(x => {
         this.projectName = x;
       })
     );
     this.subscriptions.push(
-      changeNum$.subscribe(x => {
+      this.changeModel.changeNum$.subscribe(x => {
         this.changeNum = x;
       })
     );
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index dca0d65..52a5dac 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -218,7 +218,7 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly changeService = getAppContext().changeService;
+  private readonly changeModel = getAppContext().changeModel;
 
   @property({type: Object})
   change?: ChangeInfo;
@@ -435,7 +435,7 @@
   open(focusTarget?: FocusTarget, quote?: string) {
     assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
-    this.changeService.fetchChangeUpdates(this.change).then(result => {
+    this.changeModel.fetchChangeUpdates(this.change).then(result => {
       this.knownLatestState = result.isLatest
         ? LatestPatchState.LATEST
         : LatestPatchState.NOT_LATEST;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 41e2fc4e..0349b3f 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -43,7 +43,6 @@
 import {customElement, property, queryAll, state} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subscribe} from '../../lit/subscription-controller';
-import {change$, changeNum$} from '../../../services/change/change-model';
 import {ParsedChangeInfo} from '../../../types/types';
 import {repeat} from 'lit/directives/repeat';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
@@ -200,12 +199,14 @@
   @state()
   draftsOnly = false;
 
+  private readonly changeModel = getAppContext().changeModel;
+
   private readonly userModel = getAppContext().userModel;
 
   constructor() {
     super();
-    subscribe(this, changeNum$, x => (this.changeNum = x));
-    subscribe(this, change$, x => (this.change = x));
+    subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
+    subscribe(this, this.changeModel.change$, x => (this.change = x));
     subscribe(this, this.userModel.account$, x => (this.account = x));
   }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 24c06e0..de2099e 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -55,7 +55,6 @@
   LabelNameToInfoMap,
   PatchSetNumber,
 } from '../../types/common';
-import {labels$, latestPatchNum$} from '../../services/change/change-model';
 import {getAppContext} from '../../services/app-context';
 import {spinnerStyles} from '../../styles/gr-spinner-styles';
 import {
@@ -88,11 +87,13 @@
   @state()
   labels?: LabelNameToInfoMap;
 
+  private changeModel = getAppContext().changeModel;
+
   private checksModel = getAppContext().checksModel;
 
   constructor() {
     super();
-    subscribe(this, labels$, x => (this.labels = x));
+    subscribe(this, this.changeModel.labels$, x => (this.labels = x));
   }
 
   static override get styles() {
@@ -530,7 +531,7 @@
   @state()
   repoConfig?: ConfigInfo;
 
-  private changeService = getAppContext().changeService;
+  private changeModel = getAppContext().changeModel;
 
   private configModel = getAppContext().configModel;
 
@@ -618,7 +619,7 @@
       const end = pointer?.range?.end_line;
       if (start) rangeText += `#${start}`;
       if (end && start !== end) rangeText += `-${end}`;
-      const change = this.changeService.getChange();
+      const change = this.changeModel.getChange();
       assertIsDefined(change);
       const path = pointer.path;
       const patchset = this.result?.patchset as PatchSetNumber | undefined;
@@ -726,6 +727,8 @@
    */
   private isSectionExpandedByUser = new Map<Category, boolean>();
 
+  private readonly changeModel = getAppContext().changeModel;
+
   private readonly checksModel = getAppContext().checksModel;
 
   constructor() {
@@ -745,7 +748,11 @@
       this.checksModel.checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
-    subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
+    subscribe(
+      this,
+      this.changeModel.latestPatchNum$,
+      x => (this.latestPatchsetNumber = x)
+    );
     subscribe(
       this,
       this.checksModel.someProvidersAreLoadingSelected$,
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 16c9bfe..a9c30c5 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -20,7 +20,6 @@
 import {CheckResult, CheckRun} from '../../services/checks/checks-model';
 import './gr-checks-runs';
 import './gr-checks-results';
-import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
 import {ActionTriggeredEvent} from '../../services/checks/checks-util';
 import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
@@ -62,6 +61,8 @@
     number | undefined
   >();
 
+  private readonly changeModel = getAppContext().changeModel;
+
   private readonly checksModel = getAppContext().checksModel;
 
   constructor() {
@@ -81,8 +82,12 @@
       this.checksModel.checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
-    subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
-    subscribe(this, changeNum$, x => (this.changeNum = x));
+    subscribe(
+      this,
+      this.changeModel.latestPatchNum$,
+      x => (this.latestPatchsetNumber = x)
+    );
+    subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
 
     this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
       this.handleActionTriggered(e.detail.action, e.detail.run)
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 98e4312..e527134 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
@@ -116,13 +116,7 @@
 import {filter, take} from 'rxjs/operators';
 import {Subscription, combineLatest} from 'rxjs';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {
-  diffPath$,
-  currentPatchNum$,
-  change$,
-  changeLoadingStatus$,
-  LoadingStatus,
-} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../services/change/change-model';
 import {DisplayLine} from '../../../api/diff';
 import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
 
@@ -364,7 +358,8 @@
   // Private but used in tests.
   readonly userModel = getAppContext().userModel;
 
-  private readonly changeService = getAppContext().changeService;
+  // Private but used in tests.
+  readonly changeModel = getAppContext().changeModel;
 
   // Private but used in tests.
   readonly browserModel = getAppContext().browserModel;
@@ -411,7 +406,7 @@
       })
     );
     this.subscriptions.push(
-      change$.subscribe(change => {
+      this.changeModel.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;
@@ -423,12 +418,12 @@
     // properties since the method will be called anytime a property updates
     // but we only want to call this on the initial load.
     this.subscriptions.push(
-      combineLatest(
-        currentPatchNum$,
+      combineLatest([
+        this.changeModel.currentPatchNum$,
         routerView$,
-        diffPath$,
-        this.userModel.diffPreferences$
-      )
+        this.changeModel.diffPath$,
+        this.userModel.diffPreferences$,
+      ])
         .pipe(
           filter(
             ([currentPatchNum, routerView, path, diffPrefs]) =>
@@ -443,7 +438,9 @@
           this.setReviewedStatus(currentPatchNum!, path!, diffPrefs);
         })
     );
-    this.subscriptions.push(diffPath$.subscribe(path => (this._path = path)));
+    this.subscriptions.push(
+      this.changeModel.diffPath$.subscribe(path => (this._path = path))
+    );
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
     this.cursor.replaceDiffs([this.$.diffHost]);
     this._onRenderHandler = (_: Event) => {
@@ -1059,7 +1056,7 @@
         GerritNav.navigateToChange(this._change);
         return;
       }
-      this.changeService.updatePath(comment.path);
+      this.changeModel.updatePath(comment.path);
 
       const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
       if (!latestPatchNum) throw new Error('Missing _allPatchSets');
@@ -1069,7 +1066,7 @@
       this._focusLineNum = comment.line;
     } else {
       if (this.params.path) {
-        this.changeService.updatePath(this.params.path);
+        this.changeModel.updatePath(this.params.path);
       }
       if (this.params.patchNum) {
         this._patchRange = {
@@ -1135,7 +1132,7 @@
     }
 
     this._files = {sortedFileList: [], changeFilesByPath: {}};
-    this.changeService.updatePath(undefined);
+    this.changeModel.updatePath(undefined);
     this._patchRange = undefined;
     this._commitRange = undefined;
     this._focusLineNum = undefined;
@@ -1159,10 +1156,14 @@
     }
 
     const promises: Promise<unknown>[] = [];
-    if (!this._change)
+    if (!this._change) {
       promises.push(
-        until(changeLoadingStatus$, status => status === LoadingStatus.LOADED)
+        until(
+          this.changeModel.changeLoadingStatus$,
+          status => status === LoadingStatus.LOADED
+        )
       );
+    }
     promises.push(until(this.commentsModel.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 3fad375..4c4361d 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 {_testOnly_setState as setChangeModelState} from '../../../services/change/change-model.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
@@ -138,10 +137,11 @@
         sinon.stub(element.reporting, 'diffViewDisplayed');
         sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
         sinon.spy(element, '_paramsChanged');
-        setChangeModelState({change: {
-          ...createChange(),
-          revisions: createRevisions(11),
-        }});
+        element.changeModel.setState({
+          change: {
+            ...createChange(),
+            revisions: createRevisions(11),
+          }});
       });
 
       test('comment url resolves to comment.patch_set vs latest', () => {
@@ -247,10 +247,11 @@
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
           sinon.stub(element, '_isFileUnchanged').returns(true);
           sinon.spy(element, '_paramsChanged');
-          setChangeModelState({change: {
-            ...createChange(),
-            revisions: createRevisions(11),
-          }});
+          element.changeModel.setState({
+            change: {
+              ...createChange(),
+              revisions: createRevisions(11),
+            }});
           element.params = {
             view: GerritNav.View.DIFF,
             changeNum: '42',
@@ -299,10 +300,11 @@
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
           sinon.stub(element, '_isFileUnchanged').returns(true);
           sinon.spy(element, '_paramsChanged');
-          setChangeModelState({change: {
-            ...createChange(),
-            revisions: createRevisions(11),
-          }});
+          element.changeModel.setState({
+            change: {
+              ...createChange(),
+              revisions: createRevisions(11),
+            }});
           element.params = {
             view: GerritNav.View.DIFF,
             changeNum: '42',
@@ -380,10 +382,11 @@
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sinon.spy(element, '_paramsChanged');
       element._change = undefined;
-      setChangeModelState({change: {
-        ...createChange(),
-        revisions: createRevisions(11),
-      }});
+      element.changeModel.setState({
+        change: {
+          ...createChange(),
+          revisions: createRevisions(11),
+        }});
       element._patchRange = {
         patchNum: 2,
         basePatchNum: 1,
@@ -1199,7 +1202,9 @@
         manual_review: true,
       };
       element.userModel.setDiffPreferences(diffPreferences);
-      setChangeModelState({change: createChange(), diffPath: '/COMMIT_MSG'});
+      element.changeModel.setState({
+        change: createChange(),
+        diffPath: '/COMMIT_MSG'});
 
       setRouterModelState({
         changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
@@ -1236,7 +1241,8 @@
             manual_review: false,
           };
           element.userModel.setDiffPreferences(diffPreferences);
-          setChangeModelState({change: createChange(),
+          element.changeModel.setState({
+            change: createChange(),
             diffPath: '/COMMIT_MSG'});
 
           setRouterModelState({
@@ -1261,7 +1267,9 @@
       sinon.stub(element.$.diffHost, 'reload');
 
       element.userModel.setDiffPreferences(createDefaultDiffPrefs());
-      setChangeModelState({change: createChange(), diffPath: '/COMMIT_MSG'});
+      element.changeModel.setState({
+        change: createChange(),
+        diffPath: '/COMMIT_MSG'});
 
       setRouterModelState({
         changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index debd9c0..de8a02d 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -67,7 +67,6 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {repeat} from 'lit/directives/repeat';
 import {classMap} from 'lit/directives/class-map';
-import {changeNum$, repo$} from '../../../services/change/change-model';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {ValueChangedEvent} from '../../../types/events';
 
@@ -238,6 +237,8 @@
 
   private readonly commentsModel = getAppContext().commentsModel;
 
+  private readonly changeModel = getAppContext().changeModel;
+
   private readonly userModel = getAppContext().userModel;
 
   private readonly shortcuts = new ShortcutController(this);
@@ -246,9 +247,9 @@
 
   constructor() {
     super();
-    subscribe(this, changeNum$, x => (this.changeNum = x));
+    subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
     subscribe(this, this.userModel.account$, x => (this.account = x));
-    subscribe(this, repo$, x => (this.repoName = x));
+    subscribe(this, this.changeModel.repo$, x => (this.repoName = x));
     subscribe(this, this.userModel.diffPreferences$, x =>
       this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index af2966c..1411c88 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -58,7 +58,6 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {classMap} from 'lit/directives/class-map';
-import {changeNum$, repo$} from '../../../services/change/change-model';
 import {LineNumber} from '../../../api/diff';
 import {CommentSide} from '../../../constants/constants';
 import {getRandomInt} from '../../../utils/math-util';
@@ -228,6 +227,8 @@
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly changeModel = getAppContext().changeModel;
+
   private readonly commentsModel = getAppContext().commentsModel;
 
   private readonly userModel = getAppContext().userModel;
@@ -263,8 +264,8 @@
       this.configModel.repoCommentLinks$,
       x => (this.commentLinks = x)
     );
-    subscribe(this, repo$, x => (this.repoName = x));
-    subscribe(this, changeNum$, x => (this.changeNum = x));
+    subscribe(this, this.changeModel.repo$, x => (this.repoName = x));
+    subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
     subscribe(
       this,
       this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
index b865a7c..9b3e75d 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -75,8 +75,8 @@
     restApiService: (_ctx: Partial<AppContext>) => {
       throw new Error('restApiService is not implemented');
     },
-    changeService: (_ctx: Partial<AppContext>) => {
-      throw new Error('changeService is not implemented');
+    changeModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('changeModel is not implemented');
     },
     commentsModel: (_ctx: Partial<AppContext>) => {
       throw new Error('commentsModel is not implemented');
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index f958429..46d2178 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -21,7 +21,7 @@
 import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
 import {Auth} from './gr-auth/gr-auth_impl';
 import {GrRestApiServiceImpl} from '../elements/shared/gr-rest-api-interface/gr-rest-api-impl';
-import {ChangeService} from './change/change-service';
+import {ChangeModel} from './change/change-model';
 import {ChecksModel} from './checks/checks-model';
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
 import {GrStorageService} from './storage/gr-storage_impl';
@@ -53,20 +53,25 @@
       assertIsDefined(ctx.flagsService, 'flagsService)');
       return new GrRestApiServiceImpl(ctx.authService!, ctx.flagsService!);
     },
-    changeService: (ctx: Partial<AppContext>) => {
+    changeModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ChangeService(ctx.restApiService!);
+      return new ChangeModel(ctx.restApiService!);
     },
     commentsModel: (ctx: Partial<AppContext>) => {
-      const restApi = ctx.restApiService;
+      const changeModel = ctx.changeModel;
+      const restApiService = ctx.restApiService;
       const reporting = ctx.reportingService;
-      assertIsDefined(restApi, 'restApiService');
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(restApiService, 'restApiService');
       assertIsDefined(reporting, 'reportingService');
-      return new CommentsModel(restApi, reporting);
+      return new CommentsModel(changeModel, restApiService, reporting);
     },
     checksModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ChecksModel(ctx.reportingService!);
+      const changeModel = ctx.changeModel;
+      const reporting = ctx.reportingService;
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(reporting, 'reportingService');
+      return new ChecksModel(changeModel, reporting);
     },
     jsApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.reportingService, 'reportingService');
@@ -74,8 +79,11 @@
     },
     storageService: (_ctx: Partial<AppContext>) => new GrStorageService(),
     configModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ConfigModel(ctx.restApiService!);
+      const changeModel = ctx.changeModel;
+      const restApiService = ctx.restApiService;
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(restApiService, 'restApiService');
+      return new ConfigModel(changeModel, restApiService);
     },
     userModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 3439a3d..bea371f 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -20,7 +20,7 @@
 import {ReportingService} from './gr-reporting/gr-reporting';
 import {AuthService} from './gr-auth/gr-auth';
 import {RestApiService} from './gr-rest-api/gr-rest-api';
-import {ChangeService} from './change/change-service';
+import {ChangeModel} from './change/change-model';
 import {ChecksModel} from './checks/checks-model';
 import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
 import {StorageService} from './storage/gr-storage';
@@ -36,7 +36,7 @@
   eventEmitter: EventEmitterService;
   authService: AuthService;
   restApiService: RestApiService;
-  changeService: ChangeService;
+  changeModel: ChangeModel;
   commentsModel: CommentsModel;
   checksModel: ChecksModel;
   jsApiService: JsApiService;
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 2c98320..6a819f8 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -16,12 +16,21 @@
  */
 
 import {NumericChangeId, PatchSetNum} from '../../types/common';
-import {BehaviorSubject, combineLatest, Observable} from 'rxjs';
+import {
+  combineLatest,
+  from,
+  fromEvent,
+  BehaviorSubject,
+  Observable,
+  Subscription,
+} from 'rxjs';
 import {
   map,
   filter,
   withLatestFrom,
   distinctUntilChanged,
+  startWith,
+  switchMap,
 } from 'rxjs/operators';
 import {routerPatchNum$, routerState$} from '../router/router-model';
 import {
@@ -30,6 +39,12 @@
 } from '../../utils/patch-set-util';
 import {ParsedChangeInfo} from '../../types/types';
 
+import {routerChangeNum$} from '../router/router-model';
+import {ChangeInfo} from '../../types/common';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {Finalizable} from '../registry';
+import {select} from '../../utils/observable-util';
+
 export enum LoadingStatus {
   NOT_LOADED = 'NOT_LOADED',
   LOADING = 'LOADING',
@@ -58,125 +73,189 @@
   loadingStatus: LoadingStatus.NOT_LOADED,
 };
 
-const privateState$ = new BehaviorSubject(initialState);
+export class ChangeModel implements Finalizable {
+  private readonly privateState$ = new BehaviorSubject(initialState);
 
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...initialState});
-}
+  public readonly changeState$: Observable<ChangeState> =
+    this.privateState$.asObservable();
 
-export function _testOnly_setState(state: ChangeState) {
-  privateState$.next(state);
-}
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const changeState$: Observable<ChangeState> = privateState$;
-
-export function updateStateChange(change?: ParsedChangeInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({
-    ...current,
-    change,
-    loadingStatus:
-      change === undefined ? LoadingStatus.NOT_LOADED : LoadingStatus.LOADED,
-  });
-}
-
-/**
- * Called when change detail loading is initiated.
- *
- * If the change number matches the current change in the state, then
- * this is a reload. If not, then we not just want to set the state to
- * LOADING instead of RELOADING, but we also want to set the change to
- * undefined right away. Otherwise components could see inconsistent state:
- * a new change number, but an old change.
- */
-export function updateStateLoading(changeNum: NumericChangeId) {
-  const current = privateState$.getValue();
-  const reloading = current.change?._number === changeNum;
-  privateState$.next({
-    ...current,
-    change: reloading ? current.change : undefined,
-    loadingStatus: reloading ? LoadingStatus.RELOADING : LoadingStatus.LOADING,
-  });
-}
-
-export function updateStatePath(diffPath?: string) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, diffPath});
-}
-
-/**
- * If you depend on both, router and change state, then you want to filter out
- * inconsistent state, e.g. router changeNum already updated, change not yet
- * reset to undefined.
- */
-export const changeAndRouterConsistent$ = combineLatest([
-  routerState$,
-  changeState$,
-]).pipe(
-  filter(([routerState, changeState]) => {
-    const changeNum = changeState.change?._number;
-    const routerChangeNum = routerState.changeNum;
-    return changeNum === undefined || changeNum === routerChangeNum;
-  }),
-  distinctUntilChanged()
-);
-
-export const change$ = changeState$.pipe(
-  map(changeState => changeState.change),
-  distinctUntilChanged()
-);
-
-export const changeLoadingStatus$ = changeState$.pipe(
-  map(changeState => changeState.loadingStatus),
-  distinctUntilChanged()
-);
-
-export const diffPath$ = changeState$.pipe(
-  map(changeState => changeState?.diffPath),
-  distinctUntilChanged()
-);
-
-export const changeNum$ = change$.pipe(
-  map(change => change?._number),
-  distinctUntilChanged()
-);
-
-export const repo$ = change$.pipe(
-  map(change => change?.project),
-  distinctUntilChanged()
-);
-
-export const labels$ = change$.pipe(
-  map(change => change?.labels),
-  distinctUntilChanged()
-);
-
-export const latestPatchNum$ = change$.pipe(
-  map(change => computeLatestPatchNum(computeAllPatchSets(change))),
-  distinctUntilChanged()
-);
-
-/**
- * Emits the current patchset number. If the route does not define the current
- * patchset num, then this selector waits for the change to be defined and
- * returns the number of the latest patchset.
- *
- * Note that this selector can emit a patchNum without the change being
- * available!
- */
-export const currentPatchNum$: Observable<PatchSetNum | undefined> =
-  changeAndRouterConsistent$.pipe(
-    withLatestFrom(routerPatchNum$, latestPatchNum$),
-    map(
-      ([_, routerPatchNum, latestPatchNum]) => routerPatchNum || latestPatchNum
-    ),
-    distinctUntilChanged()
+  public readonly change$ = select(
+    this.privateState$,
+    changeState => changeState.change
   );
+
+  public readonly changeLoadingStatus$ = select(
+    this.privateState$,
+    changeState => changeState.loadingStatus
+  );
+
+  public readonly diffPath$ = select(
+    this.privateState$,
+    changeState => changeState?.diffPath
+  );
+
+  public readonly changeNum$ = select(this.change$, change => change?._number);
+
+  public readonly repo$ = select(this.change$, change => change?.project);
+
+  public readonly labels$ = select(this.change$, change => change?.labels);
+
+  public readonly latestPatchNum$ = select(this.change$, change =>
+    computeLatestPatchNum(computeAllPatchSets(change))
+  );
+
+  /**
+   * Emits the current patchset number. If the route does not define the current
+   * patchset num, then this selector waits for the change to be defined and
+   * returns the number of the latest patchset.
+   *
+   * Note that this selector can emit a patchNum without the change being
+   * available!
+   */
+  public readonly currentPatchNum$: Observable<PatchSetNum | undefined> =
+    /**
+     * If you depend on both, router and change state, then you want to filter
+     * out inconsistent state, e.g. router changeNum already updated, change not
+     * yet reset to undefined.
+     */
+    combineLatest([routerState$, this.changeState$])
+      .pipe(
+        filter(([routerState, changeState]) => {
+          const changeNum = changeState.change?._number;
+          const routerChangeNum = routerState.changeNum;
+          return changeNum === undefined || changeNum === routerChangeNum;
+        }),
+        distinctUntilChanged()
+      )
+      .pipe(
+        withLatestFrom(routerPatchNum$, this.latestPatchNum$),
+        map(([_, routerPatchN, latestPatchN]) => routerPatchN || latestPatchN),
+        distinctUntilChanged()
+      );
+
+  private subscriptions: Subscription[] = [];
+
+  // For usage in `combineLatest` we need `startWith` such that reload$ has an
+  // initial value.
+  private readonly reload$: Observable<unknown> = fromEvent(
+    document,
+    'reload'
+  ).pipe(startWith(undefined));
+
+  constructor(readonly restApiService: RestApiService) {
+    this.subscriptions = [
+      combineLatest([routerChangeNum$, this.reload$])
+        .pipe(
+          map(([changeNum, _]) => changeNum),
+          switchMap(changeNum => {
+            if (changeNum !== undefined) this.updateStateLoading(changeNum);
+            return from(this.restApiService.getChangeDetail(changeNum));
+          })
+        )
+        .subscribe(change => {
+          // 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.
+          this.updateStateChange(change ?? undefined);
+        }),
+    ];
+  }
+
+  finalize() {
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+  }
+
+  // Temporary workaround until path is derived in the model itself.
+  updatePath(diffPath?: string) {
+    const current = this.getState();
+    this.setState({...current, diffPath});
+  }
+
+  /**
+   * Typically you would just subscribe to change$ yourself to get updates. But
+   * sometimes it is nice to also be able to get the current ChangeInfo on
+   * demand. So here it is for your convenience.
+   */
+  getChange() {
+    return this.getState().change;
+  }
+
+  /**
+   * Check whether there is no newer patch than the latest patch that was
+   * available when this change was loaded.
+   *
+   * @return A promise that yields true if the latest patch
+   *     has been loaded, and false if a newer patch has been uploaded in the
+   *     meantime. The promise is rejected on network error.
+   */
+  fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
+    const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
+    return this.restApiService.getChangeDetail(change._number).then(detail => {
+      if (!detail) {
+        const error = new Error('Change detail not found.');
+        return Promise.reject(error);
+      }
+      const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
+      if (!actualLatest || !knownLatest) {
+        const error = new Error('Unable to check for latest patchset.');
+        return Promise.reject(error);
+      }
+      return {
+        isLatest: actualLatest <= knownLatest,
+        newStatus: change.status !== detail.status ? detail.status : null,
+        newMessages:
+          (change.messages || []).length < (detail.messages || []).length
+            ? detail.messages![detail.messages!.length - 1]
+            : undefined,
+      };
+    });
+  }
+
+  /**
+   * Called when change detail loading is initiated.
+   *
+   * If the change number matches the current change in the state, then
+   * this is a reload. If not, then we not just want to set the state to
+   * LOADING instead of RELOADING, but we also want to set the change to
+   * undefined right away. Otherwise components could see inconsistent state:
+   * a new change number, but an old change.
+   */
+  private updateStateLoading(changeNum: NumericChangeId) {
+    const current = this.getState();
+    const reloading = current.change?._number === changeNum;
+    this.setState({
+      ...current,
+      change: reloading ? current.change : undefined,
+      loadingStatus: reloading
+        ? LoadingStatus.RELOADING
+        : LoadingStatus.LOADING,
+    });
+  }
+
+  // Private but used in tests.
+  updateStateChange(change?: ParsedChangeInfo) {
+    const current = this.getState();
+    this.setState({
+      ...current,
+      change,
+      loadingStatus:
+        change === undefined ? LoadingStatus.NOT_LOADED : LoadingStatus.LOADED,
+    });
+  }
+
+  getState(): ChangeState {
+    return this.privateState$.getValue();
+  }
+
+  // Private but used in tests
+  setState(state: ChangeState) {
+    this.privateState$.next(state);
+  }
+}
diff --git a/polygerrit-ui/app/services/change/change-services_test.ts b/polygerrit-ui/app/services/change/change-model_test.ts
similarity index 86%
rename from polygerrit-ui/app/services/change/change-services_test.ts
rename to polygerrit-ui/app/services/change/change-model_test.ts
index d05df85a..099a11f 100644
--- a/polygerrit-ui/app/services/change/change-services_test.ts
+++ b/polygerrit-ui/app/services/change/change-model_test.ts
@@ -32,15 +32,15 @@
   GerritView,
   _testOnly_setState as setRouterState,
 } from '../router/router-model';
-import {ChangeState, changeState$, LoadingStatus} from './change-model';
-import {ChangeService} from './change-service';
+import {ChangeState, LoadingStatus} from './change-model';
+import {ChangeModel} from './change-model';
 
 suite('change service tests', () => {
-  let changeService: ChangeService;
+  let changeModel: ChangeModel;
   let knownChange: ParsedChangeInfo;
   const testCompleted = new Subject<void>();
   setup(() => {
-    changeService = new ChangeService(getAppContext().restApiService);
+    changeModel = new ChangeModel(getAppContext().restApiService);
     knownChange = {
       ...createChange(),
       revisions: {
@@ -62,8 +62,8 @@
   });
 
   teardown(() => {
-    changeService.finalize();
     testCompleted.next();
+    changeModel.finalize();
   });
 
   test('load a change', async () => {
@@ -72,7 +72,9 @@
     let state: ChangeState | undefined = {
       loadingStatus: LoadingStatus.NOT_LOADED,
     };
-    changeState$.pipe(takeUntil(testCompleted)).subscribe(s => (state = s));
+    changeModel.changeState$
+      .pipe(takeUntil(testCompleted))
+      .subscribe(s => (state = s));
 
     await waitUntil(() => state?.loadingStatus === LoadingStatus.NOT_LOADED);
     assert.equal(stub.callCount, 0);
@@ -96,7 +98,9 @@
     let state: ChangeState | undefined = {
       loadingStatus: LoadingStatus.NOT_LOADED,
     };
-    changeState$.pipe(takeUntil(testCompleted)).subscribe(s => (state = s));
+    changeModel.changeState$
+      .pipe(takeUntil(testCompleted))
+      .subscribe(s => (state = s));
     setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
     promise.resolve(knownChange);
     await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
@@ -120,7 +124,9 @@
     let state: ChangeState | undefined = {
       loadingStatus: LoadingStatus.NOT_LOADED,
     };
-    changeState$.pipe(takeUntil(testCompleted)).subscribe(s => (state = s));
+    changeModel.changeState$
+      .pipe(takeUntil(testCompleted))
+      .subscribe(s => (state = s));
     setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
     promise.resolve(knownChange);
     await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
@@ -150,7 +156,9 @@
     let state: ChangeState | undefined = {
       loadingStatus: LoadingStatus.NOT_LOADED,
     };
-    changeState$.pipe(takeUntil(testCompleted)).subscribe(s => (state = s));
+    changeModel.changeState$
+      .pipe(takeUntil(testCompleted))
+      .subscribe(s => (state = s));
     setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
     promise.resolve(knownChange);
     await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
@@ -174,15 +182,15 @@
     assert.equal(state?.change, knownChange);
   });
 
-  test('changeService.fetchChangeUpdates on latest', async () => {
+  test('changeModel.fetchChangeUpdates on latest', async () => {
     stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
+    const result = await changeModel.fetchChangeUpdates(knownChange);
     assert.isTrue(result.isLatest);
     assert.isNotOk(result.newStatus);
     assert.isNotOk(result.newMessages);
   });
 
-  test('changeService.fetchChangeUpdates not on latest', async () => {
+  test('changeModel.fetchChangeUpdates not on latest', async () => {
     const actualChange = {
       ...knownChange,
       revisions: {
@@ -195,31 +203,31 @@
       },
     };
     stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
+    const result = await changeModel.fetchChangeUpdates(knownChange);
     assert.isFalse(result.isLatest);
     assert.isNotOk(result.newStatus);
     assert.isNotOk(result.newMessages);
   });
 
-  test('changeService.fetchChangeUpdates new status', async () => {
+  test('changeModel.fetchChangeUpdates new status', async () => {
     const actualChange = {
       ...knownChange,
       status: ChangeStatus.MERGED,
     };
     stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
+    const result = await changeModel.fetchChangeUpdates(knownChange);
     assert.isTrue(result.isLatest);
     assert.equal(result.newStatus, ChangeStatus.MERGED);
     assert.isNotOk(result.newMessages);
   });
 
-  test('changeService.fetchChangeUpdates new messages', async () => {
+  test('changeModel.fetchChangeUpdates new messages', async () => {
     const actualChange = {
       ...knownChange,
       messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
     };
     stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
+    const result = await changeModel.fetchChangeUpdates(knownChange);
     assert.isTrue(result.isLatest);
     assert.isNotOk(result.newStatus);
     assert.deepEqual(result.newMessages, {
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
deleted file mode 100644
index bcc62b2..0000000
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {combineLatest, from, fromEvent, Observable, Subscription} from 'rxjs';
-import {map, startWith, switchMap} from 'rxjs/operators';
-import {routerChangeNum$} from '../router/router-model';
-import {
-  change$,
-  updateStateChange,
-  updateStateLoading,
-  updateStatePath,
-} from './change-model';
-import {ParsedChangeInfo} from '../../types/types';
-import {ChangeInfo} from '../../types/common';
-import {
-  computeAllPatchSets,
-  computeLatestPatchNum,
-} from '../../utils/patch-set-util';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {Finalizable} from '../registry';
-
-export class ChangeService implements Finalizable {
-  private change?: ParsedChangeInfo;
-
-  private readonly subscriptions: Subscription[] = [];
-
-  // For usage in `combineLatest` we need `startWith` such that reload$ has an
-  // initial value.
-  private readonly reload$: Observable<unknown> = fromEvent(
-    document,
-    'reload'
-  ).pipe(startWith(undefined));
-
-  constructor(readonly restApiService: RestApiService) {
-    this.subscriptions.push(
-      combineLatest([routerChangeNum$, this.reload$])
-        .pipe(
-          map(([changeNum, _]) => changeNum),
-          switchMap(changeNum => {
-            if (changeNum !== undefined) updateStateLoading(changeNum);
-            return from(this.restApiService.getChangeDetail(changeNum));
-          })
-        )
-        .subscribe(change => {
-          // 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.
-          updateStateChange(change ?? undefined);
-        })
-    );
-    this.subscriptions.push(
-      change$.subscribe(change => {
-        this.change = change;
-      })
-    );
-  }
-
-  finalize() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions.splice(0, this.subscriptions.length);
-  }
-
-  // Temporary workaround until path is derived in the model itself.
-  updatePath(path?: string) {
-    updateStatePath(path);
-  }
-
-  /**
-   * Typically you would just subscribe to change$ yourself to get updates. But
-   * sometimes it is nice to also be able to get the current ChangeInfo on
-   * demand. So here it is for your convenience.
-   */
-  getChange() {
-    return this.change;
-  }
-
-  /**
-   * Check whether there is no newer patch than the latest patch that was
-   * available when this change was loaded.
-   *
-   * @return A promise that yields true if the latest patch
-   *     has been loaded, and false if a newer patch has been uploaded in the
-   *     meantime. The promise is rejected on network error.
-   */
-  fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
-    const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
-    return this.restApiService.getChangeDetail(change._number).then(detail => {
-      if (!detail) {
-        const error = new Error('Change detail not found.');
-        return Promise.reject(error);
-      }
-      const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
-      if (!actualLatest || !knownLatest) {
-        const error = new Error('Unable to check for latest patchset.');
-        return Promise.reject(error);
-      }
-      return {
-        isLatest: actualLatest <= knownLatest,
-        newStatus: change.status !== detail.status ? detail.status : null,
-        newMessages:
-          (change.messages || []).length < (detail.messages || []).length
-            ? detail.messages![detail.messages!.length - 1]
-            : undefined,
-      };
-    });
-  }
-}
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 6036d07..4024747 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -48,7 +48,7 @@
   FetchResponse,
   ResponseCode,
 } from '../../api/checks';
-import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
+import {ChangeModel} from '../change/change-model';
 import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
 import {getCurrentRevision} from '../../utils/change-util';
 import {getShaByPatchNum} from '../../utils/patch-set-util';
@@ -321,24 +321,28 @@
       .filter(r => r !== undefined)
   );
 
-  constructor(readonly reporting: ReportingService) {
+  constructor(
+    readonly changeModel: ChangeModel,
+    readonly reporting: ReportingService
+  ) {
     this.subscriptions = [
-      changeNum$.subscribe(x => (this.changeNum = x)),
+      this.changeModel.changeNum$.subscribe(x => (this.changeNum = x)),
       this.checkToPluginMap$.subscribe(map => {
         this.checkToPluginMap = map;
       }),
-      combineLatest([routerPatchNum$, latestPatchNum$]).subscribe(
-        ([routerPs, latestPs]) => {
-          this.latestPatchNum = latestPs;
-          if (latestPs === undefined) {
-            this.setPatchset(undefined);
-          } else if (typeof routerPs === 'number') {
-            this.setPatchset(routerPs);
-          } else {
-            this.setPatchset(latestPs);
-          }
+      combineLatest([
+        routerPatchNum$,
+        this.changeModel.latestPatchNum$,
+      ]).subscribe(([routerPs, latestPs]) => {
+        this.latestPatchNum = latestPs;
+        if (latestPs === undefined) {
+          this.setPatchset(undefined);
+        } else if (typeof routerPs === 'number') {
+          this.setPatchset(routerPs);
+        } else {
+          this.setPatchset(latestPs);
         }
-      ),
+      }),
     ];
     this.visibilityChangeListener = () => {
       this.documentVisibilityChange$.next(undefined);
@@ -634,9 +638,9 @@
     // 4. A hidden Gerrit tab becoming visible.
     this.subscriptions.push(
       combineLatest([
-        changeNum$,
+        this.changeModel.changeNum$,
         patchset === ChecksPatchset.LATEST
-          ? latestPatchNum$
+          ? this.changeModel.latestPatchNum$
           : this.checksSelectedPatchsetNumber$,
         this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
         timer(0, pollIntervalMs),
@@ -645,7 +649,7 @@
         .pipe(
           takeWhile(_ => !!this.providers[pluginName]),
           filter(_ => document.visibilityState !== 'hidden'),
-          withLatestFrom(change$),
+          withLatestFrom(this.changeModel.change$),
           switchMap(
             ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
               if (!change || !changeNum || !patchNum) return of(this.empty());
diff --git a/polygerrit-ui/app/services/checks/checks-model_test.ts b/polygerrit-ui/app/services/checks/checks-model_test.ts
index 390f6ad..0d46289 100644
--- a/polygerrit-ui/app/services/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/services/checks/checks-model_test.ts
@@ -18,7 +18,7 @@
 import './checks-model';
 import {ChecksModel, ChecksPatchset, ChecksProviderState} from './checks-model';
 import {Category, CheckRun, RunStatus} from '../../api/checks';
-import {grReportingMock} from '../gr-reporting/gr-reporting_mock';
+import {getAppContext} from '../app-context';
 
 const PLUGIN_NAME = 'test-plugin';
 
@@ -45,7 +45,10 @@
   let current: ChecksProviderState;
 
   setup(() => {
-    model = new ChecksModel(grReportingMock);
+    model = new ChecksModel(
+      getAppContext().changeModel,
+      getAppContext().reportingService
+    );
     model.checksLatest$.subscribe(c => (current = c[PLUGIN_NAME]));
   });
 
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
index ebc6655..d46e08a 100644
--- a/polygerrit-ui/app/services/comments/comments-model.ts
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -43,7 +43,7 @@
 import {fire, fireAlert, fireEvent} from '../../utils/event-util';
 import {CURRENT} from '../../utils/patch-set-util';
 import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {changeNum$, currentPatchNum$} from '../change/change-model';
+import {ChangeModel} from '../change/change-model';
 import {Interaction} from '../../constants/reporting';
 import {assertIsDefined} from '../../utils/common-util';
 import {debounce, DelayedTask} from '../../utils/async-util';
@@ -291,6 +291,7 @@
   private discardedDrafts: DraftInfo[] = [];
 
   constructor(
+    readonly changeModel: ChangeModel,
     readonly restApiService: RestApiService,
     readonly reporting: ReportingService
   ) {
@@ -301,7 +302,7 @@
       this.drafts$.subscribe(x => (this.drafts = x ?? {}))
     );
     this.subscriptions.push(
-      currentPatchNum$.subscribe(x => (this.patchNum = x))
+      this.changeModel.currentPatchNum$.subscribe(x => (this.patchNum = x))
     );
     this.subscriptions.push(
       routerChangeNum$.subscribe(changeNum => {
@@ -311,13 +312,14 @@
       })
     );
     this.subscriptions.push(
-      combineLatest([changeNum$, currentPatchNum$]).subscribe(
-        ([changeNum, patchNum]) => {
-          this.changeNum = changeNum;
-          this.patchNum = patchNum;
-          this.reloadAllPortedComments();
-        }
-      )
+      combineLatest([
+        this.changeModel.changeNum$,
+        this.changeModel.currentPatchNum$,
+      ]).subscribe(([changeNum, patchNum]) => {
+        this.changeNum = changeNum;
+        this.patchNum = patchNum;
+        this.reloadAllPortedComments();
+      })
     );
     this.reloadListener = () => {
       this.reloadAllComments();
diff --git a/polygerrit-ui/app/services/comments/comments-model_test.ts b/polygerrit-ui/app/services/comments/comments-model_test.ts
index 93a1d52..f39cad8 100644
--- a/polygerrit-ui/app/services/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/services/comments/comments-model_test.ts
@@ -30,7 +30,6 @@
 } from '../../test/test-data-generators';
 import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {getAppContext} from '../app-context';
-import {updateStateChange} from '../change/change-model';
 import {
   GerritView,
   updateState as updateRouterState,
@@ -77,6 +76,7 @@
 
   test('loads comments', async () => {
     const model = new CommentsModel(
+      getAppContext().changeModel,
       getAppContext().restApiService,
       getAppContext().reportingService
     );
@@ -103,7 +103,7 @@
     );
 
     updateRouterState(GerritView.CHANGE, TEST_NUMERIC_CHANGE_ID);
-    updateStateChange(createParsedChange());
+    model.changeModel.updateStateChange(createParsedChange());
 
     await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
     await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
diff --git a/polygerrit-ui/app/services/config/config-model.ts b/polygerrit-ui/app/services/config/config-model.ts
index 663935f..91a77b8 100644
--- a/polygerrit-ui/app/services/config/config-model.ts
+++ b/polygerrit-ui/app/services/config/config-model.ts
@@ -19,7 +19,7 @@
 import {switchMap} from 'rxjs/operators';
 import {Finalizable} from '../registry';
 import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {repo$} from '../change/change-model';
+import {ChangeModel} from '../change/change-model';
 import {select} from '../../utils/observable-util';
 
 export interface ConfigState {
@@ -55,12 +55,15 @@
 
   private subscriptions: Subscription[];
 
-  constructor(readonly restApiService: RestApiService) {
+  constructor(
+    readonly changeModel: ChangeModel,
+    readonly restApiService: RestApiService
+  ) {
     this.subscriptions = [
       from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
         this.updateServerConfig(config);
       }),
-      repo$
+      this.changeModel.repo$
         .pipe(
           switchMap((repo?: RepoName) => {
             if (repo === undefined) return of(undefined);
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index fbc0b48..b8a5e49 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -45,7 +45,6 @@
 import {_testOnly_allTasks} from '../utils/async-util';
 import {cleanUpStorage} from '../services/storage/gr-storage_mock';
 
-import {_testOnly_resetState as resetChangeState} from '../services/change/change-model';
 import {_testOnly_resetState as resetRouterState} from '../services/router/router-model';
 
 declare global {
@@ -112,7 +111,6 @@
   // tests.
   initGlobalVariables(appContext);
 
-  resetChangeState();
   resetRouterState();
 
   const shortcuts = appContext.shortcutsService;
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 42aa425..01d776e 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -25,7 +25,7 @@
 import {GrAuthMock} from '../services/gr-auth/gr-auth_mock';
 import {FlagsServiceImplementation} from '../services/flags/flags_impl';
 import {EventEmitter} from '../services/gr-event-interface/gr-event-interface_impl';
-import {ChangeService} from '../services/change/change-service';
+import {ChangeModel} from '../services/change/change-model';
 import {ChecksModel} from '../services/checks/checks-model';
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
 import {UserModel} from '../services/user/user-model';
@@ -45,18 +45,24 @@
       return new GrAuthMock(ctx.eventEmitter);
     },
     restApiService: (_ctx: Partial<AppContext>) => grRestApiMock,
-    changeService: (ctx: Partial<AppContext>) => {
+    changeModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ChangeService(ctx.restApiService!);
+      return new ChangeModel(ctx.restApiService!);
     },
     commentsModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.changeModel, 'changeModel');
       assertIsDefined(ctx.restApiService, 'restApiService');
       assertIsDefined(ctx.reportingService, 'reportingService');
-      return new CommentsModel(ctx.restApiService!, ctx.reportingService!);
+      return new CommentsModel(
+        ctx.changeModel!,
+        ctx.restApiService!,
+        ctx.reportingService!
+      );
     },
     checksModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.changeModel, 'changeModel');
       assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ChecksModel(ctx.reportingService!);
+      return new ChecksModel(ctx.changeModel!, ctx.reportingService!);
     },
     jsApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.reportingService, 'reportingService');
@@ -64,8 +70,9 @@
     },
     storageService: (_ctx: Partial<AppContext>) => grStorageMock,
     configModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.changeModel, 'changeModel');
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ConfigModel(ctx.restApiService!);
+      return new ConfigModel(ctx.changeModel!, ctx.restApiService!);
     },
     userModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');