Merge "Migration of RouterModel to be a non-singleton."
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 46e1647..1d66aa5 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
@@ -180,7 +180,7 @@
   fireReload,
   fireTitleChange,
 } from '../../../utils/event-util';
-import {GerritView, routerView$} from '../../../services/router/router-model';
+import {GerritView} from '../../../services/router/router-model';
 import {
   debounce,
   DelayedTask,
@@ -604,6 +604,8 @@
   // Private but used in tests.
   readonly changeModel = getAppContext().changeModel;
 
+  private readonly routerModel = getAppContext().routerModel;
+
   private readonly commentsModel = getAppContext().commentsModel;
 
   private readonly shortcuts = getAppContext().shortcutsService;
@@ -632,7 +634,7 @@
       })
     );
     this.subscriptions.push(
-      routerView$.subscribe(view => {
+      this.routerModel.routerView$.subscribe(view => {
         this.isViewCurrent = view === GerritView.CHANGE;
       })
     );
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 027c976..2a35494 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -66,7 +66,7 @@
   AppElementParams,
 } from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
-import {GerritView, updateState} from '../../../services/router/router-model';
+import {GerritView} from '../../../services/router/router-model';
 import {firePageError} from '../../../utils/event-util';
 import {addQuotesWhen} from '../../../utils/string-util';
 import {windowLocationReload} from '../../../utils/dom-util';
@@ -311,6 +311,8 @@
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly routerModel = getAppContext().routerModel;
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly flagsService = getAppContext().flagsService;
@@ -323,11 +325,11 @@
   }
 
   _setParams(params: AppElementParams | GenerateUrlParameters) {
-    updateState(
-      params.view,
-      'changeNum' in params ? params.changeNum : undefined,
-      'patchNum' in params ? params.patchNum ?? undefined : undefined
-    );
+    this.routerModel.updateState({
+      view: params.view,
+      changeNum: 'changeNum' in params ? params.changeNum : undefined,
+      patchNum: 'patchNum' in params ? params.patchNum ?? undefined : undefined,
+    });
     this._appElement().params = params;
   }
 
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 e527134..ff02d61 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
@@ -108,7 +108,7 @@
 import {AppElementParams, AppElementDiffViewParam} from '../../gr-app-types';
 import {EventType, OpenFixPreviewEvent} from '../../../types/events';
 import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
-import {GerritView, routerView$} from '../../../services/router/router-model';
+import {GerritView} from '../../../services/router/router-model';
 import {assertIsDefined} from '../../../utils/common-util';
 import {addGlobalShortcut, Key, toggleClass} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
@@ -356,6 +356,9 @@
   private readonly restApiService = getAppContext().restApiService;
 
   // Private but used in tests.
+  readonly routerModel = getAppContext().routerModel;
+
+  // Private but used in tests.
   readonly userModel = getAppContext().userModel;
 
   // Private but used in tests.
@@ -420,7 +423,7 @@
     this.subscriptions.push(
       combineLatest([
         this.changeModel.currentPatchNum$,
-        routerView$,
+        this.routerModel.routerView$,
         this.changeModel.diffPath$,
         this.userModel.diffPreferences$,
       ])
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 4c4361d..b2d5a27 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
@@ -21,7 +21,7 @@
 import {ChangeStatus, DiffViewMode, createDefaultDiffPrefs} from '../../../constants/constants.js';
 import {stubRestApi, stubUsers, waitUntil} from '../../../test/test-utils.js';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
-import {GerritView, _testOnly_setState as setRouterModelState} from '../../../services/router/router-model.js';
+import {GerritView} from '../../../services/router/router-model.js';
 import {
   createChange,
   createRevisions,
@@ -1206,7 +1206,7 @@
         change: createChange(),
         diffPath: '/COMMIT_MSG'});
 
