Merge "Render all comments initially if user wants to scroll to some"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 240b0ed..870e194 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1857,7 +1857,8 @@
 
 * The given change.
 * If link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
-  is enabled, include all open changes with the same topic.
+  is enabled OR if the `o=TOPIC_CLOSURE` query parameter is passed, include all
+  open changes with the same topic.
 * For each change whose submit type is not CHERRY_PICK, include unmerged
   ancestors targeting the same branch.
 
@@ -1884,7 +1885,7 @@
 
 Standard link:#query-options[formatting options] can be specified
 with the `o` parameter, as well as the `submitted_together` specific
-option `NON_VISIBLE_CHANGES`.
+options `NON_VISIBLE_CHANGES` and `TOPIC_CLOSURE`.
 
 .Response
 ----
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 9ca881d..2fb2127 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -26,6 +26,7 @@
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -41,6 +42,7 @@
 import com.google.common.base.Ticker;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.jimfs.Jimfs;
@@ -80,6 +82,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -1203,9 +1206,37 @@
   }
 
   protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
-    List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
+    assertSubmittedTogether(chId, ImmutableSet.of(), expected);
+  }
+
+  protected void assertSubmittedTogetherWithTopicClosure(String chId, String... expected)
+      throws Exception {
+    assertSubmittedTogether(chId, ImmutableSet.of(TOPIC_CLOSURE), expected);
+  }
+
+  protected void assertSubmittedTogether(
+      String chId,
+      ImmutableSet<SubmittedTogetherOption> submittedTogetherOptions,
+      String... expected)
+      throws Exception {
+    // This does not include NON_VISIBILE_CHANGES
+    List<ChangeInfo> actual =
+        submittedTogetherOptions.isEmpty()
+            ? gApi.changes().id(chId).submittedTogether()
+            : gApi.changes()
+                .id(chId)
+                .submittedTogether(EnumSet.copyOf(submittedTogetherOptions))
+                .changes;
+
+    EnumSet enumSetIncludingNonVisibleChanges =
+        submittedTogetherOptions.isEmpty()
+            ? EnumSet.of(NON_VISIBLE_CHANGES)
+            : EnumSet.copyOf(submittedTogetherOptions);
+    enumSetIncludingNonVisibleChanges.add(NON_VISIBLE_CHANGES);
+
+    // This includes NON_VISIBLE_CHANGES for comparison.
     SubmittedTogetherInfo info =
-        gApi.changes().id(chId).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+        gApi.changes().id(chId).submittedTogether(enumSetIncludingNonVisibleChanges);
 
     assertThat(info.nonVisibleChanges).isEqualTo(0);
     assertThat(Iterables.transform(actual, i1 -> i1.changeId))
diff --git a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
index e2cab4d..68a4e88 100644
--- a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
+++ b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
@@ -16,5 +16,6 @@
 
 /** Output options available for submitted_together requests. */
 public enum SubmittedTogetherOption {
-  NON_VISIBLE_CHANGES;
+  NON_VISIBLE_CHANGES,
+  TOPIC_CLOSURE;
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 5155a0d..154e45a 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -304,7 +304,10 @@
       return null; // submit not visible
     }
 
-    ChangeSet cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
+    ChangeSet cs =
+        mergeSuperSet
+            .get()
+            .completeChangeSet(cd.change(), resource.getUser(), /*includingTopicClosure= */ false);
     String topic = change.getTopic();
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index 214a001..c18e7c2 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
 import static java.util.Collections.reverseOrder;
 import static java.util.stream.Collectors.toList;
 
@@ -127,7 +128,10 @@
       int hidden;
 
       if (c.isNew()) {
-        ChangeSet cs = mergeSuperSet.get().completeChangeSet(c, resource.getUser());
+        ChangeSet cs =
+            mergeSuperSet
+                .get()
+                .completeChangeSet(c, resource.getUser(), options.contains(TOPIC_CLOSURE));
         cds = ensureRequiredDataIsLoaded(cs.changes().asList());
         hidden = cs.nonVisibleChanges().size();
       } else if (c.isMerged()) {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 64b60bb..b431299 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -480,7 +480,9 @@
       logger.atFine().log("Beginning integration of %s", change);
       try {
         ChangeSet indexBackedChangeSet =
-            mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(change, caller);
+            mergeSuperSet
+                .setMergeOpRepoManager(orm)
+                .completeChangeSet(change, caller, /* includingTopicClosure= */ false);
         if (!indexBackedChangeSet.ids().contains(change.getId())) {
           // indexBackedChangeSet contains only open changes, if the change is missing in this set
           // it might be that the change was concurrently submitted in the meantime.
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 67f2907..8581e20 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -92,7 +92,19 @@
     return this;
   }
 
-  public ChangeSet completeChangeSet(Change change, CurrentUser user)
+  /**
+   * Gets the ChangeSet of this {@code change} based on visiblity of the {@code user}. if
+   * change.submitWholeTopic is true, we return the topic closure as well as the dependent changes
+   * of the topic closure. Otherwise, we return just the dependent changes.
+   *
+   * @param change the change for which we get the dependent changes / topic closure.
+   * @param user the current user for visibility purposes.
+   * @param includingTopicClosure when true, return as if change.submitWholeTopic = true, so we
+   *     return the topic closure.
+   * @return {@link ChangeSet} object that represents the dependent changes and/or topic closure of
+   *     the requested change.
+   */
+  public ChangeSet completeChangeSet(Change change, CurrentUser user, boolean includingTopicClosure)
       throws IOException, PermissionBackendException {
     try {
       if (orm == null) {
@@ -113,7 +125,7 @@
       }
 
       ChangeSet changeSet = new ChangeSet(cd, visible);
-      if (wholeTopicEnabled(cfg)) {
+      if (wholeTopicEnabled(cfg) || includingTopicClosure) {
         return completeChangeSetIncludingTopics(changeSet, user);
       }
       try (TraceContext traceContext = PluginContext.newTrace(mergeSuperSetComputation)) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index a63d60a..0a9a098 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -306,7 +306,10 @@
   private void assertChangeSetMergeable(ChangeData change, boolean expected)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
           PermissionBackendException {
-    ChangeSet cs = mergeSuperSet.get().completeChangeSet(change.change(), user(admin));
+    ChangeSet cs =
+        mergeSuperSet
+            .get()
+            .completeChangeSet(change.change(), user(admin), /* includingTopicClosure= */ false);
     assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index a97fb49..7e0bce9 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -130,6 +130,8 @@
     } else {
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
     }
   }
 
@@ -152,6 +154,8 @@
     } else {
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
     }
   }
 
@@ -180,6 +184,9 @@
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
       assertSubmittedTogether(id3, id3, id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id3, id3, id2, id1);
     }
   }
 
@@ -227,6 +234,13 @@
       assertSubmittedTogether(id4, id4, id3, id2);
       assertSubmittedTogether(id5);
       assertSubmittedTogether(id6, id6, id5);
+
+      assertSubmittedTogetherWithTopicClosure(id1, id6, id5, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id6, id5, id2);
+      assertSubmittedTogetherWithTopicClosure(id3, id6, id5, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id4, id6, id5, id4, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id5);
+      assertSubmittedTogetherWithTopicClosure(id6, id6, id5, id2);
     }
   }
 
diff --git a/plugins/replication b/plugins/replication
index b6f4011..d9c59a0 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit b6f4011070a8f0bf31fb1158ed12be71374eb47a
+Subproject commit d9c59a0be1c423706b2c4c74c955ebbeca5a894d
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 172f807..f811ee2 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -71,7 +71,7 @@
 ];
 
 interface Rule {
-  value: RuleValue;
+  value?: RuleValue;
 }
 
 interface RuleValue {
@@ -158,17 +158,17 @@
       // Observer _handleValueChange is called after the ready()
       // method finishes. Original values must be set later to
       // avoid set .modified flag to true
-      this._setOriginalRuleValues(this.rule.value);
+      this._setOriginalRuleValues(this.rule?.value);
     }
   }
 
