Let the new syntax highlighting layer properly deal with cancellation
Release-Notes: skip
Google-Bug-Id: b/219684547
Change-Id: I7230475686d6c24801d851f9733ffa53bf35e36d
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 9d2a819..85f8ce0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -98,10 +98,8 @@
import {distinctUntilChanged, map} from 'rxjs/operators';
import {deepEqual} from '../../../utils/deep-util';
import {Category} from '../../../api/checks';
-import {
- GrSyntaxLayerWorker,
- CODE_MAX_LINES,
-} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
+import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
+import {CODE_MAX_LINES} from '../../../services/highlight/highlight-service';
const EMPTY_BLAME = 'No blame information for this diff.';
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index 59ce396..82dfb35 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -10,6 +10,7 @@
import {Side} from '../../../constants/constants';
import {getAppContext} from '../../../services/app-context';
import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
+import {CancelablePromise, util} from '../../../scripts/util';
const LANGUAGE_MAP = new Map<string, string>([
['application/dart', 'dart'],
@@ -125,29 +126,35 @@
'variable',
]);
-/**
- * Safe guard for not killing the browser.
- */
-export const CODE_MAX_LINES = 20 * 1000;
-
-/**
- * Safe guard for not killing the browser. Maximum in number of chars.
- */
-const CODE_MAX_LENGTH = 25 * CODE_MAX_LINES;
-
export class GrSyntaxLayerWorker implements DiffLayer {
diff?: DiffInfo;
enabled = true;
- private leftRanges: SyntaxLayerLine[] = [];
+ // private, but visible for testing
+ leftRanges: SyntaxLayerLine[] = [];
- private rightRanges: SyntaxLayerLine[] = [];
+ // private, but visible for testing
+ rightRanges: SyntaxLayerLine[] = [];
+
+ /**
+ * We are keeping a reference around to the async computation, such that we
+ * can cancel it, if needed.
+ */
+ private leftPromise?: CancelablePromise<SyntaxLayerLine[]>;
+
+ /**
+ * We are keeping a reference around to the async computation, such that we
+ * can cancel it, if needed.
+ */
+ private rightPromise?: CancelablePromise<SyntaxLayerLine[]>;
private listeners: DiffLayerListener[] = [];
private readonly highlightService = getAppContext().highlightService;
+ private readonly reportingService = getAppContext().reportingService;
+
setEnabled(enabled: boolean) {
this.enabled = enabled;
}
@@ -220,6 +227,10 @@
this.diff = diff;
this.leftRanges = [];
this.rightRanges = [];
+ if (this.leftPromise) this.leftPromise.cancel();
+ if (this.rightPromise) this.rightPromise.cancel();
+ this.leftPromise = undefined;
+ this.rightPromise = undefined;
if (!this.enabled || !this.diff) return;
const leftLanguage = this._getLanguage(this.diff.meta_a);
@@ -245,17 +256,26 @@
leftContent = leftContent.trimEnd();
rightContent = rightContent.trimEnd();
- const leftPromise = this.highlight(leftLanguage, leftContent);
- const rightPromise = this.highlight(rightLanguage, rightContent);
- this.leftRanges = await leftPromise;
- this.rightRanges = await rightPromise;
- this.notify();
+ try {
+ this.leftPromise = this.highlight(leftLanguage, leftContent);
+ this.rightPromise = this.highlight(rightLanguage, rightContent);
+ this.leftRanges = await this.leftPromise;
+ this.rightRanges = await this.rightPromise;
+ this.notify();
+ } catch (err) {
+ if (!err.isCanceled) this.reportingService.error(err);
+ // One source of "error" can promise cancelation.
+ this.leftRanges = [];
+ this.rightRanges = [];
+ }
}
- async highlight(language?: string, code?: string) {
- if (!language || !code) return [];
- if (code.length > CODE_MAX_LENGTH) return [];
- return this.highlightService.highlight(language, code);
+ highlight(
+ language?: string,
+ code?: string
+ ): CancelablePromise<SyntaxLayerLine[]> {
+ const hlPromise = this.highlightService.highlight(language, code);
+ return util.makeCancelable(hlPromise);
}
notify() {
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
index c9e70ad..ee82d5d 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
@@ -5,6 +5,7 @@
*/
import {DiffInfo, GrDiffLineType, Side} from '../../../api/diff';
import '../../../test/common-test-setup-karma';
+import {mockPromise, stubHighlightService} from '../../../test/test-utils';
import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
import {GrDiffLine} from '../gr-diff/gr-diff-line';
import {GrSyntaxLayerWorker} from './gr-syntax-layer-worker';
@@ -75,40 +76,78 @@
setup(() => {
layer = new GrSyntaxLayerWorker();
- listener = sinon.stub();
- layer.addListener(listener);
- sinon.stub(layer, 'highlight').callsFake((lang?: string) => {
- if (lang === 'lang-left') return Promise.resolve(leftRanges);
- if (lang === 'lang-right') return Promise.resolve(rightRanges);
- return Promise.resolve([]);
+ });
+
+ test('cancel processing', async () => {
+ const mockPromise1 = mockPromise<SyntaxLayerLine[]>();
+ const mockPromise2 = mockPromise<SyntaxLayerLine[]>();
+ const mockPromise3 = mockPromise<SyntaxLayerLine[]>();
+ const mockPromise4 = mockPromise<SyntaxLayerLine[]>();
+ const stub = stubHighlightService('highlight');
+ stub.onCall(0).returns(mockPromise1);
+ stub.onCall(1).returns(mockPromise2);
+ stub.onCall(2).returns(mockPromise3);
+ stub.onCall(3).returns(mockPromise4);
+
+ const processPromise1 = layer.process(diff);
+ // Calling the process() a second time means that the promises created
+ // during the first call are cancelled.
+ const processPromise2 = layer.process(diff);
+ // We can await the outer promise even before the inner promises resolve,
+ // because cancelling rejects the inner promises.
+ await processPromise1;
+ // It does not matter actually, whether these two inner promises are
+ // resolved or not.
+ mockPromise1.resolve(leftRanges);
+ mockPromise2.resolve(rightRanges);
+ // Both ranges must still be empty, because the promise of the first call
+ // must have been cancelled and the returned ranges ignored.
+ assert.isEmpty(layer.leftRanges);
+ assert.isEmpty(layer.rightRanges);
+ // Lets' resolve and await the promises of the second as normal.
+ mockPromise3.resolve(leftRanges);
+ mockPromise4.resolve(rightRanges);
+ await processPromise2;
+ assert.equal(layer.leftRanges, leftRanges);
+ });
+
+ suite('annotate and listen', () => {
+ setup(() => {
+ listener = sinon.stub();
+ layer.addListener(listener);
+ stubHighlightService('highlight').callsFake((lang?: string) => {
+ if (lang === 'lang-left') return Promise.resolve(leftRanges);
+ if (lang === 'lang-right') return Promise.resolve(rightRanges);
+ return Promise.resolve([]);
+ });
});
- });
- test('process and annotate line 2 LEFT', async () => {
- await layer.process(diff);
- const el = annotate(Side.LEFT, 1, 'import it;');
- assert.equal(
- el.innerHTML,
- '<hl class="gr-diff gr-syntax gr-syntax-literal">import</hl> it;'
- );
- assert.equal(listener.callCount, 2);
- assert.equal(listener.getCall(0).args[0], 1);
- assert.equal(listener.getCall(0).args[1], 1);
- assert.equal(listener.getCall(0).args[2], Side.LEFT);
- assert.equal(listener.getCall(1).args[0], 3);
- assert.equal(listener.getCall(1).args[1], 3);
- assert.equal(listener.getCall(1).args[2], Side.RIGHT);
- });
+ test('process and annotate line 2 LEFT', async () => {
+ await layer.process(diff);
+ const el = annotate(Side.LEFT, 1, 'import it;');
+ assert.equal(
+ el.innerHTML,
+ '<hl class="gr-diff gr-syntax gr-syntax-literal">import</hl> it;'
+ );
+ assert.equal(listener.callCount, 2);
+ assert.equal(listener.getCall(0).args[0], 1);
+ assert.equal(listener.getCall(0).args[1], 1);
+ assert.equal(listener.getCall(0).args[2], Side.LEFT);
+ assert.equal(listener.getCall(1).args[0], 3);
+ assert.equal(listener.getCall(1).args[1], 3);
+ assert.equal(listener.getCall(1).args[2], Side.RIGHT);
+ });
- test('process and annotate line 3 RIGHT', async () => {
- await layer.process(diff);
- const el = annotate(Side.RIGHT, 3, ' public static final {');
- assert.equal(
- el.innerHTML,
- ' <hl class="gr-diff gr-syntax gr-syntax-literal">public</hl> ' +
- '<hl class="gr-diff gr-syntax gr-syntax-keyword">static</hl> ' +
- '<hl class="gr-diff gr-syntax gr-syntax-name">final</hl> {'
- );
- assert.equal(listener.callCount, 2);
+ test('process and annotate line 3 RIGHT', async () => {
+ await layer.process(diff);
+ const el = annotate(Side.RIGHT, 3, ' public static final {');
+ assert.equal(
+ el.innerHTML,
+ ' <hl class="gr-diff gr-syntax gr-syntax-literal">public</hl> ' +
+ '<hl class="gr-diff gr-syntax gr-syntax-keyword">static</hl> ' +
+ '<hl class="gr-diff gr-syntax gr-syntax-name">final</hl> {'
+ );
+ assert.equal(listener.callCount, 2);
+ });
});
});
diff --git a/polygerrit-ui/app/services/highlight/highlight-service.ts b/polygerrit-ui/app/services/highlight/highlight-service.ts
index 3dd3cf1..80da260 100644
--- a/polygerrit-ui/app/services/highlight/highlight-service.ts
+++ b/polygerrit-ui/app/services/highlight/highlight-service.ts
@@ -30,6 +30,16 @@
const WORKER_POOL_SIZE = 3;
/**
+ * Safe guard for not killing the browser.
+ */
+export const CODE_MAX_LINES = 20 * 1000;
+
+/**
+ * Safe guard for not killing the browser. Maximum in number of chars.
+ */
+const CODE_MAX_LENGTH = 25 * CODE_MAX_LINES;
+
+/**
* Service for syntax highlighting. Maintains some HighlightJS workers doing
* their job in the background.
*/
@@ -115,7 +125,12 @@
if (resolver) resolver(result.ranges ?? []);
}
- async highlight(language: string, code: string): Promise<SyntaxLayerLine[]> {
+ async highlight(
+ language?: string,
+ code?: string
+ ): Promise<SyntaxLayerLine[]> {
+ if (!language || !code) return [];
+ if (code.length > CODE_MAX_LENGTH) return [];
const worker = await this.requestWorker();
const message: SyntaxWorkerRequest = {
type: SyntaxWorkerMessageType.REQUEST,
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index f0c8a84..0d9cf3c 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -30,6 +30,7 @@
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';
export {query, queryAll, queryAndAssert} from '../utils/common-util';
export interface MockPromise<T> extends Promise<T> {
@@ -121,6 +122,12 @@
return sinon.stub(getAppContext().shortcutsService, method);
}
+export function stubHighlightService<K extends keyof HighlightService>(
+ method: K
+) {
+ return sinon.stub(getAppContext().highlightService, method);
+}
+
export function stubStorage<K extends keyof StorageService>(method: K) {
return sinon.stub(getAppContext().storageService, method);
}