Merge changes from topic "plugin-get-change-fields"

* changes:
  Move plugin field tests into respective packages
  Split up plugin fields tests into extension/REST/SSH files
  Bind ChangeAttributeFactory as a DynamicSet
  Support plugin-defined options in extension API
  Move DynamicMap<DynamicBean> binding into sys injector
  PluginFieldsIT: Actually use extracted SSH_GSON constant
  ChangeQueryProcessor: Don't implement PluginAttributesFactory
  Support plugin fields in GetChange
  Extract ChangeAttributeFactory from ChangeQueryProcessor
  Add tests for plugin-provided fields controlled by options
  CmdLineParser: Require plugin option names to start with '-'
  Expand tests for plugin fields
  dev-plugins.txt: Recommend ServletModule instead of HttpPluginModule
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 4dea01d..a994711 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2585,30 +2585,30 @@
 
   )]}'
   {
-    "commit":{
-      "parents":[
+    "commit": {
+      "parents": [
         {
-          "commit":"1eee2c9d8f352483781e772f35dc586a69ff5646",
+          "commit": "1eee2c9d8f352483781e772f35dc586a69ff5646",
         }
       ],
-      "author":{
-        "name":"Shawn O. Pearce",
-        "email":"sop@google.com",
-        "date":"2012-04-24 18:08:08.000000000",
-        "tz":-420
+      "author": {
+        "name": "Shawn O. Pearce",
+        "email": "sop@google.com",
+        "date": "2012-04-24 18:08:08.000000000",
+        "tz": -420
        },
-       "committer":{
-         "name":"Shawn O. Pearce",
-         "email":"sop@google.com",
-         "date":"2012-04-24 18:08:08.000000000",
-         "tz":-420
+       "committer": {
+         "name": "Shawn O. Pearce",
+         "email": "sop@google.com",
+         "date": "2012-04-24 18:08:08.000000000",
+         "tz": -420
        },
-       "subject":"Use an EventBus to manage star icons",
-       "message":"Use an EventBus to manage star icons\n\nImage widgets that need to ..."
+       "subject": "Use an EventBus to manage star icons",
+       "message": "Use an EventBus to manage star icons\n\nImage widgets that need to ..."
     },
-    "base_patch_set_number":1,
-    "base_revision":"c35558e0925e6985c91f3a16921537d5e572b7a3",
-    "ref":"refs/users/01/1000001/edit-76482/1"
+    "base_patch_set_number": 1,
+    "base_revision": "c35558e0925e6985c91f3a16921537d5e572b7a3",
+    "ref": "refs/users/01/1000001/edit-76482/1"
   }
 ----
 
@@ -2796,7 +2796,7 @@
 
   )]}'
   {
-  "web_links":[
+  "web_links": [
     {
       "show_on_side_by_side_diff_view": true,
       "name": "side-by-side preview diff",
@@ -4819,30 +4819,30 @@
 
     )]}'
     {
-      "commit":{
-        "parents":[
+      "commit": {
+        "parents": [
           {
-            "commit":"1eee2c9d8f352483781e772f35dc586a69ff5646",
+            "commit": "1eee2c9d8f352483781e772f35dc586a69ff5646",
           }
         ],
-        "author":{
-          "name":"John Doe",
-          "email":"john.doe@example.com",
-          "date":"2013-05-07 15:21:27.000000000",
-          "tz":120
+        "author": {
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "date": "2013-05-07 15:21:27.000000000",
+          "tz": 120
          },
-         "committer":{
-           "name":"Jane Doe",
-           "email":"jane.doe@example.com",
-           "date":"2013-05-07 15:35:43.000000000",
-           "tz":120
+         "committer": {
+           "name": "Jane Doe",
+           "email": "jane.doe@example.com",
+           "date": "2013-05-07 15:35:43.000000000",
+           "tz": 120
          },
-         "subject":"Implement feature X",
-         "message":"Implement feature X\n\nWith this feature ..."
+         "subject": "Implement feature X",
+         "message": "Implement feature X\n\nWith this feature ..."
       },
-      "base_patch_set_number":1,
-      "base_revision":"674ac754f91e64a0efb8087e59a176484bd534d1"
-      "ref":"refs/users/01/1000001/edit-42622/1"
+      "base_patch_set_number": 1,
+      "base_revision": "674ac754f91e64a0efb8087e59a176484bd534d1"
+      "ref": "refs/users/01/1000001/edit-42622/1"
     }
 ----
 
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 9803c19..f69c4ae 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -288,11 +288,11 @@
     },
     "child-project": {
       "id": "child-project",
-      "parent":"parent-project"
+      "parent": "parent-project"
     },
     "parent-project": {
       "id": "parent-project",
-      "parent":"All-Projects"
+      "parent": "All-Projects"
     }
   }
 ----
@@ -1271,14 +1271,14 @@
   Content-Type: application/json; charset=UTF-8
 
   {
-    "add":{
-      "refs/heads/*":{
-        "permissions":{
-          "read":{
-            "rules":{
+    "add": {
+      "refs/heads/*": {
+        "permissions": {
+          "read": {
+            "rules": {
               "global:Anonymous-Users": {
-                "action":"DENY",
-                "force":false
+                "action": "DENY",
+                "force": false
               }
             }
           }
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
index deb5022..caafe24 100644
--- a/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -34,7 +34,7 @@
   public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
       new TypeLiteral<RestView<RevisionResource>>() {};
 
-  public static RevisionResource createNonCachable(ChangeResource change, PatchSet ps) {
+  public static RevisionResource createNonCacheable(ChangeResource change, PatchSet ps) {
     return new RevisionResource(change, ps, Optional.empty(), false);
   }
 
@@ -52,11 +52,11 @@
   }
 
   private RevisionResource(
-      ChangeResource change, PatchSet ps, Optional<ChangeEdit> edit, boolean cachable) {
+      ChangeResource change, PatchSet ps, Optional<ChangeEdit> edit, boolean cacheable) {
     this.change = change;
     this.ps = ps;
     this.edit = edit;
-    this.cacheable = cachable;
+    this.cacheable = cacheable;
   }
 
   public boolean isCacheable() {
diff --git a/java/com/google/gerrit/server/config/ConfigResource.java b/java/com/google/gerrit/server/config/ConfigResource.java
index 4275dc4..f2b7c8e 100644
--- a/java/com/google/gerrit/server/config/ConfigResource.java
+++ b/java/com/google/gerrit/server/config/ConfigResource.java
@@ -26,7 +26,7 @@
 
   /**
    * Default cache control that gets set on the 'Cache-Control' header for responses on this
-   * resource that are cachable.
+   * resource that are cacheable.
    *
    * <p>Not all resources are cacheable and in fact the vast majority might not be. Caching is a
    * trade-off between the freshness of data and the number of QPS that the web UI sends.
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 1f3f001..e0eb3f2 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -179,7 +179,7 @@
     CREATE_REPLACE,
     NORMAL,
     AUTOCLOSE,
-  };
+  }
 
   @Singleton
   private static class Metrics {
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index 4edd741..84257e9 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -82,7 +82,7 @@
     if (id.get().equals("current")) {
       PatchSet ps = psUtil.current(change.getNotes());
       if (ps != null && visible(change)) {
-        return RevisionResource.createNonCachable(change, ps);
+        return RevisionResource.createNonCacheable(change, ps);
       }
       throw new ResourceNotFoundException(id);
     }
diff --git a/plugins/delete-project b/plugins/delete-project
index 1304fb0..53311c0 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 1304fb030ea990d63df42cab678cdf2b46722d1b
+Subproject commit 53311c006635e83faaa7a1b68039edebe0fe8e43
diff --git a/plugins/gitiles b/plugins/gitiles
index 623105f..dcd8d7f 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 623105f14dca02cb294ed94a952f5e8ce0e96683
+Subproject commit dcd8d7f93ebe21e60688e65fa1261a836a82eb32
diff --git a/plugins/webhooks b/plugins/webhooks
index b8fcfd5..064ae07 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit b8fcfd5d712abb599e49c3e40d266b77f05a398a
+Subproject commit 064ae07f55710034062ef2a36cd030abeee93265
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 8428da3..83e5216 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
@@ -1410,6 +1410,7 @@
         ignore_whitespace: 'IGNORE_NONE',
       };
       diff._diff = mock.diffResponse;
+      diff.$.diff.flushDebouncer('renderDiffTable');
     };
 
     const renderAndGetNewDiffs = function(index) {
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 cca5635..59af36d 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -234,10 +234,8 @@
      * User-perceived app start time, should be reported when the app is ready.
      */
     appStarted(hidden) {
-      const startTime =
-          new Date().getTime() - this.performanceTiming.navigationStart;
       this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
-          TIMING.APP_STARTED, startTime);
+          TIMING.APP_STARTED, this.now());
       if (hidden) {
         this.reporter(PAGE_VISIBILITY.TYPE, PAGE_VISIBILITY.CATEGORY,
             PAGE_VISIBILITY.STARTED_HIDDEN);
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 8b85074..2087790 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
@@ -61,11 +61,11 @@
     });
 
     test('appStarted', () => {
+      sandbox.stub(element, 'now').returns(42);
       element.appStarted(true);
       assert.isTrue(
           element.reporter.calledWithExactly(
-              'timing-report', 'UI Latency', 'App Started',
-              NOW_TIME - fakePerformance.navigationStart
+              'timing-report', 'UI Latency', 'App Started', 42
       ));
       assert.isTrue(
           element.reporter.calledWithExactly(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index 29cc3b9..9646b06 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -38,6 +38,7 @@
     <gr-reporting id="reporting"></gr-reporting>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
   </template>
+  <script src="../../../scripts/util.js"></script>
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff/gr-diff-group.js"></script>
   <script src="../gr-diff-highlight/gr-annotation.js"></script>
@@ -108,6 +109,13 @@
             type: Array,
             value: () => [],
           },
+          /**
+           * The promise last returned from `render()` while the asynchronous
+           * rendering is running - `null` otherwise. Provides a `cancel()`
+           * method that rejects it with `{isCancelled: true}`.
+           * @type {?Object}
+           */
+          _cancelableRenderPromise: Object,
         },
 
         get diffElement() {
@@ -146,25 +154,32 @@
           reporting.time(TimingLabel.TOTAL);
           reporting.time(TimingLabel.CONTENT);
           this.dispatchEvent(new CustomEvent('render-start', {bubbles: true}));
-          return this.$.processor.process(this.diff.content, isBinary)
-              .then(() => {
-                if (this.isImageDiff) {
-                  this._builder.renderDiff();
-                }
-                this.dispatchEvent(new CustomEvent('render-content',
-                    {bubbles: true}));
+          this._cancelableRenderPromise = util.makeCancelable(
+              this.$.processor.process(this.diff.content, isBinary)
+                  .then(() => {
+                    if (this.isImageDiff) {
+                      this._builder.renderDiff();
+                    }
+                    this.dispatchEvent(new CustomEvent('render-content',
+                        {bubbles: true}));
 
-                if (this._diffTooLargeForSyntax()) {
-                  this.$.syntaxLayer.enabled = false;
-                }
+                    if (this._diffTooLargeForSyntax()) {
+                      this.$.syntaxLayer.enabled = false;
+                    }
 
-                reporting.timeEnd(TimingLabel.CONTENT);
-                reporting.time(TimingLabel.SYNTAX);
-                return this.$.syntaxLayer.process().then(() => {
-                  reporting.timeEnd(TimingLabel.SYNTAX);
-                  reporting.timeEnd(TimingLabel.TOTAL);
-                });
-              });
+                    reporting.timeEnd(TimingLabel.CONTENT);
+                    reporting.time(TimingLabel.SYNTAX);
+                    return this.$.syntaxLayer.process().then(() => {
+                      reporting.timeEnd(TimingLabel.SYNTAX);
+                      reporting.timeEnd(TimingLabel.TOTAL);
+                    });
+                  }));
+          return this._cancelableRenderPromise
+              .finally(() => { this._cancelableRenderPromise = null; })
+              // Mocca testing does not like uncaught rejections, so we catch
+              // the cancels which are expected and should not throw errors in
+              // tests.
+              .catch(e => { if (!e.isCanceled) return Promise.reject(e); });
         },
 
         _setupAnnotationLayers() {
@@ -259,6 +274,10 @@
         cancel() {
           this.$.processor.cancel();
           this.$.syntaxLayer.cancel();
+          if (this._cancelableRenderPromise) {
+            this._cancelableRenderPromise.cancel();
+            this._cancelableRenderPromise = null;
+          }
         },
 
         _handlePreferenceError(pref) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
index baa8bba..663cf25 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
@@ -20,5 +20,7 @@
 <dom-module id="gr-diff-processor">
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff/gr-diff-group.js"></script>
+
+  <script src="../../../scripts/util.js"></script>
   <script src="gr-diff-processor.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index dead8d7..a9268a7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -80,8 +80,18 @@
         value: 64,
       },
 
-      /** @type {number|undefined} */
+      /** @type {?number} */
       _nextStepHandle: Number,
+      /**
+       * The promise last returned from `process()` while the asynchronous
+       * processing is running - `null` otherwise. Provides a `cancel()`
+       * method that rejects it with `{isCancelled: true}`.
+       * @type {?Object}
+       */
+      _processPromise: {
+        type: Object,
+        value: null,
+      },
       _isScrolling: Boolean,
     },
 
@@ -108,6 +118,10 @@
      *     processed.
      */
     process(content, isBinary) {
+      // Cancel any still running process() calls, because they append to the
+      // same groups field.
+      this.cancel();
+
       this.groups = [];
       this.push('groups', this._makeFileComments());
 
@@ -115,57 +129,64 @@
       // so finish processing.
       if (isBinary) { return Promise.resolve(); }
 
-      return new Promise(resolve => {
-        const state = {
-          lineNums: {left: 0, right: 0},
-          sectionIndex: 0,
-        };
 
-        content = this._splitCommonGroupsWithComments(content);
+      this._processPromise = util.makeCancelable(
+          new Promise(resolve => {
+            const state = {
+              lineNums: {left: 0, right: 0},
+              sectionIndex: 0,
+            };
 
-        let currentBatch = 0;
-        const nextStep = () => {
-          if (this._isScrolling) {
-            this.async(nextStep, 100);
-            return;
-          }
-          // If we are done, resolve the promise.
-          if (state.sectionIndex >= content.length) {
-            resolve(this.groups);
-            this._nextStepHandle = undefined;
-            return;
-          }
+            content = this._splitCommonGroupsWithComments(content);
 
-          // Process the next section and incorporate the result.
-          const result = this._processNext(state, content);
-          for (const group of result.groups) {
-            this.push('groups', group);
-            currentBatch += group.lines.length;
-          }
-          state.lineNums.left += result.lineDelta.left;
-          state.lineNums.right += result.lineDelta.right;
+            let currentBatch = 0;
+            const nextStep = () => {
+              if (this._isScrolling) {
+                this.async(nextStep, 100);
+                return;
+              }
+              // If we are done, resolve the promise.
+              if (state.sectionIndex >= content.length) {
+                resolve(this.groups);
+                this._nextStepHandle = null;
+                return;
+              }
 
-          // Increment the index and recurse.
-          state.sectionIndex++;
-          if (currentBatch >= this._asyncThreshold) {
-            currentBatch = 0;
-            this._nextStepHandle = this.async(nextStep, 1);
-          } else {
+              // Process the next section and incorporate the result.
+              const result = this._processNext(state, content);
+              for (const group of result.groups) {
+                this.push('groups', group);
+                currentBatch += group.lines.length;
+              }
+              state.lineNums.left += result.lineDelta.left;
+              state.lineNums.right += result.lineDelta.right;
+
+              // Increment the index and recurse.
+              state.sectionIndex++;
+              if (currentBatch >= this._asyncThreshold) {
+                currentBatch = 0;
+                this._nextStepHandle = this.async(nextStep, 1);
+              } else {
+                nextStep.call(this);
+              }
+            };
+
             nextStep.call(this);
-          }
-        };
-
-        nextStep.call(this);
-      });
+          }));
+      return this._processPromise
+          .finally(() => { this._processPromise = null; });
     },
 
     /**
      * Cancel any jobs that are running.
      */
     cancel() {
-      if (this._nextStepHandle !== undefined) {
+      if (this._nextStepHandle != null) {
         this.cancelAsync(this._nextStepHandle);
-        this._nextStepHandle = undefined;
+        this._nextStepHandle = null;
+      }
+      if (this._processPromise) {
+        this._processPromise.cancel();
       }
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 5ecc2fc3..1c018de 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -102,6 +102,8 @@
    */
   const COMMIT_MSG_LINE_LENGTH = 72;
 
+  const RENDER_DIFF_TABLE_DEBOUNCE_NAME = 'renderDiffTable';
+
   Polymer({
     is: 'gr-diff',
 
@@ -392,6 +394,7 @@
     /** Cancel any remaining diff builder rendering work. */
     cancel() {
       this.$.diffBuilder.cancel();
+      this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
     },
 
     /** @return {!Array<!HTMLElement>} */
@@ -679,17 +682,32 @@
       this.updateStyles(stylesToUpdate);
 
       if (this.diff && !this.noRenderOnPrefsChange) {
-        this._renderDiffTable();
+        this._debounceRenderDiffTable();
       }
     },
 
     _diffChanged(newValue) {
       if (newValue) {
         this._diffLength = this.$.diffBuilder.getDiffLength();
-        this._renderDiffTable();
+        this._debounceRenderDiffTable();
       }
     },
 
+    /**
+     * When called multiple times from the same microtask, will call
+     * _renderDiffTable only once, in the next microtask, unless it is cancelled
+     * before that microtask runs.
+     *
+     * This should be used instead of calling _renderDiffTable directly to
+     * render the diff in response to an input change, because there may be
+     * multiple inputs changing in the same microtask, but we only want to
+     * render once.
+     */
+    _debounceRenderDiffTable() {
+      this.debounce(
+          RENDER_DIFF_TABLE_DEBOUNCE_NAME, () => this._renderDiffTable());
+    },
+
     _renderDiffTable() {
       this._unobserveIncrementalNodes();
       if (!this.prefs) {
@@ -791,12 +809,12 @@
 
     _handleFullBypass() {
       this._safetyBypass = FULL_CONTEXT;
-      this._renderDiffTable();
+      this._debounceRenderDiffTable();
     },
 
     _handleLimitedBypass() {
       this._safetyBypass = LIMITED_CONTEXT;
-      this._renderDiffTable();
+      this._debounceRenderDiffTable();
     },
 
     /** @return {string} */
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index 849ad0d..42f098b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -40,6 +40,8 @@
     let element;
     let sandbox;
 
+    const MINIMAL_PREFS = {tab_size: 2, line_length: 80};
+
     setup(() => {
       sandbox = sinon.sandbox.create();
     });
@@ -81,14 +83,14 @@
 
     test('line limit with line_wrapping', () => {
       element = fixture('basic');
-      element.prefs = {line_wrapping: true, line_length: 80, tab_size: 2};
+      element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: true});
       flushAsynchronousOperations();
       assert.equal(element.customStyle['--line-limit'], '80ch');
     });
 
     test('line limit without line_wrapping', () => {
       element = fixture('basic');
-      element.prefs = {line_wrapping: false, line_length: 80, tab_size: 2};
+      element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: false});
       flushAsynchronousOperations();
       assert.isNotOk(element.customStyle['--line-limit']);
     });
@@ -225,7 +227,7 @@
 
         const mock = document.createElement('mock-diff-response');
         element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
-            mock.diffResponse, {tab_size: 2, line_length: 80});
+            mock.diffResponse, Object.assign({}, MINIMAL_PREFS));
 
         // No thread groups.
         assert.isNotOk(element._getThreadGroupForLine(contentEl));
@@ -448,7 +450,7 @@
             binary: true,
           };
 
-          element.addEventListener('render', () => {
+          function rendered() {
             // Recognizes that it should be an image diff.
             assert.isTrue(element.isImageDiff);
             assert.instanceOf(
@@ -460,7 +462,9 @@
             assert.isNotOk(leftImage);
             assert.isOk(rightImage);
             done();
-          });
+            element.removeEventListener('render', rendered);
+          }
+          element.addEventListener('render', rendered);
 
           element.revisionImage = mockFile2;
           element.diff = mockDiff;
@@ -483,7 +487,7 @@
             binary: true,
           };
 
-          element.addEventListener('render', () => {
+          function rendered() {
             // Recognizes that it should be an image diff.
             assert.isTrue(element.isImageDiff);
             assert.instanceOf(
@@ -495,7 +499,9 @@
             assert.isOk(leftImage);
             assert.isNotOk(rightImage);
             done();
-          });
+            element.removeEventListener('render', rendered);
+          }
+          element.addEventListener('render', rendered);
 
           element.baseImage = mockFile1;
           element.diff = mockDiff;
@@ -519,7 +525,7 @@
           };
           mockFile1.type = 'image/jpeg-evil';
 
-          element.addEventListener('render', () => {
+          function rendered() {
             // Recognizes that it should be an image diff.
             assert.isTrue(element.isImageDiff);
             assert.instanceOf(
@@ -527,7 +533,9 @@
             const leftImage = element.$.diffTable.querySelector('td.left img');
             assert.isNotOk(leftImage);
             done();
-          });
+            element.removeEventListener('render', rendered);
+          }
+          element.addEventListener('render', rendered);
 
           element.baseImage = mockFile1;
           element.diff = mockDiff;
@@ -679,12 +687,14 @@
             change_type: 'MODIFIED',
             content: [{skip: 66}],
           };
+          element.flushDebouncer('renderDiffTable');
         });
 
         test('change in preferences re-renders diff', () => {
           sandbox.stub(element, '_renderDiffTable');
-          element.prefs = {};
-          element.prefs = {time_format: 'HHMM_12'};
+          element.prefs = Object.assign(
+              {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
+          element.flushDebouncer('renderDiffTable');
           assert.isTrue(element._renderDiffTable.called);
         });
 
@@ -692,8 +702,9 @@
             'noRenderOnPrefsChange', () => {
           sandbox.stub(element, '_renderDiffTable');
           element.noRenderOnPrefsChange = true;
-          element.prefs = {};
-          element.prefs = {time_format: 'HHMM_12'};
+          element.prefs = Object.assign(
+              {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
+          element.flushDebouncer('renderDiffTable');
           assert.isFalse(element._renderDiffTable.called);
         });
       });
@@ -760,33 +771,39 @@
       });
 
       test('large render w/ context = 10', done => {
-        element.prefs = {context: 10};
-        element.addEventListener('render', () => {
+        element.prefs = Object.assign({}, MINIMAL_PREFS, {context: 10});
+        function rendered() {
           assert.isTrue(renderStub.called);
           assert.isFalse(element._showWarning);
           done();
-        });
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
         element._renderDiffTable();
       });
 
       test('large render w/ whole file and bypass', done => {
-        element.prefs = {context: -1};
+        element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
         element._safetyBypass = 10;
-        element.addEventListener('render', () => {
+        function rendered() {
           assert.isTrue(renderStub.called);
           assert.isFalse(element._showWarning);
           done();
-        });
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
         element._renderDiffTable();
       });
 
       test('large render w/ whole file and no bypass', done => {
-        element.prefs = {context: -1};
-        element.addEventListener('render', () => {
+        element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
+        function rendered() {
           assert.isFalse(renderStub.called);
           assert.isTrue(element._showWarning);
           done();
-        });
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
         element._renderDiffTable();
       });
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
index 017cd5d..67c32bb 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
@@ -21,6 +21,7 @@
   <template>
     <gr-lib-loader id="libLoader"></gr-lib-loader>
   </template>
+  <script src="../../../scripts/util.js"></script>
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff-highlight/gr-annotation.js"></script>
   <script src="gr-syntax-layer.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index a7e7377..32eb969 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -155,6 +155,16 @@
       },
       /** @type {?number} */
       _processHandle: Number,
+      /**
+       * The promise last returned from `process()` while the asynchronous
+       * processing is running - `null` otherwise. Provides a `cancel()`
+       * method that rejects it with `{isCancelled: true}`.
+       * @type {?Object}
+       */
+      _processPromise: {
+        type: Object,
+        value: null,
+      },
       _hljs: Object,
     },
 
@@ -212,6 +222,10 @@
      * @return {Promise}
      */
     process() {
+      // Cancel any still running process() calls, because they append to the
+      // same _baseRanges and _revisionRanges fields.
+      this.cancel();
+
       // Discard existing ranges.
       this._baseRanges = [];
       this._revisionRanges = [];
@@ -220,8 +234,6 @@
         return Promise.resolve();
       }
 
-      this.cancel();
-
       if (this.diff.meta_a) {
         this._baseLanguage = this._getLanguage(this.diff.meta_a);
       }
@@ -241,49 +253,55 @@
         lastNotify: {left: 1, right: 1},
       };
 
-      return this._loadHLJS().then(() => {
-        return new Promise(resolve => {
-          const nextStep = () => {
-            this._processHandle = null;
-            this._processNextLine(state);
+      this._processPromise = util.makeCancelable(this._loadHLJS()
+          .then(() => {
+            return new Promise(resolve => {
+              const nextStep = () => {
+                this._processHandle = null;
+                this._processNextLine(state);
 
-            // Move to the next line in the section.
-            state.lineIndex++;
+                // Move to the next line in the section.
+                state.lineIndex++;
 
-            // If the section has been exhausted, move to the next one.
-            if (this._isSectionDone(state)) {
-              state.lineIndex = 0;
-              state.sectionIndex++;
-            }
+                // If the section has been exhausted, move to the next one.
+                if (this._isSectionDone(state)) {
+                  state.lineIndex = 0;
+                  state.sectionIndex++;
+                }
 
-            // If all sections have been exhausted, finish.
-            if (state.sectionIndex >= this.diff.content.length) {
-              resolve();
-              this._notify(state);
-              return;
-            }
+                // If all sections have been exhausted, finish.
+                if (state.sectionIndex >= this.diff.content.length) {
+                  resolve();
+                  this._notify(state);
+                  return;
+                }
 
-            if (state.lineIndex % 100 === 0) {
-              this._notify(state);
-              this._processHandle = this.async(nextStep, ASYNC_DELAY);
-            } else {
-              nextStep.call(this);
-            }
-          };
+                if (state.lineIndex % 100 === 0) {
+                  this._notify(state);
+                  this._processHandle = this.async(nextStep, ASYNC_DELAY);
+                } else {
+                  nextStep.call(this);
+                }
+              };
 
-          this._processHandle = this.async(nextStep, 1);
-        });
-      });
+              this._processHandle = this.async(nextStep, 1);
+            });
+          }));
+      return this._processPromise
+          .finally(() => { this._processPromise = null; });
     },
 
     /**
      * Cancel any asynchronous syntax processing jobs.
      */
     cancel() {
-      if (this._processHandle) {
+      if (this._processHandle != null) {
         this.cancelAsync(this._processHandle);
         this._processHandle = null;
       }
+      if (this._processPromise) {
+        this._processPromise.cancel();
+      }
     },
 
     _diffChanged() {
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index b4ab21a..624992b 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -41,5 +41,39 @@
     }
     return '';
   };
+
+  /**
+   * Make the promise cancelable.
+   *
+   * Returns a promise with a `cancel()` method wrapped around `promise`.
+   * Calling `cancel()` will reject the returned promise with
+   * {isCancelled: true} synchronously. If the inner promise for a cancelled
+   * promise resolves or rejects this is ignored.
+   */
+  util.makeCancelable = promise => {
+    // True if the promise is either resolved or reject (possibly cancelled)
+    let isDone = false;
+
+    let rejectPromise;
+
+    const wrappedPromise = new Promise((resolve, reject) => {
+      rejectPromise = reject;
+      promise.then(val => {
+        if (!isDone) resolve(val);
+        isDone = true;
+      }, error => {
+        if (!isDone) reject(error);
+        isDone = true;
+      });
+    });
+
+    wrappedPromise.cancel = () => {
+      if (isDone) return;
+      rejectPromise({isCanceled: true});
+      isDone = true;
+    };
+    return wrappedPromise;
+  };
+
   window.util = util;
 })(window);