diff --git a/config/gni/devtools_grd_files.gni b/config/gni/devtools_grd_files.gni index ccd0725c957..a98d7016158 100644 --- a/config/gni/devtools_grd_files.gni +++ b/config/gni/devtools_grd_files.gni @@ -1066,6 +1066,7 @@ grd_files_debug_sources = [ "front_end/models/trace/handlers/AnimationHandler.js", "front_end/models/trace/handlers/AsyncJSCallsHandler.js", "front_end/models/trace/handlers/AuctionWorkletsHandler.js", + "front_end/models/trace/handlers/DOMStatsHandler.js", "front_end/models/trace/handlers/ExtensionTraceDataHandler.js", "front_end/models/trace/handlers/FlowsHandler.js", "front_end/models/trace/handlers/FramesHandler.js", diff --git a/front_end/models/trace/handlers/BUILD.gn b/front_end/models/trace/handlers/BUILD.gn index f48394e208b..0ffa83b026e 100644 --- a/front_end/models/trace/handlers/BUILD.gn +++ b/front_end/models/trace/handlers/BUILD.gn @@ -13,6 +13,7 @@ devtools_module("handlers") { "AnimationHandler.ts", "AsyncJSCallsHandler.ts", "AuctionWorkletsHandler.ts", + "DOMStatsHandler.ts", "ExtensionTraceDataHandler.ts", "FlowsHandler.ts", "FramesHandler.ts", @@ -73,6 +74,7 @@ ts_library("unittests") { "AnimationHandler.test.ts", "AsyncJSCallsHandler.test.ts", "AuctionWorkletsHandler.test.ts", + "DOMStatsHandler.test.ts", "ExtensionTraceDataHandler.test.ts", "FlowsHandler.test.ts", "FramesHandler.test.ts", diff --git a/front_end/models/trace/handlers/DOMStatsHandler.test.ts b/front_end/models/trace/handlers/DOMStatsHandler.test.ts new file mode 100644 index 00000000000..32395ec4af6 --- /dev/null +++ b/front_end/models/trace/handlers/DOMStatsHandler.test.ts @@ -0,0 +1,30 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js'; +import {TraceLoader} from '../../../testing/TraceLoader.js'; +import * as Trace from '../trace.js'; + +describeWithEnvironment('DOMStatsHandler', () => { + beforeEach(() => { + Trace.Handlers.ModelHandlers.DOMStats.reset(); + Trace.Handlers.ModelHandlers.Meta.reset(); + }); + + it('should get DOM stats for each frame', async function() { + const {parsedTrace} = await TraceLoader.traceEngine(this, 'multi-frame-dom-stats.json.gz'); + + const {mainFrameId} = parsedTrace.Meta; + const {domStatsByFrameId} = parsedTrace.DOMStats; + + assert.strictEqual(domStatsByFrameId.size, 2); + const mainFrameStats = domStatsByFrameId.get(mainFrameId)!.at(-1); + const mainFrameData = mainFrameStats!.args.data; + assert.strictEqual(mainFrameData.totalElements, 7); + assert.strictEqual(mainFrameData.maxDepth!.depth, 3); + assert.strictEqual(mainFrameData.maxDepth!.nodeName, 'DIV id=\'child\''); + assert.strictEqual(mainFrameData.maxChildren!.numChildren, 4); + assert.strictEqual(mainFrameData.maxChildren!.nodeName, 'BODY'); + }); +}); diff --git a/front_end/models/trace/handlers/DOMStatsHandler.ts b/front_end/models/trace/handlers/DOMStatsHandler.ts new file mode 100644 index 00000000000..fe399b59166 --- /dev/null +++ b/front_end/models/trace/handlers/DOMStatsHandler.ts @@ -0,0 +1,31 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Platform from '../../../core/platform/platform.js'; +import * as Types from '../types/types.js'; + +export interface DOMStatsData { + domStatsByFrameId: Map; +} + +const domStatsByFrameId: DOMStatsData['domStatsByFrameId'] = new Map(); + +export function reset(): void { + domStatsByFrameId.clear(); +} + +export function handleEvent(event: Types.Events.Event): void { + if (!Types.Events.isDOMStats(event)) { + return; + } + const domStatEvents = Platform.MapUtilities.getWithDefault(domStatsByFrameId, event.args.data.frame, () => []); + domStatEvents.push(event); +} + +export async function finalize(): Promise { +} + +export function data(): DOMStatsData { + return {domStatsByFrameId}; +} diff --git a/front_end/models/trace/handlers/ModelHandlers.ts b/front_end/models/trace/handlers/ModelHandlers.ts index 141e2ab46e6..545e3d7a696 100644 --- a/front_end/models/trace/handlers/ModelHandlers.ts +++ b/front_end/models/trace/handlers/ModelHandlers.ts @@ -6,6 +6,7 @@ export * as AnimationFrames from './AnimationFramesHandler.js'; export * as Animations from './AnimationHandler.js'; export * as AsyncJSCalls from './AsyncJSCallsHandler.js'; export * as AuctionWorklets from './AuctionWorkletsHandler.js'; +export * as DOMStats from './DOMStatsHandler.js'; export * as ExtensionTraceData from './ExtensionTraceDataHandler.js'; export * as Flows from './FlowsHandler.js'; export * as Frames from './FramesHandler.js'; diff --git a/front_end/models/trace/insights/DOMSize.test.ts b/front_end/models/trace/insights/DOMSize.test.ts index 84135eab930..29628547c85 100644 --- a/front_end/models/trace/insights/DOMSize.test.ts +++ b/front_end/models/trace/insights/DOMSize.test.ts @@ -6,26 +6,39 @@ import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js'; import {getFirstOrError, getInsightOrError, processTrace} from '../../../testing/InsightHelpers.js'; describeWithEnvironment('DOMSize', function() { - it('finds layout reflows and style recalcs affected by DOM size', - async () => { - const {data, insights} = await processTrace(this, 'dom-size.json.gz'); + // Processing traces in this file can take a while due to a performance bottleneck + // b/38254550 + this.timeout(30_000); - // 1 large DOM update was triggered before the first navigation - { - const insight = getInsightOrError('DOMSize', insights); - assert.lengthOf(insight.largeLayoutUpdates, 1); - assert.lengthOf(insight.largeStyleRecalcs, 1); - } + it('finds layout reflows and style recalcs affected by DOM size', async () => { + const {data, insights} = await processTrace(this, 'dom-size.json.gz'); - // 1 large DOM update was triggered after the first navigation - { - const insight = - getInsightOrError('DOMSize', insights, getFirstOrError(data.Meta.navigationsByNavigationId.values())); - assert.lengthOf(insight.largeLayoutUpdates, 1); - assert.lengthOf(insight.largeStyleRecalcs, 1); - } - }) - // Processing the above trace can take a while due to a performance bottleneck - // b/382545507 - .timeout(30_000); + // 1 large DOM update was triggered before the first navigation + { + const insight = getInsightOrError('DOMSize', insights); + assert.lengthOf(insight.largeLayoutUpdates, 1); + assert.lengthOf(insight.largeStyleRecalcs, 1); + } + + // 1 large DOM update was triggered after the first navigation + { + const insight = + getInsightOrError('DOMSize', insights, getFirstOrError(data.Meta.navigationsByNavigationId.values())); + assert.lengthOf(insight.largeLayoutUpdates, 1); + assert.lengthOf(insight.largeStyleRecalcs, 1); + } + }); + + it('finds largest DOM stats event', async () => { + const {data, insights} = await processTrace(this, 'multi-frame-dom-stats.json.gz'); + + const insight = + getInsightOrError('DOMSize', insights, getFirstOrError(data.Meta.navigationsByNavigationId.values())); + const domStats = insight.maxDOMStats!.args.data; + assert.strictEqual(domStats.totalElements, 7); + assert.strictEqual(domStats.maxDepth!.depth, 3); + assert.strictEqual(domStats.maxDepth!.nodeName, 'DIV id=\'child\''); + assert.strictEqual(domStats.maxChildren!.numChildren, 4); + assert.strictEqual(domStats.maxChildren!.nodeName, 'BODY'); + }); }); diff --git a/front_end/models/trace/insights/DOMSize.ts b/front_end/models/trace/insights/DOMSize.ts index 216d6a12c44..b6d6f4dc37a 100644 --- a/front_end/models/trace/insights/DOMSize.ts +++ b/front_end/models/trace/insights/DOMSize.ts @@ -18,7 +18,7 @@ const UIStrings = { * @description Description of an insight that recommends reducing the size of the DOM tree as a means to improve page responsiveness. "DOM" is an acronym and should not be translated. "layout reflows" are when the browser will recompute the layout of content on the page. */ description: - 'A large DOM will increase memory usage, cause longer style calculations, and produce costly layout reflows which impact page responsiveness. [Learn how to avoid an excessive DOM size](https://developer.chrome.com/docs/lighthouse/performance/dom-size/).', + 'A large DOM can increase the duration of style calculations and layout reflows, impacting page responsiveness. A large DOM will also increase memory usage. [Learn how to avoid an excessive DOM size](https://developer.chrome.com/docs/lighthouse/performance/dom-size/).', }; const str_ = i18n.i18n.registerUIStrings('models/trace/insights/DOMSize.ts', UIStrings); @@ -29,10 +29,11 @@ const DOM_UPDATE_LIMIT = 800; export type DOMSizeInsightModel = InsightModel<{ largeLayoutUpdates: Types.Events.Layout[], largeStyleRecalcs: Types.Events.UpdateLayoutTree[], + maxDOMStats?: Types.Events.DOMStats, }>; -export function deps(): ['Renderer', 'AuctionWorklets'] { - return ['Renderer', 'AuctionWorklets']; +export function deps(): ['Renderer', 'AuctionWorklets', 'DOMStats'] { + return ['Renderer', 'AuctionWorklets', 'DOMStats']; } function finalize(partialModel: Omit): @@ -114,8 +115,17 @@ export function generateInsight( } } + const domStatsEvents = parsedTrace.DOMStats.domStatsByFrameId.get(context.frameId)?.filter(isWithinContext) ?? []; + let maxDOMStats: Types.Events.DOMStats|undefined; + for (const domStats of domStatsEvents) { + if (!maxDOMStats || domStats.args.data.totalElements > maxDOMStats.args.data.totalElements) { + maxDOMStats = domStats; + } + } + return finalize({ largeLayoutUpdates, largeStyleRecalcs, + maxDOMStats, }); } diff --git a/front_end/models/trace/types/TraceEvents.ts b/front_end/models/trace/types/TraceEvents.ts index d8d9e04e864..f56f1e8beee 100644 --- a/front_end/models/trace/types/TraceEvents.ts +++ b/front_end/models/trace/types/TraceEvents.ts @@ -752,6 +752,26 @@ export interface Instant extends Event { s: Scope; } +export interface DOMStats extends Instant { + name: 'DOMStats'; + args: Args&{ + data: ArgsData & { + frame: string, + totalElements: number, + maxChildren?: { + nodeId: Protocol.DOM.BackendNodeId, + nodeName: string, + numChildren: number, + }, + maxDepth?: { + nodeId: Protocol.DOM.BackendNodeId, + nodeName: string, + depth: number, + }, + }, + }; +} + export interface UpdateCounters extends Instant { name: 'UpdateCounters'; args: Args&{ @@ -1988,6 +2008,10 @@ export function isUpdateCounters(event: Event): event is UpdateCounters { return event.name === 'UpdateCounters'; } +export function isDOMStats(event: Event): event is DOMStats { + return event.name === 'DOMStats'; +} + export function isThreadName( event: Event, ): event is ThreadName { diff --git a/front_end/panels/timeline/components/insights/DOMSize.ts b/front_end/panels/timeline/components/insights/DOMSize.ts index 14145705157..3bb023cd3fe 100644 --- a/front_end/panels/timeline/components/insights/DOMSize.ts +++ b/front_end/panels/timeline/components/insights/DOMSize.ts @@ -3,6 +3,7 @@ // found in the LICENSE file. import '../../../../ui/components/icon_button/icon_button.js'; +import './Table.js'; import * as i18n from '../../../../core/i18n/i18n.js'; import type {DOMSizeInsightModel} from '../../../../models/trace/insights/DOMSize.js'; @@ -10,12 +11,29 @@ import * as LitHtml from '../../../../ui/lit-html/lit-html.js'; import type * as Overlays from '../../overlays/overlays.js'; import {BaseInsightComponent} from './BaseInsightComponent.js'; +import type {TableData} from './Table.js'; const UIStrings = { /** - * @description Text status indicating that browser operations to re-render the page were not impacted by the size of the DOM. "DOM" is an acronym and should not be translated. + * @description Header for a column containing the names of statistics as opposed to the actual statistic values. */ - noLargeRenderTasks: 'No rendering tasks impacted by DOM size', + statistic: 'Statistic', + /** + * @description Header for a column containing the value of a statistic. + */ + value: 'Value', + /** + * @description Label for a value representing the total number of elements on the page. + */ + totalElements: 'Total elements', + /** + * @description Label for a value representing the maximum depth of the Document Object Model (DOM). "DOM" is a acronym and should not be translated. + */ + maxDOMDepth: 'DOM depth', + /** + * @description Label for a value representing the maximum number of child elements of any parent element on the page. + */ + maxChildren: 'Most children', }; const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/insights/DOMSize.ts', UIStrings); @@ -45,11 +63,26 @@ export class DOMSize extends BaseInsightComponent { return LitHtml.nothing; } - if (!this.model.largeStyleRecalcs.length && !this.model.largeLayoutUpdates.length) { - return html`
${i18nString(UIStrings.noLargeRenderTasks)}
`; + const domStatsData = this.model.maxDOMStats?.args.data; + if (!domStatsData) { + return LitHtml.nothing; } - return LitHtml.nothing; + // clang-format off + return html`
+ + +
`; + // clang-format on } } diff --git a/front_end/panels/timeline/components/insights/Table.ts b/front_end/panels/timeline/components/insights/Table.ts index b7f1edaac34..1b5d4508509 100644 --- a/front_end/panels/timeline/components/insights/Table.ts +++ b/front_end/panels/timeline/components/insights/Table.ts @@ -43,7 +43,7 @@ export interface TableData { } export interface TableDataRow { - values: Array; + values: Array; overlays?: Overlays.Overlays.TimelineOverlay[]; } diff --git a/front_end/panels/timeline/fixtures/traces/BUILD.gn b/front_end/panels/timeline/fixtures/traces/BUILD.gn index 9ab967ff13b..ccbbb360bd5 100644 --- a/front_end/panels/timeline/fixtures/traces/BUILD.gn +++ b/front_end/panels/timeline/fixtures/traces/BUILD.gn @@ -57,6 +57,7 @@ copy_to_gen("traces") { "missing-process-data.json.gz", "missing-tracing-start.json.gz", "missing-url.json.gz", + "multi-frame-dom-stats.json.gz", "multiple-lcp-main-frame.json.gz", "multiple-navigations-render-blocking.json.gz", "multiple-navigations-same-id.json.gz", diff --git a/front_end/panels/timeline/fixtures/traces/multi-frame-dom-stats.json.gz b/front_end/panels/timeline/fixtures/traces/multi-frame-dom-stats.json.gz new file mode 100644 index 00000000000..6450541bf38 Binary files /dev/null and b/front_end/panels/timeline/fixtures/traces/multi-frame-dom-stats.json.gz differ diff --git a/front_end/testing/TraceHelpers.ts b/front_end/testing/TraceHelpers.ts index e903438c8c5..53ea529ca35 100644 --- a/front_end/testing/TraceHelpers.ts +++ b/front_end/testing/TraceHelpers.ts @@ -607,6 +607,9 @@ export function getBaseTraceParseModelData(overrides: Partial = {}) animationFrames: [], presentationForFrame: new Map(), }, + DOMStats: { + domStatsByFrameId: new Map(), + }, LayoutShifts: { clusters: [], clustersByNavigationId: new Map(),