Skip to content

Commit fc9bcd3

Browse files
JacksonGLfacebook-github-bot
authored andcommitted
feat(core): better display of detached DOM elements in memory leak traces
Summary: This diff updates the MemLab CLI to display more structured and readable HTML tag information when presenting DOM elements in memory leak traces. For example, previous display shows DOM elements as: ``` <div> ``` The new display will order and prioritize important attribute names and show the DOM element as: ``` <div id="example-id" role="example-role" class="..."> ``` Reviewed By: twobassdrum Differential Revision: D74909709 fbshipit-source-id: 137a3ec29df0213e9400e0990dcbdb150652cdaa
1 parent ba286e8 commit fc9bcd3

File tree

6 files changed

+320
-2
lines changed

6 files changed

+320
-2
lines changed
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
* @oncall memory_lab
9+
*/
10+
11+
import config from '../../lib/Config';
12+
import utils from '../../lib/Utils';
13+
14+
const simplifyTagAttributes = utils.simplifyTagAttributes;
15+
16+
beforeEach(() => {
17+
config.isTest = true;
18+
});
19+
20+
test('basic tag with no attributes', () => {
21+
expect(simplifyTagAttributes('Hello <div>')).toBe('Hello <div>');
22+
});
23+
24+
test('opening tag with multiple attributes', () => {
25+
expect(simplifyTagAttributes('Text <div class="a" id="b">')).toBe(
26+
'Text <div id="b" class="a">',
27+
);
28+
});
29+
30+
test('tag with boolean attributes', () => {
31+
expect(simplifyTagAttributes('<input disabled required>')).toBe(
32+
'<input disabled required>',
33+
);
34+
});
35+
36+
test('tag with self-closing syntax', () => {
37+
expect(simplifyTagAttributes('<img src="x.jpg" alt="y" />')).toBe(
38+
'<img src="x.jpg" alt="y" />',
39+
);
40+
});
41+
42+
test('tag with mixed boolean and key-value attributes', () => {
43+
expect(simplifyTagAttributes('<input disabled type="text">')).toBe(
44+
'<input disabled type="text">',
45+
);
46+
});
47+
48+
test('tag with reordered prioritized attributes', () => {
49+
expect(
50+
simplifyTagAttributes('<div class="a" data-id="123" title="test">'),
51+
).toBe('<div class="a" data-id="123" title="test">');
52+
});
53+
54+
test('tag with long attribute value gets pushed back', () => {
55+
const longVal = 'x'.repeat(100);
56+
expect(simplifyTagAttributes(`<div class="a" data-id="${longVal}">`)).toBe(
57+
`<div class="a" data-id="${longVal}">`,
58+
);
59+
});
60+
61+
test('closing tags are preserved', () => {
62+
expect(simplifyTagAttributes('Something </div>')).toBe('Something </div>');
63+
});
64+
65+
test('malformed tag returns fixed tag', () => {
66+
const input = 'Hello <div class="test"';
67+
expect(simplifyTagAttributes(input)).toBe('Hello <div class="test">');
68+
});
69+
70+
test('prefix before tag is preserved', () => {
71+
expect(simplifyTagAttributes('Detached <div class="x">')).toBe(
72+
'Detached <div class="x">',
73+
);
74+
});
75+
76+
test('only parses and simplifies the first tag', () => {
77+
expect(simplifyTagAttributes('Wrap <div id="x">text<span id="y">')).toBe(
78+
'Wrap <div id="x">',
79+
);
80+
});
81+
82+
test('tag longer than 150 chars is trimmed', () => {
83+
const longAttr = 'data-desc="' + 'x'.repeat(200) + '"';
84+
const input = `Intro <div id="x" ${longAttr}>`;
85+
const expectedStart = 'Intro <div id="x"';
86+
const output = simplifyTagAttributes(input);
87+
expect(output.startsWith(expectedStart)).toBe(true);
88+
expect(output.length).toBeLessThanOrEqual(153);
89+
expect(output.endsWith('...')).toBe(true);
90+
});
91+
92+
test('complex tag example', () => {
93+
expect(
94+
simplifyTagAttributes(
95+
'Detached <div class="CometPressableOverlay__styles.overlay x1ey2m1c ' +
96+
'xds687c x17qophe x47corl x10l6tqk x13vifvy x19991ni x1dhq9h ' +
97+
'CometPressableOverlay__styles.overlayWeb x1o1ewxj x3x9cwd x1e5q0jg ' +
98+
'x13rtm0m CometPressableOverlay__styles.overlayVisible x1hc1fzr ' +
99+
'x1mq3mr6 CometPressableOverlay__styles.defaultHoveredStyle ' +
100+
'x1wpzbip" role="none" data-visualcompletion="ignore" ' +
101+
'style="border-radius: 9px; inset: 0px;">',
102+
),
103+
).toBe(
104+
'Detached <div role="none" class="CometPressableOverlay__styles.overlay ' +
105+
'x1ey2m1c xds687c x17qophe x47...',
106+
);
107+
});
108+
109+
test('prioritized order are respected', () => {
110+
expect(
111+
simplifyTagAttributes(
112+
'Detached <div a b c="c" d="d" e f g>',
113+
new Set(['g', 'c', 'e']),
114+
),
115+
).toBe('Detached <div g c="c" e a b d="d" f>');
116+
expect(
117+
simplifyTagAttributes(
118+
'Detached <div class="x78zum5" data-testids=" | GeoBaseText | GeoBaseText">',
119+
),
120+
).toBe(
121+
'Detached <div data-testids=" | GeoBaseText | GeoBaseText" class="x78zum5">',
122+
);
123+
});
File renamed without changes.

