Skip to content

Commit ef162dc

Browse files
Connor ClarkDevtools-frontend LUCI CQ
authored andcommitted
1 parent 9b4b907 commit ef162dc

25 files changed

+747
-13
lines changed

config/gni/devtools_grd_files.gni

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,6 +1071,7 @@ grd_files_debug_sources = [
10711071
"front_end/models/trace/extras/FilmStrip.js",
10721072
"front_end/models/trace/extras/MainThreadActivity.js",
10731073
"front_end/models/trace/extras/Metadata.js",
1074+
"front_end/models/trace/extras/ScriptDuplication.js",
10741075
"front_end/models/trace/extras/StackTraceForEvent.js",
10751076
"front_end/models/trace/extras/ThirdParties.js",
10761077
"front_end/models/trace/extras/TimelineJSProfile.js",
@@ -1122,6 +1123,7 @@ grd_files_debug_sources = [
11221123
"front_end/models/trace/insights/Common.js",
11231124
"front_end/models/trace/insights/DOMSize.js",
11241125
"front_end/models/trace/insights/DocumentLatency.js",
1126+
"front_end/models/trace/insights/DuplicateJavaScript.js",
11251127
"front_end/models/trace/insights/FontDisplay.js",
11261128
"front_end/models/trace/insights/ForcedReflow.js",
11271129
"front_end/models/trace/insights/ImageDelivery.js",

front_end/models/trace/Processor.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,13 +406,14 @@ describeWithEnvironment('TraceProcessor', function() {
406406
'LCPPhases',
407407
'LCPDiscovery',
408408
'RenderBlocking',
409+
'LongCriticalNetworkTree',
409410
'ImageDelivery',
410411
'DocumentLatency',
411412
'FontDisplay',
412413
'DOMSize',
413414
'ThirdParties',
415+
'DuplicateJavaScript',
414416
'SlowCSSSelector',
415-
'LongCriticalNetworkTree',
416417
'ForcedReflow',
417418
]);
418419

@@ -425,13 +426,14 @@ describeWithEnvironment('TraceProcessor', function() {
425426
'LCPPhases',
426427
'LCPDiscovery',
427428
'RenderBlocking',
429+
'LongCriticalNetworkTree',
428430
'ImageDelivery',
429431
'DocumentLatency',
430432
'FontDisplay',
431433
'DOMSize',
432434
'ThirdParties',
435+
'DuplicateJavaScript',
433436
'SlowCSSSelector',
434-
'LongCriticalNetworkTree',
435437
'ForcedReflow',
436438
]);
437439
});

front_end/models/trace/Processor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,14 +344,15 @@ export class TraceProcessor extends EventTarget {
344344
LCPDiscovery: null,
345345
CLSCulprits: null,
346346
RenderBlocking: null,
347+
LongCriticalNetworkTree: null,
347348
ImageDelivery: null,
348349
DocumentLatency: null,
349350
FontDisplay: null,
350351
Viewport: null,
351352
DOMSize: null,
352353
ThirdParties: null,
354+
DuplicateJavaScript: null,
353355
SlowCSSSelector: null,
354-
LongCriticalNetworkTree: null,
355356
ForcedReflow: null,
356357
};
357358

