Commit 26ab92d3 authored by Lukas Eipert's avatar Lukas Eipert

Improve performance of rendering large reports

Instead of rendering all report items in 4 big lists, we make use of
vue-virtual-scroll-list and render only few dozens at once. This
improves the performance in several metrics:

- Initial load time
- Memory Pressure
- CPU Load
- DOM node count

In an example with around 11k reported security vulnerabilities:

- Initial load time: 27s -> 4.1s
- Memory Pressure: ~750 MB -> ~270 MB
- CPU Load (time spent on executing JS/Rendering): 22s -> 2.5s
- DOM node count: 430k -> 7k up to 30k while scrolling
parent 84f562e7
<script> <script>
import IssuesBlock from '~/reports/components/report_issues.vue'; import ReportItem from '~/reports/components/report_item.vue';
import { STATUS_SUCCESS, STATUS_FAILED, STATUS_NEUTRAL } from '~/reports/constants'; import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
const wrapIssueWithState = (status, isNew = false) => issue => ({
status: issue.status || status,
isNew,
issue,
});
/** /**
* Renders block of issues * Renders block of issues
*/ */
export default { export default {
components: { components: {
IssuesBlock, SmartVirtualList,
ReportItem,
}, },
success: STATUS_SUCCESS, // Typical height of a report item in px
failed: STATUS_FAILED, typicalReportItemHeight: 32,
neutral: STATUS_NEUTRAL, /*
The maximum amount of shown issues. This is calculated by
( max-height of report-block-list / typicalReportItemHeight ) + some safety margin
We will use VirtualList if we have more items than this number.
For entries lower than this number, the virtual scroll list calculates the total height of the element wrongly.
*/
maxShownReportItems: 20,
props: { props: {
newIssues: { newIssues: {
type: Array, type: Array,
...@@ -40,42 +53,34 @@ export default { ...@@ -40,42 +53,34 @@ export default {
default: '', default: '',
}, },
}, },
computed: {
issuesWithState() {
return [
...this.newIssues.map(wrapIssueWithState(STATUS_FAILED, true)),
...this.unresolvedIssues.map(wrapIssueWithState(STATUS_FAILED)),
...this.neutralIssues.map(wrapIssueWithState(STATUS_NEUTRAL)),
...this.resolvedIssues.map(wrapIssueWithState(STATUS_SUCCESS)),
];
},
},
}; };
</script> </script>
<template> <template>
<div class="report-block-container"> <smart-virtual-list
:length="issuesWithState.length"
<issues-block :remain="$options.maxShownReportItems"
v-if="newIssues.length" :size="$options.typicalReportItemHeight"
:component="component" class="report-block-container"
:issues="newIssues" wtag="ul"
class="js-mr-code-new-issues" wclass="report-block-list"
status="failed" >
is-new <report-item
/> v-for="(wrapped, index) in issuesWithState"
:key="index"
<issues-block :issue="wrapped.issue"
v-if="unresolvedIssues.length" :status="wrapped.status"
:component="component"
:issues="unresolvedIssues"
:status="$options.failed"
class="js-mr-code-new-issues"
/>
<issues-block
v-if="neutralIssues.length"
:component="component"
:issues="neutralIssues"
:status="$options.neutral"
class="js-mr-code-non-issues"
/>
<issues-block
v-if="resolvedIssues.length"
:component="component" :component="component"
:issues="resolvedIssues" :is-new="wrapped.isNew"
:status="$options.success"
class="js-mr-code-resolved-issues"
/> />
</div> </smart-virtual-list>
</template> </template>
...@@ -3,14 +3,14 @@ import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; ...@@ -3,14 +3,14 @@ import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
import { components, componentNames } from '~/reports/components/issue_body'; import { components, componentNames } from '~/reports/components/issue_body';
export default { export default {
name: 'ReportIssues', name: 'ReportItem',
components: { components: {
IssueStatusIcon, IssueStatusIcon,
...components, ...components,
}, },
props: { props: {
issues: { issue: {
type: Array, type: Object,
required: true, required: true,
}, },
component: { component: {
...@@ -33,16 +33,12 @@ export default { ...@@ -33,16 +33,12 @@ export default {
}; };
</script> </script>
<template> <template>
<div>
<ul class="report-block-list">
<li <li
v-for="(issue, index) in issues"
:key="index"
:class="{ 'is-dismissed': issue.isDismissed }" :class="{ 'is-dismissed': issue.isDismissed }"
class="report-block-list-issue" class="report-block-list-issue"
> >
<issue-status-icon <issue-status-icon
:status="issue.status || status" :status="status"
class="append-right-5" class="append-right-5"
/> />
...@@ -50,10 +46,8 @@ export default { ...@@ -50,10 +46,8 @@ export default {
:is="component" :is="component"
v-if="component" v-if="component"
:issue="issue" :issue="issue"
:status="issue.status || status" :status="status"
:is-new="isNew" :is-new="isNew"
/> />
</li> </li>
</ul>
</div>
</template> </template>
<script>
import VirtualList from 'vue-virtual-scroll-list';
export default {
name: 'SmartVirtualList',
components: { VirtualList },
props: {
size: { type: Number, required: true },
length: { type: Number, required: true },
remain: { type: Number, required: true },
rtag: { type: String, default: 'div' },
wtag: { type: String, default: 'div' },
wclass: { type: String, default: null },
},
};
</script>
<template>
<virtual-list
v-if="length > remain"
v-bind="$attrs"
:size="remain"
:remain="remain"
:rtag="rtag"
:wtag="wtag"
:wclass="wclass"
class="js-virtual-list"
>
<slot></slot>
</virtual-list>
<component
:is="rtag"
v-else
class="js-plain-element"
>
<component
:is="wtag"
:class="wclass"
>
<slot></slot>
</component>
</component>
</template>
---
title: Improve performance of rendering large reports
merge_request: 22835
author:
type: performance
...@@ -151,11 +151,11 @@ describe('Grouped Test Reports App', () => { ...@@ -151,11 +151,11 @@ describe('Grouped Test Reports App', () => {
it('renders resolved failures', done => { it('renders resolved failures', done => {
setTimeout(() => { setTimeout(() => {
expect(vm.$el.querySelector('.js-mr-code-resolved-issues').textContent).toContain( expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
resolvedFailures.suites[0].resolved_failures[0].name, resolvedFailures.suites[0].resolved_failures[0].name,
); );
expect(vm.$el.querySelector('.js-mr-code-resolved-issues').textContent).toContain( expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
resolvedFailures.suites[0].resolved_failures[1].name, resolvedFailures.suites[0].resolved_failures[1].name,
); );
done(); done();
......
...@@ -120,7 +120,7 @@ describe('Report section', () => { ...@@ -120,7 +120,7 @@ describe('Report section', () => {
'Code quality improved on 1 point and degraded on 1 point', 'Code quality improved on 1 point and degraded on 1 point',
); );
expect(vm.$el.querySelectorAll('.js-mr-code-resolved-issues li').length).toEqual( expect(vm.$el.querySelectorAll('.report-block-container li').length).toEqual(
resolvedIssues.length, resolvedIssues.length,
); );
}); });
......
import Vue from 'vue';
import SmartVirtualScrollList from '~/vue_shared/components/smart_virtual_list.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Toggle Button', () => {
let vm;
const createComponent = ({ length, remain }) => {
const smartListProperties = {
rtag: 'section',
wtag: 'ul',
wclass: 'test-class',
// Size in pixels does not matter for our tests here
size: 35,
length,
remain,
};
const Component = Vue.extend({
components: {
SmartVirtualScrollList,
},
smartListProperties,
items: Array(length).fill(1),
template: `
<smart-virtual-scroll-list v-bind="$options.smartListProperties">
<li v-for="(val, key) in $options.items" :key="key">{{ key + 1 }}</li>
</smart-virtual-scroll-list>`,
});
return mountComponent(Component);
};
afterEach(() => {
vm.$destroy();
});
describe('if the list is shorter than the maximum shown elements', () => {
const listLength = 10;
beforeEach(() => {
vm = createComponent({ length: listLength, remain: 20 });
});
it('renders without the vue-virtual-scroll-list component', () => {
expect(vm.$el.classList).not.toContain('js-virtual-list');
expect(vm.$el.classList).toContain('js-plain-element');
});
it('renders list with provided tags and classes for the wrapper elements', () => {
expect(vm.$el.tagName).toEqual('SECTION');
expect(vm.$el.firstChild.tagName).toEqual('UL');
expect(vm.$el.firstChild.classList).toContain('test-class');
});
it('renders all children list elements', () => {
expect(vm.$el.querySelectorAll('li').length).toEqual(listLength);
});
});
describe('if the list is longer than the maximum shown elements', () => {
const maxItemsShown = 20;
beforeEach(() => {
vm = createComponent({ length: 1000, remain: maxItemsShown });
});
it('uses the vue-virtual-scroll-list component', () => {
expect(vm.$el.classList).toContain('js-virtual-list');
expect(vm.$el.classList).not.toContain('js-plain-element');
});
it('renders list with provided tags and classes for the wrapper elements', () => {
expect(vm.$el.tagName).toEqual('SECTION');
expect(vm.$el.firstChild.tagName).toEqual('UL');
expect(vm.$el.firstChild.classList).toContain('test-class');
});
it('renders at max twice the maximum shown elements', () => {
expect(vm.$el.querySelectorAll('li').length).toBeLessThanOrEqual(2 * maxItemsShown);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment