Merge "Fix events emitted by gr-diff-builder-element"
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 4322c64..430e770 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -340,6 +340,7 @@
   lineNum: LineNumber;
 }
 
+// TODO: Currently unused and not fired.
 export declare interface RenderProgressEventDetail {
   linesRendered: number;
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index d200c75..d2704ac 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -26,11 +26,7 @@
   GrRangedCommentLayer,
 } from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
-import {
-  DiffViewMode,
-  RenderPreferences,
-  RenderProgressEventDetail,
-} from '../../../api/diff';
+import {DiffViewMode, RenderPreferences} from '../../../api/diff';
 import {createDefaultDiffPrefs, Side} from '../../../constants/constants';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {
@@ -39,13 +35,9 @@
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
 import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
-import {
-  fireAlert,
-  fire,
-  HTMLElementEventDetailType,
-} from '../../../utils/event-util';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
 import {assertIsDefined} from '../../../utils/common-util';
-import {afterNextRender} from '../../../utils/dom-util';
+import {untilRendered} from '../../../utils/dom-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
@@ -59,11 +51,6 @@
      */
     'render-start': CustomEvent<{}>;
     /**
-     * Fired whenever a new chunk of lines has been rendered synchronously - this
-     * only happens for full renders.
-     */
-    'render-progress': CustomEvent<RenderProgressEventDetail>;
-    /**
      * Fired when the diff finishes rendering text content - both for full
      * renders and for partial rerenders.
      */
@@ -163,6 +150,16 @@
   // visible for testing
   processor = new GrDiffProcessor();
 
+  /**
+   * Groups are mostly just passed on to the diff builder (this.builder). But
+   * we also keep track of them here for being able to fire a `render-content`
+   * event when .element of each group has rendered.
+   *
+   * TODO: Refactor DiffBuilderElement and DiffBuilders with a cleaner
+   * separation of responsibilities.
+   */
+  private groups: GrDiffGroup[] = [];
+
   constructor() {
     this.processor.consumer = this;
   }
@@ -210,7 +207,7 @@
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-    this.fireDiffEvent('render-start', {});
+    this.fireDiffEvent('render-start');
     this.cancelableRenderPromise = makeCancelable(
       this.processor
         .process(this.diff.content, isBinary)
@@ -218,7 +215,10 @@
           if (this.isImageDiff) {
             (this.builder as GrDiffBuilderImage).renderDiff();
           }
-          afterNextRender(() => this.fireDiffEvent('render-content', {}));
+          return this.untilGroupsRendered();
+        })
+        .then(() => {
+          this.fireDiffEvent('render-content');
         })
         // Mocha testing does not like uncaught rejections, so we catch
         // the cancels which are expected and should not throw errors in
@@ -233,6 +233,18 @@
     );
   }
 
+  // visible for testing
+  async untilGroupsRendered(groups: readonly GrDiffGroup[] = this.groups) {
+    for (const g of groups) {
+      // The LOST or FILE lines may be hidden and thus never resolve an
+      // untilRendered() promise.
+      const lineNumber = g.lines?.[0]?.beforeNumber;
+      if (g.skip || lineNumber === 'LOST' || lineNumber === 'FILE') continue;
+      assertIsDefined(g.element);
+      await untilRendered(g.element);
+    }
+  }
+
   private onDiffContextExpanded = (
     e: CustomEvent<DiffContextExpandedEventDetail>
   ) => {
@@ -241,17 +253,9 @@
     this.replaceGroup(e.detail.contextGroup, e.detail.groups);
   };
 
