Skip to content

Commit 88e3d03

Browse files
Adam RaineDevtools-frontend LUCI CQ
authored andcommitted
[RPP Observations] Warn if LCP increases due to emulation changes
Bug: 360379617 Change-Id: Ib9ff899faf78cc3181f17616192dc8ad8a8dc008 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6043070 Commit-Queue: Adam Raine <[email protected]> Reviewed-by: Connor Clark <[email protected]>
1 parent 5519f39 commit 88e3d03

File tree

10 files changed

+95
-38
lines changed

10 files changed

+95
-38
lines changed

front_end/models/emulation/BUILD.gn

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ devtools_entrypoint("bundle") {
3232

3333
visibility = [
3434
":*",
35+
"../*",
3536
"../../panels/emulation/*",
3637
]
3738

front_end/models/emulation/DeviceModeModel.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,21 @@ export class DeviceModeModel extends Common.ObjectWrapper.ObjectWrapper<EventTyp
187187
return deviceModeModelInstance;
188188
}
189189

190+
/**
191+
* This wraps `instance()` in a try/catch because in some DevTools entry points
192+
* (such as worker_app.ts) the Emulation panel is not included and as such
193+
* the below code fails; it tries to instantiate the model which requires
194+
* reading the value of a setting which has not been registered.
195+
* See crbug.com/361515458 for an example bug that this resolves.
196+
*/
197+
static tryInstance(opts?: {forceNew: boolean}): DeviceModeModel|null {
198+
try {
199+
return this.instance(opts);
200+
} catch {
201+
return null;
202+
}
203+
}
204+
190205
static widthValidator(value: string): {
191206
valid: boolean,
192207
errorMessage: (string|undefined),

front_end/models/live-metrics/BUILD.gn

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ devtools_module("live-metrics") {
1313
"../../core/host:bundle",
1414
"../../core/sdk:bundle",
1515
"../../generated:protocol",
16+
"../emulation:bundle",
1617
"./web-vitals-injected:web-vitals-injected",
1718
"./web-vitals-injected/spec:bundle",
1819
]

front_end/models/live-metrics/LiveMetrics.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,26 @@
44

55
import * as Common from '../../core/common/common.js';
66
import * as Host from '../../core/host/host.js';
7+
import * as i18n from '../../core/i18n/i18n.js';
78
import * as Platform from '../../core/platform/platform.js';
89
import * as Root from '../../core/root/root.js';
910
import * as SDK from '../../core/sdk/sdk.js';
1011
import type * as Protocol from '../../generated/protocol.js';
12+
import * as EmulationModel from '../../models/emulation/emulation.js';
1113

1214
import * as Spec from './web-vitals-injected/spec/spec.js';
1315

16+
const UIStrings = {
17+
/**
18+
* @description Warning text indicating that the Largest Contentful Paint (LCP) performance metric was affected by the user changing the simulated device.
19+
*/
20+
lcpEmulationWarning:
21+
'Simulating a new device after the page loads can affect LCP. Reload the page after simulating a new device for accurate LCP data.',
22+
};
23+
24+
const str_ = i18n.i18n.registerUIStrings('models/live-metrics/LiveMetrics.ts', UIStrings);
25+
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
26+
1427
const LIVE_METRICS_WORLD_NAME = 'DevTools Performance Metrics';
1528

1629
let liveMetricsInstance: LiveMetrics;
@@ -40,7 +53,9 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
4053
#interactions: InteractionMap = new Map();
4154
#interactionsByGroupId = new Map<Spec.InteractionEntryGroupId, Interaction[]>();
4255
#layoutShifts: LayoutShift[] = [];
56+
#lastEmulationChangeTime?: number;
4357
#mutex = new Common.Mutex.Mutex();
58+
#deviceModeModel = EmulationModel.DeviceModeModel.DeviceModeModel.tryInstance();
4459

4560
private constructor() {
4661
super();
@@ -137,6 +152,10 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
137152
return true;
138153
}
139154

155+
#onEmulationChanged(): void {
156+
this.#lastEmulationChangeTime = Date.now();
157+
}
158+
140159
/**
141160
* DOM nodes can't be sent over a runtime binding, so we have to retrieve
142161
* them separately.
@@ -232,9 +251,11 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
232251
webVitalsEvent: Spec.WebVitalsEvent, executionContextId: Protocol.Runtime.ExecutionContextId): Promise<void> {
233252
switch (webVitalsEvent.name) {
234253
case 'LCP': {
254+
const warnings: string[] = [];
235255
const lcpEvent: LCPValue = {
236256
value: webVitalsEvent.value,
237257
phases: webVitalsEvent.phases,
258+
warnings,
238259
};
239260
if (webVitalsEvent.nodeIndex !== undefined) {
240261
const node = await this.#resolveDomNode(webVitalsEvent.nodeIndex, executionContextId);
@@ -243,6 +264,10 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
243264
}
244265
}
245266

267+
if (this.#lastEmulationChangeTime && Date.now() - this.#lastEmulationChangeTime < 500) {
268+
warnings.push(i18nString(UIStrings.lcpEmulationWarning));
269+
}
270+
246271
this.#lcpValue = lcpEvent;
247272
break;
248273
}
@@ -495,6 +520,9 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
495520
});
496521
this.#scriptIdentifier = identifier;
497522

523+
this.#deviceModeModel?.addEventListener(
524+
EmulationModel.DeviceModeModel.Events.UPDATED, this.#onEmulationChanged, this);
525+
498526
this.#enabled = true;
499527
}
500528

@@ -526,6 +554,9 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
526554
}
527555
this.#scriptIdentifier = undefined;
528556

557+
this.#deviceModeModel?.removeEventListener(
558+
EmulationModel.DeviceModeModel.Events.UPDATED, this.#onEmulationChanged, this);
559+
529560
this.#enabled = false;
530561
}
531562
}
@@ -536,7 +567,10 @@ export const enum Events {
536567

537568
export type InteractionId = `interaction-${number}-${number}`;
538569

539-
export type MetricValue = Pick<Spec.MetricChangeEvent, 'value'>;
570+
export interface MetricValue {
571+
value: number;
572+
warnings?: string[];
573+
}
540574

541575
export interface LCPValue extends MetricValue {
542576
phases: Spec.LCPPhases;

front_end/panels/timeline/TimelinePanel.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2156,20 +2156,6 @@ export class TimelinePanel extends UI.Panel.Panel implements Client, TimelineMod
21562156
this.flameChart.getMainFlameChart().update();
21572157
}
21582158

2159-
#deviceModeModel(): EmulationModel.DeviceModeModel.DeviceModeModel|null {
2160-
// This is wrapped in a try/catch because in some DevTools entry points
2161-
// (such as worker_app.ts) the Emulation panel is not included and as such
2162-
// the below code fails; it tries to instantiate the model which requires
2163-
// reading the value of a setting which has not been registered.
2164-
// In this case, we fallback to 'ALL'. See crbug.com/361515458 for an
2165-
// example bug that this resolves.
2166-
try {
2167-
return EmulationModel.DeviceModeModel.DeviceModeModel.instance();
2168-
} catch {
2169-
return null;
2170-
}
2171-
}
2172-
21732159
/**
21742160
* This is called with we are done loading a trace from a file, or after we
21752161
* have recorded a fresh trace.
@@ -2211,7 +2197,7 @@ export class TimelinePanel extends UI.Panel.Panel implements Client, TimelineMod
22112197
}
22122198

22132199
if (!metadata) {
2214-
const deviceModeModel = this.#deviceModeModel();
2200+
const deviceModeModel = EmulationModel.DeviceModeModel.DeviceModeModel.tryInstance();
22152201
let emulatedDeviceTitle;
22162202
if (deviceModeModel?.type() === EmulationModel.DeviceModeModel.Type.Device) {
22172203
emulatedDeviceTitle = deviceModeModel.device()?.title ?? undefined;

front_end/panels/timeline/components/LiveMetricsView.ts

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ export class LiveMetricsView extends LegacyWrapper.LegacyWrapper.WrappableCompon
308308
#interactionsListEl?: HTMLElement;
309309
#layoutShiftsListEl?: HTMLElement;
310310
#listIsScrolling = false;
311+
#deviceModeModel = EmulationModel.DeviceModeModel.DeviceModeModel.tryInstance();
311312

312313
constructor() {
313314
super();
@@ -396,8 +397,8 @@ export class LiveMetricsView extends LegacyWrapper.LegacyWrapper.WrappableCompon
396397
const cruxManager = CrUXManager.CrUXManager.instance();
397398
cruxManager.addEventListener(CrUXManager.Events.FIELD_DATA_CHANGED, this.#onFieldDataChanged, this);
398399

399-
const emulationModel = this.#deviceModeModel();
400-
emulationModel?.addEventListener(EmulationModel.DeviceModeModel.Events.UPDATED, this.#onEmulationChanged, this);
400+
this.#deviceModeModel?.addEventListener(
401+
EmulationModel.DeviceModeModel.Events.UPDATED, this.#onEmulationChanged, this);
401402

402403
if (cruxManager.getConfigSetting().get().enabled) {
403404
void this.#refreshFieldDataForCurrentPage();
@@ -411,27 +412,13 @@ export class LiveMetricsView extends LegacyWrapper.LegacyWrapper.WrappableCompon
411412
void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
412413
}
413414

414-
#deviceModeModel(): EmulationModel.DeviceModeModel.DeviceModeModel|null {
415-
// This is wrapped in a try/catch because in some DevTools entry points
416-
// (such as worker_app.ts) the Emulation panel is not included and as such
417-
// the below code fails; it tries to instantiate the model which requires
418-
// reading the value of a setting which has not been registered.
419-
// In this case, we fallback to 'ALL'. See crbug.com/361515458 for an
420-
// example bug that this resolves.
421-
try {
422-
return EmulationModel.DeviceModeModel.DeviceModeModel.instance();
423-
} catch {
424-
return null;
425-
}
426-
}
427-
428415
disconnectedCallback(): void {
429416
LiveMetrics.LiveMetrics.instance().removeEventListener(LiveMetrics.Events.STATUS, this.#onMetricStatus, this);
430417

431418
const cruxManager = CrUXManager.CrUXManager.instance();
432419
cruxManager.removeEventListener(CrUXManager.Events.FIELD_DATA_CHANGED, this.#onFieldDataChanged, this);
433420

434-
this.#deviceModeModel()?.removeEventListener(
421+
this.#deviceModeModel?.removeEventListener(
435422
EmulationModel.DeviceModeModel.Events.UPDATED, this.#onEmulationChanged, this);
436423
}
437424

@@ -448,6 +435,7 @@ export class LiveMetricsView extends LegacyWrapper.LegacyWrapper.WrappableCompon
448435
fieldValue: fieldData?.percentiles?.p75,
449436
histogram: fieldData?.histogram,
450437
tooltipContainer: this.#tooltipContainerEl,
438+
warnings: this.#lcpValue?.warnings,
451439
phases: phases && [
452440
[i18nString(UIStrings.timeToFirstByte), phases.timeToFirstByte],
453441
[i18nString(UIStrings.resourceLoadDelay), phases.resourceLoadDelay],
@@ -482,6 +470,7 @@ export class LiveMetricsView extends LegacyWrapper.LegacyWrapper.WrappableCompon
482470
fieldValue: fieldData?.percentiles?.p75,
483471
histogram: fieldData?.histogram,
484472
tooltipContainer: this.#tooltipContainerEl,
473+
warnings: this.#clsValue?.warnings,
485474
} as MetricCardData}>
486475
${clusterIsVisible ? html`
487476
<div class="related-info" slot="extra-info">
@@ -512,6 +501,7 @@ export class LiveMetricsView extends LegacyWrapper.LegacyWrapper.WrappableCompon
512501
fieldValue: fieldData?.percentiles?.p75,
513502
histogram: fieldData?.histogram,
514503
tooltipContainer: this.#tooltipContainerEl,
504+
warnings: this.#inpValue?.warnings,
515505
phases: phases && [
516506
[i18nString(UIStrings.inputDelay), phases.inputDelay],
517507
[i18nString(UIStrings.processingDuration), phases.processingDuration],
@@ -756,13 +746,11 @@ export class LiveMetricsView extends LegacyWrapper.LegacyWrapper.WrappableCompon
756746
}
757747

758748
#getAutoDeviceScope(): CrUXManager.DeviceScope {
759-
const emulationModel = this.#deviceModeModel();
760-
761-
if (emulationModel === null) {
749+
if (this.#deviceModeModel === null) {
762750
return 'ALL';
763751
}
764752

765-
if (emulationModel.isMobile()) {
753+
if (this.#deviceModeModel.isMobile()) {
766754
if (this.#cruxPageResult?.[`${this.#fieldPageScope}-PHONE`]) {
767755
return 'PHONE';
768756
}

front_end/panels/timeline/components/MetricCard.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ function getDetailedCompareText(view: Element): HTMLElement|null {
4040
return view.shadowRoot!.querySelector('.detailed-compare-text');
4141
}
4242

43+
function getWarnings(view: Element): string[] {
44+
return Array.from(view.shadowRoot!.querySelectorAll('.warning')).map(w => w.textContent!);
45+
}
46+
4347
function getEnvironmentRecs(view: Element): string[] {
4448
const recs = Array.from(view.shadowRoot!.querySelectorAll('.environment-recs li'));
4549
return recs.map(rec => rec.textContent!);
@@ -177,6 +181,25 @@ describeWithMockConnection('MetricCard', () => {
177181
assert.match(histogramLabels[2], /Poor\s+\(>4.00 s\)/);
178182
});
179183

184+
it('should show warnings', async () => {
185+
const view = new Components.MetricCard.MetricCard();
186+
view.data = {
187+
metric: 'LCP',
188+
localValue: 2000,
189+
fieldValue: 1,
190+
histogram: createMockHistogram(),
191+
warnings: ['LCP warning'],
192+
};
193+
194+
renderElementIntoDOM(view);
195+
await coordinator.done();
196+
197+
const warnings = getWarnings(view);
198+
assert.deepStrictEqual(warnings, [
199+
'LCP warning',
200+
]);
201+
});
202+
180203
describe('phase table', () => {
181204
it('should not show if there is no phase data', async () => {
182205
const view = new Components.MetricCard.MetricCard();

front_end/panels/timeline/components/MetricCard.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export interface MetricCardData {
149149
histogram?: CrUXManager.MetricResponse['histogram'];
150150
tooltipContainer?: HTMLElement;
151151
phases?: Array<[string, number]>;
152+
warnings?: string[];
152153
}
153154

154155
export class MetricCard extends HTMLElement {
@@ -670,6 +671,9 @@ export class MetricCard extends HTMLElement {
670671
</div>
671672
${fieldEnabled ? html`<hr class="divider">` : nothing}
672673
${this.#renderCompareString()}
674+
${this.#data.warnings?.map(warning => html`
675+
<div class="warning">${warning}</div>
676+
`)}
673677
${this.#renderEnvironmentRecommendations()}
674678
<slot name="extra-info"></slot>
675679
</div>

front_end/panels/timeline/components/liveMetricsView.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@
279279

280280
.field-data-warning {
281281
margin-top: 4px;
282-
color: var(--color-error-text);
282+
color: var(--sys-color-error);
283283
}
284284

285285
.collection-period-range {

front_end/panels/timeline/components/metricCard.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@
5656
font-weight: var(--ref-typeface-weight-medium);
5757
}
5858

59+
.warning {
60+
margin-top: 4px;
61+
color: var(--sys-color-error);
62+
}
63+
5964
.good-bg {
6065
background-color: var(--app-color-performance-good);
6166
}

0 commit comments

Comments
 (0)