packages/core/src/lib/Config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ export class MemLabConfig {
271271
displayLeakOutlines: boolean;
272272
maxNumOfEdgesToJSONifyPerNode: number;
273273
maxLevelsOfTraceToJSONify: number;
274+
defaultPrioritizedHTMLTagAttributes: Set<string>;
274275

275276
constructor(options: ConfigOption = {}) {
276277
// init properties, they can be configured manually
@@ -413,6 +414,18 @@ export class MemLabConfig {
413414
// when JSONifying the leak trace, we serialize by invoking JSONifyNode up
414415
// to the specified depth within the objects in the trace
415416
this.maxLevelsOfTraceToJSONify = 4;
417+
// HTML tags prioritized for display in MemLab's leak trace when showing
418+
// DOM and detached DOM elements. If the representation is too long,
419+
// lower-priority tags may be omitted.
420+
this.defaultPrioritizedHTMLTagAttributes = new Set([
421+
'id',
422+
'role',
423+
'type',
424+
'data-testid',
425+
'data-testids',
426+
'name',
427+
'class',
428+
]);
416429
}
417430

418431
// initialize configurable parameters

packages/core/src/lib/Serializer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -836,7 +836,7 @@ function summarizeNodeName(node: IHeapNode, options: SummarizeOptions): string {
836836
const name = getNodeTypeShortName(node);
837837
let nodeStr = name.split('@')[0].trim();
838838
if (utils.isDetachedDOMNode(node) || utils.isDOMNodeIncomplete(node)) {
839-
nodeStr = utils.stripTagAttributes(nodeStr);
839+
nodeStr = utils.simplifyTagAttributes(nodeStr);
840840
}
841841
return options.color ? chalk.green(nodeStr) : nodeStr;
842842
}

packages/core/src/lib/Types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2454,3 +2454,19 @@ export type ConsoleOutputAnnotation = 'stack-trace';
24542454
export type ConsoleOutputOptions = {
24552455
annotation?: ConsoleOutputAnnotation;
24562456
};
2457+
2458+
/** @internal */
2459+
export type TagType = 'opening' | 'closing' | 'self-closing';
2460+
2461+
/** @internal */
2462+
export interface ParsedAttribute {
2463+
key: string;
2464+
value: string | boolean;
2465+
}
2466+
2467+
/** @internal */
2468+
export interface ParsedTag {
2469+
tagName: string;
2470+
attributes: ParsedAttribute[];
2471+
type: TagType;
2472+
}

packages/core/src/lib/Utils.ts

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111
import type {
1212
HaltOrThrowOptions,
1313
HeapNodeIdSet,
14+
ParsedAttribute,
15+
ParsedTag,
1416
ShellOptions,
1517
StringRecord,
18+
TagType,
1619
} from './Types';
1720

1821
import fs from 'fs';
@@ -925,11 +928,173 @@ function extractFiberNodeInfo(node: IHeapNode): string {
925928

926929
function getSimplifiedDOMNodeName(node: IHeapNode): string {
927930
if (isDetachedDOMNode(node) || isDOMNodeIncomplete(node)) {
928-
return stripTagAttributes(node.name);
931+
return simplifyTagAttributes(node.name);
929932
}
930933
return node.name;
931934
}
932935

936+
function limitStringLength(str: string, len: number): string {
937+
if (str.length > len) {
938+
return str.substring(0, len) + '...';
939+
}
940+
return str;
941+
}
942+
943+
function simplifyTagAttributes(
944+
str: string,
945+
prioritizedAttributes: Set<string> = config.defaultPrioritizedHTMLTagAttributes,
946+
): string {
947+
const outputLengthLimit = 100;
948+
const prefixEnd = str.indexOf('<');
949+
if (prefixEnd <= 0) {
950+
return str;
951+
}
952+
try {
953+
const prefix = str.substring(0, prefixEnd);
954+
const tagStr = str.substring(prefixEnd).trim();
955+
const parsedTag = parseHTMLTags(tagStr)[0];
956+
if (parsedTag == null) {
957+
return limitStringLength(str, outputLengthLimit);
958+
}
959+
960+
// Build maps for quick lookup
961+
const attrMap = new Map(parsedTag.attributes.map(attr => [attr.key, attr]));
962+
963+
const prioritized: ParsedAttribute[] = [];
964+
for (const key of prioritizedAttributes) {
965+
const attr = attrMap.get(key);
966+
if (attr != null) {
967+
prioritized.push(attr);
968+
attrMap.delete(key);
969+
}
970+
}
971+
972+
const remaining = parsedTag.attributes.filter(attr =>
973+
attrMap.has(attr.key),
974+
);
975+
976+
parsedTag.attributes = [...prioritized, ...remaining];
977+
978+
const finalStr = prefix + serializeParsedTags([parsedTag]);
979+
return limitStringLength(finalStr, outputLengthLimit);
980+
} catch {
981+
return limitStringLength(str, outputLengthLimit);
982+
}
983+
}
984+
985+
function parseHTMLTags(html: string): ParsedTag[] {
986+
const result: ParsedTag[] = [];
987+
let i = 0;
988+
989+
while (i < html.length) {
990+
if (html[i] === '<') {
991+
i++; // skip '<'
992+
993+
// Determine if this is a closing tag
994+
let isClosing = false;
995+
if (html[i] === '/') {
996+
isClosing = true;
997+
i++;
998+
}
999+
1000+
// Extract tag name
1001+
let tagName = '';
1002+
while (i < html.length && /[a-zA-Z0-9:-]/.test(html[i])) {
1003+
tagName += html[i++];
1004+
}
1005+
1006+
// Skip whitespace
1007+
while (i < html.length && /\s/.test(html[i])) i++;
1008+
1009+
// Parse attributes
1010+
const attributes: ParsedAttribute[] = [];
1011+
while (i < html.length && html[i] !== '>' && html[i] !== '/') {
1012+
// Extract key
1013+
let key = '';
1014+
while (i < html.length && /[^\s=>]/.test(html[i])) {
1015+
key += html[i++];
1016+
}
1017+
1018+
// Skip whitespace
1019+
while (i < html.length && /\s/.test(html[i])) i++;
1020+
1021+
// Extract value
1022+
let value: string | boolean = true;
1023+
if (html[i] === '=') {
1024+
i++; // skip '='
1025+
while (i < html.length && /\s/.test(html[i])) i++;
1026+
1027+
if (html[i] === '"' || html[i] === "'") {
1028+
const quote = html[i++];
1029+
value = '';
1030+
while (i < html.length && html[i] !== quote) {
1031+
value += html[i++];
1032+
}
1033+
i++; // skip closing quote
1034+
} else {
1035+
value = '';
1036+
while (i < html.length && /[^\s>]/.test(html[i])) {
1037+
value += html[i++];
1038+
}
1039+
}
1040+
}
1041+
1042+
if (key) {
1043+
attributes.push({key, value});
1044+
}
1045+
1046+
// Skip whitespace
1047+
while (i < html.length && /\s/.test(html[i])) i++;
1048+
}
1049+
1050+
// Check for self-closing
1051+
let isSelfClosing = false;
1052+
if (html[i] === '/') {
1053+
isSelfClosing = true;
1054+
i++; // skip '/'
1055+
}
1056+
1057+
// Skip '>'
1058+
if (html[i] === '>') i++;
1059+
1060+
const type: TagType = isClosing
1061+
? 'closing'
1062+
: isSelfClosing
1063+
? 'self-closing'
1064+
: 'opening';
1065+
1066+
result.push({tagName, attributes, type});
1067+
} else {
1068+
i++;
1069+
}
1070+
}
1071+
1072+
return result;
1073+
}
1074+
1075+
function serializeParsedTags(tags: ParsedTag[]): string {
1076+
return tags
1077+
.map(tag => {
1078+
if (tag.type === 'closing') {
1079+
return `</${tag.tagName}>`;
1080+
}
1081+
1082+
const attrString = tag.attributes
1083+
.map(({key, value}) => {
1084+
if (value === true) return key;
1085+
const escaped = String(value).replace(/"/g, '&quot;');
1086+
return `${key}="${escaped}"`;
1087+
})
1088+
.join(' ');
1089+
1090+
const space = attrString ? ' ' : '';
1091+
return tag.type === 'self-closing'
1092+
? `<${tag.tagName}${space}${attrString}/>`
1093+
: `<${tag.tagName}${space}${attrString}>`;
1094+
})
1095+
.join('');
1096+
}
1097+
9331098
// remove all attributes from the tag name
9341099
// so Detached <div prop1="xyz" prop2="xyz" ...>
9351100
// becomes Detached <div>
@@ -2324,6 +2489,7 @@ export default {
23242489
setIsRegularFiberNode,
23252490
shouldShowMoreInfo,
23262491
shuffleArray,
2492+
simplifyTagAttributes,
23272493
stripTagAttributes,
23282494
throwError,
23292495
tryToMutePuppeteerWarning,

0 commit comments

Comments
 (0)