Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core(layout-shift-elements): surface CLS contribution per shifted element #10968

Merged
merged 10 commits into from
Jun 24, 2020
Merged
5 changes: 5 additions & 0 deletions lighthouse-core/audits/layout-shift-elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const UIStrings = {
=1 {1 element found}
other {# elements found}
}`,
/** Label for a column in a data table; entries in this column will be the amount that the corresponding element contributes to the total CLS metric score. */
columnContribution: 'CLS Contribution',
};

const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
Expand Down Expand Up @@ -53,12 +55,15 @@ class LayoutShiftElements extends Audit {
nodeLabel: element.nodeLabel,
snippet: element.snippet,
}),
score: element.score,
};
});

/** @type {LH.Audit.Details.Table['headings']} */
const headings = [
{key: 'node', itemType: 'node', text: str_(i18n.UIStrings.columnElement)},
{key: 'score', itemType: 'numeric',
granularity: 0.001, text: str_(UIStrings.columnContribution)},
];

const details = Audit.makeTableDetails(headings, clsElementData);
Expand Down
68 changes: 44 additions & 24 deletions lighthouse-core/gather/gatherers/trace-elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const pageFunctions = require('../../lib/page-functions.js');
const TraceProcessor = require('../../lib/tracehouse/trace-processor.js');
const RectHelpers = require('../../lib/rect-helpers.js');

/** @typedef {{nodeId: number, score?: number}} TraceElementData */

/**
* @this {HTMLElement}
* @param {string} metricName
Expand Down Expand Up @@ -66,23 +68,31 @@ class TraceElements extends Gatherer {
}

/**
* This function finds the top (up to 5) elements that contribute to the CLS score of the page.
* Each layout shift event has a 'score' which is the amount added to the CLS as a result of the given shift(s).
* We calculate the score per element by taking the 'score' of each layout shift event and
* distributing it between all the nodes that were shifted, proportianal to the impact region of
* each shifted element.
* @param {Array<LH.TraceEvent>} mainThreadEvents
* @return {Array<number>}
* @return {Array<TraceElementData>}
*/
static getCLSNodeIdsFromMainThreadEvents(mainThreadEvents) {
const clsPerNodeMap = new Map();
/** @type {Set<number>} */
const clsNodeIds = new Set();
static getTopLayoutShiftElements(mainThreadEvents) {
connorjclark marked this conversation as resolved.
Show resolved Hide resolved
/** @type {Map<number, number>} */
const clsPerNode = new Map();
connorjclark marked this conversation as resolved.
Show resolved Hide resolved
const shiftEvents = mainThreadEvents
.filter(e => e.name === 'LayoutShift')
.map(e => e.args && e.args.data);

shiftEvents.forEach(event => {
if (!event) {
if (!event || !event.impacted_nodes || !event.score || event.had_recent_input) {
return;
}

event.impacted_nodes && event.impacted_nodes.forEach(node => {
let totalAreaOfImpact = 0;
/** @type {Map<number, number>} */
const pixelsMovedPerNode = new Map();
connorjclark marked this conversation as resolved.
Show resolved Hide resolved

event.impacted_nodes.forEach(node => {
if (!node.node_id || !node.old_rect || !node.new_rect) {
return;
}
Expand All @@ -93,18 +103,26 @@ class TraceElements extends Gatherer {
RectHelpers.getRectArea(newRect) -
RectHelpers.getRectOverlapArea(oldRect, newRect);

let prevShiftTotal = 0;
if (clsPerNodeMap.has(node.node_id)) {
prevShiftTotal += clsPerNodeMap.get(node.node_id);
}
clsPerNodeMap.set(node.node_id, prevShiftTotal + areaOfImpact);
clsNodeIds.add(node.node_id);
pixelsMovedPerNode.set(node.node_id, areaOfImpact);
totalAreaOfImpact += areaOfImpact;
});

for (const [nodeId, pixelsMoved] of pixelsMovedPerNode.entries()) {
let clsContribution = clsPerNode.get(nodeId) || 0;
clsContribution += (pixelsMoved / totalAreaOfImpact) * event.score;
clsPerNode.set(nodeId, clsContribution);
}
});

const topFive = [...clsPerNodeMap.entries()]
const topFive = [...clsPerNode.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5).map(entry => Number(entry[0]));
.slice(0, 5)
.map(([nodeId, clsContribution]) => {
return {
nodeId: nodeId,
score: clsContribution,
};
});

return topFive;
}
Expand All @@ -119,23 +137,25 @@ class TraceElements extends Gatherer {
if (!loadData.trace) {
throw new Error('Trace is missing!');
}

const {largestContentfulPaintEvt, mainThreadEvents} =
TraceProcessor.computeTraceOfTab(loadData.trace);
/** @type {Array<number>} */
const backendNodeIds = [];
/** @type {Array<TraceElementData>} */
const backendNodeData = [];

const lcpNodeId = TraceElements.getNodeIDFromTraceEvent(largestContentfulPaintEvt);
const clsNodeIds = TraceElements.getCLSNodeIdsFromMainThreadEvents(mainThreadEvents);
const clsNodeData = TraceElements.getTopLayoutShiftElements(mainThreadEvents);
if (lcpNodeId) {
backendNodeIds.push(lcpNodeId);
backendNodeData.push({nodeId: lcpNodeId});
}
backendNodeIds.push(...clsNodeIds);
backendNodeData.push(...clsNodeData);

const traceElements = [];
for (let i = 0; i < backendNodeIds.length; i++) {
for (let i = 0; i < backendNodeData.length; i++) {
const backendNodeId = backendNodeData[i].nodeId;
const metricName =
lcpNodeId === backendNodeIds[i] ? 'largest-contentful-paint' : 'cumulative-layout-shift';
const objectId = await driver.resolveNodeIdToObjectId(backendNodeIds[i]);
lcpNodeId === backendNodeId ? 'largest-contentful-paint' : 'cumulative-layout-shift';
const objectId = await driver.resolveNodeIdToObjectId(backendNodeId);
if (!objectId) continue;
const response = await driver.sendCommand('Runtime.callFunctionOn', {
objectId,
Expand All @@ -152,7 +172,7 @@ class TraceElements extends Gatherer {
});

if (response && response.result && response.result.value) {
traceElements.push(response.result.value);
traceElements.push({...response.result.value, score: backendNodeData[i].score});
}
}

Expand Down
3 changes: 3 additions & 0 deletions lighthouse-core/lib/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,9 @@
"lighthouse-core/audits/largest-contentful-paint-element.js | title": {
"message": "Largest Contentful Paint element"
},
"lighthouse-core/audits/layout-shift-elements.js | columnContribution": {
"message": "CLS Contribution"
},
"lighthouse-core/audits/layout-shift-elements.js | description": {
"message": "These DOM elements contribute most to the CLS of the page."
},
Expand Down
3 changes: 3 additions & 0 deletions lighthouse-core/lib/i18n/locales/en-XL.json
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,9 @@
"lighthouse-core/audits/largest-contentful-paint-element.js | title": {
"message": "L̂ár̂ǵêśt̂ Ćôńt̂én̂t́f̂úl̂ Ṕâín̂t́ êĺêḿêńt̂"
},
"lighthouse-core/audits/layout-shift-elements.js | columnContribution": {
"message": "ĈĹŜ Ćôńt̂ŕîb́ût́îón̂"
},
"lighthouse-core/audits/layout-shift-elements.js | description": {
"message": "T̂h́êśê D́ÔḾ êĺêḿêńt̂ś ĉón̂t́r̂íb̂út̂é m̂óŝt́ t̂ó t̂h́ê ĆL̂Ś ôf́ t̂h́ê ṕâǵê."
},
Expand Down
13 changes: 6 additions & 7 deletions lighthouse-core/report/html/renderer/details-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,13 @@ class DetailsRenderer {
}

/**
* @param {string} text
* @param {{value: number, granularity?: number}} details
* @return {Element}
*/
_renderNumeric(text) {
// TODO: this should probably accept a number and call `formatNumber` instead of being identical
// to _renderText.
_renderNumeric(details) {
const value = Util.i18n.formatNumber(details.value, details.granularity);
const element = this._dom.createElement('div', 'lh-numeric');
element.textContent = text;
element.textContent = value;
return element;
}

Expand Down Expand Up @@ -265,8 +264,8 @@ class DetailsRenderer {
return this._renderMilliseconds(msValue);
}
case 'numeric': {
const strValue = String(value);
return this._renderNumeric(strValue);
const numValue = Number(value);
return this._renderNumeric({value: numValue, granularity: heading.granularity});
}
case 'text': {
const strValue = String(value);
Expand Down
2 changes: 2 additions & 0 deletions lighthouse-core/test/audits/layout-shift-elements-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe('Performance: layout-shift-elements audit', () => {
selector: 'div.l-header > div.chorus-emc__content',
nodeLabel: 'My Test Label',
snippet: '<h1 class="test-class">',
score: 0.3,
}],
};

Expand All @@ -28,6 +29,7 @@ describe('Performance: layout-shift-elements audit', () => {
expect(auditResult.details.items).toHaveLength(1);
expect(auditResult.details.items[0]).toHaveProperty('node');
expect(auditResult.details.items[0].node).toHaveProperty('type', 'node');
expect(auditResult.details.items[0].score).toEqual(0.3);
});

it('correctly surfaces multiple CLS elements', async () => {
Expand Down
159 changes: 159 additions & 0 deletions lighthouse-core/test/gather/gatherers/trace-elements-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
* 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.
*/
'use strict';

/* eslint-env jest */

const TraceElementsGatherer = require('../../../gather/gatherers/trace-elements.js');

describe('Trace Elements gatherer - GetTopLayoutShiftElements', () => {
function makeTraceEvent(score, impactedNodes) {
return {
name: 'LayoutShift',
cat: 'loading',
ph: 'I',
pid: 4998,
tid: 775,
ts: 308559814315,
args: {
data: {
had_recent_input: false,
impacted_nodes: impactedNodes,
score: score,
},
frame: '3C4CBF06AF1ED5B9EAA59BECA70111F4',
},
};
}

/**
* @param {Array<{nodeId: number, score: number}>} shiftScores
*/
function sumScores(shiftScores) {
let sum = 0;
shiftScores.forEach(shift => sum += shift.score);
return sum;
}

function expectEqualFloat(actual, expected) {
const diff = Math.abs(actual - expected);
expect(diff).toBeLessThanOrEqual(Number.EPSILON);
}

it('returns layout shift data sorted by impact area', () => {
const traceEvents = [
makeTraceEvent(1, [
{
new_rect: [0, 0, 200, 200],
node_id: 60,
old_rect: [0, 0, 200, 100],
},
{
new_rect: [0, 300, 200, 200],
node_id: 25,
old_rect: [0, 100, 200, 100],
},
]),
];

const result = TraceElementsGatherer.getTopLayoutShiftElements(traceEvents);
expect(result).toEqual([
{nodeId: 25, score: 0.6},
{nodeId: 60, score: 0.4},
]);
const total = sumScores(result);
expectEqualFloat(total, 1.0);
});

it('combines scores for the same nodeId accross multiple shift events', () => {
const traceEvents = [
makeTraceEvent(1, [
{
new_rect: [0, 0, 200, 200],
node_id: 60,
old_rect: [0, 0, 200, 100],
},
{
new_rect: [0, 300, 200, 200],
node_id: 25,
old_rect: [0, 100, 200, 100],
},
]),
makeTraceEvent(0.3, [
{
new_rect: [0, 100, 200, 200],
node_id: 60,
old_rect: [0, 0, 200, 200],
},
]),
];

const result = TraceElementsGatherer.getTopLayoutShiftElements(traceEvents);
expect(result).toEqual([
{nodeId: 60, score: 0.7},
{nodeId: 25, score: 0.6},
connorjclark marked this conversation as resolved.
Show resolved Hide resolved
]);
const total = sumScores(result);
expectEqualFloat(total, 1.3);
});

it('returns only the top five values', () => {
const traceEvents = [
makeTraceEvent(1, [
{
new_rect: [0, 100, 100, 100],
node_id: 1,
old_rect: [0, 0, 100, 100],
},
{
new_rect: [0, 200, 100, 100],
node_id: 2,
old_rect: [0, 100, 100, 100],
},
]),
makeTraceEvent(1, [
{
new_rect: [0, 100, 200, 200],
node_id: 3,
old_rect: [0, 100, 200, 200],
},
]),
makeTraceEvent(0.75, [
{
new_rect: [0, 0, 100, 50],
node_id: 4,
old_rect: [0, 0, 100, 100],
},
{
new_rect: [0, 0, 100, 50],
node_id: 5,
old_rect: [0, 0, 100, 100],
},
{
new_rect: [0, 0, 100, 200],
node_id: 6,
old_rect: [0, 0, 100, 100],
},
{
new_rect: [0, 0, 100, 200],
node_id: 7,
old_rect: [0, 0, 100, 100],
},
]),
];

const result = TraceElementsGatherer.getTopLayoutShiftElements(traceEvents);
expect(result).toEqual([
{nodeId: 3, score: 1.0},
{nodeId: 1, score: 0.5},
{nodeId: 2, score: 0.5},
{nodeId: 6, score: 0.25},
{nodeId: 7, score: 0.25},
]);
const total = sumScores(result);
expectEqualFloat(total, 2.5);
});
});
9 changes: 9 additions & 0 deletions lighthouse-core/test/results/sample_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -1754,6 +1754,12 @@
"key": "node",
"itemType": "node",
"text": "Element"
},
{
"key": "score",
"itemType": "numeric",
"granularity": 0.001,
"text": "CLS Contribution"
}
],
"items": [
Expand Down Expand Up @@ -6463,6 +6469,9 @@
"path": "audits[layout-shift-elements].displayValue"
}
],
"lighthouse-core/audits/layout-shift-elements.js | columnContribution": [
"audits[layout-shift-elements].details.headings[1].text"
],
"lighthouse-core/audits/long-tasks.js | title": [
"audits[long-tasks].title"
],
Expand Down
Loading