react-memory-leak-detector 0.1.0
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 +183 -0
- package/babel-plugin.js +681 -0
- package/dist/runtime.d.ts +35 -0
- package/dist/runtime.js +184 -0
- package/dist/src/runtime.d.ts +26 -0
- package/dist/src/runtime.js +148 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# react-memory-leak-detector
|
|
2
|
+
|
|
3
|
+
Live memory-leak detection for React components and hooks. No heap snapshot required.
|
|
4
|
+
|
|
5
|
+
A babel plugin tags every component and hook with a uniquely-named marker, and a runtime tracker uses `WeakRef` + `FinalizationRegistry` to warn you, **live in the console**, the moment a component is unmounted but still retained by some closure / event listener / timer / subscription.
|
|
6
|
+
|
|
7
|
+
Dev-only. Zero impact on production bundles.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
1. The babel plugin injects a uniquely-named `_heap_` marker into every component/hook — searchable as `ComponentName$Heap` in Chrome DevTools heap snapshots.
|
|
12
|
+
2. The runtime tracker wraps each marker in a `WeakRef` and registers it with a `FinalizationRegistry`. A sweep every 2s checks which markers are still reachable.
|
|
13
|
+
3. To know _when_ a component actually unmounts, the babel plugin also injects a **synthetic `useEffect`** into every component and hook:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
__heap_useEffect(() => {
|
|
17
|
+
window.__heapTracker?.markMounted(_heap_);
|
|
18
|
+
return () => window.__heapTracker?.markUnmounted(_heap_);
|
|
19
|
+
}, []);
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Imported under a renamed alias so it can't collide with user code. A mount counter handles React StrictMode's double-invoke correctly.
|
|
23
|
+
|
|
24
|
+
4. If a `_heap_` is still reachable in JS ≥10s after its component unmounted, something is leaking it → console warning.
|
|
25
|
+
|
|
26
|
+
## Setup & Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install --save-dev react-memory-leak-detector
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This package provides two pieces that must be configured:
|
|
33
|
+
|
|
34
|
+
1. The **Babel Plugin** (`react-memory-leak-detector/babel-plugin`) to inject the tracking markers.
|
|
35
|
+
2. The **Runtime** (`react-memory-leak-detector/runtime`) to collect data and warn you in the console.
|
|
36
|
+
|
|
37
|
+
### 1. Add the Babel Plugin
|
|
38
|
+
|
|
39
|
+
You only want this plugin active in development environments.
|
|
40
|
+
|
|
41
|
+
**Vite + `@vitejs/plugin-react`**
|
|
42
|
+
Vite uses esbuild by default, but its official React plugin exposes Babel configuration:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// vite.config.ts
|
|
46
|
+
import { defineConfig } from "vite";
|
|
47
|
+
import react from "@vitejs/plugin-react";
|
|
48
|
+
import heapMarkers from "react-memory-leak-detector/babel-plugin";
|
|
49
|
+
|
|
50
|
+
export default defineConfig(({ mode }) => {
|
|
51
|
+
return {
|
|
52
|
+
plugins: [
|
|
53
|
+
react({
|
|
54
|
+
babel: {
|
|
55
|
+
plugins:
|
|
56
|
+
mode === "development"
|
|
57
|
+
? [
|
|
58
|
+
[
|
|
59
|
+
heapMarkers,
|
|
60
|
+
{
|
|
61
|
+
/* options */
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
]
|
|
65
|
+
: [],
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Webpack / Next.js / standard Babel**
|
|
74
|
+
Add the plugin to your `.babelrc`, `babel.config.js`, or Webpack `babel-loader` options for the development environment.
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
// .babelrc
|
|
78
|
+
{
|
|
79
|
+
"env": {
|
|
80
|
+
"development": {
|
|
81
|
+
"plugins": ["react-memory-leak-detector/babel-plugin"]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 2. Import the Runtime
|
|
88
|
+
|
|
89
|
+
Inject the runtime into the very beginning of your application (e.g., `src/index.tsx`, `src/main.tsx`, or `_app.tsx`). The runtime must be dynamically imported or guarded so it does not end up in your production bundle.
|
|
90
|
+
|
|
91
|
+
**For Webpack / Next.js:**
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
// src/index.tsx
|
|
95
|
+
if (process.env.NODE_ENV === "development") {
|
|
96
|
+
import("react-memory-leak-detector/runtime");
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**For Vite:**
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
// src/main.tsx
|
|
104
|
+
if (import.meta.env.DEV) {
|
|
105
|
+
import("react-memory-leak-detector/runtime");
|
|
106
|
+
The tracker installs `window.__heapTracker` on dev page load. You'll see:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
[heap-leak] tracker installed. Run window.\_\_heapTracker.report() for a live table.
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Warnings fire automatically as `console.warn`, debounced to once per 30s per component:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
[heap-leak] Suspected leak: GapsByPriorityCard — 1 instance(s) unmounted >10s ago still retained (live 1 total)
|
|
119
|
+
|
|
120
|
+
````
|
|
121
|
+
|
|
122
|
+
Manual API:
|
|
123
|
+
|
|
124
|
+
| Call | Purpose |
|
|
125
|
+
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
126
|
+
| `window.__heapTracker.report()` | `console.table` of every component with live instances; also returns the array. |
|
|
127
|
+
| `window.__heapTracker.configure()` | Configure runtime options. Example: `configure({ logging: false })` disables all console outputs while continuing tracking. |
|
|
128
|
+
| `window.__heapTracker.sweep()` | Force an immediate sweep (otherwise runs every 2s). |
|
|
129
|
+
| `window.__heapTracker.forceGc()` | `window.gc?.()` + re-sweep. Needs Chrome started with `--js-flags="--expose-gc"`. Use it to confirm a flag isn't just GC lag. |
|
|
130
|
+
|
|
131
|
+
Once a component shows up as stale, take a heap snapshot and search `ComponentName$Heap` → **Retainers** tab to find the offending closure.
|
|
132
|
+
|
|
133
|
+
## `live` vs `stale`
|
|
134
|
+
|
|
135
|
+
Both count `_heap_` instances still reachable in JS:
|
|
136
|
+
|
|
137
|
+
- **`live`** — total reachable instances. Includes currently-mounted, recently-unmounted (GC pending), and leaked.
|
|
138
|
+
- **`stale`** ⊆ `live` — only those where `markUnmounted` fired ≥10s ago. These are the leak suspects.
|
|
139
|
+
|
|
140
|
+
The leak signal is purely **`stale`**; `live` is context.
|
|
141
|
+
|
|
142
|
+
| Scenario | live | stale |
|
|
143
|
+
| ------------------------------------ | ---- | ----- |
|
|
144
|
+
| 80 cards on screen | 80 | 0 |
|
|
145
|
+
| Just navigated away, <10s ago | 0–80 | 0 |
|
|
146
|
+
| Unmounted but listener still holds 1 | 1+ | 1+ |
|
|
147
|
+
|
|
148
|
+
## Plugin options
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
heapMarkers({
|
|
152
|
+
include: /\.[tj]sx?$/, // file path regex
|
|
153
|
+
excludeNames: [/^useTranslation$/], // names that skip ALL instrumentation
|
|
154
|
+
excludeUnmountTracking: [], // names that keep the heap marker but skip
|
|
155
|
+
// the synthetic useEffect (use for components
|
|
156
|
+
// managed by <Activity mode="hidden">)
|
|
157
|
+
trackHooks: true, // when false, only components are instrumented
|
|
158
|
+
skipServerComponents: false, // when true, skip files without "use client"
|
|
159
|
+
logging: true, // when false, disables automatic console warnings
|
|
160
|
+
leakAgeMs: 10000, // time in ms before an unmounted component is considered leaked
|
|
161
|
+
suspectThreshold: 1, // minimum number of stale instances before warning
|
|
162
|
+
sweepIntervalMs: 2000, // how often the GC sweep checks for leaks
|
|
163
|
+
warnCooldownMs: 30000, // how long to wait before warning about the SAME component again
|
|
164
|
+
});
|
|
165
|
+
````
|
|
166
|
+
|
|
167
|
+
All options are optional with sensible defaults.
|
|
168
|
+
|
|
169
|
+
## Compatibility
|
|
170
|
+
|
|
171
|
+
| React feature | Status |
|
|
172
|
+
| ----------------------------------------- | ------------------------------------------ |
|
|
173
|
+
| React 17 / 18 function components & hooks | ✅ |
|
|
174
|
+
| React 18 concurrent, Suspense, StrictMode | ✅ |
|
|
175
|
+
| SSR (effects no-op server-side) | ✅ |
|
|
176
|
+
| React Compiler | ✅ likely; warrants a CI snapshot test |
|
|
177
|
+
| `<Activity mode="hidden">` | ⚠️ use `excludeUnmountTracking` to opt out |
|
|
178
|
+
| React Server Components | ⚠️ use `skipServerComponents: true` |
|
|
179
|
+
| Class components | ❌ not instrumented (PRs welcome) |
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT
|
package/babel-plugin.js
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Babel plugin: babel-plugin-heap-markers
|
|
3
|
+
*
|
|
4
|
+
* Automatically injects a named marker instance into React functional components
|
|
5
|
+
* so they become identifiable in Chrome DevTools heap snapshots.
|
|
6
|
+
*
|
|
7
|
+
* How it works:
|
|
8
|
+
* 1. At the top of each component body, injects a useRef-stabilized marker
|
|
9
|
+
* that survives across renders (one per component instance, not per
|
|
10
|
+
* render — otherwise the unmount effect's `[]` deps would only ever
|
|
11
|
+
* track render-1's marker and miss leaks anchored to later-render
|
|
12
|
+
* closures):
|
|
13
|
+
* var _heap_ref_ = __heap_useRef(null);
|
|
14
|
+
* if (_heap_ref_.current === null) {
|
|
15
|
+
* _heap_ref_.current = new (function ComponentName$Heap() {})();
|
|
16
|
+
* window.__heapTracker?.track(_heap_ref_.current, "ComponentName");
|
|
17
|
+
* }
|
|
18
|
+
* var _heap_ = _heap_ref_.current;
|
|
19
|
+
*
|
|
20
|
+
* 2. Into every nested closure (arrows, function expressions), injects:
|
|
21
|
+
* _heap_ && 0;
|
|
22
|
+
* This forces V8 to capture _heap_ in the closure's scope chain.
|
|
23
|
+
* (We use `_heap_ && 0` instead of `void _heap_` because esbuild
|
|
24
|
+
* strips void expressions as dead code.)
|
|
25
|
+
*
|
|
26
|
+
* If a closure leaks (e.g., useEffect without cleanup), the closure retains
|
|
27
|
+
* _heap_, which retains the ComponentName$Heap instance. In heap snapshots:
|
|
28
|
+
* - Search for "ComponentName$Heap" to find the leaked component
|
|
29
|
+
* - Check "Retainers" to see the ACTUAL closure causing the leak
|
|
30
|
+
*
|
|
31
|
+
* Targets:
|
|
32
|
+
* - Named function declarations: function MyComponent() { ... }
|
|
33
|
+
* - Const arrow functions: const MyComponent = () => { ... }
|
|
34
|
+
* - Const function expressions: const MyComponent = function() { ... }
|
|
35
|
+
* - memo-wrapped: const MyComponent = memo(() => { ... })
|
|
36
|
+
* - forwardRef-wrapped: const MyComponent = forwardRef((props, ref) => { ... })
|
|
37
|
+
* - Custom hooks (any file): function useMyHook() { ... }
|
|
38
|
+
* - Custom hooks (arrow): const useMyHook = () => { ... }
|
|
39
|
+
*
|
|
40
|
+
* Components: only .tsx/.jsx files, PascalCase names, must contain JSX.
|
|
41
|
+
* Hooks: any .ts/.tsx/.js/.jsx file, use* prefix, no JSX required.
|
|
42
|
+
*
|
|
43
|
+
* Strategy:
|
|
44
|
+
* Instead of using a nested bodyPath.traverse() (which can silently fail in
|
|
45
|
+
* Babel's visitor context), this plugin uses Babel's normal visitor pattern:
|
|
46
|
+
* - Phase 1: FunctionDeclaration/VariableDeclarator visitors detect components,
|
|
47
|
+
* inject the marker, and register the component function node in a WeakSet.
|
|
48
|
+
* - Phase 2: ArrowFunctionExpression/FunctionExpression visitors run on EVERY
|
|
49
|
+
* nested function. They check if _heap_ exists in the parent scope chain
|
|
50
|
+
* and inject `_heap_ && 0` if so. Component-level and marker functions are
|
|
51
|
+
* skipped via the WeakSet.
|
|
52
|
+
*/
|
|
53
|
+
/**
|
|
54
|
+
* Plugin options:
|
|
55
|
+
* include — RegExp matched against the file path. Files not
|
|
56
|
+
* matching are skipped entirely.
|
|
57
|
+
* Default: /\.[tj]sx?$/ (JS/TS/JSX/TSX).
|
|
58
|
+
* excludeNames — Array of RegExp matched against the component
|
|
59
|
+
* or hook identifier. A match skips ALL
|
|
60
|
+
* instrumentation for that one function.
|
|
61
|
+
* Default: [].
|
|
62
|
+
* excludeUnmountTracking — Array of RegExp matched against the identifier.
|
|
63
|
+
* A match keeps the $Heap marker + track() call
|
|
64
|
+
* (still searchable in heap snapshots) but skips
|
|
65
|
+
* the synthetic useEffect that drives live
|
|
66
|
+
* detection. Use this for components managed by
|
|
67
|
+
* <Activity mode="hidden">, where cleanup fires
|
|
68
|
+
* while the fiber is still alive — otherwise the
|
|
69
|
+
* tracker would false-positive.
|
|
70
|
+
* Default: [].
|
|
71
|
+
* trackHooks — When false, custom hooks (use* names) are NOT
|
|
72
|
+
* instrumented; only components are.
|
|
73
|
+
* Default: true.
|
|
74
|
+
* skipServerComponents — When true, files that don't begin with the
|
|
75
|
+
* "use client" directive are skipped entirely.
|
|
76
|
+
* Needed in React Server Components projects:
|
|
77
|
+
* server components can't call useEffect, so
|
|
78
|
+
* injecting it would crash at module-load time.
|
|
79
|
+
* Default: false.
|
|
80
|
+
* logging — When false, disables console warnings at runtime
|
|
81
|
+
* while still tracking.
|
|
82
|
+
* Default: true.
|
|
83
|
+
*
|
|
84
|
+
* Components additionally require a .jsx/.tsx extension and a JSX element in
|
|
85
|
+
* the body — that's structural, not a config option.
|
|
86
|
+
*/
|
|
87
|
+
module.exports = function babelPluginHeapMarkers({ types: t }, options = {}) {
|
|
88
|
+
const {
|
|
89
|
+
include = /\.[tj]sx?$/,
|
|
90
|
+
excludeNames = [],
|
|
91
|
+
excludeUnmountTracking = [],
|
|
92
|
+
trackHooks = true,
|
|
93
|
+
skipServerComponents = false,
|
|
94
|
+
logging = true,
|
|
95
|
+
leakAgeMs = 10000,
|
|
96
|
+
suspectThreshold = 1,
|
|
97
|
+
sweepIntervalMs = 2000,
|
|
98
|
+
warnCooldownMs = 30000,
|
|
99
|
+
} = options;
|
|
100
|
+
|
|
101
|
+
// Track function nodes that ARE components or hooks (don't inject _heap_ && 0 into them)
|
|
102
|
+
const instrumentedFunctions = new WeakSet();
|
|
103
|
+
// Track function nodes that are marker constructors (don't inject _heap_ && 0)
|
|
104
|
+
const markerConstructors = new WeakSet();
|
|
105
|
+
|
|
106
|
+
function isPascalCase(name) {
|
|
107
|
+
return /^[A-Z]\w*$/.test(name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isCustomHook(name) {
|
|
111
|
+
// Matches use + uppercase letter: useEffect, useMyHook, etc.
|
|
112
|
+
// Excludes bare "use"
|
|
113
|
+
return /^use[A-Z]/.test(name);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isExcludedName(name) {
|
|
117
|
+
return excludeNames.some((re) => re.test(name));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isExcludedFromUnmountTracking(name) {
|
|
121
|
+
return excludeUnmountTracking.some((re) => re.test(name));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isReactFile(filename) {
|
|
125
|
+
// Components must live in JSX/TSX files. We narrow the user's `include`
|
|
126
|
+
// to JSX extensions for component detection only.
|
|
127
|
+
return (
|
|
128
|
+
/\.[tj]sx$/.test(filename || "") && include.test(filename || "")
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isJSOrTSFile(filename) {
|
|
133
|
+
return include.test(filename || "");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function containsJSX(nodePath) {
|
|
137
|
+
let found = false;
|
|
138
|
+
nodePath.traverse({
|
|
139
|
+
JSXElement(path) {
|
|
140
|
+
found = true;
|
|
141
|
+
path.stop();
|
|
142
|
+
},
|
|
143
|
+
JSXFragment(path) {
|
|
144
|
+
found = true;
|
|
145
|
+
path.stop();
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
return found;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolves the inner function path from a variable initializer.
|
|
153
|
+
* Handles direct arrow/function expressions and common wrappers
|
|
154
|
+
* like memo() and forwardRef().
|
|
155
|
+
*/
|
|
156
|
+
function resolveComponentFunction(initPath) {
|
|
157
|
+
if (
|
|
158
|
+
initPath.isArrowFunctionExpression() ||
|
|
159
|
+
initPath.isFunctionExpression()
|
|
160
|
+
) {
|
|
161
|
+
return initPath;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Handle memo(() => {}), forwardRef(() => {}),
|
|
165
|
+
// React.memo(() => {}), React.forwardRef(() => {})
|
|
166
|
+
if (initPath.isCallExpression()) {
|
|
167
|
+
const callee = initPath.get("callee");
|
|
168
|
+
let calleeName = null;
|
|
169
|
+
|
|
170
|
+
if (callee.isIdentifier()) {
|
|
171
|
+
calleeName = callee.node.name;
|
|
172
|
+
} else if (
|
|
173
|
+
callee.isMemberExpression() &&
|
|
174
|
+
callee.get("property").isIdentifier()
|
|
175
|
+
) {
|
|
176
|
+
calleeName = callee.get("property").node.name;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (calleeName === "memo" || calleeName === "forwardRef") {
|
|
180
|
+
const args = initPath.get("arguments");
|
|
181
|
+
if (args.length > 0) {
|
|
182
|
+
const firstArg = args[0];
|
|
183
|
+
if (
|
|
184
|
+
firstArg.isArrowFunctionExpression() ||
|
|
185
|
+
firstArg.isFunctionExpression()
|
|
186
|
+
) {
|
|
187
|
+
return firstArg;
|
|
188
|
+
}
|
|
189
|
+
// Handle memo(forwardRef(() => {}))
|
|
190
|
+
if (firstArg.isCallExpression()) {
|
|
191
|
+
return resolveComponentFunction(firstArg);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Ensures the function has a block body (converts expression arrows).
|
|
202
|
+
* Returns the block body path.
|
|
203
|
+
*/
|
|
204
|
+
function ensureBlockBody(fnPath) {
|
|
205
|
+
const body = fnPath.get("body");
|
|
206
|
+
if (!body.isBlockStatement()) {
|
|
207
|
+
// Arrow with expression body: () => <div />
|
|
208
|
+
// Convert to: () => { return <div />; }
|
|
209
|
+
body.replaceWith(t.blockStatement([t.returnStatement(body.node)]));
|
|
210
|
+
}
|
|
211
|
+
return fnPath.get("body");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Local aliases we import from React under renamed names so they can't
|
|
215
|
+
// collide with anything a user might define in the same file.
|
|
216
|
+
const USE_EFFECT_ALIAS = "__heap_useEffect";
|
|
217
|
+
const USE_REF_ALIAS = "__heap_useRef";
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Builds: window.__heapTracker && window.__heapTracker.<method>(_heap_)
|
|
221
|
+
*/
|
|
222
|
+
function buildTrackerMemberCall(method, args = [t.identifier("_heap_")]) {
|
|
223
|
+
return t.expressionStatement(
|
|
224
|
+
t.logicalExpression(
|
|
225
|
+
"&&",
|
|
226
|
+
t.binaryExpression(
|
|
227
|
+
"!==",
|
|
228
|
+
t.unaryExpression("typeof", t.identifier("window"), true),
|
|
229
|
+
t.stringLiteral("undefined")
|
|
230
|
+
),
|
|
231
|
+
t.logicalExpression(
|
|
232
|
+
"&&",
|
|
233
|
+
t.memberExpression(
|
|
234
|
+
t.identifier("window"),
|
|
235
|
+
t.identifier("__heapTracker")
|
|
236
|
+
),
|
|
237
|
+
t.callExpression(
|
|
238
|
+
t.memberExpression(
|
|
239
|
+
t.memberExpression(
|
|
240
|
+
t.identifier("window"),
|
|
241
|
+
t.identifier("__heapTracker")
|
|
242
|
+
),
|
|
243
|
+
t.identifier(method)
|
|
244
|
+
),
|
|
245
|
+
args
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Builds the unmount-detection useEffect:
|
|
254
|
+
*
|
|
255
|
+
* __heap_useEffect(() => {
|
|
256
|
+
* window.__heapTracker && window.__heapTracker.markMounted(_heap_);
|
|
257
|
+
* return () => {
|
|
258
|
+
* window.__heapTracker && window.__heapTracker.markUnmounted(_heap_);
|
|
259
|
+
* };
|
|
260
|
+
* }, []);
|
|
261
|
+
*
|
|
262
|
+
* StrictMode double-invokes effects; the tracker handles that with a mount
|
|
263
|
+
* counter, so we don't need any extra guards here.
|
|
264
|
+
*/
|
|
265
|
+
function buildUnmountEffect() {
|
|
266
|
+
const cleanup = t.arrowFunctionExpression(
|
|
267
|
+
[],
|
|
268
|
+
t.blockStatement([buildTrackerMemberCall("markUnmounted")])
|
|
269
|
+
);
|
|
270
|
+
const effectBody = t.arrowFunctionExpression(
|
|
271
|
+
[],
|
|
272
|
+
t.blockStatement([
|
|
273
|
+
buildTrackerMemberCall("markMounted"),
|
|
274
|
+
t.returnStatement(cleanup),
|
|
275
|
+
])
|
|
276
|
+
);
|
|
277
|
+
return t.expressionStatement(
|
|
278
|
+
t.callExpression(t.identifier(USE_EFFECT_ALIAS), [
|
|
279
|
+
effectBody,
|
|
280
|
+
t.arrayExpression([]),
|
|
281
|
+
])
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Injects a heap marker declaration at the top of the component body,
|
|
287
|
+
* plus the live-tracker registration and (unless opted out) an
|
|
288
|
+
* unmount-detecting useEffect.
|
|
289
|
+
*
|
|
290
|
+
* The marker is stabilized across renders via useRef: it's created once
|
|
291
|
+
* on first render and reused on every subsequent render. Without this,
|
|
292
|
+
* `var _heap_ = new (...)()` would re-run every render and only render-1's
|
|
293
|
+
* marker would receive markMounted/markUnmounted (the effect has `[]` deps),
|
|
294
|
+
* so leaks from later renders' closures would never trigger a live warning.
|
|
295
|
+
*
|
|
296
|
+
* Generates:
|
|
297
|
+
* var _heap_ref_ = __heap_useRef(null);
|
|
298
|
+
* if (_heap_ref_.current === null) {
|
|
299
|
+
* _heap_ref_.current = new (function ComponentName$Heap() {})();
|
|
300
|
+
* typeof window !== "undefined" && window.__heapTracker
|
|
301
|
+
* && window.__heapTracker.track(_heap_ref_.current, "ComponentName");
|
|
302
|
+
* }
|
|
303
|
+
* var _heap_ = _heap_ref_.current;
|
|
304
|
+
* __heap_useEffect(() => {
|
|
305
|
+
* window.__heapTracker && window.__heapTracker.markMounted(_heap_);
|
|
306
|
+
* return () =>
|
|
307
|
+
* window.__heapTracker && window.__heapTracker.markUnmounted(_heap_);
|
|
308
|
+
* }, []);
|
|
309
|
+
*
|
|
310
|
+
* `_heap_` keeps its original identifier so the Phase-2 `_heap_ && 0`
|
|
311
|
+
* injector continues to capture the (now stable) marker into nested
|
|
312
|
+
* closures unchanged.
|
|
313
|
+
*
|
|
314
|
+
* The marker constructor is registered in markerConstructors WeakSet so
|
|
315
|
+
* the FunctionExpression visitor skips it. Closures inside the useEffect
|
|
316
|
+
* get processed by Phase-2 normally — harmless.
|
|
317
|
+
*/
|
|
318
|
+
function injectMarkerVariable(bodyPath, componentName, state) {
|
|
319
|
+
const markerName = `${componentName}$Heap`;
|
|
320
|
+
|
|
321
|
+
// Build the named function expression node
|
|
322
|
+
const markerFnExpr = t.functionExpression(
|
|
323
|
+
t.identifier(markerName), // named constructor — searchable in heap
|
|
324
|
+
[], // no params
|
|
325
|
+
t.blockStatement([]) // empty body
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Register the marker constructor so it gets skipped by the FunctionExpression visitor
|
|
329
|
+
markerConstructors.add(markerFnExpr);
|
|
330
|
+
|
|
331
|
+
// var _heap_ref_ = __heap_useRef(null);
|
|
332
|
+
const refDeclaration = t.variableDeclaration("var", [
|
|
333
|
+
t.variableDeclarator(
|
|
334
|
+
t.identifier("_heap_ref_"),
|
|
335
|
+
t.callExpression(t.identifier(USE_REF_ALIAS), [t.nullLiteral()])
|
|
336
|
+
),
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
// _heap_ref_.current
|
|
340
|
+
const refCurrent = () =>
|
|
341
|
+
t.memberExpression(
|
|
342
|
+
t.identifier("_heap_ref_"),
|
|
343
|
+
t.identifier("current")
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// _heap_ref_.current = new (function ComponentName$Heap() {})();
|
|
347
|
+
const assignMarker = t.expressionStatement(
|
|
348
|
+
t.assignmentExpression(
|
|
349
|
+
"=",
|
|
350
|
+
refCurrent(),
|
|
351
|
+
t.newExpression(markerFnExpr, [])
|
|
352
|
+
)
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// typeof window !== "undefined" && window.__heapTracker
|
|
356
|
+
// && window.__heapTracker.track(_heap_ref_.current, "ComponentName");
|
|
357
|
+
const trackerCall = buildTrackerMemberCall("track", [
|
|
358
|
+
refCurrent(),
|
|
359
|
+
t.stringLiteral(componentName),
|
|
360
|
+
]);
|
|
361
|
+
|
|
362
|
+
// if (_heap_ref_.current === null) { _heap_ref_.current = new …; track(); }
|
|
363
|
+
const initBlock = t.ifStatement(
|
|
364
|
+
t.binaryExpression("===", refCurrent(), t.nullLiteral()),
|
|
365
|
+
t.blockStatement([assignMarker, trackerCall])
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
// var _heap_ = _heap_ref_.current;
|
|
369
|
+
const heapAlias = t.variableDeclaration("var", [
|
|
370
|
+
t.variableDeclarator(t.identifier("_heap_"), refCurrent()),
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
const statements = [refDeclaration, initBlock, heapAlias];
|
|
374
|
+
// eslint-disable-next-line no-param-reassign
|
|
375
|
+
state.needsHeapUseRefImport = true;
|
|
376
|
+
|
|
377
|
+
// Skip the lifecycle effect for components opted out via
|
|
378
|
+
// excludeUnmountTracking (e.g. those managed by <Activity mode="hidden">,
|
|
379
|
+
// whose cleanup fires while the fiber is still alive — would cause false
|
|
380
|
+
// positives). The marker + track() still happen, so the component remains
|
|
381
|
+
// searchable in heap snapshots.
|
|
382
|
+
if (!isExcludedFromUnmountTracking(componentName)) {
|
|
383
|
+
statements.push(buildUnmountEffect());
|
|
384
|
+
// eslint-disable-next-line no-param-reassign
|
|
385
|
+
state.needsHeapUseEffectImport = true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Prepend at the top of the component body
|
|
389
|
+
bodyPath.unshiftContainer("body", statements);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Injects `_heap_ && 0` at the top of a nested closure's body.
|
|
394
|
+
* Handles both block-body and expression-body arrows.
|
|
395
|
+
*
|
|
396
|
+
* We use `_heap_ && 0` instead of `void _heap_` because Vite's esbuild
|
|
397
|
+
* pass strips `void expr` as dead code, removing the closure capture.
|
|
398
|
+
* `_heap_ && 0` is a no-op at runtime but esbuild preserves it.
|
|
399
|
+
*/
|
|
400
|
+
function injectVoidHeap(fnPath) {
|
|
401
|
+
const captureExpr = t.expressionStatement(
|
|
402
|
+
t.logicalExpression("&&", t.identifier("_heap_"), t.numericLiteral(0))
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
if (fnPath.isArrowFunctionExpression()) {
|
|
406
|
+
const body = fnPath.get("body");
|
|
407
|
+
if (body.isBlockStatement()) {
|
|
408
|
+
body.unshiftContainer("body", captureExpr);
|
|
409
|
+
} else {
|
|
410
|
+
// Expression body: () => expr → () => { _heap_ && 0; return expr; }
|
|
411
|
+
body.replaceWith(
|
|
412
|
+
t.blockStatement([captureExpr, t.returnStatement(body.node)])
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
// FunctionExpression or FunctionDeclaration — always block body
|
|
417
|
+
fnPath.get("body").unshiftContainer("body", captureExpr);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Checks if this function path is nested inside a component that has _heap_.
|
|
423
|
+
* Uses findParent to walk up the AST — this is independent of Babel's scope
|
|
424
|
+
* system, which may not reflect dynamically injected bindings.
|
|
425
|
+
*
|
|
426
|
+
* Returns true if an ancestor function is in the componentFunctions WeakSet.
|
|
427
|
+
*/
|
|
428
|
+
function shouldInjectVoidHeap(fnPath) {
|
|
429
|
+
// Don't inject into instrumented functions (components/hooks) themselves
|
|
430
|
+
if (instrumentedFunctions.has(fnPath.node)) return false;
|
|
431
|
+
// Don't inject into marker constructors
|
|
432
|
+
if (markerConstructors.has(fnPath.node)) return false;
|
|
433
|
+
|
|
434
|
+
// Walk up the AST to find a parent component or hook function
|
|
435
|
+
const parentInstrumented = fnPath.findParent(
|
|
436
|
+
(p) =>
|
|
437
|
+
(p.isFunctionDeclaration() ||
|
|
438
|
+
p.isArrowFunctionExpression() ||
|
|
439
|
+
p.isFunctionExpression() ||
|
|
440
|
+
p.isObjectMethod() ||
|
|
441
|
+
p.isClassMethod()) &&
|
|
442
|
+
instrumentedFunctions.has(p.node)
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
return parentInstrumented !== null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
name: "babel-plugin-heap-markers",
|
|
450
|
+
visitor: {
|
|
451
|
+
// ─── Phase 0: Per-file setup and (on exit) import management ───
|
|
452
|
+
Program: {
|
|
453
|
+
enter(programPath, state) {
|
|
454
|
+
/* eslint-disable no-param-reassign */
|
|
455
|
+
state.fileImportsReact = programPath.node.body.some(
|
|
456
|
+
(node) =>
|
|
457
|
+
t.isImportDeclaration(node) &&
|
|
458
|
+
node.source.value === "react" &&
|
|
459
|
+
node.importKind !== "type"
|
|
460
|
+
);
|
|
461
|
+
state.needsHeapUseEffectImport = false;
|
|
462
|
+
state.needsHeapUseRefImport = false;
|
|
463
|
+
|
|
464
|
+
// When skipServerComponents is on, instrument only files that have
|
|
465
|
+
// a top-level "use client" directive. Server components can't call
|
|
466
|
+
// useEffect; injecting it would crash at module-load time.
|
|
467
|
+
if (skipServerComponents) {
|
|
468
|
+
const directives = programPath.node.directives || [];
|
|
469
|
+
const hasUseClient = directives.some(
|
|
470
|
+
(d) => d.value && d.value.value === "use client"
|
|
471
|
+
);
|
|
472
|
+
state.skipFile = !hasUseClient;
|
|
473
|
+
} else {
|
|
474
|
+
state.skipFile = false;
|
|
475
|
+
}
|
|
476
|
+
/* eslint-enable no-param-reassign */
|
|
477
|
+
},
|
|
478
|
+
exit(programPath, state) {
|
|
479
|
+
const emitsConfig =
|
|
480
|
+
logging === false ||
|
|
481
|
+
leakAgeMs !== 10000 ||
|
|
482
|
+
suspectThreshold !== 1 ||
|
|
483
|
+
sweepIntervalMs !== 2000 ||
|
|
484
|
+
warnCooldownMs !== 30000;
|
|
485
|
+
|
|
486
|
+
if (emitsConfig && !state.skipFile) {
|
|
487
|
+
const configAst = t.ifStatement(
|
|
488
|
+
t.binaryExpression(
|
|
489
|
+
"!==",
|
|
490
|
+
t.unaryExpression("typeof", t.identifier("window")),
|
|
491
|
+
t.stringLiteral("undefined")
|
|
492
|
+
),
|
|
493
|
+
t.blockStatement([
|
|
494
|
+
t.expressionStatement(
|
|
495
|
+
t.assignmentExpression(
|
|
496
|
+
"=",
|
|
497
|
+
t.memberExpression(t.identifier("window"), t.identifier("__heapTrackerOptions")),
|
|
498
|
+
t.objectExpression([
|
|
499
|
+
t.objectProperty(t.identifier("logging"), t.booleanLiteral(logging)),
|
|
500
|
+
t.objectProperty(t.identifier("leakAgeMs"), t.numericLiteral(leakAgeMs)),
|
|
501
|
+
t.objectProperty(t.identifier("suspectThreshold"), t.numericLiteral(suspectThreshold)),
|
|
502
|
+
t.objectProperty(t.identifier("sweepIntervalMs"), t.numericLiteral(sweepIntervalMs)),
|
|
503
|
+
t.objectProperty(t.identifier("warnCooldownMs"), t.numericLiteral(warnCooldownMs)),
|
|
504
|
+
])
|
|
505
|
+
)
|
|
506
|
+
),
|
|
507
|
+
t.expressionStatement(
|
|
508
|
+
t.logicalExpression(
|
|
509
|
+
"&&",
|
|
510
|
+
t.memberExpression(t.identifier("window"), t.identifier("__heapTracker")),
|
|
511
|
+
t.callExpression(
|
|
512
|
+
t.memberExpression(
|
|
513
|
+
t.memberExpression(t.identifier("window"), t.identifier("__heapTracker")),
|
|
514
|
+
t.identifier("configure")
|
|
515
|
+
),
|
|
516
|
+
[t.memberExpression(t.identifier("window"), t.identifier("__heapTrackerOptions"))]
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
])
|
|
521
|
+
);
|
|
522
|
+
programPath.unshiftContainer("body", configAst);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const specifiers = [];
|
|
526
|
+
if (state.needsHeapUseRefImport) {
|
|
527
|
+
specifiers.push(
|
|
528
|
+
t.importSpecifier(
|
|
529
|
+
t.identifier(USE_REF_ALIAS),
|
|
530
|
+
t.identifier("useRef")
|
|
531
|
+
)
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
if (state.needsHeapUseEffectImport) {
|
|
535
|
+
specifiers.push(
|
|
536
|
+
t.importSpecifier(
|
|
537
|
+
t.identifier(USE_EFFECT_ALIAS),
|
|
538
|
+
t.identifier("useEffect")
|
|
539
|
+
)
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
if (specifiers.length === 0) return;
|
|
543
|
+
|
|
544
|
+
// Try to attach to an existing react import. Skip ones that use a
|
|
545
|
+
// namespace specifier (`import * as React from "react"`) — you can't
|
|
546
|
+
// mix `* as X` with named specifiers in the same declaration.
|
|
547
|
+
// Also skip type-only imports (`import type {...} from "react"`),
|
|
548
|
+
// which Babel's TS transform strips wholesale, taking our added
|
|
549
|
+
// value specifiers with them.
|
|
550
|
+
const reuseTarget = programPath.node.body.find(
|
|
551
|
+
(node) =>
|
|
552
|
+
t.isImportDeclaration(node) &&
|
|
553
|
+
node.source.value === "react" &&
|
|
554
|
+
node.importKind !== "type" &&
|
|
555
|
+
!node.specifiers.some((s) => t.isImportNamespaceSpecifier(s))
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
if (reuseTarget) {
|
|
559
|
+
reuseTarget.specifiers.push(...specifiers);
|
|
560
|
+
} else {
|
|
561
|
+
programPath.unshiftContainer(
|
|
562
|
+
"body",
|
|
563
|
+
t.importDeclaration(specifiers, t.stringLiteral("react"))
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
// ─── Phase 1: Detect components and inject marker declarations ───
|
|
570
|
+
|
|
571
|
+
// Handle: function MyComponent() { ... }
|
|
572
|
+
// Handle: export default function MyComponent() { ... }
|
|
573
|
+
// Handle: function useMyHook() { ... }
|
|
574
|
+
FunctionDeclaration(fnPath, state) {
|
|
575
|
+
if (state.skipFile) return;
|
|
576
|
+
// The injected marker uses useRef + useEffect, so files that don't
|
|
577
|
+
// import React can't be instrumented. Also filters out `use*`-named
|
|
578
|
+
// utilities in non-React files that aren't actually hooks.
|
|
579
|
+
if (!state.fileImportsReact) return;
|
|
580
|
+
const name = fnPath.node.id?.name;
|
|
581
|
+
if (!name) return;
|
|
582
|
+
if (isExcludedName(name)) return;
|
|
583
|
+
|
|
584
|
+
// Custom hooks: any .ts/.tsx/.js/.jsx file, use* prefix
|
|
585
|
+
if (trackHooks && isCustomHook(name) && isJSOrTSFile(state.filename)) {
|
|
586
|
+
instrumentedFunctions.add(fnPath.node);
|
|
587
|
+
injectMarkerVariable(fnPath.get("body"), name, state);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Components: .tsx/.jsx only, PascalCase, must contain JSX
|
|
592
|
+
if (!isReactFile(state.filename)) return;
|
|
593
|
+
if (!isPascalCase(name)) return;
|
|
594
|
+
if (!containsJSX(fnPath)) return;
|
|
595
|
+
|
|
596
|
+
instrumentedFunctions.add(fnPath.node);
|
|
597
|
+
injectMarkerVariable(fnPath.get("body"), name, state);
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
// Handle: const MyComponent = () => { ... }
|
|
601
|
+
// Handle: const MyComponent = memo(() => { ... })
|
|
602
|
+
// Handle: const MyComponent = forwardRef((props, ref) => { ... })
|
|
603
|
+
// Handle: const useMyHook = () => { ... }
|
|
604
|
+
VariableDeclarator(varPath, state) {
|
|
605
|
+
if (state.skipFile) return;
|
|
606
|
+
if (!state.fileImportsReact) return;
|
|
607
|
+
const name = varPath.node.id?.name;
|
|
608
|
+
if (!name) return;
|
|
609
|
+
if (isExcludedName(name)) return;
|
|
610
|
+
|
|
611
|
+
const isHook = isCustomHook(name) && trackHooks;
|
|
612
|
+
const isComponent = isPascalCase(name);
|
|
613
|
+
if (!isHook && !isComponent) return;
|
|
614
|
+
|
|
615
|
+
// Hooks can be in any JS/TS file; components only in JSX/TSX
|
|
616
|
+
if (isHook && !isJSOrTSFile(state.filename)) return;
|
|
617
|
+
if (!isHook && !isReactFile(state.filename)) return;
|
|
618
|
+
|
|
619
|
+
const init = varPath.get("init");
|
|
620
|
+
// For hooks, the init can be a direct arrow/function (no memo/forwardRef wrapper)
|
|
621
|
+
let fnPath;
|
|
622
|
+
if (isHook) {
|
|
623
|
+
fnPath =
|
|
624
|
+
init.isArrowFunctionExpression() || init.isFunctionExpression()
|
|
625
|
+
? init
|
|
626
|
+
: null;
|
|
627
|
+
} else {
|
|
628
|
+
fnPath = resolveComponentFunction(init);
|
|
629
|
+
}
|
|
630
|
+
if (!fnPath) return;
|
|
631
|
+
|
|
632
|
+
// Only components need JSX check; hooks don't
|
|
633
|
+
if (!isHook && !containsJSX(fnPath)) return;
|
|
634
|
+
|
|
635
|
+
instrumentedFunctions.add(fnPath.node);
|
|
636
|
+
const bodyPath = ensureBlockBody(fnPath);
|
|
637
|
+
injectMarkerVariable(bodyPath, name, state);
|
|
638
|
+
},
|
|
639
|
+
|
|
640
|
+
// ─── Phase 2: Inject void _heap_ into nested closures ───
|
|
641
|
+
//
|
|
642
|
+
// These visitors run on EVERY arrow/function expression in the file.
|
|
643
|
+
// Babel's depth-first traversal ensures that by the time we visit
|
|
644
|
+
// a nested closure, the parent component's marker has already been
|
|
645
|
+
// injected (because VariableDeclarator/FunctionDeclaration visitors
|
|
646
|
+
// run on the parent node first in the enter phase).
|
|
647
|
+
|
|
648
|
+
ArrowFunctionExpression(fnPath, state) {
|
|
649
|
+
if (state.skipFile) return;
|
|
650
|
+
if (!isJSOrTSFile(state.filename)) return;
|
|
651
|
+
if (shouldInjectVoidHeap(fnPath)) {
|
|
652
|
+
injectVoidHeap(fnPath);
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
|
|
656
|
+
FunctionExpression(fnPath, state) {
|
|
657
|
+
if (state.skipFile) return;
|
|
658
|
+
if (!isJSOrTSFile(state.filename)) return;
|
|
659
|
+
if (shouldInjectVoidHeap(fnPath)) {
|
|
660
|
+
injectVoidHeap(fnPath);
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
|
|
664
|
+
ObjectMethod(fnPath, state) {
|
|
665
|
+
if (state.skipFile) return;
|
|
666
|
+
if (!isJSOrTSFile(state.filename)) return;
|
|
667
|
+
if (shouldInjectVoidHeap(fnPath)) {
|
|
668
|
+
injectVoidHeap(fnPath);
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
|
|
672
|
+
ClassMethod(fnPath, state) {
|
|
673
|
+
if (state.skipFile) return;
|
|
674
|
+
if (!isJSOrTSFile(state.filename)) return;
|
|
675
|
+
if (shouldInjectVoidHeap(fnPath)) {
|
|
676
|
+
injectVoidHeap(fnPath);
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
};
|
|
681
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
declare let _config: {
|
|
2
|
+
logging: boolean;
|
|
3
|
+
leakAgeMs: number;
|
|
4
|
+
suspectThreshold: number;
|
|
5
|
+
sweepIntervalMs: number;
|
|
6
|
+
warnCooldownMs: number;
|
|
7
|
+
};
|
|
8
|
+
declare function configure(options: Partial<typeof _config>): void;
|
|
9
|
+
declare function track(instance: object, componentName: string): void;
|
|
10
|
+
declare function markMounted(instance: object): void;
|
|
11
|
+
declare function markUnmounted(instance: object): void;
|
|
12
|
+
declare function sweep(): void;
|
|
13
|
+
type ReportRow = {
|
|
14
|
+
component: string;
|
|
15
|
+
live: number;
|
|
16
|
+
stale: number;
|
|
17
|
+
lastRenderSecsAgo: number;
|
|
18
|
+
};
|
|
19
|
+
declare function report(): ReportRow[];
|
|
20
|
+
declare function forceGc(): void;
|
|
21
|
+
declare const api: {
|
|
22
|
+
track: typeof track;
|
|
23
|
+
markMounted: typeof markMounted;
|
|
24
|
+
markUnmounted: typeof markUnmounted;
|
|
25
|
+
sweep: typeof sweep;
|
|
26
|
+
report: typeof report;
|
|
27
|
+
forceGc: typeof forceGc;
|
|
28
|
+
configure: typeof configure;
|
|
29
|
+
};
|
|
30
|
+
declare global {
|
|
31
|
+
interface Window {
|
|
32
|
+
__heapTracker?: typeof api;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export {};
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Live memory-leak detector runtime. Companion to ../babel-plugin.js.
|
|
2
|
+
//
|
|
3
|
+
// The babel plugin injects `var _heap_ = new (function Foo$Heap(){})()` into
|
|
4
|
+
// every component/hook in dev. This module wraps each marker in a WeakRef and
|
|
5
|
+
// a FinalizationRegistry. A marker that survives well past its render means
|
|
6
|
+
// some closure is still retaining it — i.e. a leak.
|
|
7
|
+
//
|
|
8
|
+
// Importing this module installs `window.__heapTracker` and starts the sweep
|
|
9
|
+
// loop. Intended to be loaded once at app boot in dev. See README.md.
|
|
10
|
+
let _config = {
|
|
11
|
+
logging: true,
|
|
12
|
+
leakAgeMs: 10_000,
|
|
13
|
+
suspectThreshold: 1,
|
|
14
|
+
sweepIntervalMs: 2_000,
|
|
15
|
+
warnCooldownMs: 30_000,
|
|
16
|
+
};
|
|
17
|
+
if (typeof window !== "undefined" && window.__heapTrackerOptions) {
|
|
18
|
+
Object.assign(_config, window.__heapTrackerOptions);
|
|
19
|
+
}
|
|
20
|
+
function configure(options) {
|
|
21
|
+
const oldInterval = _config.sweepIntervalMs;
|
|
22
|
+
Object.assign(_config, options);
|
|
23
|
+
if (options.sweepIntervalMs && options.sweepIntervalMs !== oldInterval) {
|
|
24
|
+
if (intervalId !== null) {
|
|
25
|
+
clearInterval(intervalId);
|
|
26
|
+
intervalId = null;
|
|
27
|
+
startSweepLoop();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const live = new Set();
|
|
32
|
+
const stats = new Map();
|
|
33
|
+
const instanceToEntry = new WeakMap();
|
|
34
|
+
const registry = new FinalizationRegistry((entry) => {
|
|
35
|
+
live.delete(entry);
|
|
36
|
+
});
|
|
37
|
+
function getStats(name) {
|
|
38
|
+
let s = stats.get(name);
|
|
39
|
+
if (!s) {
|
|
40
|
+
s = { lastRegisteredAt: 0, lastWarnedAt: 0 };
|
|
41
|
+
stats.set(name, s);
|
|
42
|
+
}
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
function track(instance, componentName) {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const entry = {
|
|
48
|
+
ref: new WeakRef(instance),
|
|
49
|
+
componentName,
|
|
50
|
+
createdAt: now,
|
|
51
|
+
mountCount: 0,
|
|
52
|
+
unmountedAt: null,
|
|
53
|
+
};
|
|
54
|
+
live.add(entry);
|
|
55
|
+
instanceToEntry.set(instance, entry);
|
|
56
|
+
registry.register(instance, entry);
|
|
57
|
+
const s = getStats(componentName);
|
|
58
|
+
s.lastRegisteredAt = now;
|
|
59
|
+
}
|
|
60
|
+
// Called by injected useEffect body on mount. StrictMode double-invokes the
|
|
61
|
+
// effect (run → cleanup → run), so we counter-track instead of just flipping
|
|
62
|
+
// a flag — unmountedAt is only set when the counter actually returns to 0.
|
|
63
|
+
function markMounted(instance) {
|
|
64
|
+
const entry = instanceToEntry.get(instance);
|
|
65
|
+
if (!entry)
|
|
66
|
+
return;
|
|
67
|
+
entry.mountCount += 1;
|
|
68
|
+
entry.unmountedAt = null;
|
|
69
|
+
}
|
|
70
|
+
function markUnmounted(instance) {
|
|
71
|
+
const entry = instanceToEntry.get(instance);
|
|
72
|
+
if (!entry)
|
|
73
|
+
return;
|
|
74
|
+
if (entry.mountCount === 0)
|
|
75
|
+
return;
|
|
76
|
+
entry.mountCount -= 1;
|
|
77
|
+
if (entry.mountCount === 0)
|
|
78
|
+
entry.unmountedAt = Date.now();
|
|
79
|
+
}
|
|
80
|
+
function bucketLiveEntries() {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const perName = new Map();
|
|
83
|
+
live.forEach((entry) => {
|
|
84
|
+
if (!entry.ref.deref()) {
|
|
85
|
+
live.delete(entry);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
let bucket = perName.get(entry.componentName);
|
|
89
|
+
if (!bucket) {
|
|
90
|
+
bucket = { stale: 0, live: 0 };
|
|
91
|
+
perName.set(entry.componentName, bucket);
|
|
92
|
+
}
|
|
93
|
+
bucket.live += 1;
|
|
94
|
+
if (entry.unmountedAt != null &&
|
|
95
|
+
now - entry.unmountedAt >= _config.leakAgeMs) {
|
|
96
|
+
bucket.stale += 1;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
return perName;
|
|
100
|
+
}
|
|
101
|
+
function sweep() {
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
const perName = bucketLiveEntries();
|
|
104
|
+
perName.forEach((bucket, name) => {
|
|
105
|
+
const s = getStats(name);
|
|
106
|
+
const cooldownPassed = now - s.lastWarnedAt > _config.warnCooldownMs;
|
|
107
|
+
if (bucket.stale >= _config.suspectThreshold && cooldownPassed) {
|
|
108
|
+
s.lastWarnedAt = now;
|
|
109
|
+
if (_config.logging) {
|
|
110
|
+
// eslint-disable-next-line no-console
|
|
111
|
+
console.warn(`[heap-leak] Suspected leak: ${name} — ${bucket.stale} instance(s) unmounted >${_config.leakAgeMs / 1000}s ago still retained (live ${bucket.live} total)`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function report() {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
const perName = bucketLiveEntries();
|
|
119
|
+
const rows = [];
|
|
120
|
+
perName.forEach((bucket, name) => {
|
|
121
|
+
const s = getStats(name);
|
|
122
|
+
rows.push({
|
|
123
|
+
component: name,
|
|
124
|
+
live: bucket.live,
|
|
125
|
+
stale: bucket.stale,
|
|
126
|
+
lastRenderSecsAgo: Number(((now - s.lastRegisteredAt) / 1000).toFixed(1)),
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
rows.sort((a, b) => b.stale - a.stale || b.live - a.live);
|
|
130
|
+
if (_config.logging) {
|
|
131
|
+
// eslint-disable-next-line no-console
|
|
132
|
+
console.table(rows);
|
|
133
|
+
}
|
|
134
|
+
return rows;
|
|
135
|
+
}
|
|
136
|
+
function forceGc() {
|
|
137
|
+
const w = window;
|
|
138
|
+
if (typeof w.gc === "function") {
|
|
139
|
+
w.gc();
|
|
140
|
+
if (_config.logging) {
|
|
141
|
+
// eslint-disable-next-line no-console
|
|
142
|
+
console.info("[heap-leak] window.gc() invoked; re-sweeping.");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
if (_config.logging) {
|
|
147
|
+
// eslint-disable-next-line no-console
|
|
148
|
+
console.info('[heap-leak] window.gc not available. Start Chrome with --js-flags="--expose-gc" to enable.');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
sweep();
|
|
152
|
+
}
|
|
153
|
+
const api = {
|
|
154
|
+
track,
|
|
155
|
+
markMounted,
|
|
156
|
+
markUnmounted,
|
|
157
|
+
sweep,
|
|
158
|
+
report,
|
|
159
|
+
forceGc,
|
|
160
|
+
configure,
|
|
161
|
+
};
|
|
162
|
+
if (typeof window !== "undefined") {
|
|
163
|
+
window.__heapTracker = api;
|
|
164
|
+
}
|
|
165
|
+
let intervalId = null;
|
|
166
|
+
function startSweepLoop() {
|
|
167
|
+
if (typeof document === "undefined")
|
|
168
|
+
return;
|
|
169
|
+
if (intervalId !== null)
|
|
170
|
+
return;
|
|
171
|
+
intervalId = setInterval(() => {
|
|
172
|
+
if (typeof document !== "undefined" &&
|
|
173
|
+
document.visibilityState === "visible")
|
|
174
|
+
sweep();
|
|
175
|
+
}, _config.sweepIntervalMs);
|
|
176
|
+
}
|
|
177
|
+
if (typeof window !== "undefined") {
|
|
178
|
+
startSweepLoop();
|
|
179
|
+
if (_config.logging) {
|
|
180
|
+
// eslint-disable-next-line no-console
|
|
181
|
+
console.info("[heap-leak] tracker installed. Run window.__heapTracker.report() for a live table.");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
declare function track(instance: object, componentName: string): void;
|
|
2
|
+
declare function markMounted(instance: object): void;
|
|
3
|
+
declare function markUnmounted(instance: object): void;
|
|
4
|
+
declare function sweep(): void;
|
|
5
|
+
type ReportRow = {
|
|
6
|
+
component: string;
|
|
7
|
+
live: number;
|
|
8
|
+
stale: number;
|
|
9
|
+
lastRenderSecsAgo: number;
|
|
10
|
+
};
|
|
11
|
+
declare function report(): ReportRow[];
|
|
12
|
+
declare function forceGc(): void;
|
|
13
|
+
declare const api: {
|
|
14
|
+
track: typeof track;
|
|
15
|
+
markMounted: typeof markMounted;
|
|
16
|
+
markUnmounted: typeof markUnmounted;
|
|
17
|
+
sweep: typeof sweep;
|
|
18
|
+
report: typeof report;
|
|
19
|
+
forceGc: typeof forceGc;
|
|
20
|
+
};
|
|
21
|
+
declare global {
|
|
22
|
+
interface Window {
|
|
23
|
+
__heapTracker?: typeof api;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Live memory-leak detector runtime. Companion to ../babel-plugin.js.
|
|
2
|
+
//
|
|
3
|
+
// The babel plugin injects `var _heap_ = new (function Foo$Heap(){})()` into
|
|
4
|
+
// every component/hook in dev. This module wraps each marker in a WeakRef and
|
|
5
|
+
// a FinalizationRegistry. A marker that survives well past its render means
|
|
6
|
+
// some closure is still retaining it — i.e. a leak.
|
|
7
|
+
//
|
|
8
|
+
// Importing this module installs `window.__heapTracker` and starts the sweep
|
|
9
|
+
// loop. Intended to be loaded once at app boot in dev. See README.md.
|
|
10
|
+
const LEAK_AGE_MS = 10_000;
|
|
11
|
+
const SUSPECT_THRESHOLD = 1;
|
|
12
|
+
const SWEEP_INTERVAL_MS = 2_000;
|
|
13
|
+
const WARN_COOLDOWN_MS = 30_000;
|
|
14
|
+
const live = new Set();
|
|
15
|
+
const stats = new Map();
|
|
16
|
+
const instanceToEntry = new WeakMap();
|
|
17
|
+
const registry = new FinalizationRegistry((entry) => {
|
|
18
|
+
live.delete(entry);
|
|
19
|
+
});
|
|
20
|
+
function getStats(name) {
|
|
21
|
+
let s = stats.get(name);
|
|
22
|
+
if (!s) {
|
|
23
|
+
s = { lastRegisteredAt: 0, lastWarnedAt: 0 };
|
|
24
|
+
stats.set(name, s);
|
|
25
|
+
}
|
|
26
|
+
return s;
|
|
27
|
+
}
|
|
28
|
+
function track(instance, componentName) {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const entry = {
|
|
31
|
+
ref: new WeakRef(instance),
|
|
32
|
+
componentName,
|
|
33
|
+
createdAt: now,
|
|
34
|
+
mountCount: 0,
|
|
35
|
+
unmountedAt: null,
|
|
36
|
+
};
|
|
37
|
+
live.add(entry);
|
|
38
|
+
instanceToEntry.set(instance, entry);
|
|
39
|
+
registry.register(instance, entry);
|
|
40
|
+
const s = getStats(componentName);
|
|
41
|
+
s.lastRegisteredAt = now;
|
|
42
|
+
}
|
|
43
|
+
// Called by injected useEffect body on mount. StrictMode double-invokes the
|
|
44
|
+
// effect (run → cleanup → run), so we counter-track instead of just flipping
|
|
45
|
+
// a flag — unmountedAt is only set when the counter actually returns to 0.
|
|
46
|
+
function markMounted(instance) {
|
|
47
|
+
const entry = instanceToEntry.get(instance);
|
|
48
|
+
if (!entry)
|
|
49
|
+
return;
|
|
50
|
+
entry.mountCount += 1;
|
|
51
|
+
entry.unmountedAt = null;
|
|
52
|
+
}
|
|
53
|
+
function markUnmounted(instance) {
|
|
54
|
+
const entry = instanceToEntry.get(instance);
|
|
55
|
+
if (!entry)
|
|
56
|
+
return;
|
|
57
|
+
if (entry.mountCount === 0)
|
|
58
|
+
return;
|
|
59
|
+
entry.mountCount -= 1;
|
|
60
|
+
if (entry.mountCount === 0)
|
|
61
|
+
entry.unmountedAt = Date.now();
|
|
62
|
+
}
|
|
63
|
+
function bucketLiveEntries() {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const perName = new Map();
|
|
66
|
+
live.forEach((entry) => {
|
|
67
|
+
if (!entry.ref.deref()) {
|
|
68
|
+
live.delete(entry);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let bucket = perName.get(entry.componentName);
|
|
72
|
+
if (!bucket) {
|
|
73
|
+
bucket = { stale: 0, live: 0 };
|
|
74
|
+
perName.set(entry.componentName, bucket);
|
|
75
|
+
}
|
|
76
|
+
bucket.live += 1;
|
|
77
|
+
if (entry.unmountedAt != null && now - entry.unmountedAt >= LEAK_AGE_MS) {
|
|
78
|
+
bucket.stale += 1;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return perName;
|
|
82
|
+
}
|
|
83
|
+
function sweep() {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const perName = bucketLiveEntries();
|
|
86
|
+
perName.forEach((bucket, name) => {
|
|
87
|
+
const s = getStats(name);
|
|
88
|
+
const cooldownPassed = now - s.lastWarnedAt > WARN_COOLDOWN_MS;
|
|
89
|
+
if (bucket.stale >= SUSPECT_THRESHOLD && cooldownPassed) {
|
|
90
|
+
s.lastWarnedAt = now;
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.warn(`[heap-leak] Suspected leak: ${name} — ${bucket.stale} instance(s) unmounted >${LEAK_AGE_MS / 1000}s ago still retained (live ${bucket.live} total)`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function report() {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const perName = bucketLiveEntries();
|
|
99
|
+
const rows = [];
|
|
100
|
+
perName.forEach((bucket, name) => {
|
|
101
|
+
const s = getStats(name);
|
|
102
|
+
rows.push({
|
|
103
|
+
component: name,
|
|
104
|
+
live: bucket.live,
|
|
105
|
+
stale: bucket.stale,
|
|
106
|
+
lastRenderSecsAgo: Number(((now - s.lastRegisteredAt) / 1000).toFixed(1)),
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
rows.sort((a, b) => b.stale - a.stale || b.live - a.live);
|
|
110
|
+
// eslint-disable-next-line no-console
|
|
111
|
+
console.table(rows);
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
|
114
|
+
function forceGc() {
|
|
115
|
+
const w = window;
|
|
116
|
+
if (typeof w.gc === "function") {
|
|
117
|
+
w.gc();
|
|
118
|
+
// eslint-disable-next-line no-console
|
|
119
|
+
console.info("[heap-leak] window.gc() invoked; re-sweeping.");
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// eslint-disable-next-line no-console
|
|
123
|
+
console.info('[heap-leak] window.gc not available. Start Chrome with --js-flags="--expose-gc" to enable.');
|
|
124
|
+
}
|
|
125
|
+
sweep();
|
|
126
|
+
}
|
|
127
|
+
const api = { track, markMounted, markUnmounted, sweep, report, forceGc };
|
|
128
|
+
if (typeof window !== "undefined") {
|
|
129
|
+
window.__heapTracker = api;
|
|
130
|
+
}
|
|
131
|
+
let intervalId = null;
|
|
132
|
+
function startSweepLoop() {
|
|
133
|
+
if (typeof document === "undefined")
|
|
134
|
+
return;
|
|
135
|
+
if (intervalId !== null)
|
|
136
|
+
return;
|
|
137
|
+
intervalId = setInterval(() => {
|
|
138
|
+
if (typeof document !== "undefined" &&
|
|
139
|
+
document.visibilityState === "visible")
|
|
140
|
+
sweep();
|
|
141
|
+
}, SWEEP_INTERVAL_MS);
|
|
142
|
+
}
|
|
143
|
+
if (typeof window !== "undefined") {
|
|
144
|
+
startSweepLoop();
|
|
145
|
+
// eslint-disable-next-line no-console
|
|
146
|
+
console.info("[heap-leak] tracker installed. Run window.__heapTracker.report() for a live table.");
|
|
147
|
+
}
|
|
148
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-memory-leak-detector",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stop hunting memory leaks with heap snapshots. This babel plugin tags every React component and hook with a uniquely-named marker, then a runtime tracker uses WeakRef + FinalizationRegistry to warn you, live in the console, the moment something leaks.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react",
|
|
7
|
+
"memory-leak",
|
|
8
|
+
"leak-detection",
|
|
9
|
+
"heap-snapshot",
|
|
10
|
+
"babel-plugin",
|
|
11
|
+
"devtools",
|
|
12
|
+
"weakref",
|
|
13
|
+
"finalizationregistry",
|
|
14
|
+
"debugging"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"prepublishOnly": "npm run build",
|
|
19
|
+
"test": "jest"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
"./babel-plugin": "./babel-plugin.js",
|
|
23
|
+
"./runtime": "./dist/runtime.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"babel-plugin.js",
|
|
27
|
+
"dist/",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/mvladm14/react-memory-leak-detector.git"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"author": "mvladm14",
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": ">=16.8.0"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=16"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@babel/core": "^7.29.0",
|
|
44
|
+
"jest": "^30.4.2",
|
|
45
|
+
"typescript": "^6.0.3"
|
|
46
|
+
}
|
|
47
|
+
}
|