@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
package/README.md
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# heap-doctor
|
|
2
|
+
|
|
3
|
+
Analyzes Chrome V8 heap snapshots to help developers find and fix memory leaks in web applications. It parses `.heapsnapshot` files, groups leaked objects by type, walks retainer paths to identify the root JS reference holding objects in memory, matches known leak patterns, and generates prioritized fix suggestions — plus a ready-to-use AI prompt you can send to any LLM of your choice.
|
|
4
|
+
|
|
5
|
+
## Key Features
|
|
6
|
+
|
|
7
|
+
- **Parses multi-GB heap snapshots** using off-heap typed arrays (handles files up to 8 GB)
|
|
8
|
+
- **Detects common leak types** — detached DOM nodes, large closures, stale cache references
|
|
9
|
+
- **Traces retainer chains** to the exact JS reference preventing garbage collection
|
|
10
|
+
- **Pattern matching** for known leak causes (GTM data layer, AngularJS scope/watchers, React fiber trees, event listeners, timers, observers)
|
|
11
|
+
- **Snapshot comparison** — diff two snapshots to find growth in leaked objects
|
|
12
|
+
- **Scenario runner** — automate browser flows with Playwright to capture and compare snapshots
|
|
13
|
+
- **Fix priority ranking** across leak groups sharing the same root cause
|
|
14
|
+
- **AI prompt generation** — `generateAiPrompt()` produces a self-contained prompt from the analysis report that you can send to any LLM (Claude, GPT-4, Gemini, etc.)
|
|
15
|
+
- **Markdown reports** as the sole output format — clean, shareable, version-controllable
|
|
16
|
+
|
|
17
|
+
## Architecture
|
|
18
|
+
|
|
19
|
+
HeapDoctor exposes a single public class that composes all internal services. Three methods cover every use case: analyse a single snapshot, compare two snapshots, or run an automated browser scenario.
|
|
20
|
+
|
|
21
|
+
```mermaid
|
|
22
|
+
flowchart TD
|
|
23
|
+
subgraph publicAPI [HeapDoctor Public API]
|
|
24
|
+
analyse["analyse(filePath)"]
|
|
25
|
+
compare["compare(before, after)"]
|
|
26
|
+
runScenario["runScenario(scenario)"]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
subgraph core [Core]
|
|
30
|
+
SnapshotParser["SnapshotParser"]
|
|
31
|
+
Snapshot["Snapshot"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
subgraph analysis [Analysis]
|
|
35
|
+
LeakDetector["LeakDetector"]
|
|
36
|
+
RetainerTracer["RetainerTracer"]
|
|
37
|
+
PatternMatcher["PatternMatcher"]
|
|
38
|
+
SuggestionGenerator["SuggestionGenerator"]
|
|
39
|
+
SnapshotComparer["SnapshotComparer"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
subgraph scenario [Scenario]
|
|
43
|
+
PlaywrightRunner["PlaywrightScenarioRunner"]
|
|
44
|
+
Playwright["Playwright Page"]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
subgraph prompts [AI Prompt]
|
|
48
|
+
PromptGenerator["generateAiPrompt()"]
|
|
49
|
+
Consumer["Your LLM (Claude / GPT-4 / Gemini)"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
subgraph reporting [Reporting]
|
|
53
|
+
MarkdownGen["MarkdownReportGenerator"]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
analyse --> SnapshotParser
|
|
57
|
+
SnapshotParser --> Snapshot
|
|
58
|
+
Snapshot --> LeakDetector
|
|
59
|
+
LeakDetector --> RetainerTracer
|
|
60
|
+
RetainerTracer --> PatternMatcher
|
|
61
|
+
PatternMatcher --> SuggestionGenerator
|
|
62
|
+
SuggestionGenerator --> MarkdownGen
|
|
63
|
+
SuggestionGenerator --> PromptGenerator
|
|
64
|
+
PromptGenerator --> Consumer
|
|
65
|
+
|
|
66
|
+
compare --> SnapshotParser
|
|
67
|
+
compare --> SnapshotComparer
|
|
68
|
+
SnapshotComparer --> LeakDetector
|
|
69
|
+
|
|
70
|
+
runScenario --> PlaywrightRunner
|
|
71
|
+
PlaywrightRunner --> Playwright
|
|
72
|
+
PlaywrightRunner --> compare
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Analysis Pipeline
|
|
76
|
+
|
|
77
|
+
| Stage | Class | What it does |
|
|
78
|
+
| ------------- | -------------------------- | ------------------------------------------------------------------------------------------- |
|
|
79
|
+
| **Parse** | `SnapshotParser` | Reads `.heapsnapshot` as raw Buffer into off-heap typed arrays for multi-GB files |
|
|
80
|
+
| **Detect** | `LeakDetector` | Finds detached DOM, large closures, large arrays; groups by tag, ranks by retained size |
|
|
81
|
+
| **Trace** | `RetainerTracer` | Walks retainer edges via priority BFS to build JS-level chains to GC roots |
|
|
82
|
+
| **Match** | `PatternMatcher` | Matches chains against known leak patterns (GTM, AngularJS, React, event listeners, timers) |
|
|
83
|
+
| **Suggest** | `SuggestionGenerator` | Generates severity-ranked fix suggestions from patterns and chain context |
|
|
84
|
+
| **Compare** | `SnapshotComparer` | Diffs two snapshots: new/removed nodes, retained size delta, new leak groups |
|
|
85
|
+
| **Scenario** | `PlaywrightScenarioRunner` | Runs a Playwright browser scenario, captures before/after heap snapshots via CDP |
|
|
86
|
+
| **Report** | `MarkdownReportGenerator` | Renders structured results as a markdown report |
|
|
87
|
+
| **AI Prompt** | `generateAiPrompt()` | Serializes all findings into a self-contained prompt for any LLM |
|
|
88
|
+
|
|
89
|
+
### File Structure
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
lib/
|
|
93
|
+
index.ts Re-exports HeapDoctor and public types
|
|
94
|
+
heapDoctor.ts Public HeapDoctor facade class
|
|
95
|
+
types/
|
|
96
|
+
index.ts Result<T>, branded IDs, common types
|
|
97
|
+
report.ts AnalysisReport, ComparisonReport, ScenarioReport
|
|
98
|
+
leak.ts LeakGroup, RetainerChain, Suggestion, etc.
|
|
99
|
+
scenario.ts HeapDoctorScenario (uses Playwright Page)
|
|
100
|
+
errors/
|
|
101
|
+
domainError.ts Base DomainError class
|
|
102
|
+
parseError.ts Snapshot parsing errors
|
|
103
|
+
scenarioError.ts Scenario execution errors
|
|
104
|
+
core/
|
|
105
|
+
snapshot.ts Snapshot class (typed V8 heap representation)
|
|
106
|
+
snapshotParser.ts SnapshotParser class (Buffer-based parser)
|
|
107
|
+
analysis/
|
|
108
|
+
leakDetector.ts LeakDetector (detached DOM, closures, arrays)
|
|
109
|
+
retainerTracer.ts RetainerTracer (BFS retainer path walking)
|
|
110
|
+
patternMatcher.ts PatternMatcher (known leak pattern detection)
|
|
111
|
+
suggestionGenerator.ts SuggestionGenerator (fix generation)
|
|
112
|
+
snapshotComparer.ts SnapshotComparer (diff two snapshots)
|
|
113
|
+
scenario/
|
|
114
|
+
playwrightScenarioRunner.ts PlaywrightScenarioRunner (CDP snapshot capture)
|
|
115
|
+
prompts/
|
|
116
|
+
promptGenerator.ts generateAiPrompt() (LLM-ready prompt builder)
|
|
117
|
+
reporting/
|
|
118
|
+
markdownReportGenerator.ts MarkdownReportGenerator (markdown output)
|
|
119
|
+
utils/
|
|
120
|
+
formatUtils.ts FormatUtils (formatBytes, truncate, escapeHtml)
|
|
121
|
+
bin/
|
|
122
|
+
heap-doctor.ts CLI entry point (composition root)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Installation
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npm install @elliemae/encw-heap-doctor
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
For scenario support (automated browser flows):
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
npm install playwright
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Programmatic API
|
|
138
|
+
|
|
139
|
+
HeapDoctor is designed as a library-first API. All methods return `Result<T>` for type-safe error handling.
|
|
140
|
+
|
|
141
|
+
### 1. Single Snapshot Analysis
|
|
142
|
+
|
|
143
|
+
Analyse a `.heapsnapshot` file for memory leaks:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
import { HeapDoctor } from '@elliemae/encw-heap-doctor';
|
|
147
|
+
|
|
148
|
+
const doctor = new HeapDoctor({ topN: 5 });
|
|
149
|
+
const result = await doctor.analyse('app.heapsnapshot');
|
|
150
|
+
|
|
151
|
+
if (result.ok) {
|
|
152
|
+
console.log(result.value.markdown); // full markdown report
|
|
153
|
+
console.log(result.value.leakResults); // structured leak data
|
|
154
|
+
console.log(result.value.fixPriority); // ranked fix priorities
|
|
155
|
+
} else {
|
|
156
|
+
console.error(result.error.code, result.error.message);
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### 2. Snapshot Comparison
|
|
161
|
+
|
|
162
|
+
Compare two snapshots to find what grew between them:
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import { HeapDoctor } from '@elliemae/encw-heap-doctor';
|
|
166
|
+
|
|
167
|
+
const doctor = new HeapDoctor();
|
|
168
|
+
const result = await doctor.compare(
|
|
169
|
+
'before.heapsnapshot',
|
|
170
|
+
'after.heapsnapshot',
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (result.ok) {
|
|
174
|
+
const { delta } = result.value;
|
|
175
|
+
console.log(`New nodes: +${delta.newNodeCount}`);
|
|
176
|
+
console.log(`Removed: -${delta.removedNodeCount}`);
|
|
177
|
+
console.log(`Size delta: ${delta.retainedSizeDelta} bytes`);
|
|
178
|
+
|
|
179
|
+
for (const group of delta.newLeakGroups) {
|
|
180
|
+
console.log(`New leak: ${group.label} x${group.count}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Full markdown comparison report
|
|
184
|
+
console.log(result.value.markdown);
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### 3. Automated Scenario (Playwright)
|
|
189
|
+
|
|
190
|
+
Define a browser scenario and let HeapDoctor capture + compare snapshots automatically:
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
import { HeapDoctor } from '@elliemae/encw-heap-doctor';
|
|
194
|
+
import type { HeapDoctorScenario } from '@elliemae/encw-heap-doctor';
|
|
195
|
+
|
|
196
|
+
const scenario: HeapDoctorScenario = {
|
|
197
|
+
url() {
|
|
198
|
+
return 'https://myapp.com';
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async setup(page) {
|
|
202
|
+
await page.goto('https://myapp.com/login');
|
|
203
|
+
await page.getByPlaceholder('Email').fill('user@example.com');
|
|
204
|
+
await page.getByPlaceholder('Password').fill('password');
|
|
205
|
+
await page.getByRole('button', { name: 'Sign In' }).click();
|
|
206
|
+
await page.waitForURL('**/dashboard/**');
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
async action(page) {
|
|
210
|
+
await page.getByRole('button', { name: 'Open Modal' }).click();
|
|
211
|
+
await page.waitForSelector('.modal-content');
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
async back(page) {
|
|
215
|
+
await page.getByRole('button', { name: 'Close' }).click();
|
|
216
|
+
await page.waitForSelector('.modal-content', { state: 'hidden' });
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
repeat() {
|
|
220
|
+
return 3; // repeat action->back cycle 3 times
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const doctor = new HeapDoctor();
|
|
225
|
+
const result = await doctor.runScenario(scenario);
|
|
226
|
+
|
|
227
|
+
if (result.ok) {
|
|
228
|
+
console.log(`Snapshots: ${result.value.snapshotPaths.join(', ')}`);
|
|
229
|
+
console.log(result.value.markdown);
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The runner captures a "before" snapshot after `setup()`, executes `action()` -> `back()` for `repeat()` cycles, then captures an "after" snapshot. Both are compared and a full report is generated.
|
|
234
|
+
|
|
235
|
+
### 4. AI Prompt Generation
|
|
236
|
+
|
|
237
|
+
`generateAiPrompt()` converts any analysis or comparison report into a self-contained prompt string. Paste it into Claude, GPT-4, Gemini, or any LLM — no API keys or registry access required on heap-doctor's side.
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
import { HeapDoctor, generateAiPrompt } from '@elliemae/encw-heap-doctor';
|
|
241
|
+
|
|
242
|
+
const doctor = new HeapDoctor({ topN: 5 });
|
|
243
|
+
const result = await doctor.analyse('app.heapsnapshot');
|
|
244
|
+
|
|
245
|
+
if (result.ok) {
|
|
246
|
+
const prompt = generateAiPrompt(result.value);
|
|
247
|
+
|
|
248
|
+
// Option A: print to stdout and copy-paste into any chat UI
|
|
249
|
+
console.log(prompt);
|
|
250
|
+
|
|
251
|
+
// Option B: write to a file and attach to your LLM of choice
|
|
252
|
+
writeFileSync('heap-doctor-prompt.txt', prompt);
|
|
253
|
+
|
|
254
|
+
// Option C: pass directly to your own AI integration
|
|
255
|
+
const aiResponse = await myLlmClient.complete(prompt);
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Works identically for comparison reports:
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
const result = await doctor.compare(
|
|
263
|
+
'before.heapsnapshot',
|
|
264
|
+
'after.heapsnapshot',
|
|
265
|
+
);
|
|
266
|
+
if (result.ok) {
|
|
267
|
+
const prompt = generateAiPrompt(result.value);
|
|
268
|
+
// prompt includes the delta summary (new nodes, size change) as context
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The generated prompt includes:
|
|
273
|
+
|
|
274
|
+
- Full context header instructing the LLM to act as a memory leak specialist
|
|
275
|
+
- Per-leak sections: label, count, retained size, matched pattern, root cause, retainer chains, edge paths, existing suggestions
|
|
276
|
+
- Structured output format requesting `LEAK #N` sections with specific action items
|
|
277
|
+
|
|
278
|
+
## CLI Usage
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
# Build first
|
|
282
|
+
npm run build
|
|
283
|
+
|
|
284
|
+
# Analyse a single snapshot
|
|
285
|
+
heap-doctor analyse app.heapsnapshot
|
|
286
|
+
|
|
287
|
+
# Compare two snapshots
|
|
288
|
+
heap-doctor compare before.heapsnapshot after.heapsnapshot
|
|
289
|
+
|
|
290
|
+
# Run a scenario file
|
|
291
|
+
heap-doctor scenario my-scenario.ts
|
|
292
|
+
|
|
293
|
+
# Write report to file instead of stdout
|
|
294
|
+
heap-doctor analyse app.heapsnapshot -o report.md
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### CLI Options
|
|
298
|
+
|
|
299
|
+
| Flag | Description | Default |
|
|
300
|
+
| --------------------- | ----------------------------------------------- | ------- |
|
|
301
|
+
| `-n, --top <number>` | Number of top leak groups to report | `5` |
|
|
302
|
+
| `-o, --output <path>` | Write markdown report to file (default: stdout) | stdout |
|
|
303
|
+
| `-V, --version` | Print the version number | -- |
|
|
304
|
+
| `-h, --help` | Show help | -- |
|
|
305
|
+
|
|
306
|
+
### Subcommands
|
|
307
|
+
|
|
308
|
+
| Command | Description |
|
|
309
|
+
| -------------------------- | -------------------------------------------------------- |
|
|
310
|
+
| `analyse <file>` | Analyse a single heap snapshot |
|
|
311
|
+
| `compare <before> <after>` | Compare two heap snapshots |
|
|
312
|
+
| `scenario <scenario-file>` | Run a Playwright scenario and compare captured snapshots |
|
|
313
|
+
|
|
314
|
+
## Headed Mode (Scenario)
|
|
315
|
+
|
|
316
|
+
To watch the browser during scenario execution:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
HEADLESS=false npx tsx demo/scenario-run.ts
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## Example Report Output
|
|
323
|
+
|
|
324
|
+
Below is a single leak section from a generated markdown report:
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
### MEMORY LEAK DETECTED
|
|
329
|
+
|
|
330
|
+
**Node**
|
|
331
|
+
`Detached <span>`
|
|
332
|
+
|
|
333
|
+
**Retained Memory**
|
|
334
|
+
1370.16 MB
|
|
335
|
+
|
|
336
|
+
**Retaining Function**
|
|
337
|
+
`gtmDataLayer in Array`
|
|
338
|
+
|
|
339
|
+
**Leak Pattern**
|
|
340
|
+
Google Tag Manager Data Layer
|
|
341
|
+
|
|
342
|
+
**Retainer Chain**
|
|
343
|
+
|
|
344
|
+
```text
|
|
345
|
+
(GC roots)
|
|
346
|
+
└ (Eternal handles)
|
|
347
|
+
└ 307
|
|
348
|
+
└ DOMStringMap()
|
|
349
|
+
└ context
|
|
350
|
+
└ extension
|
|
351
|
+
└ gtmDataLayer in Array
|
|
352
|
+
└ [42] in Object
|
|
353
|
+
└ gtm.element in <button id="header-app-switcher" ...>
|
|
354
|
+
└ __reactFiber$kau10t4ajn9 in nd
|
|
355
|
+
└ return in nd
|
|
356
|
+
└ memoizedState in Object
|
|
357
|
+
└ <span class="em-ds-popover__arrow" ...>
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
**Suggested Fix**
|
|
361
|
+
|
|
362
|
+
- Configure GTM to not capture element references in click/form triggers
|
|
363
|
+
- Periodically trim stale entries: `window.dataLayer = window.dataLayer.slice(-50)`
|
|
364
|
+
- Use CSS selectors or element IDs in GTM triggers instead of element references
|
|
365
|
+
- Add cleanup that nullifies `gtm.element` on stale dataLayer entries
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## License
|
|
370
|
+
|
|
371
|
+
MIT
|
|
@@ -0,0 +1,195 @@
|
|
|
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 leakDetector_exports = {};
|
|
20
|
+
__export(leakDetector_exports, {
|
|
21
|
+
LeakDetector: () => LeakDetector
|
|
22
|
+
});
|
|
23
|
+
module.exports = __toCommonJS(leakDetector_exports);
|
|
24
|
+
const DOM_TAG_RE = /^<(\w+)/;
|
|
25
|
+
const DETACHED_TAG_RE = /^Detached <(\w+)/;
|
|
26
|
+
const MAX_SINGLE_BFS = 2e5;
|
|
27
|
+
const FOLLOW_TYPES = /* @__PURE__ */ new Set(["native", "hidden"]);
|
|
28
|
+
class LeakDetector {
|
|
29
|
+
/**
|
|
30
|
+
* @param snapshot - Parsed heap snapshot to scan.
|
|
31
|
+
* @param topN - Maximum number of leak groups to return.
|
|
32
|
+
* @returns Leak groups sorted by retained size, limited to `topN`.
|
|
33
|
+
*/
|
|
34
|
+
// eslint-disable-next-line max-statements, complexity
|
|
35
|
+
detect(snapshot, topN = 5) {
|
|
36
|
+
const nc = snapshot.nodeCount;
|
|
37
|
+
const explicitlyDetached = [];
|
|
38
|
+
const domElements = [];
|
|
39
|
+
for (let i = 0; i < nc; i += 1) {
|
|
40
|
+
const ni = i;
|
|
41
|
+
const name = snapshot.nodeName(ni);
|
|
42
|
+
const type = snapshot.nodeType(ni);
|
|
43
|
+
const det = snapshot.nodeDetachedness(ni);
|
|
44
|
+
if (name.startsWith("Detached <") || det > 0 && type === "native") {
|
|
45
|
+
explicitlyDetached.push(ni);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (type === "native" && DOM_TAG_RE.test(name)) {
|
|
49
|
+
domElements.push(ni);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const attachedNodes = LeakDetector.buildAttachedSet(snapshot);
|
|
53
|
+
const detachedDomNodes = [...explicitlyDetached];
|
|
54
|
+
domElements.forEach((ni) => {
|
|
55
|
+
if (!attachedNodes.has(ni)) {
|
|
56
|
+
detachedDomNodes.push(ni);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
const tagGroups = /* @__PURE__ */ new Map();
|
|
60
|
+
detachedDomNodes.forEach((ni) => {
|
|
61
|
+
const name = snapshot.nodeName(ni);
|
|
62
|
+
const tag = LeakDetector.extractTag(name) ?? "unknown";
|
|
63
|
+
if (!tagGroups.has(tag)) {
|
|
64
|
+
tagGroups.set(tag, {
|
|
65
|
+
reason: "detached-dom",
|
|
66
|
+
label: `Detached <${tag}>`,
|
|
67
|
+
tag,
|
|
68
|
+
nodeIndices: [],
|
|
69
|
+
count: 0,
|
|
70
|
+
totalSelfSize: 0,
|
|
71
|
+
topElements: [],
|
|
72
|
+
representativeIndex: ni,
|
|
73
|
+
retainedSize: 0
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const group = tagGroups.get(tag);
|
|
77
|
+
group.nodeIndices.push(ni);
|
|
78
|
+
group.count += 1;
|
|
79
|
+
group.totalSelfSize += snapshot.nodeSelfSize(ni);
|
|
80
|
+
});
|
|
81
|
+
const visited = new Uint8Array(nc);
|
|
82
|
+
const domGroups = [...tagGroups.values()];
|
|
83
|
+
domGroups.forEach((group) => {
|
|
84
|
+
LeakDetector.rankGroupElements(group, snapshot, visited);
|
|
85
|
+
});
|
|
86
|
+
domGroups.sort((a, b) => b.retainedSize - a.retainedSize);
|
|
87
|
+
return domGroups.slice(0, topN);
|
|
88
|
+
}
|
|
89
|
+
static extractTag(name) {
|
|
90
|
+
const m = name.match(DETACHED_TAG_RE) ?? name.match(DOM_TAG_RE);
|
|
91
|
+
return m ? m[1] : null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* BFS from HTMLDocument nodes through DOM tree edges to find "attached" nodes.
|
|
95
|
+
* @param snapshot
|
|
96
|
+
*/
|
|
97
|
+
// eslint-disable-next-line max-statements, complexity
|
|
98
|
+
static buildAttachedSet(snapshot) {
|
|
99
|
+
const nc = snapshot.nodeCount;
|
|
100
|
+
const attached = /* @__PURE__ */ new Set();
|
|
101
|
+
const docNodes = [];
|
|
102
|
+
for (let i = 0; i < nc; i += 1) {
|
|
103
|
+
const ni = i;
|
|
104
|
+
const name = snapshot.nodeName(ni);
|
|
105
|
+
if (name === "HTMLDocument" || name === "Document") {
|
|
106
|
+
docNodes.push(ni);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (docNodes.length === 0) return attached;
|
|
110
|
+
const visited = new Uint8Array(nc);
|
|
111
|
+
const queue = [];
|
|
112
|
+
docNodes.forEach((ni) => {
|
|
113
|
+
if (!visited[ni]) {
|
|
114
|
+
visited[ni] = 1;
|
|
115
|
+
queue.push(ni);
|
|
116
|
+
attached.add(ni);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
let head = 0;
|
|
120
|
+
while (head < queue.length) {
|
|
121
|
+
const ni = queue[head];
|
|
122
|
+
head += 1;
|
|
123
|
+
const nodeType = snapshot.nodeType(ni);
|
|
124
|
+
if (!FOLLOW_TYPES.has(nodeType)) continue;
|
|
125
|
+
for (const ei of snapshot.nodeEdges(ni)) {
|
|
126
|
+
const to = snapshot.edgeToNode(ei);
|
|
127
|
+
if (visited[to]) continue;
|
|
128
|
+
const toType = snapshot.nodeType(to);
|
|
129
|
+
const toName = snapshot.nodeName(to);
|
|
130
|
+
if (FOLLOW_TYPES.has(toType)) {
|
|
131
|
+
visited[to] = 1;
|
|
132
|
+
queue.push(to);
|
|
133
|
+
if (toType === "native" && DOM_TAG_RE.test(toName)) {
|
|
134
|
+
attached.add(to);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return attached;
|
|
140
|
+
}
|
|
141
|
+
static rankGroupElements(group, snapshot, visited) {
|
|
142
|
+
const { nodeIndices } = group;
|
|
143
|
+
const ranked = nodeIndices.map((ni) => ({
|
|
144
|
+
nodeIndex: ni,
|
|
145
|
+
edgeCount: snapshot.nodeRawEdgeCount(ni),
|
|
146
|
+
name: snapshot.nodeName(ni),
|
|
147
|
+
selfSize: snapshot.nodeSelfSize(ni),
|
|
148
|
+
retainedSize: 0
|
|
149
|
+
}));
|
|
150
|
+
ranked.sort((a, b) => (b.edgeCount ?? 0) - (a.edgeCount ?? 0));
|
|
151
|
+
const sampleSize = Math.min(ranked.length, 30);
|
|
152
|
+
const sampled = ranked.slice(0, sampleSize);
|
|
153
|
+
sampled.forEach((elem) => {
|
|
154
|
+
elem.retainedSize = LeakDetector.estimateSingleRetainedSize(
|
|
155
|
+
elem.nodeIndex,
|
|
156
|
+
snapshot,
|
|
157
|
+
visited
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
sampled.sort((a, b) => b.retainedSize - a.retainedSize);
|
|
161
|
+
group.topElements = sampled.slice(0, 5);
|
|
162
|
+
if (sampled.length > 0) {
|
|
163
|
+
group.representativeIndex = sampled[0].nodeIndex;
|
|
164
|
+
group.retainedSize = sampled.reduce((sum, e) => sum + e.retainedSize, 0);
|
|
165
|
+
} else {
|
|
166
|
+
group.representativeIndex = nodeIndices[0];
|
|
167
|
+
group.retainedSize = group.totalSelfSize;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
static estimateSingleRetainedSize(startNi, snapshot, visited) {
|
|
171
|
+
const toClean = [];
|
|
172
|
+
const queue = [startNi];
|
|
173
|
+
visited[startNi] = 1;
|
|
174
|
+
toClean.push(startNi);
|
|
175
|
+
let total = snapshot.nodeSelfSize(startNi);
|
|
176
|
+
let visits = 0;
|
|
177
|
+
let head = 0;
|
|
178
|
+
while (head < queue.length && visits < MAX_SINGLE_BFS) {
|
|
179
|
+
const ni = queue[head];
|
|
180
|
+
head += 1;
|
|
181
|
+
visits += 1;
|
|
182
|
+
for (const ei of snapshot.nodeEdges(ni)) {
|
|
183
|
+
const to = snapshot.edgeToNode(ei);
|
|
184
|
+
if (!visited[to]) {
|
|
185
|
+
visited[to] = 1;
|
|
186
|
+
toClean.push(to);
|
|
187
|
+
total += snapshot.nodeSelfSize(to);
|
|
188
|
+
queue.push(to);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for (const idx of toClean) visited[idx] = 0;
|
|
193
|
+
return total;
|
|
194
|
+
}
|
|
195
|
+
}
|