-  private fireDiffEvent<K extends keyof HTMLElementEventMap>(
-    type: K,
-    detail: HTMLElementEventDetailType<K>
-  ) {
+  private fireDiffEvent<K extends keyof HTMLElementEventMap>(type: K) {
     assertIsDefined(this.diffElement, 'diff table');
-    fire(this.diffElement, type, detail);
-  }
-
-  private fireDiffEventRenderProgress(detail: RenderProgressEventDetail) {
-    assertIsDefined(this.diffElement, 'diff table');
-    fire(this.diffElement, 'render-progress', detail);
+    fireEvent(this.diffElement, type);
   }
 
   // visible for testing
@@ -364,15 +368,12 @@
     newGroups: readonly GrDiffGroup[]
   ) {
     if (!this.builder) return;
-    this.fireDiffEvent('render-start', {});
-    const linesRendered = newGroups.reduce(
-      (sum, group) => sum + group.lines.length,
-      0
-    );
+    this.fireDiffEvent('render-start');
     this.builder.replaceGroup(contextGroup, newGroups);
-    afterNextRender(() => {
-      this.fireDiffEvent('render-progress', {linesRendered});
-      this.fireDiffEvent('render-content', {});
+    this.groups = this.groups.filter(g => g !== contextGroup);
+    this.groups.push(...newGroups);
+    this.untilGroupsRendered(newGroups).then(() => {
+      this.fireDiffEvent('render-content');
     });
   }
 
@@ -464,6 +465,7 @@
    */
   clearGroups() {
     if (!this.builder) return;
+    this.groups = [];
     this.builder.clearGroups();
   }
 
@@ -473,9 +475,7 @@
   addGroup(group: GrDiffGroup) {
     if (!this.builder) return;
     this.builder.addGroups([group]);
-    afterNextRender(() =>
-      this.fireDiffEventRenderProgress({linesRendered: group.lines.length})
-    );
+    this.groups.push(group);
   }
 
   // visible for testing
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
index 8ae08f4..d37afc0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -10,11 +10,7 @@
   createEmptyDiff,
 } from '../../../test/test-data-generators';
 import './gr-diff-builder-element';
-import {
-  nextRender,
-  queryAndAssert,
-  stubBaseUrl,
-} from '../../../test/test-utils';
+import {queryAndAssert, stubBaseUrl, waitUntil} from '../../../test/test-utils';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
@@ -601,6 +597,7 @@
         addColumnsStub = sinon.stub(builder, 'addColumns');
         builder.buildSectionElement = function (group) {
           const section = document.createElement('stub');
+          section.style.display = 'block';
           section.textContent = group.lines.reduce(
             (acc, line) => acc + line.text,
             ''
@@ -639,12 +636,11 @@
     });
 
     test('render-start and render-content are fired', async () => {
-      await nextRender();
+      await waitUntil(() => dispatchStub.callCount >= 1);
       let firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
       assert.include(firedEventTypes, 'render-start');
-      assert.include(firedEventTypes, 'render-progress');
 
-      await nextRender();
+      await waitUntil(() => dispatchStub.callCount >= 2);
       firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
       assert.include(firedEventTypes, 'render-content');
     });
@@ -678,7 +674,7 @@
       };
       element.render(keyLocations);
       // Make sure all listeners are installed.
-      await nextRender();
+      await element.untilGroupsRendered();
     });
 
     test('hides lines behind two context controls', () => {
@@ -742,7 +738,7 @@
       assert.include(diffRows[8].textContent, 'after');
       assert.include(diffRows[9].textContent, 'unchanged 11');
 
-      await nextRender();
+      await element.untilGroupsRendered();
       const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
       assert.include(firedEventTypes, 'render-content');
     });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index e80d86b..e00353a 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -336,10 +336,6 @@
     this.preventAutoScrollOnManualScroll = true;
   };
 
-  private _boundHandleDiffRenderProgress = () => {
-    this._updateStops();
-  };
-
   private _boundHandleDiffRenderContent = () => {
     this._updateStops();
     // When done rendering, turn focus on move and automatic scrolling back on
@@ -551,10 +547,6 @@
     );
     diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
     diff.removeEventListener(
-      'render-progress',
-      this._boundHandleDiffRenderProgress
-    );
-    diff.removeEventListener(
       'render-content',
       this._boundHandleDiffRenderContent
     );
@@ -570,10 +562,6 @@
       this.boundHandleDiffLoadingChanged
     );
     diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
-    diff.addEventListener(
-      'render-progress',
-      this._boundHandleDiffRenderProgress
-    );
     diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
     diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 6f71c65..616a36f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -614,6 +614,7 @@
     const result = [];
     let lastChunkEndOffset = 0;
     for (const {offset, keyLocation} of chunkEnds) {
+      if (lastChunkEndOffset === offset) continue;
       result.push({
         lines: lines.slice(lastChunkEndOffset, offset),
         keyLocation,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index b8262e0..e015d49 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -17,6 +17,7 @@
 import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
 import {LineRange, Side} from '../../../api/diff';
 import {LineNumber} from './gr-diff-line';
+import {assertIsDefined, check} from '../../../utils/common-util';
 
 export enum GrDiffGroupType {
   /** Unchanged context. */
@@ -275,7 +276,9 @@
             },
           };
         } else {
-          for (const line of options.lines ?? []) {
+          assertIsDefined(options.lines);
+          check(options.lines.length > 0, 'diff group must have lines');
+          for (const line of options.lines) {
             this.addLine(line);
           }
         }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index 43a56d1..bbf03ae 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -46,6 +46,16 @@
     ]);
   });
 
