Merge changes I189f6dad,I7a17a318,I8ba34146

* changes:
  Allow persistent caches to be not persisted by default
  Minor improvements to ProtoCacheSerializers#toByteArray
  Align ChangeColumns methods with Change fields
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
index 93ac34f..e7e8d0b 100755
--- a/contrib/populate-fixture-data.py
+++ b/contrib/populate-fixture-data.py
@@ -160,7 +160,7 @@
 def generate_random_text():
   return " ".join([random.choice("lorem ipsum "
                                  "doleret delendam "
-                                 "\n esse".split(" ")) for _ in xrange(1, 100)])
+                                 "\n esse".split(" ")) for _ in range(1, 100)])
 
 
 def set_up():
@@ -299,7 +299,7 @@
   project_names = create_gerrit_projects(group_names)
 
   for idx, u in enumerate(gerrit_users):
-    for _ in xrange(random.randint(1, 5)):
+    for _ in range(random.randint(1, 5)):
       create_change(u, project_names[4 * idx / len(gerrit_users)])
 
 main()
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index e487a54..4b92ec3 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -259,10 +259,10 @@
 
     if (accountStates.size() > 1) {
       StringBuilder msg = new StringBuilder();
-      msg.append("GPG key ").append(extIdKey.get()).append(" associated with multiple accounts: ");
-      Joiner.on(", ")
-          .appendTo(msg, Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
-      log.error(msg.toString());
+      msg.append("GPG key ")
+          .append(extIdKey.get())
+          .append(" associated with multiple accounts: ")
+          .append(Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
       throw new IllegalStateException(msg.toString());
     }
 
diff --git a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index 55bd4d5..6174644 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -167,6 +167,8 @@
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     } catch (AuthenticationFailedException e) {
+      // This exception is thrown if the user provided wrong credentials, we don't need to log a
+      // stacktrace for it.
       log.warn(authenticationFailedMsg(username, req) + ": " + e.getMessage());
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
diff --git a/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java b/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
index 24ba4ac..4671475 100644
--- a/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.account.AccountUserNameException;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.AuthenticationFailedException;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
@@ -126,10 +127,16 @@
     } catch (AuthenticationUnavailableException e) {
       sendForm(req, res, "Authentication unavailable at this time.");
       return;
-    } catch (AccountException e) {
-      log.info(String.format("'%s' failed to sign in: %s", username, e.getMessage()));
+    } catch (AuthenticationFailedException e) {
+      // This exception is thrown if the user provided wrong credentials, we don't need to log a
+      // stacktrace for it.
+      log.warn("'{}' failed to sign in: {}", username, e.getMessage());
       sendForm(req, res, "Invalid username or password.");
       return;
+    } catch (AccountException e) {
+      log.warn("'{}' failed to sign in", username, e);
+      sendForm(req, res, "Authentication failed.");
+      return;
     } catch (RuntimeException e) {
       log.error("LDAP authentication failed", e);
       sendForm(req, res, "Authentication unavailable at this time.");
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 5b60a36f..cc22d24 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -659,7 +659,7 @@
                   dst.close();
                 }
               } catch (IOException e) {
-                log.debug("Unexpected error copying input to CGI", e);
+                log.error("Unexpected error copying input to CGI", e);
               }
             },
             "Gitweb-InputFeeder")
@@ -669,14 +669,19 @@
   private void copyStderrToLog(InputStream in) {
     new Thread(
             () -> {
+              StringBuilder b = new StringBuilder();
               try (BufferedReader br =
                   new BufferedReader(new InputStreamReader(in, ISO_8859_1.name()))) {
                 String line;
                 while ((line = br.readLine()) != null) {
-                  log.error("CGI: " + line);
+                  if (b.length() > 0) {
+                    b.append('\n');
+                  }
+                  b.append("CGI: ").append(line);
                 }
+                log.error(b.toString());
               } catch (IOException e) {
-                log.debug("Unexpected error copying stderr from CGI", e);
+                log.error("Unexpected error copying stderr from CGI", e);
               }
             },
             "Gitweb-ErrorLogger")
diff --git a/java/com/google/gerrit/httpd/raw/BazelBuild.java b/java/com/google/gerrit/httpd/raw/BazelBuild.java
index 85453fb..f52792c 100644
--- a/java/com/google/gerrit/httpd/raw/BazelBuild.java
+++ b/java/com/google/gerrit/httpd/raw/BazelBuild.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Joiner;
 import com.google.common.escape.Escaper;
 import com.google.common.html.HtmlEscapers;
 import com.google.common.io.ByteStreams;
@@ -62,7 +63,8 @@
     try {
       status = rebuild.waitFor();
     } catch (InterruptedException e) {
-      throw new InterruptedIOException("interrupted waiting for " + proc.toString());
+      throw new InterruptedIOException(
+          "interrupted waiting for: " + Joiner.on(' ').join(proc.command()));
     }
     if (status != 0) {
       log.warn("build failed: " + new String(out, UTF_8));
diff --git a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
index 7256e8c..bc2846a 100644
--- a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
+++ b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
@@ -41,7 +41,7 @@
             return new OperatingSystemMXBeanProvider(sys);
           }
         } catch (ReflectiveOperationException e) {
-          log.debug(String.format("No implementation for %s: %s", name, e.getMessage()));
+          log.debug("No implementation for {}", name, e);
         }
       }
       log.warn("No implementation of UnixOperatingSystemMXBean found");
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index b6eac05..25a28a4 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -69,13 +69,9 @@
 import org.eclipse.jetty.util.thread.QueuedThreadPool;
 import org.eclipse.jetty.util.thread.ThreadPool;
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
 public class JettyServer {
-  private static final Logger log = LoggerFactory.getLogger(JettyServer.class);
-
   static class Lifecycle implements LifecycleListener {
     private final JettyServer server;
     private final Config cfg;
@@ -425,9 +421,8 @@
             "/*",
             EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC));
       } catch (Throwable e) {
-        String errorMessage = "Unable to instantiate front-end HTTP Filter " + filterClassName;
-        log.error(errorMessage, e);
-        throw new IllegalArgumentException(errorMessage, e);
+        throw new IllegalArgumentException(
+            "Unable to instantiate front-end HTTP Filter " + filterClassName, e);
       }
     }
 
diff --git a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
index c1112ae..5073200 100644
--- a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
+++ b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
@@ -30,7 +30,6 @@
 import org.slf4j.LoggerFactory;
 
 public class AllProjectsConfig extends VersionedMetaDataOnInit {
-
   private static final Logger log = LoggerFactory.getLogger(AllProjectsConfig.class);
 
   private Config cfg;
@@ -65,7 +64,7 @@
     return GroupList.parse(
         new Project.NameKey(project),
         readUTF8(GroupList.FILE_NAME),
-        GroupList.createLoggerSink(GroupList.FILE_NAME, log));
+        error -> log.error("Error parsing file {}: {}", GroupList.FILE_NAME, error.getMessage()));
   }
 
   public void save(String pluginName, String message) throws IOException, ConfigInvalidException {
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 2f36cf2..996e602 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -106,7 +106,8 @@
  *   <li>binding {@link GitReferenceUpdated#DISABLED} and
  *   <li>passing an {@link
  *       com.google.gerrit.server.account.externalids.ExternalIdNotes.FactoryNoReindex} factory as
- *       parameter of {@link AccountsUpdate.Factory#create(IdentifiedUser, ExternalIdNotesLoader)}
+ *       parameter of {@link AccountsUpdate.Factory#create(IdentifiedUser,
+ *       ExternalIdNotes.ExternalIdNotesLoader)}
  * </ul>
  *
  * <p>If there are concurrent account updates updating the user branch in NoteDb may fail with
diff --git a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
index a57dc7b..1064546 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
@@ -16,8 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.server.git.meta.TabFile;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -61,17 +59,15 @@
         String path = p.path;
         if (path.startsWith(prefix)) {
           String label = path.substring(prefix.length());
-          ValidationError.Sink errors = TabFile.createLoggerSink(path, log);
-          destinations.parseLabel(label, readUTF8(path), errors);
+          destinations.parseLabel(
+              label,
+              readUTF8(path),
+              error -> log.error("Error parsing file {}: {}", path, error.getMessage()));
         }
       }
     }
   }
 
-  public ValidationError.Sink createSink(String file) {
-    return ValidationError.createLoggerSink(file, log);
-  }
-
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     throw new UnsupportedOperationException("Cannot yet save destinations");
diff --git a/java/com/google/gerrit/server/account/VersionedAccountQueries.java b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
index b43a65d..b021d24 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountQueries.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
@@ -51,7 +51,9 @@
   protected void onLoad() throws IOException, ConfigInvalidException {
     queryList =
         QueryList.parse(
-            readUTF8(QueryList.FILE_NAME), QueryList.createLoggerSink(QueryList.FILE_NAME, log));
+            readUTF8(QueryList.FILE_NAME),
+            error ->
+                log.error("Error parsing file {}: {}", QueryList.FILE_NAME, error.getMessage()));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/auth/ldap/Helper.java b/java/com/google/gerrit/server/auth/ldap/Helper.java
index 5af730f..16c1724 100644
--- a/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/server/auth/ldap/Helper.java
@@ -196,7 +196,7 @@
       Throwables.throwIfInstanceOf(e.getException(), IOException.class);
       Throwables.throwIfInstanceOf(e.getException(), NamingException.class);
       Throwables.throwIfInstanceOf(e.getException(), RuntimeException.class);