front_end/models/trace/extras/BUILD.gn

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ devtools_module("extras") {
1313
"FilmStrip.ts",
1414
"MainThreadActivity.ts",
1515
"Metadata.ts",
16+
"ScriptDuplication.ts",
1617
"StackTraceForEvent.ts",
1718
"ThirdParties.ts",
1819
"TimelineJSProfile.ts",
@@ -48,6 +49,7 @@ ts_library("unittests") {
4849
"FilmStrip.test.ts",
4950
"MainThreadActivity.test.ts",
5051
"Metadata.test.ts",
52+
"ScriptDuplication.test.ts",
5153
"StackTraceForEvent.test.ts",
5254
"ThirdParties.test.ts",
5355
"TraceFilter.test.ts",

front_end/models/trace/extras/ScriptDuplication.test.ts

Lines changed: 357 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// Copyright 2025 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import type * as SDK from '../../../core/sdk/sdk.js';
6+
import type * as Handlers from '../handlers/handlers.js';
7+
8+
const RELATIVE_SIZE_THRESHOLD = 0.1;
9+
const ABSOLUTE_SIZE_THRESHOLD_BYTES = 1024 * 0.5;
10+
11+
type GeneratedFileSizes = {
12+
errorMessage: string,
13+
}|{files: Record<string, number>, unmappedBytes: number, totalBytes: number};
14+
15+
/**
16+
* Using a script's contents and source map, attribute every generated byte to an authored source file.
17+
*/
18+
export function computeGeneratedFileSizes(script: Handlers.ModelHandlers.Scripts.Script): GeneratedFileSizes {
19+
if (!script.sourceMap) {
20+
throw new Error('expected source map');
21+
}
22+
23+
const map = script.sourceMap;
24+
const content = script.content ?? '';
25+
const contentLength = content.length;
26+
const lines = content.split('\n');
27+
const files: Record<string, number> = {};
28+
const totalBytes = contentLength;
29+
let unmappedBytes = totalBytes;
30+
31+
const lastGeneratedColumnMap = computeLastGeneratedColumnMap(script.sourceMap);
32+
33+
for (const mapping of map.mappings()) {
34+
const source = mapping.sourceURL;
35+
const lineNum = mapping.lineNumber;
36+
const colNum = mapping.columnNumber;
37+
const lastColNum = lastGeneratedColumnMap.get(mapping);
38+
39+
// Webpack sometimes emits null mappings.
40+
// https://github.com/mozilla/source-map/pull/303
41+
if (!source) {
42+
continue;
43+
}
44+
45+
// Lines and columns are zero-based indices. Visually, lines are shown as a 1-based index.
46+
47+
const line = lines[lineNum];
48+
if (line === null || line === undefined) {
49+
const errorMessage = `${map.url()} mapping for line out of bounds: ${lineNum + 1}`;
50+
return {errorMessage};
51+
}
52+
53+
if (colNum > line.length) {
54+
const errorMessage = `${map.url()} mapping for column out of bounds: ${lineNum + 1}:${colNum}`;
55+
return {errorMessage};
56+
}
57+
58+
let mappingLength = 0;
59+
if (lastColNum !== undefined) {
60+
if (lastColNum > line.length) {
61+
const errorMessage = `${map.url()} mapping for last column out of bounds: ${lineNum + 1}:${lastColNum}`;
62+
return {errorMessage};
63+
}
64+
mappingLength = lastColNum - colNum;
65+
} else {
66+
// Add +1 to account for the newline.
67+
mappingLength = line.length - colNum + 1;
68+
}
69+
files[source] = (files[source] || 0) + mappingLength;
70+
unmappedBytes -= mappingLength;
71+
}
72+
73+
return {
74+
files,
75+
unmappedBytes,
76+
totalBytes,
77+
};
78+
}
79+
80+
interface SourceData {
81+
source: string;
82+
resourceSize: number;
83+
}
84+
85+
export function normalizeSource(source: string): string {
86+
// Trim trailing question mark - b/c webpack.
87+
source = source.replace(/\?$/, '');
88+
89+
// Normalize paths for dependencies by only keeping everything after the last `node_modules`.
90+
const lastNodeModulesIndex = source.lastIndexOf('node_modules');
91+
if (lastNodeModulesIndex !== -1) {
92+
source = source.substring(lastNodeModulesIndex);
93+
}
94+
95+
return source;
96+
}
97+
98+
function shouldIgnoreSource(source: string): boolean {
99+
// Ignore bundle overhead.
100+
if (source.includes('webpack/bootstrap')) {
101+
return true;
102+
}
103+
if (source.includes('(webpack)/buildin')) {
104+
return true;
105+
}
106+
107+
// Ignore webpack module shims, i.e. aliases of the form `module.exports = window.jQuery`
108+
if (source.includes('external ')) {
109+
return true;
110+
}
111+
112+
return false;
113+
}
114+
115+
/**
116+
* The key is a source map `sources` entry, but normalized via `normalizeSource`.
117+
*
118+
* The value is an array with an entry for every script that has a source map which
119+
* denotes that this source was used, along with the estimated resource size it takes
120+
* up in the script.
121+
*/
122+
export type ScriptDuplication = Map<string, Array<{scriptId: string, resourceSize: number}>>;
123+
124+
/**
125+
* Sorts each array within @see ScriptDuplication by resource size, and drops information
126+
* on sources that are too small.
127+
*/
128+
export function normalizeDuplication(duplication: ScriptDuplication): void {
129+
for (const [key, originalSourceData] of duplication.entries()) {
130+
let sourceData = originalSourceData;
131+
132+
// Sort by resource size.
133+
sourceData.sort((a, b) => b.resourceSize - a.resourceSize);
134+
135+
// Remove modules smaller than a % size of largest.
136+
if (sourceData.length > 1) {
137+
const largestResourceSize = sourceData[0].resourceSize;
138+
sourceData = sourceData.filter(data => {
139+
const percentSize = data.resourceSize / largestResourceSize;
140+
return percentSize >= RELATIVE_SIZE_THRESHOLD;
141+
});
142+
}
143+
144+
// Remove modules smaller than an absolute threshold.
145+
sourceData = sourceData.filter(data => data.resourceSize >= ABSOLUTE_SIZE_THRESHOLD_BYTES);
146+
147+
// Delete any that now don't have multiple source data entries.
148+
if (sourceData.length > 1) {
149+
duplication.set(key, sourceData);
150+
} else {
151+
duplication.delete(key);
152+
}
153+
}
154+
}
155+
156+
function computeLastGeneratedColumnMap(map: SDK.SourceMap.SourceMap): Map<SDK.SourceMap.SourceMapEntry, number> {
157+
const result = new Map<SDK.SourceMap.SourceMapEntry, number>();
158+
159+
const mappings = map.mappings();
160+
for (let i = 0; i < mappings.length - 1; i++) {
161+
const mapping = mappings[i];
162+
const nextMapping = mappings[i + 1];
163+
if (mapping.lineNumber === nextMapping.lineNumber) {
164+
result.set(mapping, nextMapping.columnNumber);
165+
}
166+
}
167+
168+
// Now, all but the last mapping on each line will have 'lastColumnNumber' set to a number.
169+
return result;
170+
}
171+
172+
/**
173+
* Returns a @see ScriptDuplication for the given collection of script contents + source maps.
174+
*/
175+
export function computeScriptDuplication(scriptsData: Handlers.ModelHandlers.Scripts.ScriptsData): ScriptDuplication {
176+
const sizesMap = new Map<Handlers.ModelHandlers.Scripts.Script, GeneratedFileSizes>();
177+
for (const script of scriptsData.scripts.values()) {
178+
if (script.content && script.sourceMap) {
179+
sizesMap.set(script, computeGeneratedFileSizes(script));
180+
}
181+
}
182+
183+
const sourceDatasMap = new Map<Handlers.ModelHandlers.Scripts.Script, SourceData[]>();
184+
185+
// Determine size of each `sources` entry.
186+
for (const [script, sizes] of sizesMap) {
187+
if (!script.sourceMap) {
188+
continue;
189+
}
190+
191+
if ('errorMessage' in sizes) {
192+
console.error(sizes.errorMessage);
193+
continue;
194+
}
195+
196+
const sourceDataArray: SourceData[] = [];
197+
sourceDatasMap.set(script, sourceDataArray);
198+
199+
const sources = script.sourceMap.sourceURLs();
200+
for (let i = 0; i < sources.length; i++) {
201+
if (shouldIgnoreSource(sources[i])) {
202+
continue;
203+
}
204+
205+
const sourceSize = sizes.files[sources[i]];
206+
sourceDataArray.push({
207+
source: normalizeSource(sources[i]),
208+
resourceSize: sourceSize,
209+
});
210+
}
211+
}
212+
213+
const moduleNameToSourceData: ScriptDuplication = new Map();
214+
for (const [script, sourceDataArray] of sourceDatasMap) {
215+
for (const sourceData of sourceDataArray) {
216+
let data = moduleNameToSourceData.get(sourceData.source);
217+
if (!data) {
218+
data = [];
219+
moduleNameToSourceData.set(sourceData.source, data);
220+
}
221+
data.push({
222+
scriptId: script.scriptId,
223+
resourceSize: sourceData.resourceSize,
224+
});
225+
}
226+
}
227+
228+
normalizeDuplication(moduleNameToSourceData);
229+
return moduleNameToSourceData;
230+
}

front_end/models/trace/extras/extras.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * as FetchNodes from './FetchNodes.js';
66
export * as FilmStrip from './FilmStrip.js';
77
export * as MainThreadActivity from './MainThreadActivity.js';
88
export * as Metadata from './Metadata.js';
9+
export * as ScriptDuplication from './ScriptDuplication.js';
910
export * as StackTraceForEvent from './StackTraceForEvent.js';
1011
export * as ThirdParties from './ThirdParties.js';
1112
export * as TimelineJSProfile from './TimelineJSProfile.js';

front_end/models/trace/handlers/ScriptsHandler.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ describe('ScriptsHandler', () => {
1515
Trace.Handlers.ModelHandlers.Scripts.handleEvent(event);
1616
}
1717
await Trace.Handlers.ModelHandlers.Scripts.finalize({
18-
async resolveSourceMap(url: string): Promise<SDK.SourceMap.SourceMapV3> {
18+
async resolveSourceMap(url: string): Promise<SDK.SourceMap.SourceMap> {
1919
// Don't need to actually make a source map.
2020
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2121
return {test: url} as any;
@@ -32,12 +32,20 @@ describe('ScriptsHandler', () => {
3232
assert.deepEqual([...data.scripts.values()], [
3333
{
3434
scriptId: '1',
35+
frame: '3E1717BE677B75D0536E292E00D6A34A',
36+
ts: 50442438976,
3537
url: 'http://localhost:8080/index.html',
3638
content: ' text ',
3739
sourceMapUrl: 'http://localhost:8080/source.map.json',
3840
sourceMap: {test: 'http://localhost:8080/source.map.json'}
3941
},
40-
{scriptId: '2', url: 'http://localhost:8080/index.html', content: 'source text 2'},
42+
{
43+
scriptId: '2',
44+
frame: '21D58E83A5C17916277166140F6A464B',
45+
ts: 50442438976,
46+
url: 'http://localhost:8080/index.html',
47+
content: 'source text 2'
48+
},
4149
]);
4250
});
4351
});

0 commit comments

Comments
 (0)