Add a model for router and change

Also adds a change-service, which maintains the change model.

The new router model is maintained by gr-router, which will eventually
be moved and renamed to router-service.

Change-Id: I15fcf39d1de21dea975a687fb6efb6823d658c22
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index c0b6074..2a607a7 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -39,7 +39,6 @@
 import {getBaseUrl} from '../../../utils/url-util';
 import {
   GerritNav,
-  GerritView,
   GroupDetailView,
   RepoDetailView,
 } from '../../core/gr-navigation/gr-navigation';
@@ -66,6 +65,7 @@
 import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
 import {appContext} from '../../../services/app-context';
+import {GerritView} from '../../../services/router/router-model';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index 15211cf..cac409d 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -18,9 +18,10 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-admin-view.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
+import {GerritView} from '../../../services/router/router-model.js';
 
 const basicFixture = fixtureFromElement('gr-admin-view');
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 26368ec..973ccc8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -25,7 +25,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list-view_html';
 import {page} from '../../../utils/page-wrapper-utils';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
 import {AppElementParams} from '../../gr-app-types';
 import {
@@ -41,6 +41,7 @@
 import {ChangeListViewState} from '../../../types/types';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {GerritView} from '../../../services/router/router-model';
 
 const LookupQueryPatterns = {
   CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index f3ed408..0a87503 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -29,7 +29,6 @@
 import {htmlTemplate} from './gr-dashboard-view_html';
 import {
   GerritNav,
-  GerritView,
   UserDashboard,
   YOUR_TURN,
 } from '../../core/gr-navigation/gr-navigation';
@@ -57,6 +56,7 @@
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {DashboardViewState} from '../../../types/types';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {GerritView} from '../../../services/router/router-model';
 
 const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index 1fa24ba..1f5d519 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -18,7 +18,8 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-dashboard-view.js';
 import {isHidden} from '../../../test/test-utils.js';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritView} from '../../../services/router/router-model.js';
 import {changeIsOpen} from '../../../utils/change-util.js';
 import {ChangeStatus} from '../../../constants/constants.js';
 import {createAccountWithId} from '../../../test/test-data-generators.js';
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 df52323..950fbf3 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
@@ -55,7 +55,7 @@
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
 import {getComputedStyleValue} from '../../../utils/dom-util';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
@@ -152,6 +152,7 @@
 import {fireAlert, fireEvent, firePageError} from '../../../utils/event-util';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {fireTitleChange} from '../../../utils/event-util';
+import {GerritView} from '../../../services/router/router-model';
 
 const CHANGE_ID_ERROR = {
   MISMATCH: 'mismatch',
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 5b4e65d..5cb84b4 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
@@ -30,7 +30,7 @@
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getComputedStyleValue} from '../../../utils/dom-util';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
 import {EventType, PluginApi} from '../../plugins/gr-plugin-types';
@@ -102,6 +102,7 @@
 import 'lodash/lodash';
 import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {GerritView} from '../../../services/router/router-model';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 8470611..83bf3ca 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -16,23 +16,24 @@
  */
 import {
   BranchName,
+  ChangeConfigInfo,
   ChangeInfo,
+  CommentLinks,
+  CommitId,
+  DashboardId,
+  EditPatchSetNum,
+  GroupId,
+  Hashtag,
+  NumericChangeId,
+  ParentPatchSetNum,
   PatchSetNum,
   RepoName,
-  TopicName,
-  GroupId,
-  DashboardId,
-  NumericChangeId,
-  EditPatchSetNum,
-  ChangeConfigInfo,
-  CommitId,
-  Hashtag,
-  UrlEncodedCommentId,
-  CommentLinks,
-  ParentPatchSetNum,
   ServerInfo,
+  TopicName,
+  UrlEncodedCommentId,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {GerritView} from '../../../services/router/router-model';
 
 // Navigation parameters object format:
 //
@@ -396,22 +397,6 @@
   url?: string;
 }
 
-export enum GerritView {
-  ADMIN = 'admin',
-  AGREEMENTS = 'agreements',
-  CHANGE = 'change',
-  DASHBOARD = 'dashboard',
-  DIFF = 'diff',
-  DOCUMENTATION_SEARCH = 'documentation-search',
-  EDIT = 'edit',
-  GROUP = 'group',
-  PLUGIN_SCREEN = 'plugin-screen',
-  REPO = 'repo',
-  ROOT = 'root',
-  SEARCH = 'search',
-  SETTINGS = 'settings',
-}
-
 export enum GroupDetailView {
   MEMBERS = 'members',
   LOG = 'log',
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 0665cca..07045cd 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -26,6 +26,7 @@
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {
   DashboardSection,
+  GeneratedWebLink,
   GenerateUrlChangeViewParameters,
   GenerateUrlDashboardViewParameters,
   GenerateUrlDiffViewParameters,
@@ -38,18 +39,16 @@
   GenerateWebLinksFileParameters,
   GenerateWebLinksParameters,
   GenerateWebLinksPatchsetParameters,
-  GerritView,
+  GerritNav,
+  GroupDetailView,
   isGenerateUrlDiffViewParameters,
   RepoDetailView,
   WeblinkType,
-  GroupDetailView,
-  GerritNav,
-  GeneratedWebLink,
 } from '../gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
 import {
-  patchNumEquals,
   convertToPatchSetNum,
+  patchNumEquals,
 } from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {assertNever} from '../../../utils/common-util';
@@ -64,10 +63,11 @@
 } from '../../../types/common';
 import {
   AppElement,
-  AppElementParams,
   AppElementAgreementParam,
+  AppElementParams,
 } from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
+import {GerritView, updateState} from '../../../services/router/router-model';
 
 const RoutePattern = {
   ROOT: '/',
@@ -308,8 +308,13 @@
 
   private readonly restApiService = appContext.restApiService;
 
+  private readonly changeService = appContext.changeService;
+
   constructor() {
     super();
+    // TODO: This is just an artificical dependdency such that the service is
+    // instantiated and its observables subscribed. Remove this later.
+    this.changeService.dontDoAnything();
   }
 
   start() {
@@ -320,6 +325,11 @@
   }
 
   _setParams(params: AppElementParams | GenerateUrlParameters) {
+    updateState(
+      params.view,
+      'changeNum' in params ? params.changeNum : undefined,
+      'patchNum' in params ? params.patchNum ?? undefined : undefined
+    );
     this._appElement().params = params;
   }
 
@@ -1535,8 +1545,7 @@
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlChangeViewParameters = {
       project: ctx.params[0] as RepoName,
-      // TODO(TS): remove as unknown
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum: Number(ctx.params[1]) as NumericChangeId,
       basePatchNum: convertToPatchSetNum(ctx.params[4]),
       patchNum: convertToPatchSetNum(ctx.params[6]),
       view: GerritView.CHANGE,
@@ -1550,7 +1559,7 @@
   _handleCommentRoute(ctx: PageContextWithQueryMap) {
     const params: GenerateUrlDiffViewParameters = {
       project: ctx.params[0] as RepoName,
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum: Number(ctx.params[1]) as NumericChangeId,
       commentId: ctx.params[2] as UrlEncodedCommentId,
       view: GerritView.DIFF,
       commentLink: true,
@@ -1563,7 +1572,7 @@
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlDiffViewParameters = {
       project: ctx.params[0] as RepoName,
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum: Number(ctx.params[1]) as NumericChangeId,
       basePatchNum: convertToPatchSetNum(ctx.params[4]),
       patchNum: convertToPatchSetNum(ctx.params[6]),
       path: ctx.params[8],
@@ -1581,7 +1590,7 @@
   _handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlLegacyChangeViewParameters = {
-      changeNum: (ctx.params[0] as unknown) as NumericChangeId,
+      changeNum: Number(ctx.params[0]) as NumericChangeId,
       basePatchNum: convertToPatchSetNum(ctx.params[3]),
       patchNum: convertToPatchSetNum(ctx.params[5]),
       view: GerritView.CHANGE,
@@ -1598,8 +1607,7 @@
   _handleDiffLegacyRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlLegacyDiffViewParameters = {
-      // TODO(TS): remove "as unknown"
-      changeNum: (ctx.params[0] as unknown) as NumericChangeId,
+      changeNum: Number(ctx.params[0]) as NumericChangeId,
       basePatchNum: convertToPatchSetNum(ctx.params[2]),
       patchNum: convertToPatchSetNum(ctx.params[4]),
       path: ctx.params[5],
@@ -1620,7 +1628,7 @@
     const project = ctx.params[0] as RepoName;
     this._redirectOrNavigate({
       project,
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum: Number(ctx.params[1]) as NumericChangeId,
       // for edit view params, patchNum cannot be undefined
       patchNum: convertToPatchSetNum(ctx.params[2])!,
       path: ctx.params[3],
@@ -1635,8 +1643,7 @@
     const project = ctx.params[0] as RepoName;
     this._redirectOrNavigate({
       project,
-      // TODO(TS): remove "as unknown"
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum: Number(ctx.params[1]) as NumericChangeId,
       patchNum: convertToPatchSetNum(ctx.params[3]),
       view: GerritView.CHANGE,
       edit: true,
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
index cdec405..ff8f8df 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -1540,7 +1540,7 @@
           ]);
           assertDataToParams({params: groups.slice(1)}, '_handleCommentRoute', {
             project: 'gerrit',
-            changeNum: '264833',
+            changeNum: 264833,
             commentId: '00049681_f34fd6a9',
             commentLink: true,
             view: GerritNav.View.DIFF,
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 853d3c6..36317db 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
@@ -40,7 +40,7 @@
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {pluralize} from '../../../utils/string-util';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
@@ -97,6 +97,7 @@
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 
 import {KnownExperimentId} from '../../../services/flags/flags';
+import {GerritView} from '../../../services/router/router-model';
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
 const MSG_LOADED_BLAME = 'Blame loaded';
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 1cb22e8..92494c2 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
@@ -23,7 +23,7 @@
 import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {_testOnly_findCommentById} from '../gr-comment-api/gr-comment-api.js';
-import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritView} from '../../../services/router/router-model.js';
 import {
   createChange,
   createRevisions,
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index feca35c..1d42bba 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -48,7 +48,7 @@
   Shortcut,
   SPECIAL_SHORTCUT,
 } from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {GerritNav, GerritView} from './core/gr-navigation/gr-navigation';
+import {GerritNav} from './core/gr-navigation/gr-navigation';
 import {appContext} from '../services/app-context';
 import {flush} from '@polymer/polymer/lib/utils/flush';
 import {customElement, observe, property} from '@polymer/decorators';
@@ -78,6 +78,7 @@
 } from '../types/events';
 import {ViewState} from '../types/types';
 import {EventType} from '../utils/event-util';
+import {GerritView} from '../services/router/router-model';
 
 interface ErrorInfo {
   text: string;
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index b05117f..4809562 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -16,7 +16,6 @@
  */
 import {
   GenerateUrlParameters,
-  GerritView,
   GroupDetailView,
   RepoDetailView,
 } from './core/gr-navigation/gr-navigation';
@@ -28,6 +27,7 @@
   RepoName,
   UrlEncodedCommentId,
 } from '../types/common';
+import {GerritView} from '../services/router/router-model';
 
 export interface AppElement extends HTMLElement {
   params: AppElementParams | GenerateUrlParameters;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index e428a0a..e5e8537 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -63,11 +63,11 @@
 } from '../../../types/common';
 import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
 import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
-import {GerritView} from '../../core/gr-navigation/gr-navigation';
 import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {GerritView} from '../../../services/router/router-model';
 
 const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
   'changes_per_page',
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
index a01edac..62e2a9e 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
@@ -19,7 +19,7 @@
 import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import './gr-settings-view.js';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritView} from '../../../services/router/router-model.js';
 
 const basicFixture = fixtureFromElement('gr-settings-view');
 const blankFixture = fixtureFromElement('div');
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index cb389bd4..290fba4 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -20,6 +20,7 @@
 import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
 import {Auth} from './gr-auth/gr-auth_impl';
 import {GrRestApiInterface} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
+import {ChangeService} from './change/change-service';
 
 type ServiceName = keyof AppContext;
 type ServiceCreator<T> = () => T;
@@ -67,5 +68,6 @@
     eventEmitter: () => new EventEmitter(),
     authService: () => new Auth(appContext.eventEmitter),
     restApiService: () => new GrRestApiInterface(appContext.authService),
+    changeService: () => new ChangeService(appContext.restApiService),
   });
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 720e489..869201e 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -19,6 +19,7 @@
 import {ReportingService} from './gr-reporting/gr-reporting';
 import {AuthService} from './gr-auth/gr-auth';
 import {RestApiService} from './services/gr-rest-api/gr-rest-api';
+import {ChangeService} from './change/change-service';
 
 export interface AppContext {
   flagsService: FlagsService;
@@ -26,6 +27,7 @@
   eventEmitter: EventEmitterService;
   authService: AuthService;
   restApiService: RestApiService;
+  changeService: ChangeService;
 }
 
 /**
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
new file mode 100644
index 0000000..d02535c
--- /dev/null
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -0,0 +1,106 @@
+/**
+ * @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 {PatchSetNum} from '../../types/common';
+import {BehaviorSubject, combineLatest, Observable} from 'rxjs';
+import {
+  map,
+  filter,
+  withLatestFrom,
+  distinctUntilChanged,
+} from 'rxjs/operators';
+import {routerPatchNum$, routerState$} from '../router/router-model';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+} from '../../utils/patch-set-util';
+import {ParsedChangeInfo} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+interface ChangeState {
+  change?: ParsedChangeInfo;
+}
+
+// TODO: Figure out how to best enforce immutability of all states. Use Immer?
+// Use DeepReadOnly?
+const initialState: ChangeState = {};
+
+const privateState$ = new BehaviorSubject(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const changeState$: Observable<ChangeState> = privateState$;
+
+// Must only be used by the change service or whatever is in control of this
+// model.
+export function updateState(change?: ParsedChangeInfo) {
+  privateState$.next({
+    ...privateState$.getValue(),
+    change,
+  });
+}
+
+/**
+ * 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 changeNum$ = change$.pipe(
+  map(change => change?._number),
+  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!
+ *
+ * TODO: It would be good to assert/enforce somehow that currentPatchNum$ cannot
+ * emit 'PARENT'.
+ */
+export const currentPatchNum$: Observable<
+  PatchSetNum | undefined
+> = changeAndRouterConsistent$.pipe(
+  withLatestFrom(routerPatchNum$, latestPatchNum$),
+  map(([_, routerPatchNum, latestPatchNum]) => {
+    return routerPatchNum || latestPatchNum;
+  }),
+  distinctUntilChanged()
+);
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
new file mode 100644
index 0000000..a510855
--- /dev/null
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -0,0 +1,41 @@
+/**
+ * @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 {routerChangeNum$} from '../router/router-model';
+import {updateState} from './change-model';
+import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
+import {switchMap, tap} from 'rxjs/operators';
+import {of, from} from 'rxjs';
+
+export class ChangeService {
+  private routerChangeNumEffect = routerChangeNum$.pipe(
+    switchMap(changeNum => {
+      if (!changeNum) return of(undefined);
+      return from(this.restApiService.getChangeDetail(changeNum));
+    }),
+    tap(change => {
+      updateState(change ?? undefined);
+    })
+  );
+
+  constructor(private readonly restApiService: RestApiService) {
+    this.routerChangeNumEffect.subscribe();
+  }
+
+  // TODO: Remove.
+  dontDoAnything() {}
+}
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
new file mode 100644
index 0000000..b2d99480
--- /dev/null
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -0,0 +1,76 @@
+/**
+ * @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 {NumericChangeId, PatchSetNum} from '../../types/common';
+import {BehaviorSubject, Observable} from 'rxjs';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+
+export enum GerritView {
+  ADMIN = 'admin',
+  AGREEMENTS = 'agreements',
+  CHANGE = 'change',
+  DASHBOARD = 'dashboard',
+  DIFF = 'diff',
+  DOCUMENTATION_SEARCH = 'documentation-search',
+  EDIT = 'edit',
+  GROUP = 'group',
+  PLUGIN_SCREEN = 'plugin-screen',
+  REPO = 'repo',
+  ROOT = 'root',
+  SEARCH = 'search',
+  SETTINGS = 'settings',
+}
+
+interface RouterState {
+  view?: GerritView;
+  changeNum?: NumericChangeId;
+  patchNum?: PatchSetNum;
+}
+
+// TODO: Figure out how to best enforce immutability of all states. Use Immer?
+// Use DeepReadOnly?
+const initialState: RouterState = {};
+
+const privateState$ = new BehaviorSubject<RouterState>(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const routerState$: Observable<RouterState> = privateState$;
+
+// Must only be used by the router service or whatever is in control of this
+// model.
+export function updateState(
+  view?: GerritView,
+  changeNum?: NumericChangeId,
+  patchNum?: PatchSetNum
+) {
+  privateState$.next({
+    ...privateState$.getValue(),
+    view,
+    changeNum,
+    patchNum,
+  });
+}
+
+export const routerChangeNum$ = routerState$.pipe(
+  map(state => state.changeNum),
+  distinctUntilChanged()
+);
+
+export const routerPatchNum$ = routerState$.pipe(
+  map(state => state.patchNum),
+  distinctUntilChanged()
+);
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 1d07cf3..eed31b7 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -83,7 +83,6 @@
 import {formatDate} from '../utils/date-util';
 import {GetDiffCommentsOutput} from '../services/services/gr-rest-api/gr-rest-api';
 import {AppElementChangeViewParams} from '../elements/gr-app-types';
-import {GerritView} from '../elements/core/gr-navigation/gr-navigation';
 import {
   EditRevisionInfo,
   ParsedChangeInfo,
@@ -91,6 +90,7 @@
 import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
 import {WebLinkInfo} from '../types/diff';
 import {UIComment, UIDraft} from '../utils/comment-util';
+import {GerritView} from '../services/router/router-model';
 
 export function dateToTimestamp(date: Date): Timestamp {
   const nanosecondSuffix = '.000000000';
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 1628134..5574add 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -262,7 +262,6 @@
   deletions: number; // Number of deleted lines
   total_comment_count?: number;
   unresolved_comment_count?: number;
-  // TODO(TS): Use changed_id everywhere in code instead of (legacy) _number
   _number: NumericChangeId;
   owner: AccountInfo;
   actions?: ActionNameToActionInfoMap;
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
index 06f4e3a..b144313 100644
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -16,7 +16,6 @@
  */
 import {
   GerritNav,
-  GerritView,
   RepoDetailView,
   GroupDetailView,
 } from '../elements/core/gr-navigation/gr-navigation';
@@ -28,6 +27,7 @@
 } from '../types/common';
 import {MenuLink} from '../elements/plugins/gr-admin-api/gr-admin-api';
 import {hasOwnProperty} from './common-util';
+import {GerritView} from '../services/router/router-model';
 
 const ADMIN_LINKS: NavLink[] = [
   {
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index d063168..fda302b 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -198,11 +198,9 @@
  *     above
  */
 export function computeAllPatchSets(
-  change: ChangeInfo | ParsedChangeInfo
+  change: ChangeInfo | ParsedChangeInfo | undefined
 ): PatchSet[] {
-  if (!change) {
-    return [];
-  }
+  if (!change) return [];
 
   let patchNums: PatchSet[] = [];
   if (change.revisions && Object.keys(change.revisions).length) {