-      setRouterModelState({
+      element.routerModel.setState({
         changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
       );
       element._patchRange = {
@@ -1245,7 +1245,7 @@
             change: createChange(),
             diffPath: '/COMMIT_MSG'});
 
-          setRouterModelState({
+          element.routerModel.setState({
             changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF,
             patchNum: 22}
           );
@@ -1271,7 +1271,7 @@
         change: createChange(),
         diffPath: '/COMMIT_MSG'});
 
-      setRouterModelState({
+      element.routerModel.setState({
         changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
       );
 
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 9b3e75d..38ed276 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -96,6 +96,9 @@
     userModel: (_ctx: Partial<AppContext>) => {
       throw new Error('userModel is not implemented');
     },
+    routerModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('routerModel is not implemented');
+    },
     shortcutsService: (_ctx: Partial<AppContext>) => {
       throw new Error('shortcutsService is not implemented');
     },
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 46d2178..fbf5b0f 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -27,6 +27,7 @@
 import {GrStorageService} from './storage/gr-storage_impl';
 import {UserModel} from './user/user-model';
 import {CommentsModel} from './comments/comments-model';
+import {RouterModel} from './router/router-model';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
 import {BrowserModel} from './browser/browser-model';
 import {assertIsDefined} from '../utils/common-util';
@@ -37,6 +38,7 @@
  */
 export function createAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
+    routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
     flagsService: (_ctx: Partial<AppContext>) =>
       new FlagsServiceImplementation(),
     reportingService: (ctx: Partial<AppContext>) => {
@@ -54,28 +56,41 @@
       return new GrRestApiServiceImpl(ctx.authService!, ctx.flagsService!);
     },
     changeModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ChangeModel(ctx.restApiService!);
+      const routerModel = ctx.routerModel;
+      const restApiService = ctx.restApiService;
+      assertIsDefined(routerModel, 'routerModel');
+      assertIsDefined(restApiService, 'restApiService');
+      return new ChangeModel(routerModel, restApiService);
     },
     commentsModel: (ctx: Partial<AppContext>) => {
+      const routerModel = ctx.routerModel;
       const changeModel = ctx.changeModel;
       const restApiService = ctx.restApiService;
-      const reporting = ctx.reportingService;
+      const reportingService = ctx.reportingService;
+      assertIsDefined(routerModel, 'routerModel');
       assertIsDefined(changeModel, 'changeModel');
       assertIsDefined(restApiService, 'restApiService');
-      assertIsDefined(reporting, 'reportingService');
-      return new CommentsModel(changeModel, restApiService, reporting);
+      assertIsDefined(reportingService, 'reportingService');
+      return new CommentsModel(
+        routerModel,
+        changeModel,
+        restApiService,
+        reportingService
+      );
     },
     checksModel: (ctx: Partial<AppContext>) => {
+      const routerModel = ctx.routerModel;
       const changeModel = ctx.changeModel;
-      const reporting = ctx.reportingService;
+      const reportingService = ctx.reportingService;
+      assertIsDefined(routerModel, 'routerModel');
       assertIsDefined(changeModel, 'changeModel');
-      assertIsDefined(reporting, 'reportingService');
-      return new ChecksModel(changeModel, reporting);
+      assertIsDefined(reportingService, 'reportingService');
+      return new ChecksModel(routerModel, changeModel, reportingService);
     },
     jsApiService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new GrJsApiInterface(ctx.reportingService!);
+      const reportingService = ctx.reportingService;
+      assertIsDefined(reportingService, 'reportingService');
+      return new GrJsApiInterface(reportingService!);
     },
     storageService: (_ctx: Partial<AppContext>) => new GrStorageService(),
     configModel: (ctx: Partial<AppContext>) => {
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index bea371f..367fee7 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -26,11 +26,13 @@
 import {StorageService} from './storage/gr-storage';
 import {UserModel} from './user/user-model';
 import {CommentsModel} from './comments/comments-model';
+import {RouterModel} from './router/router-model';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
 import {BrowserModel} from './browser/browser-model';
 import {ConfigModel} from './config/config-model';
 
 export interface AppContext {
+  routerModel: RouterModel;
   flagsService: FlagsService;
   reportingService: ReportingService;
   eventEmitter: EventEmitterService;
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 6a819f8..7055447 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -32,14 +32,13 @@
   startWith,
   switchMap,
 } from 'rxjs/operators';
-import {routerPatchNum$, routerState$} from '../router/router-model';
+import {RouterModel} from '../router/router-model';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
 } 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';
@@ -118,7 +117,7 @@
      * out inconsistent state, e.g. router changeNum already updated, change not
      * yet reset to undefined.
      */
-    combineLatest([routerState$, this.changeState$])
+    combineLatest([this.routerModel.routerState$, this.changeState$])
       .pipe(
         filter(([routerState, changeState]) => {
           const changeNum = changeState.change?._number;
@@ -128,7 +127,7 @@
         distinctUntilChanged()
       )
       .pipe(
-        withLatestFrom(routerPatchNum$, this.latestPatchNum$),
+        withLatestFrom(this.routerModel.routerPatchNum$, this.latestPatchNum$),
         map(([_, routerPatchN, latestPatchN]) => routerPatchN || latestPatchN),
         distinctUntilChanged()
       );
@@ -142,9 +141,12 @@
     'reload'
   ).pipe(startWith(undefined));
 
-  constructor(readonly restApiService: RestApiService) {
+  constructor(
+    readonly routerModel: RouterModel,
+    readonly restApiService: RestApiService
+  ) {
     this.subscriptions = [
-      combineLatest([routerChangeNum$, this.reload$])
+      combineLatest([this.routerModel.routerChangeNum$, this.reload$])
         .pipe(
           map(([changeNum, _]) => changeNum),
           switchMap(changeNum => {
diff --git a/polygerrit-ui/app/services/change/change-model_test.ts b/polygerrit-ui/app/services/change/change-model_test.ts
index 099a11f..0fb9712 100644
--- a/polygerrit-ui/app/services/change/change-model_test.ts
+++ b/polygerrit-ui/app/services/change/change-model_test.ts
@@ -28,10 +28,7 @@
 import {CommitId, NumericChangeId, PatchSetNum} from '../../types/common';
 import {ParsedChangeInfo} from '../../types/types';
 import {getAppContext} from '../app-context';
-import {
-  GerritView,
-  _testOnly_setState as setRouterState,
-} from '../router/router-model';
+import {GerritView} from '../router/router-model';
 import {ChangeState, LoadingStatus} from './change-model';
 import {ChangeModel} from './change-model';
 
@@ -40,7 +37,10 @@
   let knownChange: ParsedChangeInfo;
   const testCompleted = new Subject<void>();
   setup(() => {
-    changeModel = new ChangeModel(getAppContext().restApiService);
+    changeModel = new ChangeModel(
+      getAppContext().routerModel,
+      getAppContext().restApiService
+    );
     knownChange = {
       ...createChange(),
       revisions: {
@@ -80,7 +80,10 @@
     assert.equal(stub.callCount, 0);
     assert.isUndefined(state?.change);
 
-    setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
     await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADING);
     assert.equal(stub.callCount, 1);
     assert.isUndefined(state?.change);
@@ -101,7 +104,10 @@
     changeModel.changeState$
       .pipe(takeUntil(testCompleted))
       .subscribe(s => (state = s));
-    setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
     promise.resolve(knownChange);
     await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
 
@@ -127,7 +133,10 @@
     changeModel.changeState$
       .pipe(takeUntil(testCompleted))
       .subscribe(s => (state = s));
-    setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
     promise.resolve(knownChange);
     await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
 
@@ -138,7 +147,10 @@
       _number: 123 as NumericChangeId,
     };
     promise = mockPromise<ParsedChangeInfo | undefined>();
-    setRouterState({view: GerritView.CHANGE, changeNum: otherChange._number});
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: otherChange._number,
+    });
     await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADING);
     assert.equal(stub.callCount, 2);
     assert.isUndefined(state?.change);
@@ -159,7 +171,10 @@
     changeModel.changeState$
       .pipe(takeUntil(testCompleted))
       .subscribe(s => (state = s));
-    setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
     promise.resolve(knownChange);
     await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
 
@@ -167,7 +182,10 @@
 
     promise = mockPromise<ParsedChangeInfo | undefined>();
     promise.resolve(undefined);
-    setRouterState({view: GerritView.DASHBOARD, changeNum: undefined});
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: undefined,
+    });
     await waitUntil(() => state?.loadingStatus === LoadingStatus.NOT_LOADED);
     assert.equal(stub.callCount, 2);
     assert.isUndefined(state?.change);
@@ -176,7 +194,10 @@
 
     promise = mockPromise<ParsedChangeInfo | undefined>();
     promise.resolve(knownChange);
-    setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
     await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
     assert.equal(stub.callCount, 3);
     assert.equal(state?.change, knownChange);
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 4024747..134afd8 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -53,9 +53,9 @@
 import {getCurrentRevision} from '../../utils/change-util';
 import {getShaByPatchNum} from '../../utils/patch-set-util';
 import {ReportingService} from '../gr-reporting/gr-reporting';
-import {routerPatchNum$} from '../router/router-model';
 import {Execution} from '../../constants/reporting';
 import {fireAlert, fireEvent} from '../../utils/event-util';
+import {RouterModel} from '../router/router-model';
 
 /**
  * The checks model maintains the state of checks for two patchsets: the latest
@@ -322,6 +322,7 @@
   );
 
   constructor(
+    readonly routerModel: RouterModel,
     readonly changeModel: ChangeModel,
     readonly reporting: ReportingService
   ) {
@@ -331,14 +332,14 @@
         this.checkToPluginMap = map;
       }),
       combineLatest([
-        routerPatchNum$,
+        this.routerModel.routerPatchNum$,
         this.changeModel.latestPatchNum$,
       ]).subscribe(([routerPs, latestPs]) => {
         this.latestPatchNum = latestPs;
         if (latestPs === undefined) {
           this.setPatchset(undefined);
         } else if (typeof routerPs === 'number') {
-          this.setPatchset(routerPs);
+          this.setPatchset(routerPs as PatchSetNumber);
         } else {
           this.setPatchset(latestPs);
         }
diff --git a/polygerrit-ui/app/services/checks/checks-model_test.ts b/polygerrit-ui/app/services/checks/checks-model_test.ts
index 0d46289..bb90fe2 100644
--- a/polygerrit-ui/app/services/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/services/checks/checks-model_test.ts
@@ -46,6 +46,7 @@
 
   setup(() => {
     model = new ChecksModel(
+      getAppContext().routerModel,
       getAppContext().changeModel,
       getAppContext().reportingService
     );
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
index d46e08a..95a1030 100644
--- a/polygerrit-ui/app/services/comments/comments-model.ts
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -37,7 +37,7 @@
 } from '../../utils/comment-util';
 import {deepEqual} from '../../utils/deep-util';
 import {select} from '../../utils/observable-util';
-import {routerChangeNum$} from '../router/router-model';
+import {RouterModel} from '../router/router-model';
 import {Finalizable} from '../registry';
 import {combineLatest, Subscription} from 'rxjs';
 import {fire, fireAlert, fireEvent} from '../../utils/event-util';
@@ -291,6 +291,7 @@
   private discardedDrafts: DraftInfo[] = [];
 
   constructor(
+    readonly routerModel: RouterModel,
     readonly changeModel: ChangeModel,
     readonly restApiService: RestApiService,
     readonly reporting: ReportingService
@@ -305,7 +306,7 @@
       this.changeModel.currentPatchNum$.subscribe(x => (this.patchNum = x))
     );
     this.subscriptions.push(
-      routerChangeNum$.subscribe(changeNum => {
+      this.routerModel.routerChangeNum$.subscribe(changeNum => {
         this.changeNum = changeNum;
         this.setState({...initialState});
         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 f39cad8..a8f2118 100644
--- a/polygerrit-ui/app/services/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/services/comments/comments-model_test.ts
@@ -30,10 +30,7 @@
 } from '../../test/test-data-generators';
 import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {getAppContext} from '../app-context';
-import {
-  GerritView,
-  updateState as updateRouterState,
-} from '../router/router-model';
+import {GerritView} from '../router/router-model';
 import {PathToCommentsInfoMap} from '../../types/common';
 
 suite('comments model tests', () => {
@@ -76,6 +73,7 @@
 
   test('loads comments', async () => {
     const model = new CommentsModel(
+      getAppContext().routerModel,
       getAppContext().changeModel,
       getAppContext().restApiService,
       getAppContext().reportingService
@@ -102,7 +100,10 @@
       model.portedComments$.subscribe(c => (portedComments = c ?? {}))
     );
 
-    updateRouterState(GerritView.CHANGE, TEST_NUMERIC_CHANGE_ID);
+    model.routerModel.updateState({
+      view: GerritView.CHANGE,
+      changeNum: TEST_NUMERIC_CHANGE_ID,
+    });
     model.changeModel.updateStateChange(createParsedChange());
 
     await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index f549e859..73dee78 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -15,9 +15,10 @@
  * limitations under the License.
  */
 
-import {NumericChangeId, PatchSetNum} from '../../types/common';
 import {BehaviorSubject, Observable} from 'rxjs';
 import {distinctUntilChanged, map} from 'rxjs/operators';
+import {Finalizable} from '../registry';
+import {NumericChangeId, PatchSetNum} from '../../types/common';
 
 export enum GerritView {
   ADMIN = 'admin',
@@ -42,57 +43,44 @@
   patchNum?: PatchSetNum;
 }
 
-// TODO: Figure out how to best enforce immutability of all states. Use Immer?
-// Use DeepReadOnly?
-const initialState: RouterState = {};
+export class RouterModel implements Finalizable {
+  private readonly privateState$ = new BehaviorSubject<RouterState>({});
 
-const privateState$ = new BehaviorSubject<RouterState>(initialState);
+  readonly routerView$: Observable<GerritView | undefined>;
 
-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});
+  readonly routerChangeNum$: Observable<NumericChangeId | undefined>;
+
+  readonly routerPatchNum$: Observable<PatchSetNum | undefined>;
+
+  constructor() {
+    this.routerView$ = this.privateState$.pipe(
+      map(state => state.view),
+      distinctUntilChanged()
+    );
+    this.routerChangeNum$ = this.privateState$.pipe(
+      map(state => state.changeNum),
+      distinctUntilChanged()
+    );
+    this.routerPatchNum$ = this.privateState$.pipe(
+      map(state => state.patchNum),
+      distinctUntilChanged()
+    );
+  }
+
+  finalize() {}
+
+  setState(state: RouterState) {
+    this.privateState$.next(state);
+  }
+
+  updateState(partial: Partial<RouterState>) {
+    this.privateState$.next({
+      ...this.privateState$.getValue(),
+      ...partial,
+    });
+  }
+
+  get routerState$(): Observable<RouterState> {
+    return this.privateState$;
+  }
 }
-
-export function _testOnly_setState(state: RouterState) {
-  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 routerState$: Observable<RouterState> = privateState$;
-
-// Must only be used by the router service or whatever is in control of this
-// model.
-// TODO: Consider keeping params of type AppElementParams entirely in the state
-export function updateState(
-  view?: GerritView,
-  changeNum?: NumericChangeId,
-  patchNum?: PatchSetNum
-) {
-  privateState$.next({
-    ...privateState$.getValue(),
-    view,
-    changeNum,
-    patchNum,
-  });
-}
-
-export const routerView$ = routerState$.pipe(
-  map(state => state.view),
-  distinctUntilChanged()
-);
-
-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/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
index 9107b31d..441c09d 100644
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -85,7 +85,7 @@
     preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE
   );
 
-  private readonly subscriptions: Subscription[] = [];
+  private subscriptions: Subscription[] = [];
 
   get userState$(): Observable<UserState> {
     return this.privateState$;
@@ -135,7 +135,7 @@
     for (const s of this.subscriptions) {
       s.unsubscribe();
     }
-    this.subscriptions.splice(0, this.subscriptions.length);
+    this.subscriptions = [];
   }
 
   updatePreferences(prefs: Partial<PreferencesInfo>) {
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index b8a5e49..94e3f69 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -45,8 +45,6 @@
 import {_testOnly_allTasks} from '../utils/async-util';
 import {cleanUpStorage} from '../services/storage/gr-storage_mock';
 
-import {_testOnly_resetState as resetRouterState} from '../services/router/router-model';
-
 declare global {
   interface Window {
     assert: typeof chai.assert;
@@ -111,8 +109,6 @@
   // tests.
   initGlobalVariables(appContext);
 
-  resetRouterState();
-
   const shortcuts = appContext.shortcutsService;
   assert.isTrue(shortcuts._testOnly_isEmpty());
   const selection = document.getSelection();
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 01d776e..5f5507b 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -30,12 +30,14 @@
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
 import {UserModel} from '../services/user/user-model';
 import {CommentsModel} from '../services/comments/comments-model';
+import {RouterModel} from '../services/router/router-model';
 import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
 import {BrowserModel} from '../services/browser/browser-model';
 import {ConfigModel} from '../services/config/config-model';
 
 export function createTestAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
+    routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
     flagsService: (_ctx: Partial<AppContext>) =>
       new FlagsServiceImplementation(),
     reportingService: (_ctx: Partial<AppContext>) => grReportingMock,
@@ -46,23 +48,36 @@
     },
     restApiService: (_ctx: Partial<AppContext>) => grRestApiMock,
     changeModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new ChangeModel(ctx.restApiService!);
+      const routerModel = ctx.routerModel;
+      const restApiService = ctx.restApiService;
+      assertIsDefined(routerModel, 'routerModel');
+      assertIsDefined(restApiService, 'restApiService');
+      return new ChangeModel(routerModel, restApiService);
     },
     commentsModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.changeModel, 'changeModel');
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      assertIsDefined(ctx.reportingService, 'reportingService');
+      const routerModel = ctx.routerModel;
+      const changeModel = ctx.changeModel;
+      const restApiService = ctx.restApiService;
+      const reportingService = ctx.reportingService;
+      assertIsDefined(routerModel, 'routerModel');
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(restApiService, 'restApiService');
+      assertIsDefined(reportingService, 'reportingService');
       return new CommentsModel(
-        ctx.changeModel!,
-        ctx.restApiService!,
-        ctx.reportingService!
+        routerModel,
+        changeModel,
+        restApiService,
+        reportingService
       );
     },
     checksModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.changeModel, 'changeModel');
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ChecksModel(ctx.changeModel!, ctx.reportingService!);
+      const routerModel = ctx.routerModel;
+      const changeModel = ctx.changeModel;
+      const reportingService = ctx.reportingService;
+      assertIsDefined(routerModel, 'routerModel');
+      assertIsDefined(changeModel, 'changeModel');
+      assertIsDefined(reportingService, 'reportingService');
+      return new ChecksModel(routerModel, changeModel, reportingService);
     },
     jsApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.reportingService, 'reportingService');