@elliemae/encw-heap-doctor 26.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +371 -0
- package/dist/cjs/analysis/leakDetector.js +195 -0
- package/dist/cjs/analysis/patternMatcher.js +198 -0
- package/dist/cjs/analysis/retainerTracer.js +376 -0
- package/dist/cjs/analysis/snapshotComparer.js +107 -0
- package/dist/cjs/analysis/suggestionGenerator.js +167 -0
- package/dist/cjs/core/snapshot.js +211 -0
- package/dist/cjs/core/snapshotParser.js +211 -0
- package/dist/cjs/errors/domainError.js +29 -0
- package/dist/cjs/errors/index.js +28 -0
- package/dist/cjs/errors/parseError.js +27 -0
- package/dist/cjs/errors/scenarioError.js +27 -0
- package/dist/cjs/heapDoctor.js +271 -0
- package/dist/cjs/index.js +32 -0
- package/dist/cjs/package.json +7 -0
- package/dist/cjs/prompts/promptGenerator.js +132 -0
- package/dist/cjs/reporting/markdownReportGenerator.js +241 -0
- package/dist/cjs/scenario/playwrightScenarioRunner.js +118 -0
- package/dist/cjs/types/index.js +30 -0
- package/dist/cjs/types/leak.js +16 -0
- package/dist/cjs/types/report.js +16 -0
- package/dist/cjs/types/scenario.js +16 -0
- package/dist/cjs/utils/formatUtils.js +54 -0
- package/dist/esm/analysis/leakDetector.js +175 -0
- package/dist/esm/analysis/patternMatcher.js +178 -0
- package/dist/esm/analysis/retainerTracer.js +356 -0
- package/dist/esm/analysis/snapshotComparer.js +87 -0
- package/dist/esm/analysis/suggestionGenerator.js +147 -0
- package/dist/esm/core/snapshot.js +191 -0
- package/dist/esm/core/snapshotParser.js +191 -0
- package/dist/esm/errors/domainError.js +9 -0
- package/dist/esm/errors/index.js +8 -0
- package/dist/esm/errors/parseError.js +7 -0
- package/dist/esm/errors/scenarioError.js +7 -0
- package/dist/esm/heapDoctor.js +241 -0
- package/dist/esm/index.js +12 -0
- package/dist/esm/package.json +7 -0
- package/dist/esm/prompts/promptGenerator.js +112 -0
- package/dist/esm/reporting/markdownReportGenerator.js +221 -0
- package/dist/esm/scenario/playwrightScenarioRunner.js +88 -0
- package/dist/esm/types/index.js +10 -0
- package/dist/esm/types/leak.js +0 -0
- package/dist/esm/types/report.js +0 -0
- package/dist/esm/types/scenario.js +0 -0
- package/dist/esm/utils/formatUtils.js +34 -0
- package/dist/types/lib/analysis/leakDetector.d.ts +23 -0
- package/dist/types/lib/analysis/patternMatcher.d.ts +14 -0
- package/dist/types/lib/analysis/retainerTracer.d.ts +48 -0
- package/dist/types/lib/analysis/snapshotComparer.d.ts +28 -0
- package/dist/types/lib/analysis/suggestionGenerator.d.ts +16 -0
- package/dist/types/lib/core/snapshot.d.ts +111 -0
- package/dist/types/lib/core/snapshotParser.d.ts +42 -0
- package/dist/types/lib/errors/domainError.d.ts +8 -0
- package/dist/types/lib/errors/index.d.ts +3 -0
- package/dist/types/lib/errors/parseError.d.ts +5 -0
- package/dist/types/lib/errors/scenarioError.d.ts +5 -0
- package/dist/types/lib/heapDoctor.d.ts +60 -0
- package/dist/types/lib/index.d.ts +10 -0
- package/dist/types/lib/prompts/promptGenerator.d.ts +19 -0
- package/dist/types/lib/reporting/markdownReportGenerator.d.ts +26 -0
- package/dist/types/lib/scenario/playwrightScenarioRunner.d.ts +29 -0
- package/dist/types/lib/types/index.d.ts +35 -0
- package/dist/types/lib/types/leak.d.ts +68 -0
- package/dist/types/lib/types/report.d.ts +55 -0
- package/dist/types/lib/types/scenario.d.ts +28 -0
- package/dist/types/lib/utils/formatUtils.d.ts +20 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +83 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var patternMatcher_exports = {};
|
|
20
|
+
__export(patternMatcher_exports, {
|
|
21
|
+
PatternMatcher: () => PatternMatcher
|
|
22
|
+
});
|
|
23
|
+
module.exports = __toCommonJS(patternMatcher_exports);
|
|
24
|
+
var import_formatUtils = require("../utils/formatUtils.js");
|
|
25
|
+
class PatternMatcher {
|
|
26
|
+
static KNOWN_PATTERNS = [
|
|
27
|
+
{
|
|
28
|
+
id: "gtm-datalayer",
|
|
29
|
+
name: "Google Tag Manager Data Layer",
|
|
30
|
+
detect(steps) {
|
|
31
|
+
return steps.some(
|
|
32
|
+
(s) => (s.edge ?? "").includes("gtmDataLayer") || s.label.includes("gtmDataLayer")
|
|
33
|
+
);
|
|
34
|
+
},
|
|
35
|
+
why(steps) {
|
|
36
|
+
const elStep = steps.find(
|
|
37
|
+
(s) => (s.edge ?? "").startsWith("gtm.element")
|
|
38
|
+
);
|
|
39
|
+
const rawLabel = elStep?.label.replace(/^gtm\.element\s+in\s+/, "") ?? "";
|
|
40
|
+
const el = elStep ? import_formatUtils.FormatUtils.truncate(rawLabel, 80) : "a DOM element";
|
|
41
|
+
return `Google Tag Manager (GTM) captured a reference to ${el} via "gtm.element" in window.dataLayer. GTM click/form tracking stores the event target in the data layer, and these references persist indefinitely \u2014 preventing the element and its entire subtree from being garbage collected.`;
|
|
42
|
+
},
|
|
43
|
+
fixes: [
|
|
44
|
+
"Configure GTM to not capture element references in click/form triggers",
|
|
45
|
+
"Periodically trim stale entries: window.dataLayer = window.dataLayer.slice(-50)",
|
|
46
|
+
"Use CSS selectors or element IDs in GTM triggers instead of element references",
|
|
47
|
+
"Add cleanup that nullifies gtm.element on stale dataLayer entries"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "angularjs-scope",
|
|
52
|
+
name: "AngularJS Scope / Watcher Leak",
|
|
53
|
+
detect(steps) {
|
|
54
|
+
return steps.some((s) => {
|
|
55
|
+
const e = s.edge ?? "";
|
|
56
|
+
const l = s.label;
|
|
57
|
+
return e.includes("$scope") || e.includes("$$watchers") || l.includes("$scope") || l.includes("$$watchers");
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
why(steps) {
|
|
61
|
+
const ctrlStep = steps.find((s) => s.label.includes("Controller"));
|
|
62
|
+
const ctrl = ctrlStep ? import_formatUtils.FormatUtils.truncate(ctrlStep.label, 60) : "a controller";
|
|
63
|
+
return `An AngularJS $scope from ${ctrl} was not destroyed when the component was removed. Its $$watchers hold closures that still reference DOM elements, keeping them alive after removal from the document.`;
|
|
64
|
+
},
|
|
65
|
+
fixes: [
|
|
66
|
+
"Call $scope.$destroy() when the directive/component is removed",
|
|
67
|
+
"Deregister $watch listeners by calling the returned deregistration function",
|
|
68
|
+
'Ensure $on("$destroy", cleanup) handlers are properly registered',
|
|
69
|
+
"Upgrade from AngularJS to a modern framework with automatic cleanup"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "angularjs-cache",
|
|
74
|
+
name: "AngularJS Element Cache",
|
|
75
|
+
detect(steps) {
|
|
76
|
+
const hasAngular = steps.some(
|
|
77
|
+
(s) => (s.edge ?? "") === "angular" || s.label.includes("angular")
|
|
78
|
+
);
|
|
79
|
+
const hasCache = steps.some(
|
|
80
|
+
(s) => (s.edge ?? "") === "cache" || s.label.includes("in cache")
|
|
81
|
+
);
|
|
82
|
+
return hasAngular && hasCache;
|
|
83
|
+
},
|
|
84
|
+
why() {
|
|
85
|
+
return `AngularJS's internal element cache (angular.element.cache) stores data and event handlers for DOM elements. When elements are removed without proper AngularJS cleanup ($destroy), their cache entries persist indefinitely \u2014 keeping detached DOM nodes alive.`;
|
|
86
|
+
},
|
|
87
|
+
fixes: [
|
|
88
|
+
"Use angular.element(el).remove() instead of native DOM removal to trigger cache cleanup",
|
|
89
|
+
"Ensure directives call element.off() and scope.$destroy() in $destroy handlers",
|
|
90
|
+
"Monitor angular.element.cache size for growth in long-running SPAs"
|
|
91
|
+
]
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "react-fiber",
|
|
95
|
+
name: "React Component Tree Retention",
|
|
96
|
+
detect(steps) {
|
|
97
|
+
return steps.some((s) => {
|
|
98
|
+
const e = s.edge ?? "";
|
|
99
|
+
return e.startsWith("__reactFiber") || e === "stateNode" || e.startsWith("__reactInternalInstance");
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
why(steps) {
|
|
103
|
+
const depth = steps.filter(
|
|
104
|
+
(s) => s.edge === "return" || s.edge === "child" || s.edge === "memoizedState"
|
|
105
|
+
).length;
|
|
106
|
+
return `A React Fiber node retains the detached DOM element through its internal tree structure (${depth} hops via return/child/memoizedState pointers). This typically means a parent component holds a ref or state that outlives the child element.`;
|
|
107
|
+
},
|
|
108
|
+
fixes: [
|
|
109
|
+
"Return cleanup functions from useEffect to release references on unmount",
|
|
110
|
+
"Set refs to null in cleanup: useEffect(() => () => { ref.current = null })",
|
|
111
|
+
"Guard async setState: if (!cancelled) setState(...)",
|
|
112
|
+
"Check for missing dependency arrays in useEffect"
|
|
113
|
+
]
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "event-listener",
|
|
117
|
+
name: "Forgotten Event Listener",
|
|
118
|
+
detect(steps) {
|
|
119
|
+
return steps.some((s) => {
|
|
120
|
+
const l = s.label.toLowerCase();
|
|
121
|
+
const e = (s.edge ?? "").toLowerCase();
|
|
122
|
+
return l.includes("listener") || l.includes("eventlistener") || e.includes("listener");
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
why() {
|
|
126
|
+
return `An event listener callback holds a closure reference to detached DOM nodes. The listener was not removed when the element was detached from the document.`;
|
|
127
|
+
},
|
|
128
|
+
fixes: [
|
|
129
|
+
"Call removeEventListener with the same function reference on cleanup",
|
|
130
|
+
"Use { once: true } for one-shot event listeners",
|
|
131
|
+
"Use AbortController.signal to bulk-remove listeners on cleanup",
|
|
132
|
+
"In React: clean up listeners in the useEffect return function"
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: "timer-leak",
|
|
137
|
+
name: "Timer / Interval Leak",
|
|
138
|
+
detect(steps) {
|
|
139
|
+
return steps.some((s) => {
|
|
140
|
+
const l = s.label;
|
|
141
|
+
const e = s.edge ?? "";
|
|
142
|
+
return l.includes("Timeout") || l.includes("Interval") || e.includes("timer") || e.includes("interval");
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
why() {
|
|
146
|
+
return `A setInterval or setTimeout callback holds closure references to detached DOM nodes. The timer was not cleared when the component was unmounted.`;
|
|
147
|
+
},
|
|
148
|
+
fixes: [
|
|
149
|
+
"Store the timer ID and call clearInterval/clearTimeout on cleanup",
|
|
150
|
+
"In React: clear timers inside the useEffect return function",
|
|
151
|
+
"Use AbortController to coordinate timer cleanup with other async operations"
|
|
152
|
+
]
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "observer-leak",
|
|
156
|
+
name: "Observer Leak (MutationObserver / ResizeObserver)",
|
|
157
|
+
detect(steps) {
|
|
158
|
+
return steps.some((s) => {
|
|
159
|
+
const l = s.label;
|
|
160
|
+
return l.includes("MutationObserver") || l.includes("ResizeObserver") || l.includes("IntersectionObserver");
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
why(steps) {
|
|
164
|
+
const obsStep = steps.find((s) => s.label.includes("Observer"));
|
|
165
|
+
const obsName = obsStep ? import_formatUtils.FormatUtils.truncate(obsStep.label, 40) : "An observer";
|
|
166
|
+
return `${obsName} is still observing elements that have been removed from the DOM. The observer's callback closure keeps references to the detached elements alive.`;
|
|
167
|
+
},
|
|
168
|
+
fixes: [
|
|
169
|
+
"Call observer.disconnect() when the component unmounts",
|
|
170
|
+
"In React: disconnect in the useEffect cleanup function",
|
|
171
|
+
"Ensure observed elements are unobserved before removal"
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
];
|
|
175
|
+
/**
|
|
176
|
+
* Match known leak patterns against the given retainer chains.
|
|
177
|
+
* @param chainResults - Retainer chains from leak analysis.
|
|
178
|
+
* @returns Primary and contributing matched patterns.
|
|
179
|
+
*/
|
|
180
|
+
match(chainResults) {
|
|
181
|
+
const allSteps = chainResults.flatMap((c) => c.chain);
|
|
182
|
+
const matched = [];
|
|
183
|
+
PatternMatcher.KNOWN_PATTERNS.forEach((pattern) => {
|
|
184
|
+
if (pattern.detect(allSteps)) {
|
|
185
|
+
matched.push({
|
|
186
|
+
id: pattern.id,
|
|
187
|
+
name: pattern.name,
|
|
188
|
+
why: pattern.why(allSteps),
|
|
189
|
+
fixes: pattern.fixes
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
return {
|
|
194
|
+
primary: matched[0] ?? null,
|
|
195
|
+
contributing: matched.slice(1)
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var retainerTracer_exports = {};
|
|
20
|
+
__export(retainerTracer_exports, {
|
|
21
|
+
RetainerTracer: () => RetainerTracer
|
|
22
|
+
});
|
|
23
|
+
module.exports = __toCommonJS(retainerTracer_exports);
|
|
24
|
+
class RetainerTracer {
|
|
25
|
+
static GC_ROOT_NAMES = /* @__PURE__ */ new Set([
|
|
26
|
+
"(GC roots)",
|
|
27
|
+
"(Internals)",
|
|
28
|
+
"(Global handles)",
|
|
29
|
+
"(Strong roots)",
|
|
30
|
+
"(Bootstrapper)",
|
|
31
|
+
"(Strong root list)",
|
|
32
|
+
"(Traced handles)",
|
|
33
|
+
"(Builtins)",
|
|
34
|
+
"(Internalized strings)",
|
|
35
|
+
"(External strings)",
|
|
36
|
+
"(Smi roots)",
|
|
37
|
+
"(Read-only roots)",
|
|
38
|
+
"C++ Persistent roots"
|
|
39
|
+
]);
|
|
40
|
+
static PREFERRED_ROOTS = /* @__PURE__ */ new Set([
|
|
41
|
+
"(GC roots)",
|
|
42
|
+
"(Global handles)"
|
|
43
|
+
]);
|
|
44
|
+
static SKIP_GLOBALS = /* @__PURE__ */ new Set([
|
|
45
|
+
"Window",
|
|
46
|
+
"window",
|
|
47
|
+
"global",
|
|
48
|
+
"globalThis"
|
|
49
|
+
]);
|
|
50
|
+
static V8_ENGINE_NAMES = /* @__PURE__ */ new Set([
|
|
51
|
+
"<dummy>",
|
|
52
|
+
"instruction_stream",
|
|
53
|
+
"dependent_code",
|
|
54
|
+
"js_map_map",
|
|
55
|
+
"no_elements_protector",
|
|
56
|
+
"trusted_function_data",
|
|
57
|
+
"constant_elements",
|
|
58
|
+
"shared_function_info",
|
|
59
|
+
"feedback_vector",
|
|
60
|
+
"bytecode_array",
|
|
61
|
+
"scope_info",
|
|
62
|
+
"prototype_info",
|
|
63
|
+
"enum_cache",
|
|
64
|
+
"function_data",
|
|
65
|
+
"raw_properties_or_hash",
|
|
66
|
+
"descriptors",
|
|
67
|
+
"transitions",
|
|
68
|
+
"constructor_or_back_pointer"
|
|
69
|
+
]);
|
|
70
|
+
static V8_ENGINE_EDGE_NAMES = /* @__PURE__ */ new Set([
|
|
71
|
+
"__proto__",
|
|
72
|
+
"map",
|
|
73
|
+
"properties",
|
|
74
|
+
"elements",
|
|
75
|
+
"shared_function_info",
|
|
76
|
+
"feedback_vector",
|
|
77
|
+
"code",
|
|
78
|
+
"dependent_code",
|
|
79
|
+
"instruction_stream",
|
|
80
|
+
"js_map_map",
|
|
81
|
+
"no_elements_protector",
|
|
82
|
+
"trusted_function_data",
|
|
83
|
+
"constant_elements",
|
|
84
|
+
"bytecode_array",
|
|
85
|
+
"scope_info",
|
|
86
|
+
"prototype_info",
|
|
87
|
+
"enum_cache",
|
|
88
|
+
"raw_properties_or_hash",
|
|
89
|
+
"descriptors",
|
|
90
|
+
"transitions",
|
|
91
|
+
"constructor_or_back_pointer"
|
|
92
|
+
]);
|
|
93
|
+
static JS_ROOTS = /* @__PURE__ */ new Set([
|
|
94
|
+
"(GC roots)",
|
|
95
|
+
"(Global handles)",
|
|
96
|
+
"C++ Persistent roots"
|
|
97
|
+
]);
|
|
98
|
+
static MAX_DEPTH = 40;
|
|
99
|
+
static MAX_PATHS_INTERNAL = 3;
|
|
100
|
+
static MAX_PATHS = 1;
|
|
101
|
+
/**
|
|
102
|
+
* Trace retainer paths from a leaked node back to GC roots.
|
|
103
|
+
* @param startNi - Node index of the leaked element.
|
|
104
|
+
* @param snapshot - The parsed heap snapshot.
|
|
105
|
+
* @returns An array of traced RetainerChain results.
|
|
106
|
+
*/
|
|
107
|
+
trace(startNi, snapshot) {
|
|
108
|
+
const rawPaths = RetainerTracer.findRetainerPaths(startNi, snapshot);
|
|
109
|
+
return rawPaths.map((path) => {
|
|
110
|
+
const result = RetainerTracer.buildRetainerChain(path, snapshot);
|
|
111
|
+
const compactChain = RetainerTracer.formatChainString(result);
|
|
112
|
+
return { ...result, compactChain };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Walk up the DOM tree from a deeply nested element to find the root
|
|
117
|
+
* of its detached subtree — the ancestor directly retained by JS objects.
|
|
118
|
+
* @param startNi
|
|
119
|
+
* @param snapshot
|
|
120
|
+
* @param maxSteps
|
|
121
|
+
*/
|
|
122
|
+
// eslint-disable-next-line complexity
|
|
123
|
+
walkToSubtreeRoot(startNi, snapshot) {
|
|
124
|
+
const indices = snapshot.groupIndicesForWalk ?? [startNi];
|
|
125
|
+
for (const ni of indices) {
|
|
126
|
+
for (const ei of snapshot.nodeRetainers(ni)) {
|
|
127
|
+
const fromNi = snapshot.edgeFromNode(ei);
|
|
128
|
+
const fromType = snapshot.nodeType(fromNi);
|
|
129
|
+
const edgeName = String(snapshot.edgeName(ei) ?? "");
|
|
130
|
+
const hasJSRetainer = fromType === "object" || fromType === "closure" || fromType === "array" || edgeName.startsWith("__reactFiber") || edgeName === "stateNode" || edgeName.startsWith("__reactInternalInstance") || edgeName === "gtm.element";
|
|
131
|
+
if (hasJSRetainer) return ni;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return startNi;
|
|
135
|
+
}
|
|
136
|
+
// ── Path finding ──
|
|
137
|
+
static findRetainerPaths(startNi, snapshot) {
|
|
138
|
+
let paths = RetainerTracer.bfsRetainers(startNi, snapshot, true, true);
|
|
139
|
+
if (paths.length === 0) {
|
|
140
|
+
paths = RetainerTracer.bfsRetainers(startNi, snapshot, true, false);
|
|
141
|
+
}
|
|
142
|
+
if (paths.length === 0) {
|
|
143
|
+
paths = RetainerTracer.bfsRetainers(startNi, snapshot, false, false);
|
|
144
|
+
}
|
|
145
|
+
paths.sort((a, b) => {
|
|
146
|
+
const rootA = a[a.length - 1];
|
|
147
|
+
const rootB = b[b.length - 1];
|
|
148
|
+
const nameA = snapshot.nodeName(rootA.ni);
|
|
149
|
+
const nameB = snapshot.nodeName(rootB.ni);
|
|
150
|
+
const prefA = RetainerTracer.PREFERRED_ROOTS.has(nameA) ? 0 : 1;
|
|
151
|
+
const prefB = RetainerTracer.PREFERRED_ROOTS.has(nameB) ? 0 : 1;
|
|
152
|
+
if (prefA !== prefB) return prefA - prefB;
|
|
153
|
+
const internalA = a.filter(
|
|
154
|
+
(s) => s.ei >= 0 && RetainerTracer.isV8Engine(
|
|
155
|
+
snapshot.edgeFromNode(s.ei),
|
|
156
|
+
snapshot
|
|
157
|
+
)
|
|
158
|
+
).length;
|
|
159
|
+
const internalB = b.filter(
|
|
160
|
+
(s) => s.ei >= 0 && RetainerTracer.isV8Engine(
|
|
161
|
+
snapshot.edgeFromNode(s.ei),
|
|
162
|
+
snapshot
|
|
163
|
+
)
|
|
164
|
+
).length;
|
|
165
|
+
return internalA - internalB;
|
|
166
|
+
});
|
|
167
|
+
return paths.slice(0, RetainerTracer.MAX_PATHS);
|
|
168
|
+
}
|
|
169
|
+
// eslint-disable-next-line max-params, max-statements, complexity
|
|
170
|
+
static bfsRetainers(startNi, snapshot, strict, jsRootsOnly) {
|
|
171
|
+
const paths = [];
|
|
172
|
+
const visited = /* @__PURE__ */ new Set([startNi]);
|
|
173
|
+
const queue = [
|
|
174
|
+
{ ni: startNi, path: [] }
|
|
175
|
+
];
|
|
176
|
+
const isValidRoot = jsRootsOnly ? (ni) => RetainerTracer.isJSRoot(ni, snapshot) : (ni) => RetainerTracer.isGCRoot(ni, snapshot);
|
|
177
|
+
while (queue.length > 0 && paths.length < RetainerTracer.MAX_PATHS_INTERNAL) {
|
|
178
|
+
const { ni, path } = queue.shift();
|
|
179
|
+
if (path.length > 0 && isValidRoot(ni)) {
|
|
180
|
+
paths.push([...path, { ni, ei: -1 }]);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (path.length >= RetainerTracer.MAX_DEPTH) continue;
|
|
184
|
+
const retainerEdges = [];
|
|
185
|
+
for (const ei of snapshot.nodeRetainers(ni)) {
|
|
186
|
+
retainerEdges.push(ei);
|
|
187
|
+
}
|
|
188
|
+
retainerEdges.sort(
|
|
189
|
+
(a, b) => RetainerTracer.edgePriority(a, snapshot) - RetainerTracer.edgePriority(b, snapshot)
|
|
190
|
+
);
|
|
191
|
+
const bestPri = retainerEdges.length > 0 ? RetainerTracer.edgePriority(retainerEdges[0], snapshot) : Infinity;
|
|
192
|
+
for (const ei of retainerEdges.slice(0, 8)) {
|
|
193
|
+
const pri = RetainerTracer.edgePriority(ei, snapshot);
|
|
194
|
+
if (strict && pri >= 500 && bestPri < 500) continue;
|
|
195
|
+
const fromNi = snapshot.edgeFromNode(ei);
|
|
196
|
+
if (visited.has(fromNi)) continue;
|
|
197
|
+
visited.add(fromNi);
|
|
198
|
+
queue.push({
|
|
199
|
+
ni: fromNi,
|
|
200
|
+
path: [...path, { ni, ei }]
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return paths;
|
|
205
|
+
}
|
|
206
|
+
// ── Classification helpers ──
|
|
207
|
+
static isGCRoot(ni, snapshot) {
|
|
208
|
+
return ni === 0 || RetainerTracer.GC_ROOT_NAMES.has(snapshot.nodeName(ni)) || snapshot.nodeType(ni) === "synthetic";
|
|
209
|
+
}
|
|
210
|
+
static isJSRoot(ni, snapshot) {
|
|
211
|
+
const name = snapshot.nodeName(ni);
|
|
212
|
+
return ni === 0 || RetainerTracer.JS_ROOTS.has(name);
|
|
213
|
+
}
|
|
214
|
+
static isV8Engine(ni, snapshot) {
|
|
215
|
+
const name = snapshot.nodeName(ni);
|
|
216
|
+
const type = snapshot.nodeType(ni);
|
|
217
|
+
if (type === "code") return true;
|
|
218
|
+
if (RetainerTracer.V8_ENGINE_NAMES.has(name)) return true;
|
|
219
|
+
if (name === "<dummy>") return true;
|
|
220
|
+
if (name.startsWith("blink::")) return true;
|
|
221
|
+
if (/^\d+$/.test(name)) return true;
|
|
222
|
+
if (/^\d+ \/ blink::/.test(name)) return true;
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
static isGlobalLike(name) {
|
|
226
|
+
if (RetainerTracer.SKIP_GLOBALS.has(name)) return true;
|
|
227
|
+
if (name.startsWith("Window / ")) return true;
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
// eslint-disable-next-line complexity
|
|
231
|
+
static edgePriority(ei, snapshot) {
|
|
232
|
+
const fromNi = snapshot.edgeFromNode(ei);
|
|
233
|
+
const fromType = snapshot.nodeType(fromNi);
|
|
234
|
+
const fromName = snapshot.nodeName(fromNi);
|
|
235
|
+
const edgeType = snapshot.edgeType(ei);
|
|
236
|
+
const edgeName = String(snapshot.edgeName(ei) ?? "");
|
|
237
|
+
if (edgeType === "weak") return 1e3;
|
|
238
|
+
if (RetainerTracer.isV8Engine(fromNi, snapshot)) return 500;
|
|
239
|
+
if (RetainerTracer.V8_ENGINE_EDGE_NAMES.has(edgeName)) return 200;
|
|
240
|
+
if (fromName === "InternalNode") return 30;
|
|
241
|
+
if (fromType === "hidden" && !fromName.startsWith("system / ")) return 35;
|
|
242
|
+
if (fromName.startsWith("system / ")) return 100;
|
|
243
|
+
if (fromType === "synthetic") return 50;
|
|
244
|
+
switch (edgeType) {
|
|
245
|
+
case "property":
|
|
246
|
+
return 0;
|
|
247
|
+
case "context":
|
|
248
|
+
return 1;
|
|
249
|
+
case "element":
|
|
250
|
+
return 2;
|
|
251
|
+
case "shortcut":
|
|
252
|
+
return 3;
|
|
253
|
+
case "internal":
|
|
254
|
+
return 15;
|
|
255
|
+
case "hidden":
|
|
256
|
+
return 20;
|
|
257
|
+
default:
|
|
258
|
+
return 5;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// ── Chain building ──
|
|
262
|
+
static isV8InternalEdgeName(edge) {
|
|
263
|
+
if (!edge) return false;
|
|
264
|
+
if (/^\d+ \/ /.test(edge)) return true;
|
|
265
|
+
if (edge.includes("part of key")) return true;
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
static formatEdgeName(ei, snapshot) {
|
|
269
|
+
const type = snapshot.edgeType(ei);
|
|
270
|
+
if (type === "element") return `[${snapshot.edgeName(ei)}]`;
|
|
271
|
+
const name = String(snapshot.edgeName(ei) ?? "");
|
|
272
|
+
if (!name || name === "undefined") return null;
|
|
273
|
+
return name;
|
|
274
|
+
}
|
|
275
|
+
// eslint-disable-next-line complexity
|
|
276
|
+
static isDisplayInternal(ni, snapshot) {
|
|
277
|
+
const name = snapshot.nodeName(ni);
|
|
278
|
+
const type = snapshot.nodeType(ni);
|
|
279
|
+
if (type === "code") return true;
|
|
280
|
+
if (type === "synthetic") return true;
|
|
281
|
+
if (name.startsWith("system / ")) return true;
|
|
282
|
+
if (name === "(internal array)") return true;
|
|
283
|
+
if (name === "InternalNode") return true;
|
|
284
|
+
if (name.startsWith("(object properties)")) return true;
|
|
285
|
+
if (name.startsWith("(object elements)")) return true;
|
|
286
|
+
if (RetainerTracer.V8_ENGINE_NAMES.has(name)) return true;
|
|
287
|
+
if (/^\d+$/.test(name)) return true;
|
|
288
|
+
if (/^\d+ \/ /.test(name)) return true;
|
|
289
|
+
if (type === "hidden" && !name.startsWith("<")) return true;
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
// eslint-disable-next-line max-statements, complexity
|
|
293
|
+
static buildRetainerChain(path, snapshot) {
|
|
294
|
+
const steps = [];
|
|
295
|
+
for (let i = path.length - 1; i >= 0; i -= 1) {
|
|
296
|
+
const { ni, ei } = path[i];
|
|
297
|
+
const nodeName = snapshot.nodeName(ni);
|
|
298
|
+
const nodeType = snapshot.nodeType(ni);
|
|
299
|
+
const nodeId = snapshot.nodeId(ni);
|
|
300
|
+
const edgeName = ei >= 0 ? RetainerTracer.formatEdgeName(ei, snapshot) : null;
|
|
301
|
+
const internal = RetainerTracer.isDisplayInternal(ni, snapshot);
|
|
302
|
+
steps.push({
|
|
303
|
+
ni,
|
|
304
|
+
edgeName,
|
|
305
|
+
nodeName,
|
|
306
|
+
nodeType,
|
|
307
|
+
nodeId,
|
|
308
|
+
isInternal: internal,
|
|
309
|
+
isRoot: RetainerTracer.isGCRoot(ni, snapshot),
|
|
310
|
+
isLeaked: i === 0,
|
|
311
|
+
isGlobal: RetainerTracer.isGlobalLike(nodeName)
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const chain = [];
|
|
315
|
+
steps.forEach((step) => {
|
|
316
|
+
let label;
|
|
317
|
+
if (step.isRoot) {
|
|
318
|
+
label = step.nodeName || "(GC roots)";
|
|
319
|
+
} else if (step.isLeaked) {
|
|
320
|
+
label = step.nodeType === "closure" ? `${step.nodeName || "(anonymous)"}()` : step.nodeName;
|
|
321
|
+
} else {
|
|
322
|
+
const parts = [];
|
|
323
|
+
if (step.edgeName) parts.push(step.edgeName);
|
|
324
|
+
if (!step.isInternal && !step.isGlobal) {
|
|
325
|
+
if (step.nodeType === "closure") {
|
|
326
|
+
parts.push(`${step.nodeName || "(anonymous)"}()`);
|
|
327
|
+
} else if (step.nodeType === "object" && step.nodeName && !step.nodeName.startsWith("(")) {
|
|
328
|
+
parts.push(`in ${step.nodeName}`);
|
|
329
|
+
} else if (step.nodeType === "native" && step.nodeName && step.nodeName.startsWith("<")) {
|
|
330
|
+
parts.push(`in ${step.nodeName}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
label = parts.join(" ") || step.nodeName;
|
|
334
|
+
}
|
|
335
|
+
let stepType;
|
|
336
|
+
if (step.isRoot) {
|
|
337
|
+
stepType = "root";
|
|
338
|
+
} else if (step.isLeaked) {
|
|
339
|
+
stepType = "leaked";
|
|
340
|
+
} else if (step.isInternal) {
|
|
341
|
+
stepType = "internal";
|
|
342
|
+
} else if (step.isGlobal) {
|
|
343
|
+
stepType = "global";
|
|
344
|
+
} else {
|
|
345
|
+
stepType = "code";
|
|
346
|
+
}
|
|
347
|
+
chain.push({
|
|
348
|
+
label,
|
|
349
|
+
type: stepType,
|
|
350
|
+
edge: step.edgeName,
|
|
351
|
+
nodeType: step.nodeType
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
let leakSource = null;
|
|
355
|
+
let firstCodeStep = null;
|
|
356
|
+
for (let i = 0; i < chain.length; i += 1) {
|
|
357
|
+
const step = chain[i];
|
|
358
|
+
if (step.type === "root" || step.type === "global" || step.type === "leaked")
|
|
359
|
+
continue;
|
|
360
|
+
if (step.type === "internal") continue;
|
|
361
|
+
if (step.edge && RetainerTracer.isV8InternalEdgeName(step.edge)) continue;
|
|
362
|
+
if (step.type === "code") {
|
|
363
|
+
if (!firstCodeStep) firstCodeStep = { label: step.label, index: i };
|
|
364
|
+
if (step.nodeType === "closure") {
|
|
365
|
+
leakSource = { label: step.label, index: i };
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!leakSource) leakSource = firstCodeStep;
|
|
371
|
+
return { chain, leakSource };
|
|
372
|
+
}
|
|
373
|
+
static formatChainString(chainResult) {
|
|
374
|
+
return chainResult.chain.filter((s) => s.type !== "internal" && s.type !== "global").map((s) => s.label);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var snapshotComparer_exports = {};
|
|
20
|
+
__export(snapshotComparer_exports, {
|
|
21
|
+
SnapshotComparer: () => SnapshotComparer
|
|
22
|
+
});
|
|
23
|
+
module.exports = __toCommonJS(snapshotComparer_exports);
|
|
24
|
+
class SnapshotComparer {
|
|
25
|
+
leakDetector;
|
|
26
|
+
constructor(leakDetector) {
|
|
27
|
+
this.leakDetector = leakDetector;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Diff two snapshots and report what changed.
|
|
31
|
+
* @param before - The "before" snapshot (baseline).
|
|
32
|
+
* @param after - The "after" snapshot (current state).
|
|
33
|
+
* @param topN - Max leak groups to detect per snapshot.
|
|
34
|
+
* @returns A delta summary describing new and removed nodes, size changes, etc.
|
|
35
|
+
*/
|
|
36
|
+
compare(before, after, topN = 5) {
|
|
37
|
+
const beforeIds = SnapshotComparer.collectNodeIds(before);
|
|
38
|
+
const afterIds = SnapshotComparer.collectNodeIds(after);
|
|
39
|
+
let newNodeCount = 0;
|
|
40
|
+
let removedNodeCount = 0;
|
|
41
|
+
for (const id of afterIds.keys()) {
|
|
42
|
+
if (!beforeIds.has(id)) newNodeCount += 1;
|
|
43
|
+
}
|
|
44
|
+
for (const id of beforeIds.keys()) {
|
|
45
|
+
if (!afterIds.has(id)) removedNodeCount += 1;
|
|
46
|
+
}
|
|
47
|
+
const beforeRetained = SnapshotComparer.totalRetainedSize(before);
|
|
48
|
+
const afterRetained = SnapshotComparer.totalRetainedSize(after);
|
|
49
|
+
const retainedSizeDelta = afterRetained - beforeRetained;
|
|
50
|
+
const beforeLeaks = this.leakDetector.detect(before, topN);
|
|
51
|
+
const afterLeaks = this.leakDetector.detect(after, topN);
|
|
52
|
+
const beforeLabels = new Set(beforeLeaks.map((l) => l.label));
|
|
53
|
+
const newLeakGroups = afterLeaks.filter((l) => !beforeLabels.has(l.label));
|
|
54
|
+
const detachedDomDelta = SnapshotComparer.computeDetachedDelta(
|
|
55
|
+
beforeLeaks,
|
|
56
|
+
afterLeaks
|
|
57
|
+
);
|
|
58
|
+
const delta = {
|
|
59
|
+
newNodeCount,
|
|
60
|
+
removedNodeCount,
|
|
61
|
+
retainedSizeDelta,
|
|
62
|
+
newLeakGroups,
|
|
63
|
+
detachedDomDelta
|
|
64
|
+
};
|
|
65
|
+
return { delta, afterLeaks };
|
|
66
|
+
}
|
|
67
|
+
static collectNodeIds(snapshot) {
|
|
68
|
+
const map = /* @__PURE__ */ new Map();
|
|
69
|
+
for (let i = 0; i < snapshot.nodeCount; i += 1) {
|
|
70
|
+
const ni = i;
|
|
71
|
+
map.set(snapshot.nodeId(ni), ni);
|
|
72
|
+
}
|
|
73
|
+
return map;
|
|
74
|
+
}
|
|
75
|
+
static totalRetainedSize(snapshot) {
|
|
76
|
+
let total = 0;
|
|
77
|
+
for (let i = 0; i < snapshot.nodeCount; i += 1) {
|
|
78
|
+
total += snapshot.nodeSelfSize(i);
|
|
79
|
+
}
|
|
80
|
+
return total;
|
|
81
|
+
}
|
|
82
|
+
// eslint-disable-next-line complexity
|
|
83
|
+
static computeDetachedDelta(beforeLeaks, afterLeaks) {
|
|
84
|
+
const delta = /* @__PURE__ */ new Map();
|
|
85
|
+
const beforeByTag = /* @__PURE__ */ new Map();
|
|
86
|
+
const afterByTag = /* @__PURE__ */ new Map();
|
|
87
|
+
beforeLeaks.forEach((leak) => {
|
|
88
|
+
if (leak.reason === "detached-dom" && leak.tag) {
|
|
89
|
+
beforeByTag.set(
|
|
90
|
+
leak.tag,
|
|
91
|
+
(beforeByTag.get(leak.tag) ?? 0) + leak.count
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
afterLeaks.forEach((leak) => {
|
|
96
|
+
if (leak.reason === "detached-dom" && leak.tag) {
|
|
97
|
+
afterByTag.set(leak.tag, (afterByTag.get(leak.tag) ?? 0) + leak.count);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
const allTags = /* @__PURE__ */ new Set([...beforeByTag.keys(), ...afterByTag.keys()]);
|
|
101
|
+
allTags.forEach((tag) => {
|
|
102
|
+
const diff = (afterByTag.get(tag) ?? 0) - (beforeByTag.get(tag) ?? 0);
|
|
103
|
+
if (diff !== 0) delta.set(tag, diff);
|
|
104
|
+
});
|
|
105
|
+
return delta;
|
|
106
|
+
}
|
|
107
|
+
}
|