-      LdapRealm.log.warn("Internal error", e.getException());
+      log.warn("Internal error", e.getException());
       return null;
     } finally {
       ctx.logout();
@@ -343,7 +343,7 @@
             }
           }
         } catch (NamingException e) {
-          LdapRealm.log.warn("Could not find group " + groupDN, e);
+          log.warn("Could not find group {}", groupDN, e);
         }
         cachedParentsDNs = dns.build();
         parentGroups.put(groupDN, cachedParentsDNs);
@@ -474,10 +474,10 @@
       try {
         return LdapType.guessType(ctx);
       } catch (NamingException e) {
-        LdapRealm.log.warn(
-            "Cannot discover type of LDAP server at "
-                + server
-                + ", assuming the server is RFC 2307 compliant.",
+        log.warn(
+            "Cannot discover type of LDAP server at {},"
+                + " assuming the server is RFC 2307 compliant.",
+            server,
             e);
         return LdapType.RFC_2307;
       }
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 6184674..b83c7b2 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -61,7 +61,8 @@
 
 @Singleton
 class LdapRealm extends AbstractRealm {
-  static final Logger log = LoggerFactory.getLogger(LdapRealm.class);
+  private static final Logger log = LoggerFactory.getLogger(LdapRealm.class);
+
   static final String LDAP = "com.sun.jndi.ldap.LdapCtxFactory";
   static final String USERNAME = "username";
 
diff --git a/java/com/google/gerrit/server/git/ValidationError.java b/java/com/google/gerrit/server/git/ValidationError.java
index 2fd65d2..28d5171 100644
--- a/java/com/google/gerrit/server/git/ValidationError.java
+++ b/java/com/google/gerrit/server/git/ValidationError.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.git;
 
 import java.util.Objects;
-import org.slf4j.Logger;
 
 /** Indicates a problem with Git based data. */
 public class ValidationError {
@@ -46,10 +45,6 @@
     void error(ValidationError error);
   }
 
-  public static Sink createLoggerSink(String message, Logger log) {
-    return error -> log.error(message + error.getMessage());
-  }
-
   @Override
   public boolean equals(Object o) {
     if (o == this) {
diff --git a/java/com/google/gerrit/server/git/meta/TabFile.java b/java/com/google/gerrit/server/git/meta/TabFile.java
index 68950602..ef25cd8 100644
--- a/java/com/google/gerrit/server/git/meta/TabFile.java
+++ b/java/com/google/gerrit/server/git/meta/TabFile.java
@@ -24,7 +24,6 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import org.slf4j.Logger;
 
 public class TabFile {
   @FunctionalInterface
@@ -141,8 +140,4 @@
     }
     return r.toString();
   }
-
-  public static ValidationError.Sink createLoggerSink(String file, Logger log) {
-    return ValidationError.createLoggerSink("Error parsing file " + file + ": ", log);
-  }
 }
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java b/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
index 5ce3c1c..bff2952 100644
--- a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
+++ b/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
@@ -142,8 +142,8 @@
      * InternalGroupUpdate}.
      *
      * <p>This modification can be tweaked further and passed to {@link
-     * #setMemberModification(MemberModification)} in order to combine multiple member additions,
-     * deletions, or other modifications into one update.
+     * #setMemberModification(InternalGroupUpdate.MemberModification)} in order to combine multiple
+     * member additions, deletions, or other modifications into one update.
      */
     public abstract MemberModification getMemberModification();
 
@@ -155,8 +155,8 @@
      * InternalGroupUpdate}.
      *
      * <p>This modification can be tweaked further and passed to {@link
-     * #setSubgroupModification(SubgroupModification)} in order to combine multiple subgroup
-     * additions, deletions, or other modifications into one update.
+     * #setSubgroupModification(InternalGroupUpdate.SubgroupModification)} in order to combine
+     * multiple subgroup additions, deletions, or other modifications into one update.
      */
     public abstract SubgroupModification getSubgroupModification();
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index c1eb39c..83f1565 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -431,17 +431,21 @@
       return GrCountStringFormatter.computeShortString(commentCount, 'c');
     },
 
-    _reviewFile(path) {
+    /**
+     * @param {string} path
+     * @param {boolean=} opt_reviewed
+     */
+    _reviewFile(path, opt_reviewed) {
       if (this.editMode) { return; }
       const index = this._files.findIndex(file => file.__path === path);
-      const reviewed = this._files[index].isReviewed;
+      const reviewed = opt_reviewed || !this._files[index].isReviewed;
 
-      this.set(['_files', index, 'isReviewed'], !reviewed);
+      this.set(['_files', index, 'isReviewed'], reviewed);
       if (index < this._shownFiles.length) {
-        this.set(['_shownFiles', index, 'isReviewed'], !reviewed);
+        this.set(['_shownFiles', index, 'isReviewed'], reviewed);
       }
 
-      this._saveReviewedState(path, !reviewed);
+      this._saveReviewedState(path, reviewed);
     },
 
     _saveReviewedState(path, reviewed) {
@@ -961,7 +965,7 @@
               path, this.patchRange, this.projectConfig);
           const promises = [diffElem.reload()];
           if (this._loggedIn && !this.diffPrefs.manual_review) {
-            promises.push(this._reviewFile(path));
+            promises.push(this._reviewFile(path, true));
           }
           return Promise.all(promises);
         }).then(() => {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index 0e0a39e..3c90a1f 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -60,7 +60,6 @@
       stub('gr-rest-api-interface', {
         getLoggedIn() { return Promise.resolve(true); },
         getPreferences() { return Promise.resolve({}); },
-        fetchJSON() { return Promise.resolve({}); },
         getDiffComments() { return Promise.resolve({}); },
         getDiffRobotComments() { return Promise.resolve({}); },
         getDiffDrafts() { return Promise.resolve({}); },
@@ -1036,6 +1035,7 @@
         delete element.diffPrefs.manual_review;
         return element._renderInOrder(['p'], diffs, 1).then(() => {
           assert.isTrue(reviewStub.called);
+          assert.isTrue(reviewStub.calledWithExactly('p', true));
         });
       });
     });
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
index bbe2877..d1ae719 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
@@ -31,7 +31,7 @@
           color: var(--header-text-color);
         }
         --gr-dropdown-item: {
-          color: var(--header-text-color);
+          color: var(--primary-text-color);
         }
       }
       gr-avatar {
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector.js
new file mode 100644
index 0000000..28c46f4
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector.js
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
+(function() {
+  'use strict';
+
+  const JANK_SLEEP_TIME_MS = 1000;
+
+  const GrJankDetector = {
+    // Slowdowns counter.
+    jank: 0,
+    fps: 0,
+    _lastFrameTime: 0,
+
+    start() {
+      this._requestAnimationFrame(this._detect.bind(this));
+    },
+
+    _requestAnimationFrame(callback) {
+      window.requestAnimationFrame(callback);
+    },
+
+    _detect(now) {
+      if (this._lastFrameTime === 0) {
+        this._lastFrameTime = now;
+        this.fps = 0;
+        this._requestAnimationFrame(this._detect.bind(this));
+        return;
+      }
+      const fpsNow = 1000/(now - this._lastFrameTime);
+      this._lastFrameTime = now;
+      // Calculate moving average within last 3 measurements.
+      this.fps = this.fps === 0 ? fpsNow : ((this.fps * 2 + fpsNow) / 3);
+      if (this.fps > 10) {
+        this._requestAnimationFrame(this._detect.bind(this));
+      } else {
+        this.jank++;
+        console.warn('JANK', this.jank);
+        this._lastFrameTime = 0;
+        window.setTimeout(
+            () => this._requestAnimationFrame(this._detect.bind(this)),
+            JANK_SLEEP_TIME_MS);
+      }
+    },
+  };
+
+  window.GrJankDetector = GrJankDetector;
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html
new file mode 100644
index 0000000..6faeec1
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html
@@ -0,0 +1,78 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-jank-detector</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<script src="gr-jank-detector.js"></script>
+
+<script>
+  suite('gr-jank-detector tests', () => {
+    let sandbox;
+    let clock;
+    let instance;
+
+    const NOW_TIME = 100;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      clock = sinon.useFakeTimers(NOW_TIME);
+      instance = GrJankDetector;
+      instance._lastFrameTime = 0;
+      sandbox.stub(instance, '_requestAnimationFrame');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('start() installs frame callback', () => {
+      sandbox.stub(instance, '_detect');
+      instance._requestAnimationFrame.callsArg(0);
+      instance.start();
+      assert.isTrue(instance._detect.calledOnce);
+    });
+
+    test('measures fps', () => {
+      instance._detect(10);
+      instance._detect(30);
+      assert.equal(instance.fps, 50);
+    });
+
+    test('detects jank', () => {
+      let now = 10;
+      instance._detect(now);
+      const fastFrame = () => instance._detect(now += 20);
+      const slowFrame = () => instance._detect(now += 300);
+      fastFrame();
+      assert.equal(instance.jank, 0);
+      _.times(4, slowFrame);
+      assert.equal(instance.jank, 0);
+      instance._requestAnimationFrame.reset();
+      slowFrame();
+      assert.equal(instance.jank, 1);
+      assert.isFalse(instance._requestAnimationFrame.called);
+      clock.tick(1000);
+      assert.isTrue(instance._requestAnimationFrame.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
index 2970a26..cbb2c09 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
@@ -19,5 +19,6 @@
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-reporting">
+  <script src="gr-jank-detector.js"></script>
   <script src="gr-reporting.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index 0db442f..ae67dac 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -48,6 +48,14 @@
     STARTED_HIDDEN: 'hidden',
   };
 
+  // Frame rate related constants.
+  const JANK = {
+    TYPE: 'lifecycle',
+    CATEGORY: 'UI Latency',
+    // Reported events - alphabetize below.
+    COUNT: 'Jank count',
+  };
+
   // Navigation reporting constants.
   const NAVIGATION = {
     TYPE: 'nav-report',
@@ -118,6 +126,8 @@
   };
   catchErrors();
 
+  GrJankDetector.start();
+
   const GrReporting = Polymer({
     is: 'gr-reporting',
 
@@ -206,6 +216,11 @@
     },
 
     beforeLocationChanged() {
+      if (GrJankDetector.jank > 0) {
+        this.reporter(
+            JANK.TYPE, JANK.CATEGORY, JANK.COUNT, GrJankDetector.jank);
+        GrJankDetector.jank = 0;
+      }
       for (const prop of Object.keys(this._baselines)) {
         delete this._baselines[prop];
       }
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
index bfb45f6..e2bb83d 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -93,7 +93,11 @@
     test('beforeLocationChanged', () => {
       element._baselines['garbage'] = 'monster';
       sandbox.stub(element, 'time');
+      GrJankDetector.jank = 42;
       element.beforeLocationChanged();
+      assert.equal(GrJankDetector.jank, 0);
+      assert.isTrue(element.reporter.calledWithExactly(
+          'lifecycle', 'UI Latency', 'Jank count', 42));
       assert.isTrue(element.time.calledWithExactly('DashboardDisplayed'));
       assert.isTrue(element.time.calledWithExactly('ChangeDisplayed'));
       assert.isTrue(element.time.calledWithExactly('DiffViewDisplayed'));
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index d7d50d1..de62646 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -34,8 +34,8 @@
 
 <link rel="import" href="../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../styles/app-theme.html">
 <link rel="import" href="../styles/shared-styles.html">
+<link rel="import" href="../styles/themes/app-theme.html">
 <link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
 <link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
 <link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index b866088..921415f 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -127,6 +127,10 @@
         this._version = version;
       });
 
+      if (window.localStorage.getItem('dark-theme')) {
+        this.importHref('../styles/themes/dark-theme.html');
+      }
+
       // Note: this is evaluated here to ensure that it only happens after the
       // router has been initialized. @see Issue 7837
       this._settingsUrl = Gerrit.Nav.getUrlForSettings();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 9dc51ba..9a5851b 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -27,6 +27,36 @@
    */
   Defs.patchRange;
 
+  /**
+   * Object to describe a request for passing into _fetchJSON or _fetchRawJSON.
+   * - url is the URL for the request (excluding get params)
+   * - errFn is a function to invoke when the request fails.
+   * - cancelCondition is a function that, if provided and returns true, will
+   *     cancel the response after it resolves.
+   * - params is a key-value hash to specify get params for the request URL.
+   * @typedef {{
+   *    url: string,
+   *    errFn: (function(?Response, string=)|null|undefined),
+   *    cancelCondition: (function()|null|undefined),
+   *    params: (Object|null|undefined),
+   *    fetchOptions: (Object|null|undefined),
+   * }}
+   */
+  Defs.FetchJSONRequest;
+
+  /**
+   * @typedef {{
+   *   changeNum: (string|number),
+   *   endpoint: string,
+   *   patchNum: (string|number|null|undefined),
+   *   errFn: (function(?Response, string=)|null|undefined),
+   *   cancelCondition: (function()|null|undefined),
+   *   params: (Object|null|undefined),
+   *   fetchOptions: (Object|null|undefined),
+   * }}
+   */
+  Defs.ChangeFetchRequest;
+
   const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
@@ -112,23 +142,17 @@
      * Returns a Promise that resolves to a native Response.
      * Doesn't do error checking. Supports cancel condition. Performs auth.
      * Validates auth expiry errors.
-     * @param {string} url
-     * @param {?function(?Response, string=)=} opt_errFn
-     *    passed as null sometimes.
-     * @param {?function()=} opt_cancelCondition
-     *    passed as null sometimes.
-     * @param {?Object=} opt_params URL params, key-value hash.
-     * @param {?Object=} opt_options Fetch options.
+     * @param {Defs.FetchJSONRequest} req
+     * @return {Promise}
      */
-    _fetchRawJSON(url, opt_errFn, opt_cancelCondition, opt_params,
-        opt_options) {
-      const urlWithParams = this._urlWithParams(url, opt_params);
-      return this._auth.fetch(urlWithParams, opt_options).then(response => {
-        if (opt_cancelCondition && opt_cancelCondition()) {
-          response.body.cancel();
+    _fetchRawJSON(req) {
+      const urlWithParams = this._urlWithParams(req.url, req.params);
+      return this._auth.fetch(urlWithParams, req.fetchOptions).then(res => {
+        if (req.cancelCondition && req.cancelCondition()) {
+          res.body.cancel();
           return;
         }
-        return response;
+        return res;
       }).catch(err => {
         const isLoggedIn = !!this._cache['/accounts/self/detail'];
         if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) {
@@ -139,8 +163,8 @@
               CHECK_SIGN_IN_DEBOUNCE_MS);
           return;
         }
-        if (opt_errFn) {
-          opt_errFn.call(undefined, null, err);
+        if (req.errFn) {
+          req.errFn.call(undefined, null, err);
         } else {
           this.fire('network-error', {error: err});
         }
@@ -152,31 +176,23 @@
      * Fetch JSON from url provided.
      * Returns a Promise that resolves to a parsed response.
      * Same as {@link _fetchRawJSON}, plus error handling.
-     * @param {string} url
-     * @param {?function(?Response, string=)=} opt_errFn
-     *    passed as null sometimes.
-     * @param {?function()=} opt_cancelCondition
-     *    passed as null sometimes.
-     * @param {?Object=} opt_params URL params, key-value hash.
-     * @param {?Object=} opt_options Fetch options.
+     * @param {Defs.FetchJSONRequest} req
      */
-    fetchJSON(url, opt_errFn, opt_cancelCondition, opt_params, opt_options) {
-      return this._fetchRawJSON(
-          url, opt_errFn, opt_cancelCondition, opt_params, opt_options)
-          .then(response => {
-            if (!response) {
-              return;
-            }
-            if (!response.ok) {
-              if (opt_errFn) {
-                opt_errFn.call(null, response);
-                return;
-              }
-              this.fire('server-error', {response});
-              return;
-            }
-            return response && this.getResponseObject(response);
-          });
+    _fetchJSON(req) {
+      return this._fetchRawJSON(req).then(response => {
+        if (!response) {
+          return;
+        }
+        if (!response.ok) {
+          if (req.errFn) {
+            req.errFn.call(null, response);
+            return;
+          }
+          this.fire('server-error', {response});
+          return;
+        }
+        return response && this.getResponseObject(response);
+      });
     },
 
     /**
@@ -236,39 +252,45 @@
 
     getConfig(noCache) {
       if (!noCache) {
-        return this._fetchSharedCacheURL('/config/server/info');
+        return this._fetchSharedCacheURL({url: '/config/server/info'});
       }
 
-      return this.fetchJSON('/config/server/info');
+      return this._fetchJSON({url: '/config/server/info'});
     },
 
     getRepo(repo, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchSharedCacheURL(
-          '/projects/' + encodeURIComponent(repo), opt_errFn);
+      return this._fetchSharedCacheURL({
+        url: '/projects/' + encodeURIComponent(repo),
+        errFn: opt_errFn,
+      });
     },
 
     getProjectConfig(repo, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchSharedCacheURL(
-          '/projects/' + encodeURIComponent(repo) + '/config', opt_errFn);
+      return this._fetchSharedCacheURL({
+        url: '/projects/' + encodeURIComponent(repo) + '/config',
+        errFn: opt_errFn,
+      });
     },
 
     getRepoAccess(repo) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchSharedCacheURL(
-          '/access/?project=' + encodeURIComponent(repo));
+      return this._fetchSharedCacheURL({
+        url: '/access/?project=' + encodeURIComponent(repo),
+      });
     },
 
     getRepoDashboards(repo, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchSharedCacheURL(
-          `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
-          opt_errFn);
+      return this._fetchSharedCacheURL({
+        url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
+        errFn: opt_errFn,
+      });
     },
 
     saveRepoConfig(repo, config, opt_errFn, opt_ctx) {
@@ -315,8 +337,10 @@
     },
 
     getGroupConfig(group, opt_errFn) {
-      const encodeName = encodeURIComponent(group);
-      return this.fetchJSON(`/groups/${encodeName}/detail`, opt_errFn);
+      return this._fetchJSON({
+        url: `/groups/${encodeURIComponent(group)}/detail`,
+        errFn: opt_errFn,
+      });
     },
 
     /**
@@ -394,7 +418,7 @@
      */
     getIsGroupOwner(groupName) {
       const encodeName = encodeURIComponent(groupName);
-      return this._fetchSharedCacheURL(`/groups/?owned&q=${encodeName}`)
+      return this._fetchSharedCacheURL({url: `/groups/?owned&q=${encodeName}`})
           .then(configs => configs.hasOwnProperty(groupName));
     },
 
@@ -432,8 +456,10 @@
     },
 
     getGroupAuditLog(group, opt_errFn) {
-      return this._fetchSharedCacheURL(
-          '/groups/' + group + '/log.audit', opt_errFn);
+      return this._fetchSharedCacheURL({
+        url: '/groups/' + group + '/log.audit',
+        errFn: opt_errFn,
+      });
     },
 
     saveGroupMembers(groupName, groupMembers) {
@@ -470,13 +496,15 @@
     },
 
     getVersion() {
-      return this._fetchSharedCacheURL('/config/server/version');
+      return this._fetchSharedCacheURL({url: '/config/server/version'});
     },
 
     getDiffPreferences() {
       return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
-          return this._fetchSharedCacheURL('/accounts/self/preferences.diff');
+          return this._fetchSharedCacheURL({
+            url: '/accounts/self/preferences.diff',
+          });
         }
         // These defaults should match the defaults in
         // java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -504,7 +532,9 @@
     getEditPreferences() {
       return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
-          return this._fetchSharedCacheURL('/accounts/self/preferences.edit');
+          return this._fetchSharedCacheURL({
+            url: '/accounts/self/preferences.edit',
+          });
         }
         // These defaults should match the defaults in
         // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
@@ -570,15 +600,18 @@
     },
 
     getAccount() {
-      return this._fetchSharedCacheURL('/accounts/self/detail', resp => {
-        if (!resp || resp.status === 403) {
-          this._cache['/accounts/self/detail'] = null;
-        }
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/detail',
+        errFn: resp => {
+          if (!resp || resp.status === 403) {
+            this._cache['/accounts/self/detail'] = null;
+          }
+        },
       });
     },
 
     getExternalIds() {
-      return this.fetchJSON('/accounts/self/external.ids');
+      return this._fetchJSON({url: '/accounts/self/external.ids'});
     },
 
     deleteAccountIdentity(id) {
@@ -591,11 +624,13 @@
      * @return {!Promise<!Object>}
      */
     getAccountDetails(userId) {
-      return this.fetchJSON(`/accounts/${encodeURIComponent(userId)}/detail`);
+      return this._fetchJSON({
+        url: `/accounts/${encodeURIComponent(userId)}/detail`,
+      });
     },
 
     getAccountEmails() {
-      return this._fetchSharedCacheURL('/accounts/self/emails');
+      return this._fetchSharedCacheURL({url: '/accounts/self/emails'});
     },
 
     /**
@@ -692,15 +727,17 @@
     },
 
     getAccountStatus(userId) {
-      return this.fetchJSON(`/accounts/${encodeURIComponent(userId)}/status`);
+      return this._fetchJSON({
+        url: `/accounts/${encodeURIComponent(userId)}/status`,
+      });
     },
 
     getAccountGroups() {
-      return this.fetchJSON('/accounts/self/groups');
+      return this._fetchJSON({url: '/accounts/self/groups'});
     },
 
     getAccountAgreements() {
-      return this.fetchJSON('/accounts/self/agreements');
+      return this._fetchJSON({url: '/accounts/self/agreements'});
     },
 
     saveAccountAgreement(name) {
@@ -717,8 +754,9 @@
             .map(param => { return encodeURIComponent(param); })
             .join('&q=');
       }
-      return this._fetchSharedCacheURL('/accounts/self/capabilities' +
-          queryString);
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/capabilities' + queryString,
+      });
     },
 
     getLoggedIn() {
@@ -741,31 +779,31 @@
 
     checkCredentials() {
       // Skip the REST response cache.
-      return this._fetchRawJSON('/accounts/self/detail').then(response => {
-        if (!response) { return; }
-        if (response.status === 403) {
+      return this._fetchRawJSON({url: '/accounts/self/detail'}).then(res => {
+        if (!res) { return; }
+        if (res.status === 403) {
           this.fire('auth-error');
           this._cache['/accounts/self/detail'] = null;
-        } else if (response.ok) {
-          return this.getResponseObject(response);
+        } else if (res.ok) {
+          return this.getResponseObject(res);
         }
-      }).then(response => {
-        if (response) {
-          this._cache['/accounts/self/detail'] = response;
+      }).then(res => {
+        if (res) {
+          this._cache['/accounts/self/detail'] = res;
         }
-        return response;
+        return res;
       });
     },
 
     getDefaultPreferences() {
-      return this._fetchSharedCacheURL('/config/server/preferences');
+      return this._fetchSharedCacheURL({url: '/config/server/preferences'});
     },
 
     getPreferences() {
       return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
-          return this._fetchSharedCacheURL('/accounts/self/preferences').then(
-              res => {
+          return this._fetchSharedCacheURL({url: '/accounts/self/preferences'})
+              .then(res => {
                 if (this._isNarrowScreen()) {
                   res.default_diff_view = DiffViewMode.UNIFIED;
                 } else {
@@ -786,7 +824,9 @@
     },
 
     getWatchedProjects() {
-      return this._fetchSharedCacheURL('/accounts/self/watched.projects');
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/watched.projects',
+      });
     },
 
     /**
@@ -813,29 +853,28 @@
     },
 
     /**
-     * @param {string} url
-     * @param {function(?Response, string=)=} opt_errFn
+     * @param {Defs.FetchJSONRequest} req
      */
-    _fetchSharedCacheURL(url, opt_errFn) {
-      if (this._sharedFetchPromises[url]) {
-        return this._sharedFetchPromises[url];
+    _fetchSharedCacheURL(req) {
+      if (this._sharedFetchPromises[req.url]) {
+        return this._sharedFetchPromises[req.url];
       }
       // TODO(andybons): Periodic cache invalidation.
-      if (this._cache[url] !== undefined) {
-        return Promise.resolve(this._cache[url]);
+      if (this._cache[req.url] !== undefined) {
+        return Promise.resolve(this._cache[req.url]);
       }
-      this._sharedFetchPromises[url] = this.fetchJSON(url, opt_errFn)
+      this._sharedFetchPromises[req.url] = this._fetchJSON(req)
           .then(response => {
             if (response !== undefined) {
-              this._cache[url] = response;
+              this._cache[req.url] = response;
             }
-            this._sharedFetchPromises[url] = undefined;
+            this._sharedFetchPromises[req.url] = undefined;
             return response;
           }).catch(err => {
-            this._sharedFetchPromises[url] = undefined;
+            this._sharedFetchPromises[req.url] = undefined;
             throw err;
           });
-      return this._sharedFetchPromises[url];
+      return this._sharedFetchPromises[req.url];
     },
 
     _isNarrowScreen() {
@@ -848,8 +887,8 @@
      * @param {number|string=} opt_offset
      * @param {!Object=} opt_options
      * @return {?Array<!Object>|?Array<!Array<!Object>>} If opt_query is an
-     *     array, fetchJSON will return an array of arrays of changeInfos. If it
-     *     is unspecified or a string, fetchJSON will return an array of
+     *     array, _fetchJSON will return an array of arrays of changeInfos. If it
+     *     is unspecified or a string, _fetchJSON will return an array of
      *     changeInfos.
      */
     getChanges(opt_changesPerPage, opt_query, opt_offset, opt_options) {
@@ -874,7 +913,7 @@
           this._maybeInsertInLookup(change);
         }
       };
-      return this.fetchJSON('/changes/', null, null, params).then(response => {
+      return this._fetchJSON({url: '/changes/', params}).then(response => {
         // Response may be an array of changes OR an array of arrays of
         // changes.
         if (opt_query instanceof Array) {
@@ -959,43 +998,43 @@
      * @param {function(?Response, string=)=} opt_errFn
      * @param {function()=} opt_cancelCondition
      */
-    _getChangeDetail(changeNum, params, opt_errFn,
-        opt_cancelCondition) {
+    _getChangeDetail(changeNum, params, opt_errFn, opt_cancelCondition) {
       return this.getChangeActionURL(changeNum, null, '/detail').then(url => {
         const urlWithParams = this._urlWithParams(url, params);
-        return this._fetchRawJSON(
-            url,
-            opt_errFn,
-            opt_cancelCondition,
-            {O: params},
-            this._etags.getOptions(urlWithParams))
-            .then(response => {
-              if (response && response.status === 304) {
-                return Promise.resolve(this._parsePrefixedJSON(
-                    this._etags.getCachedPayload(urlWithParams)));
-              }
+        const req = {
+          url,
+          errFn: opt_errFn,
+          cancelCondition: opt_cancelCondition,
+          params: {O: params},
+          fetchOptions: this._etags.getOptions(urlWithParams),
+        };
+        return this._fetchRawJSON(req).then(response => {
+          if (response && response.status === 304) {
+            return Promise.resolve(this._parsePrefixedJSON(
+                this._etags.getCachedPayload(urlWithParams)));
+          }
 
-              if (response && !response.ok) {
-                if (opt_errFn) {
-                  opt_errFn.call(null, response);
-                } else {
-                  this.fire('server-error', {response});
-                }
-                return;
-              }
+          if (response && !response.ok) {
+            if (opt_errFn) {
+              opt_errFn.call(null, response);
+            } else {
+              this.fire('server-error', {response});
+            }
+            return;
+          }
 
-              const payloadPromise = response ?
-                  this._readResponsePayload(response) :
-                  Promise.resolve(null);
+          const payloadPromise = response ?
+              this._readResponsePayload(response) :
+              Promise.resolve(null);
 
-              return payloadPromise.then(payload => {
-                if (!payload) { return null; }
-                this._etags.collect(urlWithParams, response, payload.raw);
-                this._maybeInsertInLookup(payload.parsed);
+          return payloadPromise.then(payload => {
+            if (!payload) { return null; }
+            this._etags.collect(urlWithParams, response, payload.raw);
+            this._maybeInsertInLookup(payload.parsed);
 
-                return payload.parsed;
-              });
-            });
+            return payload.parsed;
+          });
+        });
       });
     },
 
@@ -1004,7 +1043,11 @@
      * @param {number|string} patchNum
      */
     getChangeCommitInfo(changeNum, patchNum) {
-      return this._getChangeURLAndFetch(changeNum, '/commit?links', patchNum);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/commit?links',
+        patchNum,
+      });
     },
 
     /**
@@ -1019,8 +1062,12 @@
       } else if (!this.patchNumEquals(patchRange.basePatchNum, 'PARENT')) {
         params = {base: patchRange.basePatchNum};
       }
-      return this._getChangeURLAndFetch(changeNum, '/files',
-          patchRange.patchNum, undefined, undefined, params);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/files',
+        patchNum: patchRange.patchNum,
+        params,
+      });
     },
 
     /**
@@ -1032,7 +1079,7 @@
       if (patchRange.basePatchNum !== 'PARENT') {
         endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + '');
       }
-      return this._getChangeURLAndFetch(changeNum, endpoint);
+      return this._getChangeURLAndFetch({changeNum, endpoint});
     },
 
     /**
@@ -1042,8 +1089,11 @@
      * @return {!Promise<!Object>}
      */
     queryChangeFiles(changeNum, patchNum, query) {
-      return this._getChangeURLAndFetch(changeNum,
-          `/files?q=${encodeURIComponent(query)}`, patchNum);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: `/files?q=${encodeURIComponent(query)}`,
+        patchNum,
+      });
     },
 
     /**
@@ -1071,16 +1121,16 @@
     },
 
     getChangeRevisionActions(changeNum, patchNum) {
-      return this._getChangeURLAndFetch(changeNum, '/actions', patchNum)
-          .then(revisionActions => {
-            // The rebase button on change screen is always enabled.
-            if (revisionActions.rebase) {
-              revisionActions.rebase.rebaseOnCurrent =
-                  !!revisionActions.rebase.enabled;
-              revisionActions.rebase.enabled = true;
-            }
-            return revisionActions;
-          });
+      const req = {changeNum, endpoint: '/actions', patchNum};
+      return this._getChangeURLAndFetch(req).then(revisionActions => {
+        // The rebase button on change screen is always enabled.
+        if (revisionActions.rebase) {
+          revisionActions.rebase.rebaseOnCurrent =
+              !!revisionActions.rebase.enabled;
+          revisionActions.rebase.enabled = true;
+        }
+        return revisionActions;
+      });
     },
 
     /**
@@ -1091,15 +1141,19 @@
     getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) {
       const params = {n: 10};
       if (inputVal) { params.q = inputVal; }
-      return this._getChangeURLAndFetch(changeNum, '/suggest_reviewers', null,
-          opt_errFn, null, params);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/suggest_reviewers',
+        errFn: opt_errFn,
+        params,
+      });
     },
 
     /**
      * @param {number|string} changeNum
      */
     getChangeIncludedIn(changeNum) {
-      return this._getChangeURLAndFetch(changeNum, '/in', null);
+      return this._getChangeURLAndFetch({changeNum, endpoint: '/in'});
     },
 
     _computeFilter(filter) {
@@ -1122,10 +1176,10 @@
     getGroups(filter, groupsPerPage, opt_offset) {
       const offset = opt_offset || 0;
 
-      return this._fetchSharedCacheURL(
-          `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
-          this._computeFilter(filter)
-      );
+      return this._fetchSharedCacheURL({
+        url: `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
+            this._computeFilter(filter),
+      });
     },
 
     /**
@@ -1139,10 +1193,10 @@
 
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchSharedCacheURL(
-          `/projects/?d&n=${reposPerPage + 1}&S=${offset}` +
-          this._computeFilter(filter)
-      );
+      return this._fetchSharedCacheURL({
+        url: `/projects/?d&n=${reposPerPage + 1}&S=${offset}` +
+            this._computeFilter(filter),
+      });
     },
 
     setRepoHead(repo, ref) {
@@ -1162,15 +1216,13 @@
      */
     getRepoBranches(filter, repo, reposBranchesPerPage, opt_offset, opt_errFn) {
       const offset = opt_offset || 0;
-
+      const count = reposBranchesPerPage + 1;
+      filter = this._computeFilter(filter);
+      repo = encodeURIComponent(repo);
+      const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this.fetchJSON(
-          `/projects/${encodeURIComponent(repo)}/branches` +
-          `?n=${reposBranchesPerPage + 1}&S=${offset}` +
-          this._computeFilter(filter),
-          opt_errFn
-      );
+      return this._fetchJSON({url, errFn: opt_errFn});
     },
 
     /**
@@ -1183,15 +1235,14 @@
      */
     getRepoTags(filter, repo, reposTagsPerPage, opt_offset, opt_errFn) {
       const offset = opt_offset || 0;
-
+      const encodedRepo = encodeURIComponent(repo);
+      const n = reposTagsPerPage + 1;
+      const encodedFilter = this._computeFilter(filter);
+      const url = `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` +
+          encodedFilter;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this.fetchJSON(
-          `/projects/${encodeURIComponent(repo)}/tags` +
-          `?n=${reposTagsPerPage + 1}&S=${offset}` +
-          this._computeFilter(filter),
-          opt_errFn
-      );
+      return this._fetchJSON({url, errFn: opt_errFn});
     },
 
     /**
@@ -1203,21 +1254,19 @@
      */
     getPlugins(filter, pluginsPerPage, opt_offset, opt_errFn) {
       const offset = opt_offset || 0;
-
-      return this.fetchJSON(
-          `/plugins/?all&n=${pluginsPerPage + 1}&S=${offset}` +
-          this._computeFilter(filter),
-          opt_errFn
-      );
+      const encodedFilter = this._computeFilter(filter);
+      const n = pluginsPerPage + 1;
+      const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
+      return this._fetchJSON({url, errFn: opt_errFn});
     },
 
     getRepoAccessRights(repoName, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this.fetchJSON(
-          `/projects/${encodeURIComponent(repoName)}/access`,
-          opt_errFn
-      );
+      return this._fetchJSON({
+        url: `/projects/${encodeURIComponent(repoName)}/access`,
+        errFn: opt_errFn,
+      });
     },
 
     setRepoAccessRights(repoName, repoInfo) {
@@ -1243,7 +1292,12 @@
     getSuggestedGroups(inputVal, opt_n, opt_errFn, opt_ctx) {
       const params = {s: inputVal};
       if (opt_n) { params.n = opt_n; }
-      return this.fetchJSON('/groups/', opt_errFn, opt_ctx, params);
+      return this._fetchJSON({
+        url: '/groups/',
+        errFn: opt_errFn,
+        cancelCondition: opt_ctx,
+        params,
+      });
     },
 
     /**
@@ -1259,7 +1313,12 @@
         type: 'ALL',
       };
       if (opt_n) { params.n = opt_n; }
-      return this.fetchJSON('/projects/', opt_errFn, opt_ctx, params);
+      return this._fetchJSON({
+        url: '/projects/',
+        errFn: opt_errFn,
+        cancelCondition: opt_ctx,
+        params,
+      });
     },
 
     /**
@@ -1274,7 +1333,12 @@
       }
       const params = {suggest: null, q: inputVal};
       if (opt_n) { params.n = opt_n; }
-      return this.fetchJSON('/accounts/', opt_errFn, opt_ctx, params);
+      return this._fetchJSON({
+        url: '/accounts/',
+        errFn: opt_errFn,
+        cancelCondition: opt_ctx,
+        params,
+      });
     },
 
     addChangeReviewer(changeNum, reviewerID) {
@@ -1305,11 +1369,18 @@
     },
 
     getRelatedChanges(changeNum, patchNum) {
-      return this._getChangeURLAndFetch(changeNum, '/related', patchNum);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/related',
+        patchNum,
+      });
     },
 
     getChangesSubmittedTogether(changeNum) {
-      return this._getChangeURLAndFetch(changeNum, '/submitted_together', null);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/submitted_together',
+      });
     },
 
     getChangeConflicts(changeNum) {
@@ -1321,7 +1392,7 @@
         O: options,
         q: 'status:open is:mergeable conflicts:' + changeNum,
       };
-      return this.fetchJSON('/changes/', null, null, params);
+      return this._fetchJSON({url: '/changes/', params});
     },
 
     getChangeCherryPicks(project, changeID, changeNum) {
@@ -1339,7 +1410,7 @@
         O: options,
         q: query,
       };
-      return this.fetchJSON('/changes/', null, null, params);
+      return this._fetchJSON({url: '/changes/', params});
     },
 
     getChangesWithSameTopic(topic) {
@@ -1353,11 +1424,15 @@
         O: options,
         q: 'status:open topic:' + topic,
       };
-      return this.fetchJSON('/changes/', null, null, params);
+      return this._fetchJSON({url: '/changes/', params});
     },
 
     getReviewedFiles(changeNum, patchNum) {
-      return this._getChangeURLAndFetch(changeNum, '/files?reviewed', patchNum);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/files?reviewed',
+        patchNum,
+      });
     },
 
     /**
@@ -1395,10 +1470,12 @@
     getChangeEdit(changeNum, opt_download_commands) {
       const params = opt_download_commands ? {'download-commands': true} : null;
       return this.getLoggedIn().then(loggedIn => {
-        return loggedIn ?
-            this._getChangeURLAndFetch(changeNum, '/edit/', null, null, null,
-                params) :
-            false;
+        if (!loggedIn) { return false; }
+        return this._getChangeURLAndFetch({
+          changeNum,
+          endpoint: '/edit/',
+          params,
+        });
       });
     },
 
@@ -1607,8 +1684,14 @@
       }
       const endpoint = `/files/${encodeURIComponent(path)}/diff`;
 
-      return this._getChangeURLAndFetch(changeNum, endpoint, patchNum,
-          opt_errFn, opt_cancelCondition, params);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint,
+        patchNum,
+        errFn: opt_errFn,
+        cancelCondition: opt_cancelCondition,
+        params,
+      });
     },
 
     /**
@@ -1695,7 +1778,11 @@
        * @return {!Promise<!Object>} Diff comments response.
        */
       const fetchComments = opt_patchNum => {
-        return this._getChangeURLAndFetch(changeNum, endpoint, opt_patchNum);
+        return this._getChangeURLAndFetch({
+          changeNum,
+          endpoint,
+          patchNum: opt_patchNum,
+        });
       };
 
       if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
@@ -1809,9 +1896,10 @@
     },
 
     getCommitInfo(project, commit) {
-      return this.fetchJSON(
-          '/projects/' + encodeURIComponent(project) +
-          '/commits/' + encodeURIComponent(commit));
+      return this._fetchJSON({
+        url: '/projects/' + encodeURIComponent(project) +
+            '/commits/' + encodeURIComponent(commit),
+      });
     },
 
     _fetchB64File(url) {
@@ -1940,7 +2028,7 @@
     },
 
     getAccountSSHKeys() {
-      return this._fetchSharedCacheURL('/accounts/self/sshkeys');
+      return this._fetchSharedCacheURL({url: '/accounts/self/sshkeys'});
     },
 
     addAccountSSHKey(key) {
@@ -1963,7 +2051,7 @@
     },
 
     getAccountGPGKeys() {
-      return this.fetchJSON('/accounts/self/gpgkeys');
+      return this._fetchJSON({url: '/accounts/self/gpgkeys'});
     },
 
     addAccountGPGKey(key) {
@@ -2006,7 +2094,10 @@
     },
 
     getCapabilities(token, opt_errFn) {
-      return this.fetchJSON('/config/server/capabilities', opt_errFn);
+      return this._fetchJSON({
+        url: '/config/server/capabilities',
+        errFn: opt_errFn,
+      });
     },
 
     setAssignee(changeNum, assignee) {
@@ -2073,11 +2164,13 @@
      */
     getChange(changeNum, opt_errFn) {
       // Cannot use _changeBaseURL, as this function is used by _projectLookup.
-      return this.fetchJSON(`/changes/?q=change:${changeNum}`, opt_errFn)
-          .then(res => {
-            if (!res || !res.length) { return null; }
-            return res[0];
-          });
+      return this._fetchJSON({
+        url: `/changes/?q=change:${changeNum}`,
+        errFn: opt_errFn,
+      }).then(res => {
+        if (!res || !res.length) { return null; }
+        return res[0];
+      });
     },
 
     /**
@@ -2140,23 +2233,20 @@
       });
     },
 
-   /**
-    * Alias for _changeBaseURL.then(fetchJSON).
-    * @todo(beckysiegel) clean up comments
-    * @param {string|number} changeNum
-    * @param {string} endpoint
-    * @param {?string|number=} opt_patchNum gets passed as null.
-    * @param {?function(?Response, string=)=} opt_errFn gets passed as null.
-    * @param {?function()=} opt_cancelCondition gets passed as null.
-    * @param {?Object=} opt_params gets passed as null.
-    * @param {!Object=} opt_options
-    * @return {!Promise<!Object>}
-    */
-    _getChangeURLAndFetch(changeNum, endpoint, opt_patchNum, opt_errFn,
-        opt_cancelCondition, opt_params, opt_options) {
-      return this._changeBaseURL(changeNum, opt_patchNum).then(url => {
-        return this.fetchJSON(url + endpoint, opt_errFn, opt_cancelCondition,
-            opt_params, opt_options);
+    /**
+     * Alias for _changeBaseURL.then(_fetchJSON).
+     * @param {Defs.ChangeFetchRequest} req
+     * @return {!Promise<!Object>}
+     */
+    _getChangeURLAndFetch(req) {
+      return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
+        return this._fetchJSON({
+          url: url + req.endpoint,
+          errFn: req.errFn,
+          cancelCondition: req.cancelCondition,
+          params: req.params,
+          fetchOptions: req.fetchOptions,
+        });
       });
     },
 
@@ -2171,9 +2261,12 @@
      */
     getBlame(changeNum, patchNum, path, opt_base) {
       const encodedPath = encodeURIComponent(path);
-      return this._getChangeURLAndFetch(changeNum,
-          `/files/${encodedPath}/blame`, patchNum, undefined, undefined,
-          opt_base ? {base: 't'} : undefined);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: `/files/${encodedPath}/blame`,
+        patchNum,
+        params: opt_base ? {base: 't'} : undefined,
+      });
     },
 
     /**
@@ -2217,7 +2310,7 @@
     getDashboard(project, dashboard, opt_errFn) {
       const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' +
           encodeURIComponent(dashboard);
-      return this._fetchSharedCacheURL(url, opt_errFn);
+      return this._fetchSharedCacheURL({url, errFn: opt_errFn});
     },
 
     getMergeable(changeNum) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index fb20da4..7e71efa 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -58,7 +58,7 @@
     });
 
     test('JSON prefix is properly removed', done => {
-      element.fetchJSON('/dummy/url').then(obj => {
+      element._fetchJSON('/dummy/url').then(obj => {
         assert.deepEqual(obj, {hello: 'bonjour'});
         done();
       });
@@ -66,7 +66,7 @@
 
     test('cached results', done => {
       let n = 0;
-      sandbox.stub(element, 'fetchJSON', () => {
+      sandbox.stub(element, '_fetchJSON', () => {
         return Promise.resolve(++n);
       });
       const promises = [];
@@ -86,7 +86,7 @@
     test('cached promise', done => {
       const promise = Promise.reject('foo');
       element._cache['/foo'] = promise;
-      element._fetchSharedCacheURL('/foo').catch(p => {
+      element._fetchSharedCacheURL({url: '/foo'}).catch(p => {
         assert.equal(p, 'foo');
         done();
       });
@@ -120,7 +120,8 @@
           cancel() { cancelCalled = true; },
         },
       }));
-      element.fetchJSON('/dummy/url', null, () => { return true; }).then(
+      const cancelCondition = () => { return true; };
+      element._fetchJSON({url: '/dummy/url', cancelCondition}).then(
           obj => {
             assert.isUndefined(obj);
             assert.isTrue(cancelCalled);
@@ -129,7 +130,7 @@
     });
 
     test('parent diff comments are properly grouped', done => {
-      sandbox.stub(element, 'fetchJSON', () => {
+      sandbox.stub(element, '_fetchJSON', () => {
         return Promise.resolve({
           '/COMMIT_MSG': [],
           'sieve.go': [
@@ -272,7 +273,8 @@
     test('differing patch diff comments are properly grouped', done => {
       sandbox.stub(element, 'getFromProjectLookup')
           .returns(Promise.resolve('test'));
-      sandbox.stub(element, 'fetchJSON', url => {
+      sandbox.stub(element, '_fetchJSON', request => {
+        const url = request.url;
         if (url === '/changes/test~42/revisions/1') {
           return Promise.resolve({
             '/COMMIT_MSG': [],
@@ -386,11 +388,11 @@
     });
 
     suite('rebase action', () => {
-      let resolveFetchJSON;
+      let resolve_fetchJSON;
       setup(() => {
-        sandbox.stub(element, 'fetchJSON').returns(
+        sandbox.stub(element, '_fetchJSON').returns(
             new Promise(resolve => {
-              resolveFetchJSON = resolve;
+              resolve_fetchJSON = resolve;
             }));
       });
 
@@ -401,7 +403,7 @@
               assert.isFalse(response.rebase.rebaseOnCurrent);
               done();
             });
-        resolveFetchJSON({rebase: {}});
+        resolve_fetchJSON({rebase: {}});
       });
 
       test('rebase on current', done => {
@@ -411,7 +413,7 @@
               assert.isTrue(response.rebase.rebaseOnCurrent);
               done();
             });
-        resolveFetchJSON({rebase: {enabled: true}});
+        resolve_fetchJSON({rebase: {enabled: true}});
       });
     });
 
@@ -423,7 +425,7 @@
         element.addEventListener('server-error', resolve);
       });
 
-      element.fetchJSON().then(response => {
+      element._fetchJSON({}).then(response => {
         assert.isUndefined(response);
         assert.isTrue(getResponseObjectStub.notCalled);
         serverErrorEventPromise.then(() => done());
@@ -444,7 +446,7 @@
       element.addEventListener('server-error', serverErrorStub);
       const authErrorStub = sandbox.stub();
       element.addEventListener('auth-error', authErrorStub);
-      element.fetchJSON('/bar').then(r => {
+      element._fetchJSON('/bar').then(r => {
         flush(() => {
           assert.isTrue(authErrorStub.called);
           assert.isFalse(serverErrorStub.called);
@@ -484,10 +486,10 @@
     });
 
     test('legacy n,z key in change url is replaced', () => {
-      const stub = sandbox.stub(element, 'fetchJSON')
+      const stub = sandbox.stub(element, '_fetchJSON')
           .returns(Promise.resolve([]));
       element.getChanges(1, null, 'n,z');
-      assert.equal(stub.args[0][3].S, 0);
+      assert.equal(stub.lastCall.args[0].params.S, 0);
     });
 
     test('saveDiffPreferences invalidates cache line', () => {
@@ -512,7 +514,7 @@
           });
 
           element._cache[cacheKey] = 'fake cache';
-          stub.callArg(1);
+          stub.lastCall.args[0].errFn();
         });
 
     test('getAccount does not add to the cache when resp.status is 403',
@@ -527,7 +529,7 @@
             done();
           });
           element._cache[cacheKey] = 'fake cache';
-          stub.callArgWith(1, {status: 403});
+          stub.lastCall.args[0].errFn({status: 403});
         });
 
     test('getAccount when resp is successful', done => {
@@ -541,7 +543,8 @@
         done();
       });
       element._cache[cacheKey] = 'fake cache';
-      stub.callArg(1, {});
+
+      stub.lastCall.args[0].errFn({});
     });
 
     const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
@@ -872,66 +875,69 @@
       const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
           .returns(Promise.resolve());
       return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
-        assert.deepEqual(fetchStub.lastCall.args,
-            ['42', '/files?q=test%2Fpath.js', 'edit']);
+        assert.deepEqual(fetchStub.lastCall.args[0], {
+          changeNum: '42',
+          endpoint: '/files?q=test%2Fpath.js',
+          patchNum: 'edit',
+        });
       });
     });
 
     test('getRepos', () => {
       sandbox.stub(element, '_fetchSharedCacheURL');
       element.getRepos('test', 25);
-      assert.isTrue(element._fetchSharedCacheURL.lastCall
-          .calledWithExactly('/projects/?d&n=26&S=0&m=test'));
+      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+          '/projects/?d&n=26&S=0&m=test');
 
       element.getRepos(null, 25);
-      assert.isTrue(element._fetchSharedCacheURL.lastCall
-          .calledWithExactly('/projects/?d&n=26&S=0'));
+      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+          '/projects/?d&n=26&S=0');
 
       element.getRepos('test', 25, 25);
-      assert.isTrue(element._fetchSharedCacheURL.lastCall
-          .calledWithExactly('/projects/?d&n=26&S=25&m=test'));
+      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+          '/projects/?d&n=26&S=25&m=test');
     });
 
     test('getRepos filter', () => {
       sandbox.stub(element, '_fetchSharedCacheURL');
       element.getRepos('test/test/test', 25);
-      assert.isTrue(element._fetchSharedCacheURL.lastCall
-          .calledWithExactly('/projects/?d&n=26&S=0&m=test%2Ftest%2Ftest'));
+      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+          '/projects/?d&n=26&S=0&m=test%2Ftest%2Ftest');
     });
 
     test('getRepos filter regex', () => {
       sandbox.stub(element, '_fetchSharedCacheURL');
       element.getRepos('^test.*', 25);
-      assert.isTrue(element._fetchSharedCacheURL.lastCall
-          .calledWithExactly('/projects/?d&n=26&S=0&r=%5Etest.*'));
+      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+          '/projects/?d&n=26&S=0&r=%5Etest.*');
     });
 
     test('getGroups filter regex', () => {
       sandbox.stub(element, '_fetchSharedCacheURL');
       element.getGroups('^test.*', 25);
-      assert.isTrue(element._fetchSharedCacheURL.lastCall
-          .calledWithExactly('/groups/?n=26&S=0&r=%5Etest.*'));
+      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+          '/groups/?n=26&S=0&r=%5Etest.*');
     });
 
     test('gerrit auth is used', () => {
       sandbox.stub(Gerrit.Auth, 'fetch').returns(Promise.resolve());
-      element.fetchJSON('foo');
+      element._fetchJSON('foo');
       assert(Gerrit.Auth.fetch.called);
     });
 
-    test('getSuggestedAccounts does not return fetchJSON', () => {
-      const fetchJSONSpy = sandbox.spy(element, 'fetchJSON');
+    test('getSuggestedAccounts does not return _fetchJSON', () => {
+      const _fetchJSONSpy = sandbox.spy(element, '_fetchJSON');
       return element.getSuggestedAccounts().then(accts => {
-        assert.isFalse(fetchJSONSpy.called);
+        assert.isFalse(_fetchJSONSpy.called);
         assert.equal(accts.length, 0);
       });
     });
 
-    test('fetchJSON gets called by getSuggestedAccounts', () => {
-      const fetchJSONStub = sandbox.stub(element, 'fetchJSON',
+    test('_fetchJSON gets called by getSuggestedAccounts', () => {
+      const _fetchJSONStub = sandbox.stub(element, '_fetchJSON',
           () => Promise.resolve());
       return element.getSuggestedAccounts('own').then(() => {
-        assert.deepEqual(fetchJSONStub.lastCall.args[3], {
+        assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
           q: 'own',
           suggest: null,
         });
@@ -1064,7 +1070,7 @@
 
     suite('getChanges populates _projectLookup', () => {
       test('multiple queries', () => {
-        sandbox.stub(element, 'fetchJSON')
+        sandbox.stub(element, '_fetchJSON')
             .returns(Promise.resolve([
               [
                 {_number: 1, project: 'test'},
@@ -1073,7 +1079,7 @@
                 {_number: 3, project: 'test/test'},
               ],
             ]));
-        // When opt_query instanceof Array, fetchJSON returns
+        // When opt_query instanceof Array, _fetchJSON returns
         // Array<Array<Object>>.
         return element.getChanges(null, []).then(() => {
           assert.equal(Object.keys(element._projectLookup).length, 3);
@@ -1084,14 +1090,14 @@
       });
 
       test('no query', () => {
-        sandbox.stub(element, 'fetchJSON')
+        sandbox.stub(element, '_fetchJSON')
             .returns(Promise.resolve([
               {_number: 1, project: 'test'},
               {_number: 2, project: 'test'},
               {_number: 3, project: 'test/test'},
             ]));
 
-        // When opt_query !instanceof Array, fetchJSON returns
+        // When opt_query !instanceof Array, _fetchJSON returns
         // Array<Object>.
         return element.getChanges().then(() => {
           assert.equal(Object.keys(element._projectLookup).length, 3);
@@ -1104,10 +1110,12 @@
 
     test('_getChangeURLAndFetch', () => {
       element._projectLookup = {1: 'test'};
-      const fetchStub = sandbox.stub(element, 'fetchJSON')
+      const fetchStub = sandbox.stub(element, '_fetchJSON')
           .returns(Promise.resolve());
-      return element._getChangeURLAndFetch(1, '/test', 1).then(() => {
-        assert.isTrue(fetchStub.calledWith('/changes/test~1/revisions/1/test'));
+      const req = {changeNum: 1, endpoint: '/test', patchNum: 1};
+      return element._getChangeURLAndFetch(req).then(() => {
+        assert.equal(fetchStub.lastCall.args[0].url,
+            '/changes/test~1/revisions/1/test');
       });
     });
 
@@ -1170,8 +1178,8 @@
         const range = {basePatchNum: 'PARENT', patchNum: 2};
         return element.getChangeFiles(123, range).then(() => {
           assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[2], 2);
-          assert.isNotOk(fetchStub.lastCall.args[5]);
+          assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+          assert.isNotOk(fetchStub.lastCall.args[0].params);
         });
       });
 
@@ -1181,10 +1189,10 @@
         const range = {basePatchNum: 4, patchNum: 5};
         return element.getChangeFiles(123, range).then(() => {
           assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[2], 5);
-          assert.isOk(fetchStub.lastCall.args[5]);
-          assert.equal(fetchStub.lastCall.args[5].base, 4);
-          assert.isNotOk(fetchStub.lastCall.args[5].parent);
+          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+          assert.isOk(fetchStub.lastCall.args[0].params);
+          assert.equal(fetchStub.lastCall.args[0].params.base, 4);
+          assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
         });
       });
 
@@ -1194,10 +1202,10 @@
         const range = {basePatchNum: -3, patchNum: 5};
         return element.getChangeFiles(123, range).then(() => {
           assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[2], 5);
-          assert.isOk(fetchStub.lastCall.args[5]);
-          assert.isNotOk(fetchStub.lastCall.args[5].base);
-          assert.equal(fetchStub.lastCall.args[5].parent, 3);
+          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+          assert.isOk(fetchStub.lastCall.args[0].params);
+          assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+          assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
         });
       });
     });
@@ -1208,10 +1216,10 @@
             .returns(Promise.resolve());
         return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
           assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[2], 2);
-          assert.isOk(fetchStub.lastCall.args[5]);
-          assert.isNotOk(fetchStub.lastCall.args[5].parent);
-          assert.isNotOk(fetchStub.lastCall.args[5].base);
+          assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+          assert.isOk(fetchStub.lastCall.args[0].params);
+          assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+          assert.isNotOk(fetchStub.lastCall.args[0].params.base);
         });
       });
 
@@ -1220,10 +1228,10 @@
             .returns(Promise.resolve());
         return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
           assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[2], 5);
-          assert.isOk(fetchStub.lastCall.args[5]);
-          assert.isNotOk(fetchStub.lastCall.args[5].parent);
-          assert.equal(fetchStub.lastCall.args[5].base, 4);
+          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+          assert.isOk(fetchStub.lastCall.args[0].params);
+          assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+          assert.equal(fetchStub.lastCall.args[0].params.base, 4);
         });
       });
 
@@ -1232,10 +1240,10 @@
             .returns(Promise.resolve());
         return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
           assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[2], 5);
-          assert.isOk(fetchStub.lastCall.args[5]);
-          assert.isNotOk(fetchStub.lastCall.args[5].base);
-          assert.equal(fetchStub.lastCall.args[5].parent, 3);
+          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+          assert.isOk(fetchStub.lastCall.args[0].params);
+          assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+          assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
         });
       });
     });
@@ -1245,7 +1253,7 @@
       element.getDashboard('gerrit/project', 'default:main');
       assert.isTrue(fetchStub.calledOnce);
       assert.equal(
-          fetchStub.lastCall.args[0],
+          fetchStub.lastCall.args[0].url,
           '/projects/gerrit%2Fproject/dashboards/default%3Amain');
     });
 
diff --git a/polygerrit-ui/app/embed/embed.html b/polygerrit-ui/app/embed/embed.html
index f3c727e..9fb5c23 100644
--- a/polygerrit-ui/app/embed/embed.html
+++ b/polygerrit-ui/app/embed/embed.html
@@ -21,4 +21,4 @@
 <link rel="import" href="../elements/change-list/gr-change-list-view/gr-change-list-view.html">
 <link rel="import" href="../elements/change-list/gr-change-list/gr-change-list.html">
 <link rel="import" href="../elements/change-list/gr-dashboard-view/gr-dashboard-view.html">
-<link rel="import" href="../styles/app-theme.html">
+<link rel="import" href="../styles/themes/app-theme.html">
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index b60aa22..199a947 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -62,6 +62,15 @@
   )
 
   native.filegroup(
+    name = name + "_theme_sources",
+    srcs = native.glob(
+      ["styles/themes/*.html"],
+      # app-theme.html already included via an import in gr-app.html.
+      exclude = ["styles/themes/app-theme.html"],
+    ),
+  )
+
+  native.filegroup(
     name = name + "_top_sources",
     srcs = [
         "favicon.ico",
@@ -73,6 +82,7 @@
     srcs = [
       name + "_app_sources",
       name + "_css_sources",
+      name + "_theme_sources",
       name + "_top_sources",
       "//lib/fonts:robotofonts",
       "//lib/js:highlightjs_files",
@@ -82,11 +92,12 @@
     ],
     outs = outs,
     cmd = " && ".join([
-      "mkdir -p $$TMP/polygerrit_ui/{styles,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
+      "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
       "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/"  + appName + ".$$ext; done",
       "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
       "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
       "for f in $(locations "+ name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
+      "for f in $(locations "+ name + "_theme_sources); do cp $$f $$TMP/polygerrit_ui/styles/themes; done",
       "for f in $(locations //lib/js:highlightjs_files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
       "unzip -qd $$TMP/polygerrit_ui/bower_components $(location @webcomponentsjs//:zipfile) webcomponentsjs/webcomponents-lite.js",
       "cd $$TMP",
diff --git a/polygerrit-ui/app/styles/app-theme.html b/polygerrit-ui/app/styles/themes/app-theme.html
similarity index 100%
rename from polygerrit-ui/app/styles/app-theme.html
rename to polygerrit-ui/app/styles/themes/app-theme.html
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.html b/polygerrit-ui/app/styles/themes/dark-theme.html
new file mode 100644
index 0000000..1f473da
--- /dev/null
+++ b/polygerrit-ui/app/styles/themes/dark-theme.html
@@ -0,0 +1,83 @@
+<dom-module id="dark-theme">
+  <style is="custom-style">
+    html {
+      --primary-text-color: #e2e2e2;
+      --view-background-color: #212121;
+      --border-color: #555555;
+      --table-header-background-color: #353637;
+      --table-subheader-background-color: rgb(23, 27, 31);
+      --header-background-color: #5487E5;
+      --header-text-color: var(--primary-text-color);
+      --deemphasized-text-color: #9a9a9a;
+      --footer-background-color: var(--table-header-background-color);
+      --expanded-background-color: #26282b;
+      --link-color: #5487E5;
+      --primary-button-background-color: var(--link-color);
+      --primary-button-text-color: var(--primary-text-color);
+      --secondary-button-background-color: var(--primary-text-color);
+      --secondary-button-text-color: var(--deemphasized-text-color);
+      --default-button-text-color: var(--link-color);
+      --default-button-background-color: var(--table-subheader-background-color);
+      --dropdown-background-color: var(--table-header-background-color);
+      --dialog-background-color: var(--view-background-color);
+      --chip-background-color: var(--table-header-background-color);
+
+      --select-background-color: var(--table-subheader-background-color);
+
+      --assignee-highlight-color: rgb(58, 54, 28);
+
+      --diff-selection-background-color: #3A71D8;
+      --light-remove-highlight-color: rgb(53, 27, 27);
+      --light-add-highlight-color: rgb(24, 45, 24);
+      --light-rebased-remove-highlight-color: rgb(60, 37, 8);
+      --light-rebased-add-highlight-color: rgb(72, 113, 101);
+      --dark-remove-highlight-color: rgba(255, 0, 0, 0.15);
+      --dark-add-highlight-color: rgba(0, 255, 0, 0.15);
+      --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
+      --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
+      --diff-context-control-color: var(--table-header-background-color);
+      --diff-context-control-border-color: var(--border-color);
+      --diff-highlight-range-color: rgba(0, 100, 200, 0.5);
+      --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
+      --comment-text-color: var(--primary-text-color);
+      --comment-background-color: #0B162B;
+      --unresolved-comment-background-color: rgb(56, 90, 154);
+
+      --vote-color-approved: rgb(127, 182, 107);
+      --vote-color-recommended: rgb(63, 103, 50);
+      --vote-color-rejected: #ac2d3e;
+      --vote-color-disliked: #bf6874;
+      --vote-color-neutral: #597280;
+
+      --edit-mode-background-color: rgb(92, 10, 54);
+      --emphasis-color: #383f4a;
+
+      --tooltip-background-color: #111;
+
+      --syntax-default-color: var(--primary-text-color);
+      --syntax-meta-color: #6D7EEE;
+      --syntax-keyword-color: #CD4CF0;
+      --syntax-number-color: #00998A;
+      --syntax-selector-class-color: #FFCB68;
+      --syntax-variable-color: #F77669;
+      --syntax-template-variable-color: #F77669;
+      --syntax-comment-color: var(--deemphasized-text-color);
+      --syntax-string-color: #C3E88D;
+      --syntax-selector-id-color: #F77669;
+      --syntax-built_in-color: rgb(247, 195, 105);
+      --syntax-tag-color: #F77669;
+      --syntax-link-color: #C792EA;
+      --syntax-meta-keyword-color: #EEFFF7;
+      --syntax-type-color: #DD5F5F;
+      --syntax-title-color: #75A5FF;
+      --syntax-attr-color: #80CBBF;
+      --syntax-literal-color: #EEFFF7;
+      --syntax-selector-pseudo-color: #C792EA;
+      --syntax-regexp-color: #F77669;
+      --syntax-selector-attr-color: #80CBBF;
+      --syntax-template-tag-color: #C792EA;
+
+      background-color: var(--view-background-color);
+    }
+  </style>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 6cf674a..6a562fc 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -88,6 +88,7 @@
     'core/gr-error-manager/gr-error-manager_test.html',
     'core/gr-main-header/gr-main-header_test.html',
     'core/gr-navigation/gr-navigation_test.html',
+    'core/gr-reporting/gr-jank-detector_test.html',
     'core/gr-reporting/gr-reporting_test.html',
     'core/gr-router/gr-router_test.html',
     'core/gr-search-bar/gr-search-bar_test.html',