+  test('group must have lines', () => {
+    try {
+      new GrDiffGroup({type: GrDiffGroupType.BOTH});
+    } catch (e) {
+      // expected
+      return;
+    }
+    assert.fail('a standard diff group cannot be empty');
+  });
+
   test('group/header line pairs', () => {
     const l1 = new GrDiffLine(GrDiffLineType.BOTH, 64, 128);
     const l2 = new GrDiffLine(GrDiffLineType.BOTH, 65, 129);
@@ -232,11 +242,6 @@
       assert.isTrue(group.isTotal());
     });
 
-    test('not total for empty', () => {
-      const group = new GrDiffGroup({type: GrDiffGroupType.BOTH});
-      assert.isFalse(group.isTotal());
-    });
-
     test('not total for non-delta', () => {
       const lines = [];
       for (let idx = 0; idx < 10; idx++) {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index 0eed83d..1422223 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -613,8 +613,7 @@
       threadEl.setAttribute('slot', 'right-1');
       const content = [
         {
-          a: [],
-          b: [],
+          a: ['asdf'],
         },
         {
           ab: Array(13).fill('text'),
@@ -624,7 +623,7 @@
       await waitForEventOnce(element, 'render');
 
       element.appendChild(threadEl);
-      await flush();
+      await waitForEventOnce(element, 'render');
 
       const hint = queryAndAssert<GrRangedCommentHint>(
         element,
@@ -651,21 +650,18 @@
       firstHint.setAttribute('slot', 'right-1');
       const content = [
         {
-          a: [],
-          b: [],
+          a: ['asdf'],
         },
         {
           ab: Array(13).fill('text'),
         },
       ];
       setupSampleDiff({content});
+      await waitForEventOnce(element, 'render');
 
       element.appendChild(firstHint);
-      await flush();
-      element._handleRenderContent();
-      await flush();
       element.appendChild(threadEl);
-      await flush();
+      await waitForEventOnce(element, 'render');
 
       assert.equal(
         element.querySelectorAll('gr-ranged-comment-hint').length,
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index ea7865e..985bec1 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -27,7 +27,7 @@
 import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
 import {queryAndAssert, query} from '../utils/common-util';
 import {FlagsService} from '../services/flags/flags';
-import {afterNextRender, Key, Modifier} from '../utils/dom-util';
+import {Key, Modifier} from '../utils/dom-util';
 import {Observable} from 'rxjs';
 import {filter, take, timeout} from 'rxjs/operators';
 import {HighlightService} from '../services/highlight/highlight-service';
@@ -224,10 +224,6 @@
   return waitUntil(() => stub.called, `${name} was not called`);
 }
 
-export async function nextRender() {
-  return new Promise(resolve => afterNextRender(resolve));
-}
-
 /**
  * Subscribes to the observable and resolves once it emits a matching value.
  * Usage:
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index f2e0994..be6aa06 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -491,8 +491,18 @@
   return false;
 }
 
+/** Returns a promise that waits for the element's height to become > 0. */
+export function untilRendered(el: HTMLElement) {
+  return new Promise(resolve => {
+    whenRendered(el, resolve);
+  });
+}
+
 /** Executes the given callback when the element's height is > 0. */
-export function whenRendered(el: HTMLElement, callback: () => void) {
+export function whenRendered(
+  el: HTMLElement,
+  callback: (value?: unknown) => void
+) {
   if (el.clientHeight > 0) {
     callback();
     return;
@@ -505,14 +515,3 @@
   });
   obs.observe(el);
 }
-
-/**
- * Mimics a Polymer utility. `requestAnimationFrame` is called before the next
- * browser paint. An additional `setTimeout` ensures that the paint has
- * actually happened.
- */
-export function afterNextRender(callback: (value?: unknown) => void) {
-  requestAnimationFrame(() => {
-    setTimeout(callback);
-  });
-}