Skip to content

Commit

Permalink
core(layout_shifts): new_audit
Browse files Browse the repository at this point in the history
  • Loading branch information
connorjclark committed Dec 19, 2023
1 parent f7ea338 commit 67845f5
Show file tree
Hide file tree
Showing 15 changed files with 2,303 additions and 313 deletions.
126 changes: 126 additions & 0 deletions core/audits/layout-shifts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* @license Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {Audit} from './audit.js';
import * as i18n from '../lib/i18n/i18n.js';
import {CumulativeLayoutShift as CumulativeLayoutShiftComputed} from '../computed/metrics/cumulative-layout-shift.js';
import CumulativeLayoutShift from './metrics/cumulative-layout-shift.js';
import TraceElements from '../gather/gatherers/trace-elements.js';

/** @typedef {LH.Audit.Details.TableItem & {node?: LH.Audit.Details.NodeValue, score: number, subItems?: {type: 'subitems', items: SubItem[]}}} Item */
/** @typedef {{node?: LH.Audit.Details.NodeValue, cause: LH.IcuMessage}} SubItem */

const UIStrings = {
/** Descriptive title of a diagnostic audit that provides the top elements affected by layout shifts. */
title: 'Avoid large layout shifts',
/** Description of a diagnostic audit that provides the top elements affected by layout shifts. "windowing" means the metric value is calculated using the subset of events in a small window of time during the run. "normalization" is a good substitute for "windowing". The last sentence starting with 'Learn' becomes link text to additional documentation. */
description: 'These are the largest layout shifts observed on the page. Some layout shifts may not be included in the CLS metric value due to [windowing](https://web.dev/articles/cls#what_is_cls). [Learn how to improve CLS](https://web.dev/articles/optimize-cls)',
/** Label for a column in a data table; entries in this column will be a number representing how large the layout shift was. */
columnScore: 'Layout shift score',
/** A possible reason why that the layout shift occured. */
rootCauseUnsizedMedia: 'The size of an element changed when CSS was applied',
};

const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);

class LayoutShifts extends Audit {
/**
* @return {LH.Audit.Meta}
*/
static get meta() {
return {
id: 'layout-shifts',
title: str_(UIStrings.title),
description: str_(UIStrings.description),
scoreDisplayMode: Audit.SCORING_MODES.METRIC_SAVINGS,
guidanceLevel: 2,
requiredArtifacts: ['traces', 'TraceElements'],
};
}

/**
* @param {LH.Artifacts} artifacts
* @param {LH.Audit.Context} context
* @return {Promise<LH.Audit.Product>}
*/
static async audit(artifacts, context) {
const trace = artifacts.traces[Audit.DEFAULT_PASS];
const clusters = trace.traceEngineResult?.data.LayoutShifts.clusters ?? [];
const {cumulativeLayoutShift: clsSavings, impactByNodeId} =
await CumulativeLayoutShiftComputed.request(trace, context);

/** @type {Item[]} */
const items = [];
const layoutShiftEvents = clusters.flatMap(c => c.events);
for (const event of layoutShiftEvents) {
const biggestImpactNodeId = TraceElements.getBiggestImpactNodeForShiftEvent(
event.args.data.impacted_nodes, impactByNodeId);
const biggestImpactElement = artifacts.TraceElements.find(
t => t.traceEventType === 'layout-shift' && t.nodeId === biggestImpactNodeId);

// Turn root causes into sub-items.
const index = layoutShiftEvents.indexOf(event);
const rootCauses = trace.traceEngineResult?.rootCauses.layoutShifts[index];
/** @type {SubItem[]} */
const subItems = [];
if (rootCauses) {
// TODO ! finish these
for (const cause of rootCauses.fontChanges) {
// subItems.push({cause: JSON.stringify(cause)});
}
for (const cause of rootCauses.iframes) {
// subItems.push({cause: JSON.stringify(cause)});
}
for (const cause of rootCauses.renderBlockingRequests) {
// subItems.push({cause: JSON.stringify(cause)});
}
for (const cause of rootCauses.unsizedMedia) {
const unsizedMediaElement = artifacts.TraceElements.find(
t => t.traceEventType === 'layout-shift' && t.nodeId === cause.node.backendNodeId);
subItems.push({
node: unsizedMediaElement ? Audit.makeNodeItem(unsizedMediaElement.node) : undefined,
cause: str_(UIStrings.rootCauseUnsizedMedia),
});
}
}

items.push({
node: biggestImpactElement ? Audit.makeNodeItem(biggestImpactElement.node) : undefined,
score: event.args.data.weighted_score_delta,
subItems: subItems.length ? {type: 'subitems', items: subItems} : undefined,
});
}

Check warning on line 94 in core/audits/layout-shifts.js

View check run for this annotation

Codecov / codecov/patch

core/audits/layout-shifts.js#L58-L94

Added lines #L58 - L94 were not covered by tests

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

const details = Audit.makeTableDetails(headings, items);

let displayValue;
if (items.length > 0) {
displayValue = str_(i18n.UIStrings.displayValueElementsFound,
{nodeCount: items.length});
}

Check warning on line 108 in core/audits/layout-shifts.js

View check run for this annotation

Codecov / codecov/patch

core/audits/layout-shifts.js#L106-L108

Added lines #L106 - L108 were not covered by tests

const passed = clsSavings <= CumulativeLayoutShift.defaultOptions.p10;

return {
score: passed ? 1 : 0,
scoreDisplayMode: passed ? Audit.SCORING_MODES.INFORMATIVE : undefined,
metricSavings: {
CLS: clsSavings,
},
notApplicable: details.items.length === 0,
displayValue,
details,
};
}
}

