Skip to content

Commit 287142b

Browse files
Adriana IxbaDevtools-frontend LUCI CQ
authored andcommitted
[RPP] Include unsized images to CLS culprits
https://screenshot.googleplex.com/54UR4P4ZKj8NZMm Bug:373488475 Change-Id: I9b70d65518fea2ad2dc01ef173adef8e66ed7a0d Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/5933343 Commit-Queue: Adriana Ixba <[email protected]> Reviewed-by: Paul Irish <[email protected]>
1 parent 5da583c commit 287142b

File tree

10 files changed

+157
-23
lines changed

10 files changed

+157
-23
lines changed

front_end/models/trace/handlers/LayoutShiftsHandler.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,13 @@ interface LayoutShifts {
4646
// We use these to calculate root causes for a given LayoutShift
4747
// TODO(crbug/41484172): should be readonly
4848
prePaintEvents: Types.Events.PrePaint[];
49+
paintImageEvents: Types.Events.PaintImage[];
4950
layoutInvalidationEvents: readonly Types.Events.LayoutInvalidationTracking[];
5051
scheduleStyleInvalidationEvents: readonly Types.Events.ScheduleStyleInvalidationTracking[];
5152
styleRecalcInvalidationEvents: readonly Types.Events.StyleRecalcInvalidationTracking[];
5253
renderFrameImplCreateChildFrameEvents: readonly Types.Events.RenderFrameImplCreateChildFrame[];
5354
domLoadingEvents: readonly Types.Events.DomLoading[];
55+
layoutImageUnsizedEvents: readonly Types.Events.LayoutImageUnsized[];
5456
beginRemoteFontLoadEvents: readonly Types.Events.BeginRemoteFontLoad[];
5557
scoreRecords: readonly ScoreRecord[];
5658
// TODO(crbug/41484172): should be readonly
@@ -81,6 +83,7 @@ const scheduleStyleInvalidationEvents: Types.Events.ScheduleStyleInvalidationTra
8183
const styleRecalcInvalidationEvents: Types.Events.StyleRecalcInvalidationTracking[] = [];
8284
const renderFrameImplCreateChildFrameEvents: Types.Events.RenderFrameImplCreateChildFrame[] = [];
8385
const domLoadingEvents: Types.Events.DomLoading[] = [];
86+
const layoutImageUnsizedEvents: Types.Events.LayoutImageUnsized[] = [];
8487
const beginRemoteFontLoadEvents: Types.Events.BeginRemoteFontLoad[] = [];
8588

8689
const backendNodeIds = new Set<Protocol.DOM.BackendNodeId>();
@@ -91,6 +94,8 @@ const backendNodeIds = new Set<Protocol.DOM.BackendNodeId>();
9194
// node of such shift.
9295
const prePaintEvents: Types.Events.PrePaint[] = [];
9396

97+
const paintImageEvents: Types.Events.PaintImage[] = [];
98+
9499
let sessionMaxScore = 0;
95100

96101
let clsWindowID = -1;
@@ -125,7 +130,9 @@ export function reset(): void {
125130
scheduleStyleInvalidationEvents.length = 0;
126131
styleRecalcInvalidationEvents.length = 0;
127132
prePaintEvents.length = 0;
133+
paintImageEvents.length = 0;
128134
renderFrameImplCreateChildFrameEvents.length = 0;
135+
layoutImageUnsizedEvents.length = 0;
129136
domLoadingEvents.length = 0;
130137
beginRemoteFontLoadEvents.length = 0;
131138
backendNodeIds.clear();
@@ -165,9 +172,15 @@ export function handleEvent(event: Types.Events.Event): void {
165172
if (Types.Events.isDomLoading(event)) {
166173
domLoadingEvents.push(event);
167174
}
175+
if (Types.Events.isLayoutImageUnsized(event)) {
176+
layoutImageUnsizedEvents.push(event);
177+
}
168178
if (Types.Events.isBeginRemoteFontLoad(event)) {
169179
beginRemoteFontLoadEvents.push(event);
170180
}
181+
if (Types.Events.isPaintImage(event)) {
182+
paintImageEvents.push(event);
183+
}
171184
}
172185

173186
function traceWindowFromTime(time: Types.Timing.MicroSeconds): Types.Timing.TraceWindowMicroSeconds {
@@ -251,7 +264,9 @@ export async function finalize(): Promise<void> {
251264
layoutInvalidationEvents.sort((a, b) => a.ts - b.ts);
252265
renderFrameImplCreateChildFrameEvents.sort((a, b) => a.ts - b.ts);
253266
domLoadingEvents.sort((a, b) => a.ts - b.ts);
267+
layoutImageUnsizedEvents.sort((a, b) => a.ts - b.ts);
254268
beginRemoteFontLoadEvents.sort((a, b) => a.ts - b.ts);
269+
paintImageEvents.sort((a, b) => a.ts - b.ts);
255270

256271
// Each function transforms the data used by the next, as such the invoke order
257272
// is important.
@@ -508,11 +523,13 @@ export function data(): LayoutShifts {
508523
styleRecalcInvalidationEvents: [],
509524
renderFrameImplCreateChildFrameEvents,
510525
domLoadingEvents,
526+
layoutImageUnsizedEvents,
511527
beginRemoteFontLoadEvents,
512528
scoreRecords,
513529
// TODO(crbug/41484172): change the type so no need to clone
514530
backendNodeIds: [...backendNodeIds],
515531
clustersByNavigationId: new Map(clustersByNavigationId),
532+
paintImageEvents,
516533
};
517534
}
518535

front_end/models/trace/insights/CumulativeLayoutShift.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,28 @@ describeWithEnvironment('CumulativeLayoutShift', function() {
189189
assert.isOk(shift3);
190190
assert.isEmpty(shift3[1].fontRequests);
191191
});
192+
193+
it('handles potential unsized images root cause correctly', async function() {
194+
const {data, insights} = await processTrace(this, 'unsized-images.json.gz');
195+
const firstNav = getFirstOrError(data.Meta.navigationsByNavigationId.values());
196+
const insight = getInsightOrError('CumulativeLayoutShift', insights, firstNav);
197+
const {shifts} = insight;
198+
assert.exists(shifts);
199+
assert.strictEqual(shifts.size, 2);
200+
201+
const unsizedImages = data.LayoutShifts.layoutImageUnsizedEvents;
202+
assert.strictEqual(unsizedImages.length, 2);
203+
204+
const layoutShiftEvents = Array.from(shifts.entries());
205+
const shift1 = layoutShiftEvents.at(0);
206+
assert.isOk(shift1);
207+
// Root cause should match the nodeId of the unsized images events.
208+
assert.strictEqual(shift1[1].unsizedImages[0], unsizedImages[0].args.data.nodeId);
209+
210+
const shift2 = layoutShiftEvents.at(1);
211+
assert.isOk(shift2);
212+
assert.strictEqual(shift2[1].unsizedImages[0], unsizedImages[1].args.data.nodeId);
213+
});
192214
});
193215
});
194216
describe('clusters', function() {

front_end/models/trace/insights/CumulativeLayoutShift.ts

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// found in the LICENSE file.
44

55
import * as Platform from '../../../core/platform/platform.js';
6+
import type * as Protocol from '../../../generated/protocol.js';
67
import * as Helpers from '../helpers/helpers.js';
78
import * as Types from '../types/types.js';
89

@@ -146,17 +147,23 @@ const ACTIONABLE_FAILURE_REASONS = [
146147

147148
// 500ms window.
148149
// Use this window to consider events and requests that may have caused a layout shift.
149-
const INVALIDATION_WINDOW = Helpers.Timing.secondsToMicroseconds(Types.Timing.Seconds(0.5));
150+
const ROOT_CAUSE_WINDOW = Helpers.Timing.secondsToMicroseconds(Types.Timing.Seconds(0.5));
150151

151152
export interface LayoutShiftRootCausesData {
152153
iframeIds: string[];
153154
fontRequests: Types.Events.SyntheticNetworkRequest[];
154155
nonCompositedAnimations: NoncompositedAnimationFailure[];
156+
unsizedImages: Protocol.DOM.BackendNodeId[];
155157
}
156158

157-
function isInInvalidationWindow(event: Types.Events.Event, targetEvent: Types.Events.Event): boolean {
159+
/**
160+
* Returns if an event happens within the root cause window, before the target event.
161+
* ROOT_CAUSE_WINDOW v target event
162+
* |------------------------|=======================
163+
*/
164+
function isInRootCauseWindow(event: Types.Events.Event, targetEvent: Types.Events.Event): boolean {
158165
const eventEnd = event.dur ? event.ts + event.dur : event.ts;
159-
return eventEnd < targetEvent.ts && eventEnd >= targetEvent.ts - INVALIDATION_WINDOW;
166+
return eventEnd < targetEvent.ts && eventEnd >= targetEvent.ts - ROOT_CAUSE_WINDOW;
160167
}
161168

162169
export function getNonCompositedFailure(animationEvent: Types.Events.SyntheticAnimationPair):
@@ -205,14 +212,14 @@ function getNonCompositedFailureRootCauses(
205212
}
206213
allAnimationFailures.push(...failures);
207214

208-
const nextPrePaint = getNextPrePaintEvent(prePaintEvents, animation);
215+
const nextPrePaint = getNextEvent(prePaintEvents, animation) as Types.Events.PrePaint | null;
209216
// If no following prePaint, this is not a root cause.
210217
if (!nextPrePaint) {
211218
continue;
212219
}
213220

214-
// If the animation event is outside the INVALIDATION_WINDOW, it could not be a root cause.
215-
if (!isInInvalidationWindow(animation, nextPrePaint)) {
221+
// If the animation event is outside the ROOT_CAUSE_WINDOW, it could not be a root cause.
222+
if (!isInRootCauseWindow(animation, nextPrePaint)) {
216223
continue;
217224
}
218225

@@ -269,19 +276,18 @@ function getShiftsByPrePaintEvents(
269276
}
270277

271278
/**
272-
* This gets the first prePaint event that follows the provided event and returns it.
279+
* Given a source event list, this returns the first event of that list that directly follows the target event.
273280
*/
274-
function getNextPrePaintEvent(
275-
prePaintEvents: Types.Events.PrePaint[], targetEvent: Types.Events.Event): Types.Events.PrePaint|undefined {
276-
// Get the first PrePaint event that happened after the targetEvent.
277-
const nextPrePaintIndex = Platform.ArrayUtilities.nearestIndexFromBeginning(
278-
prePaintEvents, prePaint => prePaint.ts > targetEvent.ts + (targetEvent.dur || 0));
281+
function getNextEvent(sourceEvents: Types.Events.Event[], targetEvent: Types.Events.Event): Types.Events.Event|
282+
undefined {
283+
const index = Platform.ArrayUtilities.nearestIndexFromBeginning(
284+
sourceEvents, source => source.ts > targetEvent.ts + (targetEvent.dur || 0));
279285
// No PrePaint event registered after this event
280-
if (nextPrePaintIndex === null) {
286+
if (index === null) {
281287
return undefined;
282288
}
283289

284-
return prePaintEvents[nextPrePaintIndex];
290+
return sourceEvents[index];
285291
}
286292

287293
/**
@@ -294,7 +300,7 @@ function getIframeRootCauses(
294300
rootCausesByShift: Map<Types.Events.LayoutShift, LayoutShiftRootCausesData>,
295301
domLoadingEvents: readonly Types.Events.DomLoading[]): Map<Types.Events.LayoutShift, LayoutShiftRootCausesData> {
296302
for (const iframeEvent of iframeCreatedEvents) {
297-
const nextPrePaint = getNextPrePaintEvent(prePaintEvents, iframeEvent);
303+
const nextPrePaint = getNextEvent(prePaintEvents, iframeEvent) as Types.Events.PrePaint | null;
298304
// If no following prePaint, this is not a root cause.
299305
if (!nextPrePaint) {
300306
continue;
@@ -324,10 +330,43 @@ function getIframeRootCauses(
324330
return rootCausesByShift;
325331
}
326332

333+
/**
334+
* An unsized image is considered a root cause if its PaintImage can be correlated to a
335+
* layout shift. We can correlate PaintImages with unsized images by their matching nodeIds.
336+
* X <- layout shift
337+
* |----------------|
338+
* ^ PrePaint event |-----|
339+
* ^ PaintImage
340+
*/
341+
function getUnsizedImageRootCauses(
342+
unsizedImageEvents: readonly Types.Events.LayoutImageUnsized[], paintImageEvents: Types.Events.PaintImage[],
343+
shiftsByPrePaint: Map<Types.Events.PrePaint, Types.Events.LayoutShift[]>,
344+
rootCausesByShift: Map<Types.Events.LayoutShift, LayoutShiftRootCausesData>):
345+
Map<Types.Events.LayoutShift, LayoutShiftRootCausesData> {
346+
shiftsByPrePaint.forEach((shifts, prePaint) => {
347+
const paintImage = getNextEvent(paintImageEvents, prePaint) as Types.Events.PaintImage | null;
348+
// The unsized image corresponds to this PaintImage.
349+
const matchingNode =
350+
unsizedImageEvents.find(unsizedImage => unsizedImage.args.data.nodeId === paintImage?.args.data.nodeId);
351+
if (!matchingNode) {
352+
return;
353+
}
354+
// The unsized image is a potential root cause of all the shifts of this prePaint.
355+
for (const shift of shifts) {
356+
const rootCausesForShift = rootCausesByShift.get(shift);
357+
if (!rootCausesForShift) {
358+
throw new Error('Unaccounted shift');
359+
}
360+
rootCausesForShift.unsizedImages.push(matchingNode.args.data.nodeId);
361+
}
362+
});
363+
return rootCausesByShift;
364+
}
365+
327366
/**
328367
* A font request is considered a root cause if the request occurs before a prePaint event
329368
* and within this prePaint event a layout shift(s) occurs. Additionally, this font request should
330-
* happen within the INVALIDATION_WINDOW of the prePaint event.
369+
* happen within the ROOT_CAUSE_WINDOW of the prePaint event.
331370
*/
332371
function getFontRootCauses(
333372
networkRequests: Types.Events.SyntheticNetworkRequest[], prePaintEvents: Types.Events.PrePaint[],
@@ -338,13 +377,13 @@ function getFontRootCauses(
338377
networkRequests.filter(req => req.args.data.resourceType === 'Font' && req.args.data.mimeType.startsWith('font'));
339378

340379
for (const req of fontRequests) {
341-
const nextPrePaint = getNextPrePaintEvent(prePaintEvents, req);
380+
const nextPrePaint = getNextEvent(prePaintEvents, req) as Types.Events.PrePaint | null;
342381
if (!nextPrePaint) {
343382
continue;
344383
}
345384

346-
// If the req is outside the INVALIDATION_WINDOW, it could not be a root cause.
347-
if (!isInInvalidationWindow(req, nextPrePaint)) {
385+
// If the req is outside the ROOT_CAUSE_WINDOW, it could not be a root cause.
386+
if (!isInRootCauseWindow(req, nextPrePaint)) {
348387
continue;
349388
}
350389

@@ -374,25 +413,28 @@ export function generateInsight(parsedTrace: RequiredData<typeof deps>, context:
374413
const iframeEvents = parsedTrace.LayoutShifts.renderFrameImplCreateChildFrameEvents.filter(isWithinContext);
375414
const networkRequests = parsedTrace.NetworkRequests.byTime.filter(isWithinContext);
376415
const domLoadingEvents = parsedTrace.LayoutShifts.domLoadingEvents.filter(isWithinContext);
416+
const unsizedImageEvents = parsedTrace.LayoutShifts.layoutImageUnsizedEvents.filter(isWithinContext);
377417

378418
const clusterKey = context.navigation ? context.navigationId : Types.Events.NO_NAVIGATION;
379419
const clusters = parsedTrace.LayoutShifts.clustersByNavigationId.get(clusterKey) ?? [];
380420
const clustersByScore = clusters.toSorted((a, b) => b.clusterCumulativeScore - a.clusterCumulativeScore);
381421
const worstCluster = clustersByScore.at(0);
382422
const layoutShifts = clusters.flatMap(cluster => cluster.events);
383423
const prePaintEvents = parsedTrace.LayoutShifts.prePaintEvents.filter(isWithinContext);
424+
const paintImageEvents = parsedTrace.LayoutShifts.paintImageEvents.filter(isWithinContext);
384425

385426
// Get root causes.
386427
const rootCausesByShift = new Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData>();
387428
const shiftsByPrePaint = getShiftsByPrePaintEvents(layoutShifts, prePaintEvents);
388429

389430
for (const shift of layoutShifts) {
390-
rootCausesByShift.set(shift, {iframeIds: [], fontRequests: [], nonCompositedAnimations: []});
431+
rootCausesByShift.set(shift, {iframeIds: [], fontRequests: [], nonCompositedAnimations: [], unsizedImages: []});
391432
}
392433

393434
// Populate root causes for rootCausesByShift.
394435
getIframeRootCauses(iframeEvents, prePaintEvents, shiftsByPrePaint, rootCausesByShift, domLoadingEvents);
395436
getFontRootCauses(networkRequests, prePaintEvents, shiftsByPrePaint, rootCausesByShift);
437+
getUnsizedImageRootCauses(unsizedImageEvents, paintImageEvents, shiftsByPrePaint, rootCausesByShift);
396438
const animationFailures =
397439
getNonCompositedFailureRootCauses(compositeAnimationEvents, prePaintEvents, shiftsByPrePaint, rootCausesByShift);
398440

front_end/models/trace/types/TraceEvents.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,10 +1193,25 @@ export interface RenderFrameImplCreateChildFrame extends Event {
11931193
frame_token: string,
11941194
};
11951195
}
1196+
11961197
export function isRenderFrameImplCreateChildFrame(event: Event): event is RenderFrameImplCreateChildFrame {
11971198
return event.name === Name.RENDER_FRAME_IMPL_CREATE_CHILD_FRAME;
11981199
}
11991200

1201+
export interface LayoutImageUnsized extends Event {
1202+
name: Name.LAYOUT_IMAGE_UNSIZED;
1203+
args: Args&{
1204+
data: {
1205+
nodeId: Protocol.DOM.BackendNodeId,
1206+
frameId: string,
1207+
},
1208+
};
1209+
}
1210+
1211+
export function isLayoutImageUnsized(event: Event): event is LayoutImageUnsized {
1212+
return event.name === Name.LAYOUT_IMAGE_UNSIZED;
1213+
}
1214+
12001215
export interface PrePaint extends Complete {
12011216
name: 'PrePaint';
12021217
}
@@ -2758,6 +2773,7 @@ export const enum Name {
27582773
HANDLE_POST_MESSAGE = 'HandlePostMessage',
27592774

27602775
RENDER_FRAME_IMPL_CREATE_CHILD_FRAME = 'RenderFrameImpl::createChildFrame',
2776+
LAYOUT_IMAGE_UNSIZED = 'LayoutImageUnsized',
27612777

27622778
DOM_LOADING = 'domLoading',
27632779
BEGIN_REMOTE_FONT_LOAD = 'BeginRemoteFontLoad',

front_end/panels/timeline/components/LayoutShiftDetails.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ const UIStrings = {
7171
* @description Text referring to the total cumulative score of a layout shift cluster.
7272
*/
7373
total: 'Total',
74+
/**
75+
* @description Text for a culprit type of Unsized image.
76+
*/
77+
unsizedImage: 'Unsized image',
7478
};
7579

7680
const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/LayoutShiftDetails.ts', UIStrings);
@@ -184,12 +188,26 @@ export class LayoutShiftDetails extends HTMLElement {
184188
// clang-format on
185189
}
186190

191+
#renderUnsizedImage(node: Protocol.DOM.BackendNodeId): LitHtml.TemplateResult|null {
192+
// clang-format off
193+
const el = html`
194+
<devtools-performance-node-link
195+
.data=${{
196+
backendNodeId: node,
197+
} as Insights.NodeLink.NodeLinkData}>
198+
</devtools-performance-node-link>`;
199+
return html`
200+
<span class="culprit"><span class="culprit-type">${i18nString(UIStrings.unsizedImage)}: </span><span class="culprit-value">${el}</span></span>`;
201+
// clang-format on
202+
}
203+
187204
#renderRootCauseValues(rootCauses: Trace.Insights.InsightRunners.CumulativeLayoutShift.LayoutShiftRootCausesData|
188205
undefined): LitHtml.TemplateResult|null {
189206
return html`
190207
${rootCauses?.fontRequests.map(fontReq => this.#renderFontRequest(fontReq))}
191208
${rootCauses?.iframeIds.map(iframe => this.#renderIframe(iframe))}
192209
${rootCauses?.nonCompositedAnimations.map(failure => this.#renderAnimation(failure))}
210+
${rootCauses?.unsizedImages.map(image => this.#renderUnsizedImage(image))}
193211
`;
194212
}
195213

@@ -217,7 +235,8 @@ export class LayoutShiftDetails extends HTMLElement {
217235
}
218236
const hasCulprits = Boolean(
219237
rootCauses &&
220-
(rootCauses.fontRequests.length || rootCauses.iframeIds.length || rootCauses.nonCompositedAnimations.length));
238+
(rootCauses.fontRequests.length || rootCauses.iframeIds.length || rootCauses.nonCompositedAnimations.length ||
239+
rootCauses.unsizedImages.length));
221240

222241
// clang-format off
223242
return html`
@@ -280,7 +299,8 @@ export class LayoutShiftDetails extends HTMLElement {
280299
const rootCauses = clsInsight.shifts.get(layoutShift);
281300
const elementsShifted = layoutShift.args.data?.impacted_nodes ?? [];
282301
const hasCulprits = rootCauses &&
283-
(rootCauses.fontRequests.length || rootCauses.iframeIds.length || rootCauses.nonCompositedAnimations.length);
302+
(rootCauses.fontRequests.length || rootCauses.iframeIds.length || rootCauses.nonCompositedAnimations.length ||
303+
rootCauses.unsizedImages.length);
284304
const hasShiftedElements = elementsShifted?.length;
285305

286306
const parentCluster = clsInsight.clusters.find(cluster => {

0 commit comments

Comments
 (0)