-  _setupValues(rule: Rule) {
-    if (!rule.value) {
+  _setupValues(rule?: Rule) {
+    if (!rule?.value) {
       this._setDefaultRuleValues();
     }
   }
 
-  _computeForce(permission: AccessPermissionId, action: string) {
+  _computeForce(permission: AccessPermissionId, action?: string) {
     if (AccessPermissionId.PUSH === permission && action !== Action.DENY) {
       return true;
     }
@@ -176,7 +176,7 @@
     return AccessPermissionId.EDIT_TOPIC_NAME === permission;
   }
 
-  _computeForceClass(permission: AccessPermissionId, action: string) {
+  _computeForceClass(permission: AccessPermissionId, action?: string) {
     return this._computeForce(permission, action) ? 'force' : '';
   }
 
@@ -213,7 +213,7 @@
     return classList.join(' ');
   }
 
-  _computeForceOptions(permission: string, action: string) {
+  _computeForceOptions(permission: string, action?: string) {
     if (permission === AccessPermissionId.PUSH) {
       if (action === Action.ALLOW) {
         return ForcePushOptions.ALLOW;
@@ -259,7 +259,7 @@
   }
 
   _handleRemoveRule() {
-    if (!this.rule) return;
+    if (!this.rule?.value) return;
     if (this.rule.value.added) {
       fireEvent(this, 'added-rule-removed');
     }
@@ -269,13 +269,13 @@
   }
 
   _handleUndoRemove() {
-    if (!this.rule) return;
+    if (!this.rule?.value) return;
     this._deleted = false;
     delete this.rule.value.deleted;
   }
 
   _handleUndoChange() {
-    if (!this.rule) return;
+    if (!this.rule?.value) return;
     // gr-permission will take care of removing rules that were added but
     // unsaved. We need to keep the added bit for the filter.
     if (this.rule.value.added) {
@@ -289,7 +289,7 @@
 
   @observe('rule.value.*')
   _handleValueChange() {
-    if (!this._originalRuleValues || !this.rule) {
+    if (!this._originalRuleValues || !this.rule?.value) {
       return;
     }
     this.rule.value.modified = true;
@@ -297,7 +297,8 @@
     fireEvent(this, 'access-modified');
   }
 
-  _setOriginalRuleValues(value: RuleValue) {
+  _setOriginalRuleValues(value?: RuleValue) {
+    if (value === undefined) return;
     this._originalRuleValues = {...value};
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
deleted file mode 100644
index f3df132..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ /dev/null
@@ -1,586 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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 '../../../test/common-test-setup-karma.js';
-import './gr-rule-editor.js';
-
-const basicFixture = fixtureFromElement('gr-rule-editor');
-
-suite('gr-rule-editor tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('unit tests', () => {
-    test('_computeForce, _computeForceClass, and _computeForceOptions',
-        () => {
-          const ForcePushOptions = {
-            ALLOW: [
-              {name: 'Allow pushing (but not force pushing)', value: false},
-              {name: 'Allow pushing with or without force', value: true},
-            ],
-            BLOCK: [
-              {name: 'Block pushing with or without force', value: false},
-              {name: 'Block force pushing', value: true},
-            ],
-          };
-
-          const FORCE_EDIT_OPTIONS = [
-            {
-              name: 'No Force Edit',
-              value: false,
-            },
-            {
-              name: 'Force Edit',
-              value: true,
-            },
-          ];
-          let permission = 'push';
-          let action = 'ALLOW';
-          assert.isTrue(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action),
-              'force');
-          assert.deepEqual(element._computeForceOptions(permission, action),
-              ForcePushOptions.ALLOW);
-
-          action = 'BLOCK';
-          assert.isTrue(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action),
-              'force');
-          assert.deepEqual(element._computeForceOptions(permission, action),
-              ForcePushOptions.BLOCK);
-
-          action = 'DENY';
-          assert.isFalse(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action), '');
-          assert.equal(
-              element._computeForceOptions(permission, action).length, 0);
-
-          permission = 'editTopicName';
-          assert.isTrue(element._computeForce(permission));
-          assert.equal(element._computeForceClass(permission), 'force');
-          assert.deepEqual(element._computeForceOptions(permission),
-              FORCE_EDIT_OPTIONS);
-          permission = 'submit';
-          assert.isFalse(element._computeForce(permission));
-          assert.equal(element._computeForceClass(permission), '');
-          assert.deepEqual(element._computeForceOptions(permission), []);
-        });
-
-    test('_computeSectionClass', () => {
-      let deleted = true;
-      let editing = false;
-      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
-      deleted = false;
-      assert.equal(element._computeSectionClass(editing, deleted), '');
-
-      editing = true;
-      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
-      deleted = true;
-      assert.equal(element._computeSectionClass(editing, deleted),
-          'editing deleted');
-    });
-
-    test('_getDefaultRuleValues', () => {
-      let permission = 'priority';
-      let label;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'BATCH'});
-      permission = 'label-Code-Review';
-      label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW', max: 2, min: -2});
-      permission = 'push';
-      label = undefined;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW', force: false});
-      permission = 'submit';
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW'});
-    });
-
-    test('_setDefaultRuleValues', () => {
-      element.rule = {id: 123};
-      const defaultValue = {action: 'ALLOW'};
-      sinon.stub(element, '_getDefaultRuleValues').returns(defaultValue);
-      element._setDefaultRuleValues();
-      assert.isTrue(element._getDefaultRuleValues.called);
-      assert.equal(element.rule.value, defaultValue);
-    });
-
-    test('_computeOptions', () => {
-      const PRIORITY_OPTIONS = [
-        'BATCH',
-        'INTERACTIVE',
-      ];
-      const DROPDOWN_OPTIONS = [
-        'ALLOW',
-        'DENY',
-        'BLOCK',
-      ];
-      let permission = 'priority';
-      assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
-      permission = 'submit';
-      assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
-    });
-
-    test('_handleValueChange', () => {
-      const modifiedHandler = sinon.stub();
-      element.rule = {value: {}};
-      element.addEventListener('access-modified', modifiedHandler);
-      element._handleValueChange();
-      assert.isNotOk(element.rule.value.modified);
-      element._originalRuleValues = {};
-      element._handleValueChange();
-      assert.isTrue(element.rule.value.modified);
-      assert.isTrue(modifiedHandler.called);
-    });
-
-    test('_handleAccessSaved', () => {
-      const originalValue = {action: 'DENY'};
-      const newValue = {action: 'ALLOW'};
-      element._originalRuleValues = originalValue;
-      element.rule = {value: newValue};
-      element._handleAccessSaved();
-      assert.deepEqual(element._originalRuleValues, newValue);
-    });
-
-    test('_setOriginalRuleValues', () => {
-      const value = {
-        action: 'ALLOW',
-        force: false,
-      };
-      element._setOriginalRuleValues(value);
-      assert.deepEqual(element._originalRuleValues, value);
-    });
-  });
-
-  suite('already existing generic rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'submit';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: false,
-        },
-      };
-      element.section = 'refs/*';
-
-      // Typically called on ready since elements will have properties defined
-      // by the parent element.
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.isNotOk(element.root.querySelector('#labelMin'));
-      assert.isNotOk(element.root.querySelector('#labelMax'));
-      assert.isFalse(element.$.force.classList.contains('force'));
-    });
-
-    test('modify and cancel restores original values', () => {
-      element.editing = true;
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = 'DENY';
-      assert.isTrue(element.rule.value.modified);
-      element.editing = false;
-      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-      assert.equal(element.$.action.bindValue, 'ALLOW');
-      assert.isNotOk(element.rule.value.modified);
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = 'DENY';
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('all selects are disabled when not in edit mode', () => {
-      const selects = element.root.querySelectorAll('select');
-      for (const select of selects) {
-        assert.isTrue(select.disabled);
-      }
-      element.editing = true;
-      for (const select of selects) {
-        assert.isFalse(select.disabled);
-      }
-    });
-
-    test('remove rule and undo remove', () => {
-      element.editing = true;
-      element.rule = {id: 123, value: {action: 'ALLOW'}};
-      assert.isFalse(
-          element.$.deletedContainer.classList.contains('deleted'));
-      MockInteractions.tap(element.$.removeBtn);
-      assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.rule.value.deleted);
-
-      MockInteractions.tap(element.$.undoRemoveBtn);
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.rule.value.deleted);
-    });
-
-    test('remove rule and cancel', () => {
-      element.editing = true;
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-
-      element.rule = {id: 123, value: {action: 'ALLOW'}};
-      MockInteractions.tap(element.$.removeBtn);
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.rule.value.deleted);
-
-      element.editing = false;
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.rule.value.deleted);
-      assert.isNotOk(element.rule.value.modified);
-
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-    });
-
-    test('_computeGroupPath', () => {
-      const group = '123';
-      assert.equal(element._computeGroupPath(group),
-          `/admin/groups/123`);
-    });
-  });
-
-  suite('new edit rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'editTopicName';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.rule.value.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      const expectedRuleValue = {
-        action: 'ALLOW',
-        force: false,
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.force.bindValue = true;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('remove value', () => {
-      element.editing = true;
-      const removeStub = sinon.stub();
-      element.addEventListener('added-rule-removed', removeStub);
-      MockInteractions.tap(element.$.removeBtn);
-      flush();
-      assert.isTrue(removeStub.called);
-    });
-  });
-
-  suite('already existing rule with labels', () => {
-    setup(async () => {
-      element.label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      element.group = 'Group Name';
-      element.permission = 'label-Code-Review';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: false,
-          max: 2,
-          min: -2,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          element.root.querySelector('#labelMin').bindValue,
-          element.rule.value.min);
-      assert.equal(
-          element.root.querySelector('#labelMax').bindValue,
-          element.rule.value.max);
-      assert.isFalse(element.$.force.classList.contains('force'));
-    });
-
-    test('modify value', () => {
-      const removeStub = sinon.stub();
-      element.addEventListener('added-rule-removed', removeStub);
-      assert.isNotOk(element.rule.value.modified);
-      element.root.querySelector('#labelMin').bindValue = 1;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-      assert.isFalse(removeStub.called);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('new rule with labels', () => {
-    setup(async () => {
-      sinon.spy(element, '_setDefaultRuleValues');
-      element.label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      element.group = 'Group Name';
-      element.permission = 'label-Code-Review';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.rule.value.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      assert.isTrue(element._setDefaultRuleValues.called);
-
-      const expectedRuleValue = {
-        max: element.label.values[element.label.values.length - 1].value,
-        min: element.label.values[0].value,
-        action: 'ALLOW',
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(
-            element.$.action.bindValue,
-            expectedRuleValue.action);
-        assert.equal(
-            element.root.querySelector('#labelMin').bindValue,
-            expectedRuleValue.min);
-        assert.equal(
-            element.root.querySelector('#labelMax').bindValue,
-            expectedRuleValue.max);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.root.querySelector('#labelMin').bindValue = 1;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('already existing push rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'push';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: true,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.isTrue(element.$.force.classList.contains('force'));
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          element.root.querySelector('#force').bindValue,
-          element.rule.value.force);
-      assert.isNotOk(element.root.querySelector('#labelMin'));
-      assert.isNotOk(element.root.querySelector('#labelMax'));
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = false;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('new push rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'push';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.rule.value.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      const expectedRuleValue = {
-        action: 'ALLOW',
-        force: false,
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.force.bindValue = true;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('already existing edit rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'editTopicName';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: true,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.isTrue(element.$.force.classList.contains('force'));
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          element.root.querySelector('#force').bindValue,
-          element.rule.value.force);
-      assert.isNotOk(element.root.querySelector('#labelMin'));
-      assert.isNotOk(element.root.querySelector('#labelMax'));
-    });
-
-    test('modify value', async () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = false;
-      await flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
new file mode 100644
index 0000000..1afd123
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
@@ -0,0 +1,685 @@
+/**
+ * @license
+ * Copyright (C) 2017 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 '../../../test/common-test-setup-karma';
+import './gr-rule-editor';
+import {GrRuleEditor} from './gr-rule-editor';
+import {AccessPermissionId} from '../../../utils/access-util';
+import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-rule-editor');
+
+suite('gr-rule-editor tests', () => {
+  let element: GrRuleEditor;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('unit tests', () => {
+    test('_computeForce, _computeForceClass, and _computeForceOptions', () => {
+      const ForcePushOptions = {
+        ALLOW: [
+          {name: 'Allow pushing (but not force pushing)', value: false},
+          {name: 'Allow pushing with or without force', value: true},
+        ],
+        BLOCK: [
+          {name: 'Block pushing with or without force', value: false},
+          {name: 'Block force pushing', value: true},
+        ],
+      };
+
+      const FORCE_EDIT_OPTIONS = [
+        {
+          name: 'No Force Edit',
+          value: false,
+        },
+        {
+          name: 'Force Edit',
+          value: true,
+        },
+      ];
+      let permission = 'push' as AccessPermissionId;
+      let action = 'ALLOW';
+      assert.isTrue(element._computeForce(permission, action));
+      assert.equal(element._computeForceClass(permission, action), 'force');
+      assert.deepEqual(
+        element._computeForceOptions(permission, action),
+        ForcePushOptions.ALLOW
+      );
+
+      action = 'BLOCK';
+      assert.isTrue(element._computeForce(permission, action));
+      assert.equal(element._computeForceClass(permission, action), 'force');
+      assert.deepEqual(
+        element._computeForceOptions(permission, action),
+        ForcePushOptions.BLOCK
+      );
+
+      action = 'DENY';
+      assert.isFalse(element._computeForce(permission, action));
+      assert.equal(element._computeForceClass(permission, action), '');
+      assert.equal(element._computeForceOptions(permission, action).length, 0);
+
+      permission = 'editTopicName' as AccessPermissionId;
+      assert.isTrue(element._computeForce(permission));
+      assert.equal(element._computeForceClass(permission), 'force');
+      assert.deepEqual(
+        element._computeForceOptions(permission),
+        FORCE_EDIT_OPTIONS
+      );
+      permission = 'submit' as AccessPermissionId;
+      assert.isFalse(element._computeForce(permission));
+      assert.equal(element._computeForceClass(permission), '');
+      assert.deepEqual(element._computeForceOptions(permission), []);
+    });
+
+    test('_computeSectionClass', () => {
+      let deleted = true;
+      let editing = false;
+      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
+
+      deleted = false;
+      assert.equal(element._computeSectionClass(editing, deleted), '');
+
+      editing = true;
+      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+
+      deleted = true;
+      assert.equal(
+        element._computeSectionClass(editing, deleted),
+        'editing deleted'
+      );
+    });
+
+    test('_getDefaultRuleValues', () => {
+      let permission = 'priority' as AccessPermissionId;
+      let label;
+      assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+        action: 'BATCH',
+      });
+      permission = 'label-Code-Review' as AccessPermissionId;
+      label = {
+        values: [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ],
+      };
+      assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+        action: 'ALLOW',
+        max: 2,
+        min: -2,
+      });
+      permission = 'push' as AccessPermissionId;
+      label = undefined;
+      assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+        action: 'ALLOW',
+        force: false,
+      });
+      permission = 'submit' as AccessPermissionId;
+      assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+        action: 'ALLOW',
+      });
+    });
+
+    test('_setDefaultRuleValues', async () => {
+      element.rule = {value: {}};
+      const defaultValue = {action: 'ALLOW'};
+      const getDefaultRuleValuesStub = sinon
+        .stub(element, '_getDefaultRuleValues')
+        .returns(defaultValue);
+      element._setDefaultRuleValues();
+      assert.isTrue(getDefaultRuleValuesStub.called);
+      assert.equal(element.rule!.value, defaultValue);
+    });
+
+    test('_computeOptions', () => {
+      const PRIORITY_OPTIONS = ['BATCH', 'INTERACTIVE'];
+      const DROPDOWN_OPTIONS = ['ALLOW', 'DENY', 'BLOCK'];
+      let permission = 'priority';
+      assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
+      permission = 'submit';
+      assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
+    });
+
+    test('_handleValueChange', () => {
+      const modifiedHandler = sinon.stub();
+      element.rule = {value: {}};
+      element.addEventListener('access-modified', modifiedHandler);
+      element._handleValueChange();
+      assert.isNotOk(element.rule!.value!.modified);
+      element._originalRuleValues = {};
+      element._handleValueChange();
+      assert.isTrue(element.rule!.value!.modified);
+      assert.isTrue(modifiedHandler.called);
+    });
+
+    test('_handleAccessSaved', () => {
+      const originalValue = {action: 'DENY'};
+      const newValue = {action: 'ALLOW'};
+      element._originalRuleValues = originalValue;
+      element.rule = {value: newValue};
+      element._handleAccessSaved();
+      assert.deepEqual(element._originalRuleValues, newValue);
+    });
+
+    test('_setOriginalRuleValues', () => {
+      const value = {
+        action: 'ALLOW',
+        force: false,
+      };
+      element._setOriginalRuleValues(value);
+      assert.deepEqual(element._originalRuleValues, value);
+    });
+  });
+
+  suite('already existing generic rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'submit' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: 'ALLOW',
+          force: false,
+        },
+      };
+      element.section = 'refs/*';
+
+      // Typically called on ready since elements will have properties defined
+      // by the parent element.
+      element._setupValues(element.rule);
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+      assert.isFalse(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+    });
+
+    test('modify and cancel restores original values', () => {
+      element.editing = true;
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = 'DENY';
+      assert.isTrue(element.rule!.value!.modified);
+      element.editing = false;
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        'ALLOW'
+      );
+      assert.isNotOk(element.rule!.value!.modified);
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = 'DENY';
+      flush();
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+
+    test('all selects are disabled when not in edit mode', () => {
+      const selects = queryAll<HTMLSelectElement>(element, 'select');
+      for (const select of selects) {
+        assert.isTrue(select.disabled);
+      }
+      element.editing = true;
+      for (const select of selects) {
+        assert.isFalse(select.disabled);
+      }
+    });
+
+    test('remove rule and undo remove', () => {
+      element.editing = true;
+      element.rule = {value: {action: 'ALLOW'}};
+      assert.isFalse(
+        queryAndAssert<HTMLDivElement>(
+          element,
+          '#deletedContainer'
+        ).classList.contains('deleted')
+      );
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      assert.isTrue(
+        queryAndAssert<HTMLDivElement>(
+          element,
+          '#deletedContainer'
+        ).classList.contains('deleted')
+      );
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.rule!.value!.deleted);
+
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#undoRemoveBtn'));
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.rule!.value!.deleted);
+    });
+
+    test('remove rule and cancel', () => {
+      element.editing = true;
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+        ).display,
+        'none'
+      );
+
+      element.rule = {value: {action: 'ALLOW'}};
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.notEqual(
+        getComputedStyle(
+          queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+        ).display,
+        'none'
+      );
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.rule!.value!.deleted);
+
+      element.editing = false;
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.rule!.value!.deleted);
+      assert.isNotOk(element.rule!.value!.modified);
+
+      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+        ).display,
+        'none'
+      );
+    });
+
+    test('_computeGroupPath', () => {
+      const group = '123';
+      assert.equal(element._computeGroupPath(group), '/admin/groups/123');
+    });
+  });
+
+  suite('new edit rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'editTopicName' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element._setupValues(element.rule!);
+      await flush();
+      element.rule!.value!.added = true;
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      const expectedRuleValue = {
+        action: 'ALLOW',
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#action').bindValue,
+          expectedRuleValue.action
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#force').bindValue,
+          expectedRuleValue.action
+        );
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+      forceBindValue.bindValue = true;
+      flush();
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+
+    test('remove value', () => {
+      element.editing = true;
+      const removeStub = sinon.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      flush();
+      assert.isTrue(removeStub.called);
+    });
+  });
+
+  suite('already existing rule with labels', () => {
+    setup(async () => {
+      element.label = {
+        values: [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ],
+      };
+      element.groupName = 'Group Name';
+      element.permission = 'label-Code-Review' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: 'ALLOW',
+          force: false,
+          max: 2,
+          min: -2,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+        element.rule!.value!.min
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+        element.rule!.value!.max
+      );
+      assert.isFalse(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+    });
+
+    test('modify value', () => {
+      const removeStub = sinon.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      assert.isNotOk(element.rule!.value!.modified);
+      const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
+      labelMinBindValue.bindValue = 1;
+      flush();
+      assert.isTrue(element.rule!.value!.modified);
+      assert.isFalse(removeStub.called);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('new rule with labels', () => {
+    let setDefaultRuleValuesSpy: sinon.SinonSpy;
+
+    setup(async () => {
+      setDefaultRuleValuesSpy = sinon.spy(element, '_setDefaultRuleValues');
+      element.label = {
+        values: [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ],
+      };
+      element.groupName = 'Group Name';
+      element.permission = 'label-Code-Review' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element._setupValues(element.rule!);
+      await flush();
+      element.rule!.value!.added = true;
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      assert.isTrue(setDefaultRuleValuesSpy.called);
+
+      const expectedRuleValue = {
+        max: element.label!.values![element.label!.values.length - 1].value,
+        min: element.label!.values![0].value,
+        action: 'ALLOW',
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#action').bindValue,
+          expectedRuleValue.action
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+          expectedRuleValue.min
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+          expectedRuleValue.max
+        );
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
+      labelMinBindValue.bindValue = 1;
+      flush();
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('already existing push rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'push' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: 'ALLOW',
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule!);
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        element.rule!.value!.force
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = false;
+      flush();
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('new push rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'push' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element._setupValues(element.rule!);
+      await flush();
+      element.rule!.value!.added = true;
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      const expectedRuleValue = {
+        action: 'ALLOW',
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#action').bindValue,
+          expectedRuleValue.action
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#force').bindValue,
+          expectedRuleValue.action
+        );
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+      forceBindValue.bindValue = true;
+      flush();
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('already existing edit rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'editTopicName' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: 'ALLOW',
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      await flush();
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        element.rule!.value!.force
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = false;
+      await flush();
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 80608da..d4b0328 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -44,10 +44,10 @@
 } from '../../../types/common';
 import {assertNever, hasOwnProperty} from '../../../utils/common-util';
 import {pluralize} from '../../../utils/string-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   getRequirements,
   iconForStatus,
+  showNewSubmitRequirements,
   StandardLabels,
 } from '../../../utils/label-util';
 import {SubmitRequirementStatus} from '../../../api/rest-api';
@@ -113,17 +113,12 @@
 
   @state() private dynamicCellEndpoints?: string[];
 
-  @state() private isSubmitRequirementsUiEnabled = false;
-
   reporting: ReportingService = getAppContext().reportingService;
 
   private readonly flagsService = getAppContext().flagsService;
 
   override connectedCallback() {
     super.connectedCallback();
-    this.isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-      KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-    );
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -537,7 +532,7 @@
   }
 
   private renderCommentsInfoWithLabel(labelName: string) {
-    if (!this.isSubmitRequirementsUiEnabled) return;
+    if (!showNewSubmitRequirements(this.flagsService, this.change)) return;
     if (labelName !== StandardLabels.CODE_REVIEW) return;
     if (!this.change?.unresolved_comment_count) return;
     return html`<iron-icon
@@ -593,7 +588,7 @@
   // private but used in test
   computeLabelClass(labelName: string) {
     const classes = ['cell', 'label'];
-    if (this.isSubmitRequirementsUiEnabled) {
+    if (showNewSubmitRequirements(this.flagsService, this.change)) {
       const requirements = getRequirements(this.change).filter(
         sr => sr.name === labelName
       );
@@ -644,7 +639,7 @@
 
   // private but used in test
   computeLabelIcon(labelName: string): string {
-    if (this.isSubmitRequirementsUiEnabled) {
+    if (showNewSubmitRequirements(this.flagsService, this.change)) {
       const requirements = getRequirements(this.change).filter(
         sr => sr.name === labelName
       );
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 58f9dcc..7d618f5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -51,8 +51,10 @@
 import {fireEvent, fireReload} from '../../../utils/event-util';
 import {ScrollMode} from '../../../constants/constants';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {PRIORITY_REQUIREMENTS_ORDER} from '../../../utils/label-util';
+import {
+  PRIORITY_REQUIREMENTS_ORDER,
+  showNewSubmitRequirements,
+} from '../../../utils/label-util';
 import {addGlobalShortcut, Key} from '../../../utils/dom-util';
 
 const NUMBER_FIXED_COLUMNS = 3;
@@ -205,20 +207,22 @@
     return column.toLowerCase();
   }
 
-  @observe('account', 'preferences', '_config')
+  @observe('account', 'preferences', '_config', 'sections')
   _computePreferences(
     account?: AccountInfo,
     preferences?: PreferencesInput,
-    config?: ServerInfo
+    config?: ServerInfo,
+    sections?: ChangeListSection[]
   ) {
     if (!config) {
       return;
     }
 
+    const changes = (sections ?? []).map(section => section.results).flat();
     this.changeTableColumns = columnNames;
     this.showNumber = false;
     this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
-      this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
+      this._isColumnEnabled(col, config, changes)
     );
     if (account && preferences) {
       this.showNumber = !!(
@@ -229,11 +233,7 @@
           column === 'Project' ? 'Repo' : column
         );
         this.visibleChangeTableColumns = prefColumns.filter(col =>
-          this._isColumnEnabled(
-            col,
-            config,
-            this.flagsService.enabledExperiments
-          )
+          this._isColumnEnabled(col, config, changes)
         );
       }
     }
@@ -242,12 +242,15 @@
   /**
    * Is the column disabled by a server config or experiment?
    */
-  _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
+  _isColumnEnabled(column: string, config: ServerInfo, changes?: ChangeInfo[]) {
     if (!columnNames.includes(column)) return false;
     if (!config || !config.change) return true;
-    if (column === 'Comments') return experiments.includes('comments-column');
+    if (column === 'Comments')
+      return this.flagsService.isEnabled('comments-column');
     if (column === 'Requirements')
-      return experiments.includes(KnownExperimentId.SUBMIT_REQUIREMENTS_UI);
+      return (changes ?? []).every(change =>
+        showNewSubmitRequirements(this.flagsService, change)
+      );
     return true;
   }
 
@@ -302,9 +305,10 @@
         labels = labels.concat(currentLabels.filter(nonExistingLabel));
       }
     }
+    const changes = sections.map(section => section.results).flat();
     if (
-      this.flagsService.enabledExperiments.includes(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+      (changes ?? []).every(change =>
+        showNewSubmitRequirements(this.flagsService, change)
       )
     ) {
       labels = labels.filter(l => PRIORITY_REQUIREMENTS_ORDER.includes(l));
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-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 416bd43..d0126fd 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -87,7 +87,7 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {Interaction} from '../../../constants/reporting';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {showNewSubmitRequirements} from '../../../utils/label-util';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -213,9 +213,6 @@
   @property({type: Object})
   queryTopic?: AutocompleteQuery;
 
-  @property({type: Boolean})
-  _isSubmitRequirementsUiEnabled = false;
-
   restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
@@ -225,9 +222,6 @@
   override ready() {
     super.ready();
     this.queryTopic = (input: string) => this._getTopicSuggestions(input);
-    this._isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-      KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-    );
   }
 
   @observe('change.labels')
@@ -713,13 +707,7 @@
   }
 
   _showNewSubmitRequirements(change?: ParsedChangeInfo) {
-    if (!this._isSubmitRequirementsUiEnabled) return false;
-    return (change?.submit_requirements ?? []).length > 0;
-  }
-
-  _showNewSubmitRequirementWarning(change?: ParsedChangeInfo) {
-    if (!this._isSubmitRequirementsUiEnabled) return false;
-    return (change?.submit_requirements ?? []).length === 0;
+    return showNewSubmitRequirements(this.flagsService, change);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index 15631e4..46303a9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -113,10 +113,6 @@
       --iron-icon-height: 18px;
       --iron-icon-width: 18px;
     }
-    .submit-requirement-error {
-      color: var(--deemphasized-text-color);
-      padding-left: var(--metadata-horizontal-padding);
-    }
   </style>
   <gr-external-style id="externalStyle" name="change-metadata">
     <div class="metadata-header">
@@ -479,11 +475,6 @@
           mutable="[[_mutable]]"
         ></gr-change-requirements>
       </template>
-      <template is="dom-if" if="[[_showNewSubmitRequirementWarning(change)]]">
-        <div class="submit-requirement-error">
-          New Submit Requirements don't work on this change.
-        </div>
-      </template>
     </div>
     <section
       id="webLinks"
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
index 6991c03..8161592 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -154,7 +154,6 @@
           mutable="[[mutable]]"
           label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
-          showAlwaysOldUI
         ></gr-label-info>
       </div>
     </section>
@@ -205,7 +204,6 @@
           mutable="[[mutable]]"
           label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
-          showAlwaysOldUI
         ></gr-label-info>
       </div>
     </section>
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index e8c5916..02d1beb 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -59,10 +59,6 @@
 import {modifierPressed} from '../../../utils/dom-util';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {fontStyles} from '../../../styles/gr-font-styles';
-import {
-  changeComments$,
-  threads$,
-} from '../../../services/comments/comments-model';
 
 export enum SummaryChipStyles {
   INFO = 'info',
@@ -405,6 +401,8 @@
 
   private showAllChips = new Map<RunStatus | Category, boolean>();
 
+  private commentsModel = getAppContext().commentsModel;
+
   private userModel = getAppContext().userModel;
 
   private checksModel = getAppContext().checksModel;
@@ -441,8 +439,16 @@
       this.checksModel.topLevelActionsLatest$,
       x => (this.actions = x)
     );
-    subscribe(this, changeComments$, x => (this.changeComments = x));
-    subscribe(this, threads$, x => (this.commentThreads = x));
+    subscribe(
+      this,
+      this.commentsModel.changeComments$,
+      x => (this.changeComments = x)
+    );
+    subscribe(
+      this,
+      this.commentsModel.threads$,
+      x => (this.commentThreads = x)
+    );
     subscribe(this, this.userModel.account$, x => (this.selfAccount = x));
   }
 
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 5bd0579..46e1647 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
@@ -191,20 +191,12 @@
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {
-  changeComments$,
-  drafts$,
-} from '../../../services/comments/comments-model';
-import {
   getAddedByReason,
   getRemovedByReason,
   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;
 
@@ -281,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.
    */
@@ -560,15 +543,6 @@
   })
   resolveWeblinks?: GeneratedWebLink[];
 
-  readonly restApiService = getAppContext().restApiService;
-
-  // Private but used in tests.
-  readonly userModel = getAppContext().userModel;
-
-  private readonly commentsService = getAppContext().commentsService;
-
-  private readonly shortcuts = getAppContext().shortcutsService;
-
   override keyboardShortcuts(): ShortcutListener[] {
     return [
       listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
@@ -615,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;
@@ -644,7 +637,7 @@
       })
     );
     this.subscriptions.push(
-      drafts$.subscribe(drafts => {
+      this.commentsModel.drafts$.subscribe(drafts => {
         this._diffDrafts = {...drafts};
       })
     );
@@ -654,12 +647,12 @@
       })
     );
     this.subscriptions.push(
-      changeComments$.subscribe(changeComments => {
+      this.commentsModel.changeComments$.subscribe(changeComments => {
         this._changeComments = changeComments;
       })
     );
     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;
@@ -1889,7 +1882,7 @@
     }
 
     const detailCompletes = until(
-      changeLoadingStatus$,
+      this.changeModel.changeLoadingStatus$,
       status => status === LoadingStatus.LOADED
     );
     const editCompletes = this._getEdit();
@@ -2222,13 +2215,13 @@
     const promises = [this._getCommitInfo(), this.$.fileList.reload()];
     if (patchNumChanged) {
       promises.push(
-        this.commentsService.reloadPortedComments(
+        this.commentsModel.reloadPortedComments(
           this._changeNum,
           this._patchRange?.patchNum
         )
       );
       promises.push(
-        this.commentsService.reloadPortedDrafts(
+        this.commentsModel.reloadPortedDrafts(
           this._changeNum,
           this._patchRange?.patchNum
         )
@@ -2338,7 +2331,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 b7a3eda..e8cce2d 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
@@ -104,10 +104,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';
@@ -1524,7 +1521,7 @@
 
   test('topic is coalesced to null', async () => {
     sinon.stub(element, '_changeChanged');
-    setChangeState({
+    element.changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1540,7 +1537,7 @@
 
   test('commit sha is populated from getChangeDetail', async () => {
     sinon.stub(element, '_changeChanged');
-    setChangeState({
+    element.changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1557,7 +1554,7 @@
   test('edit is added to change', () => {
     sinon.stub(element, '_changeChanged');
     const changeRevision = createRevision();
-    setChangeState({
+    element.changeModel.setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1978,7 +1975,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(),
@@ -2009,7 +2006,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 1778b2b..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,8 +29,7 @@
 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 {threads$} from '../../../services/comments/comments-model';
+import {getAppContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
 
 @customElement('gr-confirm-submit-dialog')
@@ -62,6 +61,10 @@
   @state()
   initialised = false;
 
+  private commentsModel = getAppContext().commentsModel;
+
+  private changeModel = getAppContext().changeModel;
+
   static override get styles() {
     return [
       sharedStyles,
@@ -90,10 +93,10 @@
 
   constructor() {
     super();
-    subscribe(this, change$, x => (this.change = x));
+    subscribe(this, this.changeModel.change$, x => (this.change = x));
     subscribe(
       this,
-      threads$,
+      this.commentsModel.threads$,
       x => (this.unresolvedThreads = x.filter(isUnresolved))
     );
   }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index f286098..112077a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -83,7 +83,6 @@
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
-import {changeComments$} from '../../../services/comments/comments-model';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
 import {select} from '../../../utils/observable-util';
 
@@ -316,6 +315,8 @@
 
   private readonly userModel = getAppContext().userModel;
 
+  private readonly commentsModel = getAppContext().commentsModel;
+
   private readonly browserModel = getAppContext().browserModel;
 
   private subscriptions: Subscription[] = [];
@@ -375,7 +376,7 @@
   override connectedCallback() {
     super.connectedCallback();
     this.subscriptions = [
-      changeComments$.subscribe(changeComments => {
+      this.commentsModel.changeComments$.subscribe(changeComments => {
         this.changeComments = changeComments;
       }),
       this.browserModel.diffViewMode$.subscribe(
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 38875f5..fd6b0d4 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -1473,8 +1473,7 @@
     }
 
     async function renderAndGetNewDiffs(index) {
-      const diffs =
-          element.root.querySelectorAll('gr-diff-host');
+      const diffs = element.root.querySelectorAll('gr-diff-host');
 
       for (let i = index; i < diffs.length; i++) {
         await setupDiff(diffs[i]);
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 3c4702c..be04e6e 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -33,10 +33,13 @@
   LabelValuesMap,
 } from '../gr-label-score-row/gr-label-score-row';
 import {getAppContext} from '../../../services/app-context';
-import {getTriggerVotes, labelCompare} from '../../../utils/label-util';
+import {
+  getTriggerVotes,
+  labelCompare,
+  showNewSubmitRequirements,
+} from '../../../utils/label-util';
 import {Execution} from '../../../constants/reporting';
 import {ChangeStatus} from '../../../constants/constants';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {fontStyles} from '../../../styles/gr-font-styles';
 
 @customElement('gr-label-scores')
@@ -90,7 +93,7 @@
   }
 
   override render() {
-    if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
+    if (showNewSubmitRequirements(this.flagsService, this.change)) {
       return this.renderNewSubmitRequirements();
     } else {
       return this.renderOldSubmitRequirements();
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 c3ad0b6..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,12 +51,6 @@
   FormattedReviewerUpdateInfo,
   ParsedChangeInfo,
 } from '../../../types/types';
-import {threads$} from '../../../services/comments/comments-model';
-import {
-  change$,
-  changeNum$,
-  repo$,
-} from '../../../services/change/change-model';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -257,6 +251,11 @@
 
   private readonly userModel = getAppContext().userModel;
 
+  // Private but used in tests.
+  readonly commentsModel = getAppContext().commentsModel;
+
+  private readonly changeModel = getAppContext().changeModel;
+
   private readonly reporting = getAppContext().reportingService;
 
   private readonly shortcuts = getAppContext().shortcutsService;
@@ -266,12 +265,12 @@
   override connectedCallback() {
     super.connectedCallback();
     this.subscriptions.push(
-      threads$.subscribe(x => {
+      this.commentsModel.threads$.subscribe(x => {
         this.commentThreads = x;
       })
     );
     this.subscriptions.push(
-      change$.subscribe(x => {
+      this.changeModel.change$.subscribe(x => {
         this.change = x;
       })
     );
@@ -281,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-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
index 50e1a38..65c3c74 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -22,7 +22,6 @@
 import {MessageTag} from '../../../constants/constants.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {stubRestApi} from '../../../test/test-utils.js';
-import {updateStateComments} from '../../../services/comments/comments-model.js';
 
 createCommentApiMockWithTemplateElement(
     'gr-messages-list-comment-mock-api', html`
@@ -129,7 +128,7 @@
   };
 
   suite('basic tests', () => {
-    setup(() => {
+    setup(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getDiffComments').returns(Promise.resolve(comments));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
@@ -140,9 +139,9 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.messagesList;
-      updateStateComments(comments);
+      await element.commentsModel.reloadComments();
       element.messages = messages;
-      flush();
+      await flush();
     });
 
     test('expand/collapse all', () => {
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 84a5fa7..490162b 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-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 9c62d1a..25a1a00 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -85,7 +85,6 @@
 import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
 import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
 import {Timing} from '../../../constants/reporting';
-import {changeComments$} from '../../../services/comments/comments-model';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {Subscription} from 'rxjs';
 import {DisplayLine, RenderPreferences} from '../../../api/diff';
@@ -268,6 +267,8 @@
 
   private readonly browserModel = getAppContext().browserModel;
 
+  private readonly commentsModel = getAppContext().commentsModel;
+
   private readonly reporting = getAppContext().reportingService;
 
   private readonly flags = getAppContext().flagsService;
@@ -320,7 +321,7 @@
       this._loggedIn = loggedIn;
     });
     this.subscriptions.push(
-      changeComments$.subscribe(changeComments => {
+      this.commentsModel.changeComments$.subscribe(changeComments => {
         this.changeComments = changeComments;
       })
     );
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 50d6b24..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
@@ -113,20 +113,10 @@
 import {addGlobalShortcut, Key, toggleClass} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
 import {isFalse, throttleWrap, until} from '../../../utils/async-util';
-import {
-  changeComments$,
-  commentsLoading$,
-} from '../../../services/comments/comments-model';
 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';
 
@@ -368,14 +358,14 @@
   // 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;
 
-  // We just want to make sure that CommentsService is instantiated.
-  // Otherwise subscribing to the model won't emit any data.
-  private readonly _commentsService = getAppContext().commentsService;
+  // Private but used in tests.
+  readonly commentsModel = getAppContext().commentsModel;
 
   private readonly shortcuts = getAppContext().shortcutsService;
 
@@ -387,11 +377,6 @@
 
   private subscriptions: Subscription[] = [];
 
-  constructor() {
-    super();
-    this._commentsService;
-  }
-
   override connectedCallback() {
     super.connectedCallback();
     this._throttledToggleFileReviewed = throttleWrap(_ =>
@@ -405,7 +390,7 @@
     });
 
     this.subscriptions.push(
-      changeComments$.subscribe(changeComments => {
+      this.commentsModel.changeComments$.subscribe(changeComments => {
         this._changeComments = changeComments;
       })
     );
@@ -421,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;
@@ -433,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]) =>
@@ -453,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) => {
@@ -1069,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');
@@ -1079,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 = {
@@ -1145,7 +1132,7 @@
     }
 
     this._files = {sortedFileList: [], changeFilesByPath: {}};
-    this.changeService.updatePath(undefined);
+    this.changeModel.updatePath(undefined);
     this._patchRange = undefined;
     this._commitRange = undefined;
     this._focusLineNum = undefined;
@@ -1169,11 +1156,15 @@
     }
 
     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(commentsLoading$, isFalse));
+    }
+    promises.push(until(this.commentsModel.commentsLoading$, isFalse));
     promises.push(
       this._getChangeEdit().then(edit => {
         if (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 d47a03e..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,8 +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';
-import {_testOnly_setState as setCommentState} from '../../../services/comments/comments-model.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
@@ -93,7 +91,7 @@
       ]});
       await flush();
 
-      setCommentState({
+      element.commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {},
@@ -139,14 +137,15 @@
         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', () => {
-        setCommentState({
+        element.commentsModel.setState({
           comments: {
             '/COMMIT_MSG': [
               {
@@ -220,7 +219,7 @@
     test('unchanged diff X vs latest from comment links navigates to base vs X'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-          setCommentState({
+          element.commentsModel.setState({
             comments: {
               '/COMMIT_MSG': [
                 {
@@ -248,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',
@@ -272,7 +272,7 @@
     test('unchanged diff Base vs latest from comment does not navigate'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-          setCommentState({
+          element.commentsModel.setState({
             comments: {
               '/COMMIT_MSG': [
                 {
@@ -300,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',
@@ -352,7 +353,7 @@
     });
 
     test('diff toast to go to latest is shown and not base', async () => {
-      setCommentState({
+      element.commentsModel.setState({
         comments: {
           '/COMMIT_MSG': [
             {
@@ -381,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,
@@ -1200,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}
@@ -1237,7 +1241,8 @@
             manual_review: false,
           };
           element.userModel.setDiffPreferences(diffPreferences);
-          setChangeModelState({change: createChange(),
+          element.changeModel.setState({
+            change: createChange(),
             diffPath: '/COMMIT_MSG'});
 
           setRouterModelState({
@@ -1262,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/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 3a2def0..71fee62 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -51,7 +51,6 @@
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators';
 import {subscribe} from '../../lit/subscription-controller';
-import {changeComments$} from '../../../services/comments/comments-model';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -127,9 +126,15 @@
   private readonly reporting: ReportingService =
     getAppContext().reportingService;
 
+  private readonly commentsModel = getAppContext().commentsModel;
+
   constructor() {
     super();
-    subscribe(this, changeComments$, x => (this.changeComments = x));
+    subscribe(
+      this,
+      this.commentsModel.changeComments$,
+      x => (this.changeComments = x)
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index 8b47437..0a9fbbf 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -24,6 +24,7 @@
 import {ServerInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 @customElement('gr-change-table-editor')
 export class GrChangeTableEditor extends PolymerElement {
@@ -48,24 +49,25 @@
   @observe('serverConfig')
   _configChanged(config: ServerInfo) {
     this.defaultColumns = columnNames.filter(col =>
-      this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
+      this._isColumnEnabled(col, config)
     );
     if (!this.displayedColumns) return;
     this.displayedColumns = this.displayedColumns.filter(column =>
-      this._isColumnEnabled(
-        column,
-        config,
-        this.flagsService.enabledExperiments
-      )
+      this._isColumnEnabled(column, config)
     );
   }
 
   /**
    * Is the column disabled by a server config or experiment?
    */
-  _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
+  _isColumnEnabled(column: string, config: ServerInfo) {
     if (!config || !config.change) return true;
-    if (column === 'Comments') return experiments.includes('comments-column');
+    if (column === 'Comments')
+      return this.flagsService.isEnabled('comments-column');
+    if (column === 'Requirements')
+      return this.flagsService.isEnabled(
+        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+      );
     return true;
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index 3813213..c2bcec2 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -104,7 +104,7 @@
 
   test('_getDisplayedColumns', () => {
     const enabledColumns = columns.filter(column =>
-      element._isColumnEnabled(column, element.serverConfig!, [])
+      element._isColumnEnabled(column, element.serverConfig!)
     );
     assert.deepEqual(element._getDisplayedColumns(), enabledColumns);
     const input = queryAndAssert<HTMLInputElement>(
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 1e99b7f..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';
 
@@ -236,7 +235,9 @@
   @state()
   saving = false;
 
-  private readonly commentsService = getAppContext().commentsService;
+  private readonly commentsModel = getAppContext().commentsModel;
+
+  private readonly changeModel = getAppContext().changeModel;
 
   private readonly userModel = getAppContext().userModel;
 
@@ -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)
     );
@@ -769,7 +770,7 @@
     } else {
       try {
         this.saving = true;
-        await this.commentsService.saveDraft(unsaved);
+        await this.commentsModel.saveDraft(unsaved);
       } finally {
         this.saving = false;
       }
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 aa5a328..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,7 +227,9 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly commentsService = getAppContext().commentsService;
+  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)),
@@ -1085,7 +1086,7 @@
       if (messageToSave === '') {
         // Don't try to discard UnsavedInfo. Nothing to do then.
         if (this.comment.id) {
-          await this.commentsService.discardDraft(this.comment.id);
+          await this.commentsModel.discardDraft(this.comment.id);
         }
       } else {
         // No need to make a backend call when nothing has changed.
@@ -1108,7 +1109,7 @@
   /** For sharing between save() and autoSave(). */
   private rawSave(message: string, options: {showToast: boolean}) {
     if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
-    return this.commentsService.saveDraft(
+    return this.commentsModel.saveDraft(
       {
         ...this.comment,
         message,
@@ -1126,7 +1127,7 @@
   private async openDeleteCommentOverlay() {
     this.showConfirmDeleteOverlay = true;
     await this.updateComplete;
-    this.confirmDeleteOverlay?.open();
+    await this.confirmDeleteOverlay?.open();
   }
 
   private closeDeleteCommentOverlay() {
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 888f477..70de130 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -44,6 +44,7 @@
   getVotingRangeOrDefault,
   hasNeutralStatus,
   hasVoted,
+  showNewSubmitRequirements,
   valueString,
 } from '../../../utils/label-util';
 import {getAppContext} from '../../../services/app-context';
@@ -53,7 +54,6 @@
 import {votingStyles} from '../../../styles/gr-voting-styles';
 import {ifDefined} from 'lit/directives/if-defined';
 import {fireReload} from '../../../utils/event-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {sortReviewers} from '../../../utils/attention-set-util';
 
 declare global {
@@ -104,10 +104,6 @@
   @property({type: Boolean})
   showAllReviewers = true;
 
-  /** temporary until submit requirements are finished */
-  @property({type: Boolean})
-  showAlwaysOldUI = false;
-
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
@@ -214,10 +210,7 @@
   }
 
   override render() {
-    if (
-      this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) &&
-      !this.showAlwaysOldUI
-    ) {
+    if (showNewSubmitRequirements(this.flagsService, this.change)) {
       return this.renderNewSubmitRequirements();
     } else {
       return this.renderOldSubmitRequirements();
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 44db793..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,11 +75,11 @@
     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');
     },
-    commentsService: (_ctx: Partial<AppContext>) => {
-      throw new Error('commentsService is not implemented');
+    commentsModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('commentsModel is not implemented');
     },
     checksModel: (_ctx: Partial<AppContext>) => {
       throw new Error('checksModel is not implemented');
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index c43ed4e..46d2178 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -21,12 +21,12 @@
 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';
 import {UserModel} from './user/user-model';
-import {CommentsService} from './comments/comments-service';
+import {CommentsModel} from './comments/comments-model';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
 import {BrowserModel} from './browser/browser-model';
 import {assertIsDefined} from '../utils/common-util';
@@ -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!);
     },
-    commentsService: (ctx: Partial<AppContext>) => {
-      const restApi = ctx.restApiService;
+    commentsModel: (ctx: Partial<AppContext>) => {
+      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 CommentsService(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 4488b12..bea371f 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -20,12 +20,12 @@
 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';
 import {UserModel} from './user/user-model';
-import {CommentsService} from './comments/comments-service';
+import {CommentsModel} from './comments/comments-model';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
 import {BrowserModel} from './browser/browser-model';
 import {ConfigModel} from './config/config-model';
@@ -36,8 +36,8 @@
   eventEmitter: EventEmitterService;
   authService: AuthService;
   restApiService: RestApiService;
-  changeService: ChangeService;
-  commentsService: CommentsService;
+  changeModel: ChangeModel;
+  commentsModel: CommentsModel;
   checksModel: ChecksModel;
   jsApiService: JsApiService;
   storageService: StorageService;
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 62aecbc..d46e08a 100644
--- a/polygerrit-ui/app/services/comments/comments-model.ts
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -15,19 +15,42 @@
  * limitations under the License.
  */
 
-import {BehaviorSubject, Observable} from 'rxjs';
+import {BehaviorSubject} from 'rxjs';
 import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
 import {
+  CommentBasics,
   CommentInfo,
+  NumericChangeId,
+  PatchSetNum,
+  RevisionId,
+  UrlEncodedCommentId,
   PathToCommentsInfoMap,
   RobotCommentInfo,
-  UrlEncodedCommentId,
 } from '../../types/common';
-import {addPath, DraftInfo, isDraft, isUnsaved} from '../../utils/comment-util';
+import {
+  addPath,
+  DraftInfo,
+  isDraft,
+  isUnsaved,
+  reportingDetails,
+  UnsavedInfo,
+} from '../../utils/comment-util';
 import {deepEqual} from '../../utils/deep-util';
 import {select} from '../../utils/observable-util';
+import {routerChangeNum$} from '../router/router-model';
+import {Finalizable} from '../registry';
+import {combineLatest, Subscription} from 'rxjs';
+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 {ChangeModel} from '../change/change-model';
+import {Interaction} from '../../constants/reporting';
+import {assertIsDefined} from '../../utils/common-util';
+import {debounce, DelayedTask} from '../../utils/async-util';
+import {pluralize} from '../../utils/string-util';
+import {ReportingService} from '../gr-reporting/gr-reporting';
 
-interface CommentState {
+export interface CommentState {
   /** undefined means 'still loading' */
   comments?: PathToCommentsInfoMap;
   /** undefined means 'still loading' */
@@ -60,134 +83,93 @@
   discardedDrafts: [],
 };
 
-const privateState$ = new BehaviorSubject(initialState);
+const TOAST_DEBOUNCE_INTERVAL = 200;
 
-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});
+function getSavingMessage(numPending: number, requestFailed?: boolean) {
+  if (requestFailed) {
+    return 'Unable to save draft';
+  }
+  if (numPending === 0) {
+    return 'All changes saved';
+  }
+  return `Saving ${pluralize(numPending, 'draft')}...`;
 }
 
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const commentState$: Observable<CommentState> = privateState$;
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-export function _testOnly_setState(state: CommentState) {
-  privateState$.next(state);
-}
-
-export const commentsLoading$ = select(
-  commentState$,
-  commentState =>
-    commentState.comments === undefined ||
-    commentState.robotComments === undefined ||
-    commentState.drafts === undefined
-);
-
-export const comments$ = select(
-  commentState$,
-  commentState => commentState.comments
-);
-
-export const drafts$ = select(
-  commentState$,
-  commentState => commentState.drafts
-);
-
-export const portedComments$ = select(
-  commentState$,
-  commentState => commentState.portedComments
-);
-
-export const discardedDrafts$ = select(
-  commentState$,
-  commentState => commentState.discardedDrafts
-);
-
-// Emits a new value even if only a single draft is changed. Components should
-// aim to subsribe to something more specific.
-export const changeComments$ = select(
-  commentState$,
-  commentState =>
-    new ChangeComments(
-      commentState.comments,
-      commentState.robotComments,
-      commentState.drafts,
-      commentState.portedComments,
-      commentState.portedDrafts
-    )
-);
-
-export const threads$ = select(changeComments$, changeComments =>
-  changeComments.getAllThreadsForChange()
-);
-
-export function thread$(id: UrlEncodedCommentId) {
-  return select(threads$, threads => threads.find(t => t.rootId === id));
-}
-
-function publishState(state: CommentState) {
-  privateState$.next(state);
-}
-
-/** Called when the change number changes. Wipes out all data from the state. */
-export function updateStateReset() {
-  publishState({...initialState});
-}
-
-export function updateStateComments(comments?: {
-  [path: string]: CommentInfo[];
-}) {
-  const nextState = {...privateState$.getValue()};
-  if (deepEqual(comments, nextState.comments)) return;
+// Private but used in tests.
+export function setComments(
+  state: CommentState,
+  comments?: {
+    [path: string]: CommentInfo[];
+  }
+): CommentState {
+  const nextState = {...state};
+  if (deepEqual(comments, nextState.comments)) return state;
   nextState.comments = addPath(comments) || {};
-  publishState(nextState);
+  return nextState;
 }
 
-export function updateStateRobotComments(robotComments?: {
-  [path: string]: RobotCommentInfo[];
-}) {
-  const nextState = {...privateState$.getValue()};
-  if (deepEqual(robotComments, nextState.robotComments)) return;
+// Private but used in tests.
+export function setRobotComments(
+  state: CommentState,
+  robotComments?: {
+    [path: string]: RobotCommentInfo[];
+  }
+): CommentState {
+  if (deepEqual(robotComments, state.robotComments)) return state;
+  const nextState = {...state};
   nextState.robotComments = addPath(robotComments) || {};
-  publishState(nextState);
+  return nextState;
 }
 
-export function updateStateDrafts(drafts?: {[path: string]: DraftInfo[]}) {
-  const nextState = {...privateState$.getValue()};
-  if (deepEqual(drafts, nextState.drafts)) return;
+// Private but used in tests.
+export function setDrafts(
+  state: CommentState,
+  drafts?: {[path: string]: DraftInfo[]}
+): CommentState {
+  if (deepEqual(drafts, state.drafts)) return state;
+  const nextState = {...state};
   nextState.drafts = addPath(drafts);
-  publishState(nextState);
+  return nextState;
 }
 
-export function updateStatePortedComments(
+// Private but used in tests.
+export function setPortedComments(
+  state: CommentState,
   portedComments?: PathToCommentsInfoMap
-) {
-  const nextState = {...privateState$.getValue()};
-  if (deepEqual(portedComments, nextState.portedComments)) return;
+): CommentState {
+  if (deepEqual(portedComments, state.portedComments)) return state;
+  const nextState = {...state};
   nextState.portedComments = portedComments || {};
-  publishState(nextState);
+  return nextState;
 }
 
-export function updateStatePortedDrafts(portedDrafts?: PathToCommentsInfoMap) {
-  const nextState = {...privateState$.getValue()};
-  if (deepEqual(portedDrafts, nextState.portedDrafts)) return;
+// Private but used in tests.
+export function setPortedDrafts(
+  state: CommentState,
+  portedDrafts?: PathToCommentsInfoMap
+): CommentState {
+  if (deepEqual(portedDrafts, state.portedDrafts)) return state;
+  const nextState = {...state};
   nextState.portedDrafts = portedDrafts || {};
-  publishState(nextState);
+  return nextState;
 }
 
-export function updateStateSetDiscardedDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
+// Private but used in tests.
+export function setDiscardedDraft(
+  state: CommentState,
+  draft: DraftInfo
+): CommentState {
+  const nextState = {...state};
   nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
-  publishState(nextState);
+  return nextState;
 }
 
-export function updateStateDeleteDiscardedDraft(draftID?: string) {
-  const nextState = {...privateState$.getValue()};
+// Private but used in tests.
+export function deleteDiscardedDraft(
+  state: CommentState,
+  draftID?: string
+): CommentState {
+  const nextState = {...state};
   const drafts = [...nextState.discardedDrafts];
   const index = drafts.findIndex(d => d.id === draftID);
   if (index === -1) {
@@ -195,12 +177,12 @@
   }
   drafts.splice(index, 1);
   nextState.discardedDrafts = drafts;
-  publishState(nextState);
+  return nextState;
 }
 
 /** Adds or updates a draft. */
-export function updateStateSetDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
+export function setDraft(state: CommentState, draft: DraftInfo): CommentState {
+  const nextState = {...state};
   if (!draft.path) throw new Error('draft path undefined');
   if (!isDraft(draft)) throw new Error('draft is not a draft');
   if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
@@ -215,11 +197,14 @@
   } else {
     drafts[draft.path].push(draft);
   }
-  publishState(nextState);
+  return nextState;
 }
 
-export function updateStateDeleteDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
+export function deleteDraft(
+  state: CommentState,
+  draft: DraftInfo
+): CommentState {
+  const nextState = {...state};
   if (!draft.path) throw new Error('draft path undefined');
   if (!isDraft(draft)) throw new Error('draft is not a draft');
   if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
@@ -228,10 +213,333 @@
   const index = (drafts[draft.path] || []).findIndex(
     d => d.id && d.id === draft.id
   );
-  if (index === -1) return;
+  if (index === -1) return state;
   const discardedDraft = drafts[draft.path][index];
   drafts[draft.path] = [...drafts[draft.path]];
   drafts[draft.path].splice(index, 1);
-  publishState(nextState);
-  updateStateSetDiscardedDraft(discardedDraft);
+  return setDiscardedDraft(nextState, discardedDraft);
+}
+
+export class CommentsModel implements Finalizable {
+  private readonly privateState$: BehaviorSubject<CommentState> =
+    new BehaviorSubject(initialState);
+
+  public readonly commentsLoading$ = select(
+    this.privateState$,
+    commentState =>
+      commentState.comments === undefined ||
+      commentState.robotComments === undefined ||
+      commentState.drafts === undefined
+  );
+
+  public readonly comments$ = select(
+    this.privateState$,
+    commentState => commentState.comments
+  );
+
+  public readonly drafts$ = select(
+    this.privateState$,
+    commentState => commentState.drafts
+  );
+
+  public readonly portedComments$ = select(
+    this.privateState$,
+    commentState => commentState.portedComments
+  );
+
+  public readonly discardedDrafts$ = select(
+    this.privateState$,
+    commentState => commentState.discardedDrafts
+  );
+
+  // Emits a new value even if only a single draft is changed. Components should
+  // aim to subsribe to something more specific.
+  public readonly changeComments$ = select(
+    this.privateState$,
+    commentState =>
+      new ChangeComments(
+        commentState.comments,
+        commentState.robotComments,
+        commentState.drafts,
+        commentState.portedComments,
+        commentState.portedDrafts
+      )
+  );
+
+  public readonly threads$ = select(this.changeComments$, changeComments =>
+    changeComments.getAllThreadsForChange()
+  );
+
+  public thread$(id: UrlEncodedCommentId) {
+    return select(this.threads$, threads => threads.find(t => t.rootId === id));
+  }
+
+  private numPendingDraftRequests = 0;
+
+  private changeNum?: NumericChangeId;
+
+  private patchNum?: PatchSetNum;
+
+  private readonly reloadListener: () => void;
+
+  private readonly subscriptions: Subscription[] = [];
+
+  private drafts: {[path: string]: DraftInfo[]} = {};
+
+  private draftToastTask?: DelayedTask;
+
+  private discardedDrafts: DraftInfo[] = [];
+
+  constructor(
+    readonly changeModel: ChangeModel,
+    readonly restApiService: RestApiService,
+    readonly reporting: ReportingService
+  ) {
+    this.subscriptions.push(
+      this.discardedDrafts$.subscribe(x => (this.discardedDrafts = x))
+    );
+    this.subscriptions.push(
+      this.drafts$.subscribe(x => (this.drafts = x ?? {}))
+    );
+    this.subscriptions.push(
+      this.changeModel.currentPatchNum$.subscribe(x => (this.patchNum = x))
+    );
+    this.subscriptions.push(
+      routerChangeNum$.subscribe(changeNum => {
+        this.changeNum = changeNum;
+        this.setState({...initialState});
+        this.reloadAllComments();
+      })
+    );
+    this.subscriptions.push(
+      combineLatest([
+        this.changeModel.changeNum$,
+        this.changeModel.currentPatchNum$,
+      ]).subscribe(([changeNum, patchNum]) => {
+        this.changeNum = changeNum;
+        this.patchNum = patchNum;
+        this.reloadAllPortedComments();
+      })
+    );
+    this.reloadListener = () => {
+      this.reloadAllComments();
+      this.reloadAllPortedComments();
+    };
+    document.addEventListener('reload', this.reloadListener);
+  }
+
+  finalize() {
+    document.removeEventListener('reload', this.reloadListener!);
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions.splice(0, this.subscriptions.length);
+  }
+
+  // Note that this does *not* reload ported comments.
+  async reloadAllComments() {
+    if (!this.changeNum) return;
+    await Promise.all([
+      this.reloadComments(this.changeNum),
+      this.reloadRobotComments(this.changeNum),
+      this.reloadDrafts(this.changeNum),
+    ]);
+  }
+
+  async reloadAllPortedComments() {
+    if (!this.changeNum) return;
+    if (!this.patchNum) return;
+    await Promise.all([
+      this.reloadPortedComments(this.changeNum, this.patchNum),
+      this.reloadPortedDrafts(this.changeNum, this.patchNum),
+    ]);
+  }
+
+  // visible for testing
+  updateState(reducer: (state: CommentState) => CommentState) {
+    const current = this.privateState$.getValue();
+    this.setState(reducer({...current}));
+  }
+
+  // visible for testing
+  setState(state: CommentState) {
+    this.privateState$.next(state);
+  }
+
+  async reloadComments(changeNum: NumericChangeId): Promise<void> {
+    const comments = await this.restApiService.getDiffComments(changeNum);
+    this.updateState(s => setComments(s, comments));
+  }
+
+  async reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
+    const robotComments = await this.restApiService.getDiffRobotComments(
+      changeNum
+    );
+    this.updateState(s => setRobotComments(s, robotComments));
+  }
+
+  async reloadDrafts(changeNum: NumericChangeId): Promise<void> {
+    const drafts = await this.restApiService.getDiffDrafts(changeNum);
+    this.updateState(s => setDrafts(s, drafts));
+  }
+
+  async reloadPortedComments(
+    changeNum: NumericChangeId,
+    patchNum = CURRENT as RevisionId
+  ): Promise<void> {
+    const portedComments = await this.restApiService.getPortedComments(
+      changeNum,
+      patchNum
+    );
+    this.updateState(s => setPortedComments(s, portedComments));
+  }
+
+  async reloadPortedDrafts(
+    changeNum: NumericChangeId,
+    patchNum = CURRENT as RevisionId
+  ): Promise<void> {
+    const portedDrafts = await this.restApiService.getPortedDrafts(
+      changeNum,
+      patchNum
+    );
+    this.updateState(s => setPortedDrafts(s, portedDrafts));
+  }
+
+  async restoreDraft(id: UrlEncodedCommentId) {
+    const found = this.discardedDrafts?.find(d => d.id === id);
+    if (!found) throw new Error('discarded draft not found');
+    const newDraft = {
+      ...found,
+      id: undefined,
+      updated: undefined,
+      __draft: undefined,
+      __unsaved: true,
+    };
+    await this.saveDraft(newDraft);
+    this.updateState(s => deleteDiscardedDraft(s, id));
+  }
+
+  /**
+   * Saves a new or updates an existing draft.
+   * The model will only be updated when a successful response comes back.
+   */
+  async saveDraft(draft: DraftInfo | UnsavedInfo, showToast = true) {
+    assertIsDefined(this.changeNum, 'change number');
+    assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+    if (!draft.message?.trim()) throw new Error('Cannot save empty draft.');
+
+    // Saving the change number as to make sure that the response is still
+    // relevant when it comes back. The user maybe have navigated away.
+    const changeNum = this.changeNum;
+    this.report(Interaction.SAVE_COMMENT, draft);
+    if (showToast) this.showStartRequest();
+    const result = await this.restApiService.saveDiffDraft(
+      changeNum,
+      draft.patch_set,
+      draft
+    );
+    if (changeNum !== this.changeNum) throw new Error('change changed');
+    if (!result.ok) {
+      if (showToast) this.handleFailedDraftRequest();
+      throw new Error(
+        `Failed to save draft comment: ${JSON.stringify(result)}`
+      );
+    }
+    const obj = await this.restApiService.getResponseObject(result);
+    const savedComment = obj as unknown as CommentInfo;
+    const updatedDraft = {
+      ...draft,
+      id: savedComment.id,
+      updated: savedComment.updated,
+      __draft: true,
+      __unsaved: undefined,
+    };
+    if (showToast) this.showEndRequest();
+    this.updateState(s => setDraft(s, updatedDraft));
+    this.report(Interaction.COMMENT_SAVED, updatedDraft);
+  }
+
+  async discardDraft(draftId: UrlEncodedCommentId) {
+    const draft = this.lookupDraft(draftId);
+    assertIsDefined(this.changeNum, 'change number');
+    assertIsDefined(draft, `draft not found by id ${draftId}`);
+    assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+
+    if (!draft.message?.trim()) throw new Error('saved draft cant be empty');
+    // Saving the change number as to make sure that the response is still
+    // relevant when it comes back. The user maybe have navigated away.
+    const changeNum = this.changeNum;
+    this.report(Interaction.DISCARD_COMMENT, draft);
+    this.showStartRequest();
+    const result = await this.restApiService.deleteDiffDraft(
+      changeNum,
+      draft.patch_set,
+      {id: draft.id}
+    );
+    if (changeNum !== this.changeNum) throw new Error('change changed');
+    if (!result.ok) {
+      this.handleFailedDraftRequest();
+      throw new Error(
+        `Failed to discard draft comment: ${JSON.stringify(result)}`
+      );
+    }
+    this.showEndRequest();
+    this.updateState(s => deleteDraft(s, draft));
+    // We don't store empty discarded drafts and don't need an UNDO then.
+    if (draft.message?.trim()) {
+      fire(document, 'show-alert', {
+        message: 'Draft Discarded',
+        action: 'Undo',
+        callback: () => this.restoreDraft(draft.id),
+      });
+    }
+    this.report(Interaction.COMMENT_DISCARDED, draft);
+  }
+
+  private report(interaction: Interaction, comment: CommentBasics) {
+    const details = reportingDetails(comment);
+    this.reporting.reportInteraction(interaction, details);
+  }
+
+  private showStartRequest() {
+    this.numPendingDraftRequests += 1;
+    this.updateRequestToast();
+  }
+
+  private showEndRequest() {
+    this.numPendingDraftRequests -= 1;
+    this.updateRequestToast();
+  }
+
+  private handleFailedDraftRequest() {
+    this.numPendingDraftRequests -= 1;
+    this.updateRequestToast(/* requestFailed=*/ true);
+  }
+
+  private updateRequestToast(requestFailed?: boolean) {
+    if (this.numPendingDraftRequests === 0 && !requestFailed) {
+      fireEvent(document, 'hide-alert');
+      return;
+    }
+    const message = getSavingMessage(
+      this.numPendingDraftRequests,
+      requestFailed
+    );
+    this.draftToastTask = debounce(
+      this.draftToastTask,
+      () => {
+        // Note: the event is fired on the body rather than this element because
+        // this element may not be attached by the time this executes, in which
+        // case the event would not bubble.
+        fireAlert(document.body, message);
+      },
+      TOAST_DEBOUNCE_INTERVAL
+    );
+  }
+
+  private lookupDraft(id: UrlEncodedCommentId): DraftInfo | undefined {
+    return Object.values(this.drafts)
+      .flat()
+      .find(d => d.id === id);
+  }
 }
diff --git a/polygerrit-ui/app/services/comments/comments-model_test.ts b/polygerrit-ui/app/services/comments/comments-model_test.ts
index 30fc7cf..f39cad8 100644
--- a/polygerrit-ui/app/services/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/services/comments/comments-model_test.ts
@@ -19,17 +19,28 @@
 import {UrlEncodedCommentId} from '../../types/common';
 import {DraftInfo} from '../../utils/comment-util';
 import './comments-model';
+import {CommentsModel} from './comments-model';
+import {deleteDraft} from './comments-model';
+import {Subscription} from 'rxjs';
+import '../../test/common-test-setup-karma';
 import {
-  updateStateDeleteDraft,
-  _testOnly_getState,
-  _testOnly_setState,
-} from './comments-model';
+  createComment,
+  createParsedChange,
+  TEST_NUMERIC_CHANGE_ID,
+} 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 {PathToCommentsInfoMap} from '../../types/common';
 
 suite('comments model tests', () => {
   test('updateStateDeleteDraft', () => {
     const draft = createDraft();
     draft.id = '1' as UrlEncodedCommentId;
-    _testOnly_setState({
+    const state = {
       comments: {},
       robotComments: {},
       drafts: {
@@ -38,9 +49,9 @@
       portedComments: {},
       portedDrafts: {},
       discardedDrafts: [],
-    });
-    updateStateDeleteDraft(draft);
-    assert.deepEqual(_testOnly_getState(), {
+    };
+    const output = deleteDraft(state, draft);
+    assert.deepEqual(output, {
       comments: {},
       robotComments: {},
       drafts: {
@@ -52,3 +63,65 @@
     });
   });
 });
+
+suite('change service tests', () => {
+  let subscriptions: Subscription[] = [];
+
+  teardown(() => {
+    for (const s of subscriptions) {
+      s.unsubscribe();
+    }
+    subscriptions = [];
+  });
+
+  test('loads comments', async () => {
+    const model = new CommentsModel(
+      getAppContext().changeModel,
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+      Promise.resolve({'foo.c': [createComment()]})
+    );
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+      Promise.resolve({})
+    );
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+      Promise.resolve({})
+    );
+    const portedCommentsSpy = stubRestApi('getPortedComments').returns(
+      Promise.resolve({'foo.c': [createComment()]})
+    );
+    const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
+      Promise.resolve({})
+    );
+    let comments: PathToCommentsInfoMap = {};
+    subscriptions.push(model.comments$.subscribe(c => (comments = c ?? {})));
+    let portedComments: PathToCommentsInfoMap = {};
+    subscriptions.push(
+      model.portedComments$.subscribe(c => (portedComments = c ?? {}))
+    );
+
+    updateRouterState(GerritView.CHANGE, TEST_NUMERIC_CHANGE_ID);
+    model.changeModel.updateStateChange(createParsedChange());
+
+    await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
+    await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
+    await waitUntilCalled(diffDraftsSpy, 'diffDraftsSpy');
+    await waitUntilCalled(portedCommentsSpy, 'portedCommentsSpy');
+    await waitUntilCalled(portedDraftsSpy, 'portedDraftsSpy');
+    await waitUntil(
+      () => Object.keys(comments).length > 0,
+      'comment in model not set'
+    );
+    await waitUntil(
+      () => Object.keys(portedComments).length > 0,
+      'ported comment in model not set'
+    );
+
+    assert.equal(comments['foo.c'].length, 1);
+    assert.equal(comments['foo.c'][0].id, '12345');
+    assert.equal(portedComments['foo.c'].length, 1);
+    assert.equal(portedComments['foo.c'][0].id, '12345');
+  });
+});
diff --git a/polygerrit-ui/app/services/comments/comments-service.ts b/polygerrit-ui/app/services/comments/comments-service.ts
deleted file mode 100644
index ac32258..0000000
--- a/polygerrit-ui/app/services/comments/comments-service.ts
+++ /dev/null
@@ -1,315 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 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, Subscription} from 'rxjs';
-import {
-  CommentBasics,
-  CommentInfo,
-  NumericChangeId,
-  PatchSetNum,
-  RevisionId,
-  UrlEncodedCommentId,
-} from '../../types/common';
-import {
-  reportingDetails,
-  DraftInfo,
-  UnsavedInfo,
-} from '../../utils/comment-util';
-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 {
-  updateStateSetDraft,
-  updateStateDeleteDraft,
-  updateStateComments,
-  updateStateRobotComments,
-  updateStateDrafts,
-  updateStatePortedComments,
-  updateStatePortedDrafts,
-  updateStateDeleteDiscardedDraft,
-  discardedDrafts$,
-  updateStateReset,
-  drafts$,
-} from './comments-model';
-import {changeNum$, currentPatchNum$} from '../change/change-model';
-import {Finalizable} from '../registry';
-import {routerChangeNum$} from '../router/router-model';
-import {Interaction} from '../../constants/reporting';
-import {assertIsDefined} from '../../utils/common-util';
-import {debounce, DelayedTask} from '../../utils/async-util';
-import {pluralize} from '../../utils/string-util';
-import {ReportingService} from '../gr-reporting/gr-reporting';
-
-const TOAST_DEBOUNCE_INTERVAL = 200;
-
-function getSavingMessage(numPending: number, requestFailed?: boolean) {
-  if (requestFailed) {
-    return 'Unable to save draft';
-  }
-  if (numPending === 0) {
-    return 'All changes saved';
-  }
-  return `Saving ${pluralize(numPending, 'draft')}...`;
-}
-
-export class CommentsService implements Finalizable {
-  private numPendingDraftRequests = 0;
-
-  private changeNum?: NumericChangeId;
-
-  private patchNum?: PatchSetNum;
-
-  private readonly reloadListener: () => void;
-
-  private readonly subscriptions: Subscription[] = [];
-
-  private drafts: {[path: string]: DraftInfo[]} = {};
-
-  private draftToastTask?: DelayedTask;
-
-  private discardedDrafts: DraftInfo[] = [];
-
-  constructor(
-    readonly restApiService: RestApiService,
-    readonly reporting: ReportingService
-  ) {
-    this.subscriptions.push(
-      discardedDrafts$.subscribe(x => (this.discardedDrafts = x))
-    );
-    this.subscriptions.push(drafts$.subscribe(x => (this.drafts = x ?? {})));
-    this.subscriptions.push(
-      currentPatchNum$.subscribe(x => (this.patchNum = x))
-    );
-    this.subscriptions.push(
-      routerChangeNum$.subscribe(changeNum => {
-        this.changeNum = changeNum;
-        updateStateReset();
-        this.reloadAllComments();
-      })
-    );
-    this.subscriptions.push(
-      combineLatest([changeNum$, currentPatchNum$]).subscribe(
-        ([changeNum, patchNum]) => {
-          this.changeNum = changeNum;
-          this.patchNum = patchNum;
-          this.reloadAllPortedComments();
-        }
-      )
-    );
-    this.reloadListener = () => {
-      this.reloadAllComments();
-      this.reloadAllPortedComments();
-    };
-    document.addEventListener('reload', this.reloadListener);
-  }
-
-  finalize() {
-    document.removeEventListener('reload', this.reloadListener!);
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions.splice(0, this.subscriptions.length);
-  }
-
-  // Note that this does *not* reload ported comments.
-  reloadAllComments() {
-    if (!this.changeNum) return;
-    this.reloadComments(this.changeNum);
-    this.reloadRobotComments(this.changeNum);
-    this.reloadDrafts(this.changeNum);
-  }
-
-  reloadAllPortedComments() {
-    if (!this.changeNum) return;
-    if (!this.patchNum) return;
-    this.reloadPortedComments(this.changeNum, this.patchNum);
-    this.reloadPortedDrafts(this.changeNum, this.patchNum);
-  }
-
-  reloadComments(changeNum: NumericChangeId): Promise<void> {
-    return this.restApiService
-      .getDiffComments(changeNum)
-      .then(comments => updateStateComments(comments));
-  }
-
-  reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
-    return this.restApiService
-      .getDiffRobotComments(changeNum)
-      .then(robotComments => updateStateRobotComments(robotComments));
-  }
-
-  reloadDrafts(changeNum: NumericChangeId): Promise<void> {
-    return this.restApiService
-      .getDiffDrafts(changeNum)
-      .then(drafts => updateStateDrafts(drafts));
-  }
-
-  reloadPortedComments(
-    changeNum: NumericChangeId,
-    patchNum = CURRENT as RevisionId
-  ): Promise<void> {
-    return this.restApiService
-      .getPortedComments(changeNum, patchNum)
-      .then(portedComments => updateStatePortedComments(portedComments));
-  }
-
-  reloadPortedDrafts(
-    changeNum: NumericChangeId,
-    patchNum = CURRENT as RevisionId
-  ): Promise<void> {
-    return this.restApiService
-      .getPortedDrafts(changeNum, patchNum)
-      .then(portedDrafts => updateStatePortedDrafts(portedDrafts));
-  }
-
-  async restoreDraft(id: UrlEncodedCommentId) {
-    const found = this.discardedDrafts?.find(d => d.id === id);
-    if (!found) throw new Error('discarded draft not found');
-    const newDraft = {
-      ...found,
-      id: undefined,
-      updated: undefined,
-      __draft: undefined,
-      __unsaved: true,
-    };
-    await this.saveDraft(newDraft);
-    updateStateDeleteDiscardedDraft(id);
-  }
-
-  /**
-   * Saves a new or updates an existing draft.
-   * The model will only be updated when a successful response comes back.
-   */
-  async saveDraft(draft: DraftInfo | UnsavedInfo, showToast = true) {
-    assertIsDefined(this.changeNum, 'change number');
-    assertIsDefined(draft.patch_set, 'patchset number of comment draft');
-    if (!draft.message?.trim()) throw new Error('Cannot save empty draft.');
-
-    // Saving the change number as to make sure that the response is still
-    // relevant when it comes back. The user maybe have navigated away.
-    const changeNum = this.changeNum;
-    this.report(Interaction.SAVE_COMMENT, draft);
-    if (showToast) this.showStartRequest();
-    const result = await this.restApiService.saveDiffDraft(
-      changeNum,
-      draft.patch_set,
-      draft
-    );
-    if (changeNum !== this.changeNum) throw new Error('change changed');
-    if (!result.ok) {
-      if (showToast) this.handleFailedDraftRequest();
-      throw new Error(
-        `Failed to save draft comment: ${JSON.stringify(result)}`
-      );
-    }
-    const obj = await this.restApiService.getResponseObject(result);
-    const savedComment = obj as unknown as CommentInfo;
-    const updatedDraft = {
-      ...draft,
-      id: savedComment.id,
-      updated: savedComment.updated,
-      __draft: true,
-      __unsaved: undefined,
-    };
-    if (showToast) this.showEndRequest();
-    updateStateSetDraft(updatedDraft);
-    this.report(Interaction.COMMENT_SAVED, updatedDraft);
-  }
-
-  async discardDraft(draftId: UrlEncodedCommentId) {
-    const draft = this.lookupDraft(draftId);
-    assertIsDefined(this.changeNum, 'change number');
-    assertIsDefined(draft, `draft not found by id ${draftId}`);
-    assertIsDefined(draft.patch_set, 'patchset number of comment draft');
-
-    if (!draft.message?.trim()) throw new Error('saved draft cant be empty');
-    // Saving the change number as to make sure that the response is still
-    // relevant when it comes back. The user maybe have navigated away.
-    const changeNum = this.changeNum;
-    this.report(Interaction.DISCARD_COMMENT, draft);
-    this.showStartRequest();
-    const result = await this.restApiService.deleteDiffDraft(
-      changeNum,
-      draft.patch_set,
-      {id: draft.id}
-    );
-    if (changeNum !== this.changeNum) throw new Error('change changed');
-    if (!result.ok) {
-      this.handleFailedDraftRequest();
-      throw new Error(
-        `Failed to discard draft comment: ${JSON.stringify(result)}`
-      );
-    }
-    this.showEndRequest();
-    updateStateDeleteDraft(draft);
-    // We don't store empty discarded drafts and don't need an UNDO then.
-    if (draft.message?.trim()) {
-      fire(document, 'show-alert', {
-        message: 'Draft Discarded',
-        action: 'Undo',
-        callback: () => this.restoreDraft(draft.id),
-      });
-    }
-    this.report(Interaction.COMMENT_DISCARDED, draft);
-  }
-
-  private report(interaction: Interaction, comment: CommentBasics) {
-    const details = reportingDetails(comment);
-    this.reporting.reportInteraction(interaction, details);
-  }
-
-  private showStartRequest() {
-    this.numPendingDraftRequests += 1;
-    this.updateRequestToast();
-  }
-
-  private showEndRequest() {
-    this.numPendingDraftRequests -= 1;
-    this.updateRequestToast();
-  }
-
-  private handleFailedDraftRequest() {
-    this.numPendingDraftRequests -= 1;
-    this.updateRequestToast(/* requestFailed=*/ true);
-  }
-
-  private updateRequestToast(requestFailed?: boolean) {
-    if (this.numPendingDraftRequests === 0 && !requestFailed) {
-      fireEvent(document, 'hide-alert');
-      return;
-    }
-    const message = getSavingMessage(
-      this.numPendingDraftRequests,
-      requestFailed
-    );
-    this.draftToastTask = debounce(
-      this.draftToastTask,
-      () => {
-        // Note: the event is fired on the body rather than this element because
-        // this element may not be attached by the time this executes, in which
-        // case the event would not bubble.
-        fireAlert(document.body, message);
-      },
-      TOAST_DEBOUNCE_INTERVAL
-    );
-  }
-
-  private lookupDraft(id: UrlEncodedCommentId): DraftInfo | undefined {
-    return Object.values(this.drafts)
-      .flat()
-      .find(d => d.id === id);
-  }
-}
diff --git a/polygerrit-ui/app/services/comments/comments-service_test.ts b/polygerrit-ui/app/services/comments/comments-service_test.ts
deleted file mode 100644
index 91ac37c..0000000
--- a/polygerrit-ui/app/services/comments/comments-service_test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 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 {Subscription} from 'rxjs';
-import '../../test/common-test-setup-karma';
-import {
-  createComment,
-  createParsedChange,
-  TEST_NUMERIC_CHANGE_ID,
-} from '../../test/test-data-generators';
-import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
-import {getAppContext} from '../app-context';
-import {CommentsService} from './comments-service';
-import {updateStateChange} from '../change/change-model';
-import {
-  GerritView,
-  updateState as updateRouterState,
-} from '../router/router-model';
-import {comments$, portedComments$} from './comments-model';
-import {PathToCommentsInfoMap} from '../../types/common';
-
-suite('change service tests', () => {
-  let subscriptions: Subscription[] = [];
-
-  teardown(() => {
-    for (const s of subscriptions) {
-      s.unsubscribe();
-    }
-    subscriptions = [];
-  });
-
-  test('loads comments', async () => {
-    new CommentsService(
-      getAppContext().restApiService,
-      getAppContext().reportingService
-    );
-    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
-      Promise.resolve({'foo.c': [createComment()]})
-    );
-    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
-      Promise.resolve({})
-    );
-    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
-      Promise.resolve({})
-    );
-    const portedCommentsSpy = stubRestApi('getPortedComments').returns(
-      Promise.resolve({'foo.c': [createComment()]})
-    );
-    const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
-      Promise.resolve({})
-    );
-    let comments: PathToCommentsInfoMap = {};
-    subscriptions.push(comments$.subscribe(c => (comments = c ?? {})));
-    let portedComments: PathToCommentsInfoMap = {};
-    subscriptions.push(
-      portedComments$.subscribe(c => (portedComments = c ?? {}))
-    );
-
-    updateRouterState(GerritView.CHANGE, TEST_NUMERIC_CHANGE_ID);
-    updateStateChange(createParsedChange());
-
-    await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
-    await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
-    await waitUntilCalled(diffDraftsSpy, 'diffDraftsSpy');
-    await waitUntilCalled(portedCommentsSpy, 'portedCommentsSpy');
-    await waitUntilCalled(portedDraftsSpy, 'portedDraftsSpy');
-    await waitUntil(
-      () => Object.keys(comments).length > 0,
-      'comment in model not set'
-    );
-    await waitUntil(
-      () => Object.keys(portedComments).length > 0,
-      'ported comment in model not set'
-    );
-
-    assert.equal(comments['foo.c'].length, 1);
-    assert.equal(comments['foo.c'][0].id, '12345');
-    assert.equal(portedComments['foo.c'].length, 1);
-    assert.equal(portedComments['foo.c'][0].id, '12345');
-  });
-});
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 fc621a2..b8a5e49 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 resetChangeState} from '../services/change/change-model';
-import {_testOnly_resetState as resetCommentsState} from '../services/comments/comments-model';
 import {_testOnly_resetState as resetRouterState} from '../services/router/router-model';
 
 declare global {
@@ -113,8 +111,6 @@
   // tests.
   initGlobalVariables(appContext);
 
-  resetChangeState();
-  resetCommentsState();
   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 946f813..01d776e 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -25,11 +25,11 @@
 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';
-import {CommentsService} from '../services/comments/comments-service';
+import {CommentsModel} from '../services/comments/comments-model';
 import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
 import {BrowserModel} from '../services/browser/browser-model';
 import {ConfigModel} from '../services/config/config-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!);
     },
-    commentsService: (ctx: Partial<AppContext>) => {
+    commentsModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.changeModel, 'changeModel');
       assertIsDefined(ctx.restApiService, 'restApiService');
       assertIsDefined(ctx.reportingService, 'reportingService');
-      return new CommentsService(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');
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 57d2b0c..da4601b 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -741,6 +741,7 @@
     name: 'Verified',
     status: SubmitRequirementStatus.SATISFIED,
     submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    is_legacy: false,
   };
 }
 
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 50e08fb..ebeb1db 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -23,7 +23,7 @@
 import {StorageService} from '../services/storage/gr-storage';
 import {AuthService} from '../services/gr-auth/gr-auth';
 import {ReportingService} from '../services/gr-reporting/gr-reporting';
-import {CommentsService} from '../services/comments/comments-service';
+import {CommentsModel} from '../services/comments/comments-model';
 import {UserModel} from '../services/user/user-model';
 import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
 import {queryAndAssert, query} from '../utils/common-util';
@@ -112,8 +112,8 @@
   return sinon.spy(getAppContext().restApiService, method);
 }
 
-export function stubComments<K extends keyof CommentsService>(method: K) {
-  return sinon.stub(getAppContext().commentsService, method);
+export function stubComments<K extends keyof CommentsModel>(method: K) {
+  return sinon.stub(getAppContext().commentsModel, method);
 }
 
 export function stubUsers<K extends keyof UserModel>(method: K) {
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 8904cac..c154958 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -20,6 +20,7 @@
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../api/rest-api';
+import {FlagsService, KnownExperimentId} from '../services/flags/flags';
 import {
   AccountInfo,
   ApprovalInfo,
@@ -262,20 +263,9 @@
  * If there is at least one non-legacy requirement, filter legacy requirements.
  */
 export function getRequirements(change?: ParsedChangeInfo | ChangeInfo) {
-  let submit_requirements = (change?.submit_requirements ?? []).filter(
-    req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
-  );
-
-  const hasNonLegacyRequirements = submit_requirements.some(
-    req => req.is_legacy === false
-  );
-  if (hasNonLegacyRequirements) {
-    submit_requirements = submit_requirements.filter(
-      req => req.is_legacy === false
-    );
-  }
-
-  return submit_requirements;
+  return (change?.submit_requirements ?? [])
+    .filter(req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE)
+    .filter(req => req.is_legacy === false);
 }
 
 // TODO(milutin): This may be temporary for demo purposes
@@ -309,3 +299,16 @@
     label => !labelAssociatedWithSubmitReqs.includes(label)
   );
 }
+
+export function showNewSubmitRequirements(
+  flagsService: FlagsService,
+  change?: ParsedChangeInfo | ChangeInfo
+) {
+  const isSubmitRequirementsUiEnabled = flagsService.isEnabled(
+    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+  );
+  if (!isSubmitRequirementsUiEnabled) return false;
+  if ((getRequirements(change) ?? []).length === 0) return false;
+
+  return true;
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
index 6cb04af..1b8ad0c 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -298,7 +298,7 @@
         is_legacy: true,
       };
       const change = createChangeInfoWith([requirement]);
-      assert.deepEqual(getRequirements(change), [requirement]);
+      assert.deepEqual(getRequirements(change), []);
     });
     test('legacy and non-legacy - filter legacy', () => {
       const requirement = {
@@ -313,10 +313,7 @@
       assert.deepEqual(getRequirements(change), [requirement2]);
     });
     test('filter not applicable', () => {
-      const requirement = {
-        ...createSubmitRequirementResultInfo(),
-        is_legacy: true,
-      };
+      const requirement = createSubmitRequirementResultInfo();
       const requirement2 = {
         ...createSubmitRequirementResultInfo(),
         status: SubmitRequirementStatus.NOT_APPLICABLE,
@@ -348,6 +345,7 @@
               ...createSubmitRequirementExpressionInfo(),
               expression: `label:${triggerVote}=MAX`,
             },
+            is_legacy: false,
           },
         ],
         labels: {