export default LayoutShifts;
export {UIStrings};
2 changes: 2 additions & 0 deletions core/config/default-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ const defaultConfig = {
'largest-contentful-paint-element',
'lcp-lazy-loaded',
'layout-shift-elements',
'layout-shifts',
'long-tasks',
'no-unload-listeners',
'non-composited-animations',
Expand Down Expand Up @@ -477,6 +478,7 @@ const defaultConfig = {
{id: 'largest-contentful-paint-element', weight: 0},
{id: 'lcp-lazy-loaded', weight: 0},
{id: 'layout-shift-elements', weight: 0},
{id: 'layout-shifts', weight: 0},
{id: 'uses-passive-event-listeners', weight: 0},
{id: 'no-document-write', weight: 0},
{id: 'long-tasks', weight: 0},
Expand Down
1 change: 1 addition & 0 deletions core/config/metrics-to-audits.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const tbtRelevantAudits = [

const clsRelevantAudits = [
'layout-shift-elements',
'layout-shifts',
'non-composited-animations',
'unsized-images',
// 'preload-fonts', // actually in BP, rather than perf
Expand Down
79 changes: 75 additions & 4 deletions core/gather/gatherers/trace-elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {ExecutionContext} from '../driver/execution-context.js';
/** @typedef {{nodeId: number, animations?: {name?: string, failureReasonsMask?: number, unsupportedProperties?: string[]}[], type?: string}} TraceElementData */

const MAX_LAYOUT_SHIFT_ELEMENTS = 15;
const MAX_LAYOUT_SHIFTS = 15;

/**
* @this {HTMLElement}
Expand Down Expand Up @@ -64,19 +65,89 @@ class TraceElements extends BaseGatherer {
}

/**
* This function finds the top (up to 15) elements that contribute to the CLS score of the page.
* This function finds the top (up to 15) elements that shift on the page.
*
* @param {LH.Trace} trace
* @param {LH.Gatherer.Context} context
* @return {Promise<Array<TraceElementData>>}
* @return {Promise<Array<number>>}
*/
static async getTopLayoutShiftElements(trace, context) {
const {impactByNodeId} = await CumulativeLayoutShift.request(trace, context);

return [...impactByNodeId.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_LAYOUT_SHIFT_ELEMENTS)
.map(([nodeId]) => ({nodeId}));
.map(([nodeId]) => nodeId);
}

/**
* We want to a single representative node to represent the shift, so let's pick
* the one with the largest impact (size x distance moved).
*
* @param {LH.Artifacts.TraceImpactedNode[]} impactedNodes
* @param {Map<number, number>} impactByNodeId
* @return {number|undefined}
*/
static getBiggestImpactNodeForShiftEvent(impactedNodes, impactByNodeId) {
let biggestImpactNodeId;
let biggestImpactNodeScore = Number.NEGATIVE_INFINITY;
for (const node of impactedNodes) {
const impactScore = impactByNodeId.get(node.node_id);
if (impactScore !== undefined && impactScore > biggestImpactNodeScore) {
biggestImpactNodeId = node.node_id;
biggestImpactNodeScore = impactScore;
}
}
return biggestImpactNodeId;
}

Check warning on line 102 in core/gather/gatherers/trace-elements.js

View check run for this annotation

Codecov / codecov/patch

core/gather/gatherers/trace-elements.js#L92-L102

Added lines #L92 - L102 were not covered by tests

/**
* This function finds the top (up to 15) layout shifts on the page, and returns
* the id of the largest impacted node of each shift, along with any related nodes
* that may have caused the shift.
*
* @param {LH.Trace} trace
* @param {LH.Gatherer.Context} context
* @return {Promise<Array<number>>}
*/
static async getTopLayoutShifts(trace, context) {
const {impactByNodeId} = await CumulativeLayoutShift.request(trace, context);
const clusters = trace.traceEngineResult?.data.LayoutShifts.clusters ?? [];
const layoutShiftEvents = clusters.flatMap(c => c.events);

return layoutShiftEvents
.sort((a, b) => b.args.data.weighted_score_delta - a.args.data.weighted_score_delta)
.slice(0, MAX_LAYOUT_SHIFTS)
.flatMap(event => {
const nodeIds = [];
const biggestImpactedNodeId =
this.getBiggestImpactNodeForShiftEvent(event.args.data.impacted_nodes, impactByNodeId);
if (biggestImpactedNodeId !== undefined) {
nodeIds.push(biggestImpactedNodeId);
}

const index = layoutShiftEvents.indexOf(event);
const rootCauses = trace.traceEngineResult?.rootCauses.layoutShifts[index];
if (rootCauses) {
for (const cause of rootCauses.unsizedMedia) {
nodeIds.push(cause.node.backendNodeId);
}
}

return nodeIds;

Check warning on line 137 in core/gather/gatherers/trace-elements.js

View check run for this annotation

Codecov / codecov/patch

core/gather/gatherers/trace-elements.js#L122-L137

Added lines #L122 - L137 were not covered by tests
});
}

/**
* @param {LH.Trace} trace
* @param {LH.Gatherer.Context} context
* @return {Promise<Array<TraceElementData>>}
*/
static async getTopLayoutShiftsNodeIds(trace, context) {
const resultOne = await this.getTopLayoutShiftElements(trace, context);
const resultTwo = await this.getTopLayoutShifts(trace, context);
const unique = [...new Set([...resultOne, ...resultTwo])];
return unique.map(nodeId => ({nodeId}));
}

/**
Expand Down Expand Up @@ -210,7 +281,7 @@ class TraceElements extends BaseGatherer {
const {mainThreadEvents} = processedTrace;

const lcpNodeData = await TraceElements.getLcpElement(trace, context);
const clsNodeData = await TraceElements.getTopLayoutShiftElements(trace, context);
const clsNodeData = await TraceElements.getTopLayoutShiftsNodeIds(trace, context);
const animatedElementData = await this.getAnimatedElements(mainThreadEvents);
const responsivenessElementData = await TraceElements.getResponsivenessElement(trace, context);

Expand Down
Loading

0 comments on commit 67845f5

Please sign in to comment.